From 7936dcab9df32bc265208e4668ae3dead33abc83 Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Fri, 15 May 2026 10:44:42 +0200 Subject: [PATCH 001/121] =?UTF-8?q?feat(demo=5Flab=5Fnetwork):=20new=20sce?= =?UTF-8?q?nario=20=E2=80=94=20demo=5Flab=20+=20inter-bridge=20iptables=20?= =?UTF-8?q?isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the network isolation design from issue #20. Adds `scenarios/demo_lab_network/` — a self-contained scenario based on demo_lab that enforces zone separation between the admin (vmbr142) and CTF (vmbr144) bridges via iptables FORWARD chain rules on the Proxmox host. New: 05_network_isolation/stage_00/proxmox_forward_rules.yml - Installs iptables-persistent on pve01 - ACCEPT: admin → ctf (management access) - ACCEPT: ctf → wazuh :1514/:1515 (agent reporting) - DROP: ctf → admin (zone isolation) - DROP: ctf → vmbr0/WAN (air-gap) - Rules are idempotent and persist across reboots via netfilter-persistent All rules implemented as Ansible tasks (not Python API calls), per hyde-repo's recommendation in the issue discussion. Two-zone design (admin/ctf) chosen over three-zone; the dedicated management bridge alternative is documented in README.md as a future option. Closes #20 --- .../_main_download_cloudinit_files.yml | 41 ++ .../templates/alpine/place_holder | 0 .../templates/debian/place_holder | 0 .../ubuntu_noble/00-template-vm-nano.yml | 99 +++++ .../01-template-vm-micro-01-2g-24g.yml | 99 +++++ .../01-template-vm-micro-02-2g-24g.yml | 99 +++++ .../02-template-vm-small-01-4g-32g.yml | 99 +++++ .../02-template-vm-small-02-4g-32g.yml | 99 +++++ .../02-template-vm-small-04-4g-32g.yml | 99 +++++ .../03-template-vm-medium-02-8g-64g.yml | 99 +++++ .../03-template-vm-medium-04-8g-64g.yml | 99 +++++ .../03-template-vm-medium-06-8g-64g.yml | 99 +++++ .../04-template-vm-large-04-8g-64g.yml | 99 +++++ .../04-template-vm-large-06-8g-64g.yml | 99 +++++ .../04-template-vm-large-08-8g-64g.yml | 99 +++++ .../ubuntu_noble/_main_ubuntu_noble.yml | 15 + .../ubuntu_noble/test_setup_templates.sh | 8 + .../ubuntu_noble/test_setup_templates.yml | 99 +++++ .../_main.reinstall.sh | 10 + .../02_admin_infrastructure/_main.yml | 148 +++++++ .../stage_00/_r42_admin_group.yml | 131 +++++++ .../stage_00/_testing/wazuh_client_01.yml | 116 ++++++ .../stage_00/builder_api_devkit.yml | 118 ++++++ .../stage_00/builder_docker_registry.yml | 118 ++++++ .../stage_00/deployer_api_backend.yml | 118 ++++++ .../stage_00/deployer_api_gateway.yml | 123 ++++++ .../stage_00/deployer_ui.yml | 118 ++++++ .../stage_00/mon_wazuh.yml | 123 ++++++ ...demo_lab_network.r42_admin_group.delete.sh | 38 ++ ...emo_lab_network.r42_admin_group.install.sh | 8 + .../demo_lab_network.r42_admin.install.sh | 8 + .../demo_lab_network.r42_admin.revert.sh | 38 ++ .../demo_lab_network.r42_admin.snapshot.sh | 22 ++ .../stage_01/_r42_admin.yml | 120 ++++++ ...ab_network.deployer_api_gateway.install.sh | 8 + ...lab_network.deployer_api_gateway.revert.sh | 7 + ...b_network.deployer_api_gateway.snapshot.sh | 6 + .../stage_01/deployer_api_gateway.yml | 71 ++++ .../demo_lab_network.deployer_ui.install.sh | 8 + .../demo_lab_network.deployer_ui.revert.sh | 7 + .../demo_lab_network.deployer_ui.snapshot.sh | 6 + .../stage_01/deployer_ui.yml | 31 ++ .../demo_lab_network.mon_wazuh.install.sh | 8 + .../demo_lab_network.mon_wazuh.revert.sh | 7 + .../demo_lab_network.mon_wazuh.snapshot.sh | 6 + .../stage_01/mon_wazuh.yml | 195 ++++++++++ .../04_ctf_infrastructure/_main.reinstall.sh | 10 + .../04_ctf_infrastructure/_main.yml | 159 ++++++++ .../stage_00/_r42_vuln_box_group.yml | 90 +++++ .../demo_lab_network.vuln_box_01.delete.sh | 35 ++ .../demo_lab_network.vuln_box_01.install.sh | 8 + .../stage_00/vuln_box_00.yml | 116 ++++++ .../stage_00/vuln_box_01.yml | 116 ++++++ .../stage_00/vuln_box_02.yml | 116 ++++++ .../stage_00/vuln_box_03.yml | 116 ++++++ .../stage_00/vuln_box_04.yml | 116 ++++++ .../demo_lab_network.r42_vuln_box.install.sh | 8 + .../demo_lab_network.r42_vuln_box.revert.sh | 34 ++ .../demo_lab_network.r42_vuln_box.snapshot.sh | 20 + .../stage_01/_r42_vuln_box_group.yml | 115 ++++++ .../demo_lab_network.vuln_box_00.install.sh | 8 + .../demo_lab_network.vuln_box_00.revert.sh | 7 + .../demo_lab_network.vuln_box_00.snapshot.sh | 7 + .../stage_01/vuln_box_00.yml | 67 ++++ .../demo_lab_network.vuln_box_01.install.sh | 8 + .../demo_lab_network.vuln_box_01.revert.sh | 7 + .../demo_lab_network.vuln_box_01.snapshot.sh | 7 + .../stage_01/vuln_box_01.yml | 145 +++++++ .../demo_lab_network.vuln_box_02.install.sh | 8 + .../demo_lab_network.vuln_box_02.revert.sh | 7 + .../demo_lab_network.vuln_box_02.snapshot.sh | 6 + .../stage_01/vuln_box_02.yml | 169 ++++++++ .../demo_lab_network.vuln_box_03.install.sh | 8 + .../demo_lab_network.vuln_box_03.revert.sh | 7 + .../demo_lab_network.vuln_box_03.snapshot.sh | 7 + .../stage_01/vuln_box_03.yml | 178 +++++++++ .../demo_lab_network.vuln_box_04.install.sh | 8 + .../demo_lab_network.vuln_box_04.revert.sh | 7 + .../demo_lab_network.vuln_box_04.snapshot.sh | 7 + .../stage_01/vuln_box_04.yml | 361 ++++++++++++++++++ .../05_network_isolation/_main.yml | 8 + .../stage_00/proxmox_forward_rules.yml | 111 ++++++ scenarios/demo_lab_network/README.md | 105 +++++ .../demo_lab_network.delete_all.sh | 55 +++ .../demo_lab_network.delete_vms_only.sh | 42 ++ .../demo_lab_network.reset.setup.sh | 14 + .../demo_lab_network.reset.ssh_keys.sh | 24 ++ .../demo_lab_network.setup.sh | 5 + .../demo_lab_network.setup_vms_only.sh | 9 + scenarios/demo_lab_network/main.yml | 31 ++ scenarios/demo_lab_network/main_vms_only.yml | 17 + .../manifest/scenario_vms.json | 30 ++ .../templates/ansible-inventory.j2 | 62 +++ .../templates/ansible-vars.yml | 35 ++ .../demo_lab_network/templates/ssh-config.j2 | 84 ++++ .../templates/vault-example.yml | 65 ++++ 96 files changed, 5853 insertions(+) create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/_main_download_cloudinit_files.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/alpine/place_holder create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/debian/place_holder create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/00-template-vm-nano.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/01-template-vm-micro-01-2g-24g.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/01-template-vm-micro-02-2g-24g.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-01-4g-32g.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-02-4g-32g.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-04-4g-32g.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-02-8g-64g.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-04-8g-64g.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-06-8g-64g.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-04-8g-64g.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-06-8g-64g.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-08-8g-64g.yml create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/_main_ubuntu_noble.yml create mode 100755 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/test_setup_templates.sh create mode 100644 scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/test_setup_templates.yml create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/_main.reinstall.sh create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/_main.yml create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_00/_r42_admin_group.yml create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_00/_testing/wazuh_client_01.yml create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_00/builder_api_devkit.yml create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_00/builder_docker_registry.yml create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_api_backend.yml create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_api_gateway.yml create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_ui.yml create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_00/mon_wazuh.yml create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_00/r42_admin_group.devkit/demo_lab_network.r42_admin_group.delete.sh create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_00/r42_admin_group.devkit/demo_lab_network.r42_admin_group.install.sh create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.install.sh create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.revert.sh create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.snapshot.sh create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.yml create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.install.sh create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.revert.sh create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.snapshot.sh create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.yml create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.install.sh create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.revert.sh create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.snapshot.sh create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.yml create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.install.sh create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.revert.sh create mode 100755 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.snapshot.sh create mode 100644 scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.yml create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/_main.reinstall.sh create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/_main.yml create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/_r42_vuln_box_group.yml create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/r42_vuln_box_group.devkit/demo_lab_network.vuln_box_01.delete.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/r42_vuln_box_group.devkit/demo_lab_network.vuln_box_01.install.sh create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_00.yml create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_01.yml create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_02.yml create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_03.yml create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_04.yml create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.install.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.revert.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.snapshot.sh create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.yml create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.install.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.revert.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.snapshot.sh create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.yml create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.install.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.revert.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.snapshot.sh create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.yml create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.install.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.revert.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.snapshot.sh create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.yml create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.install.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.revert.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.snapshot.sh create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.yml create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.install.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.revert.sh create mode 100755 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.snapshot.sh create mode 100644 scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.yml create mode 100644 scenarios/demo_lab_network/05_network_isolation/_main.yml create mode 100644 scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml create mode 100644 scenarios/demo_lab_network/README.md create mode 100755 scenarios/demo_lab_network/demo_lab_network.delete_all.sh create mode 100755 scenarios/demo_lab_network/demo_lab_network.delete_vms_only.sh create mode 100755 scenarios/demo_lab_network/demo_lab_network.reset.setup.sh create mode 100755 scenarios/demo_lab_network/demo_lab_network.reset.ssh_keys.sh create mode 100755 scenarios/demo_lab_network/demo_lab_network.setup.sh create mode 100755 scenarios/demo_lab_network/demo_lab_network.setup_vms_only.sh create mode 100644 scenarios/demo_lab_network/main.yml create mode 100644 scenarios/demo_lab_network/main_vms_only.yml create mode 100644 scenarios/demo_lab_network/manifest/scenario_vms.json create mode 100644 scenarios/demo_lab_network/templates/ansible-inventory.j2 create mode 100644 scenarios/demo_lab_network/templates/ansible-vars.yml create mode 100644 scenarios/demo_lab_network/templates/ssh-config.j2 create mode 100644 scenarios/demo_lab_network/templates/vault-example.yml diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/_main_download_cloudinit_files.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/_main_download_cloudinit_files.yml new file mode 100644 index 00000000..1a6ba6c0 --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/_main_download_cloudinit_files.yml @@ -0,0 +1,41 @@ +## +## + + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - download cloud init files +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + tasks: + - name: PROMOX INIT - DOWNLOAD - CLOUD INIT images + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "storage_download_iso" + # proxmox_node: "px-testing" + proxmox_storage: "local" + iso_file_content_type: "iso" + iso_url: "{{ item.iso_url }}" + iso_file_name: "{{ item.iso_file_name }}" + + loop: + - { + iso_url: "https://cloud-images.ubuntu.com/minimal/daily/noble/current/noble-minimal-cloudimg-amd64.img", + iso_file_name: "noble-minimal-cloudimg-amd64.img", + } + - { + iso_url: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", + iso_file_name: "noble-server-cloudimg-amd64.img", + } + - { + iso_url: "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.raw", + iso_file_name: "debian-12-genericcloud-amd64.img", + } diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/alpine/place_holder b/scenarios/demo_lab_network/01_init_proxmox/templates/alpine/place_holder new file mode 100644 index 00000000..e69de29b diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/debian/place_holder b/scenarios/demo_lab_network/01_init_proxmox/templates/debian/place_holder new file mode 100644 index 00000000..e69de29b diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/00-template-vm-nano.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/00-template-vm-nano.yml new file mode 100644 index 00000000..ba639a16 --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/00-template-vm-nano.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 9901 +# cpu core : 1 +# ram : 1g +# disk : 16g +# ip : 192.168.142.201 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-nano + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9901 + vm_name: "template-vm-nano" + vm_cpu: "host" + vm_cores: 1 + vm_sockets: 1 + vm_memory: 1024 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-nano + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9901 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-nano + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9901 + vm_disk_size: "16g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-nano + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9901 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.201" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9901 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/01-template-vm-micro-01-2g-24g.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/01-template-vm-micro-01-2g-24g.yml new file mode 100644 index 00000000..4b2d5fbf --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/01-template-vm-micro-01-2g-24g.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 9211 +# cpu core : 1 +# ram : 2g +# disk : 24g +# ip : 192.168.142.211 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-micro-01-2g-24g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9211 + vm_name: "template-vm-micro-01-2g-24g" + vm_cpu: "host" + vm_cores: 1 + vm_sockets: 1 + vm_memory: 2048 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-micro-01-2g-24g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9211 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-micro-01-2g-24g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9211 + vm_disk_size: "24g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-micro-01-2g-24g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9211 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.211" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG - template-vm-micro-01-2g-24g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9211 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/01-template-vm-micro-02-2g-24g.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/01-template-vm-micro-02-2g-24g.yml new file mode 100644 index 00000000..36c5a11a --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/01-template-vm-micro-02-2g-24g.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 92212 +# cpu core : 2 +# ram : 2g +# disk : 24g +# ip : 192.168.142.212 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-micro-02-2g-24g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9212 + vm_name: "template-vm-micro-02-2g-24g" + vm_cpu: "host" + vm_cores: 2 + vm_sockets: 1 + vm_memory: 2048 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-micro-02-2g-24g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9212 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-micro-02-2g-24g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9212 + vm_disk_size: "24g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-micro-02-2g-24g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9212 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.212" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG - template-vm-micro-02-2g-24g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9212 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-01-4g-32g.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-01-4g-32g.yml new file mode 100644 index 00000000..860b9cef --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-01-4g-32g.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 9221 +# cpu core : 1 +# ram : 4g +# disk : 32g +# ip : 192.168.142.221 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-small-01-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9221 + vm_name: "template-vm-small-01-4g-32g" + vm_cpu: "host" + vm_cores: 1 + vm_sockets: 1 + vm_memory: 4096 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-small-01-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9221 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-small-01-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9221 + vm_disk_size: "32g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-small-01-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9221 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.221" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG - template-vm-small-01-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9221 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-02-4g-32g.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-02-4g-32g.yml new file mode 100644 index 00000000..1cbd6d42 --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-02-4g-32g.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 9222 +# cpu core : 2 +# ram : 4g +# disk : 32g +# ip : 192.168.142.222 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-small-02-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9222 + vm_name: "template-vm-small-02-4g-32g" + vm_cpu: "host" + vm_cores: 2 + vm_sockets: 1 + vm_memory: 4096 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-small-02-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9222 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-small-02-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9222 + vm_disk_size: "32g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-small-02-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9222 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.222" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG - template-vm-small-02-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9222 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-04-4g-32g.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-04-4g-32g.yml new file mode 100644 index 00000000..875f8d91 --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/02-template-vm-small-04-4g-32g.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 9224 +# cpu core : 4 +# ram : 4g +# disk : 32g +# ip : 192.168.142.224 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-small-04-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9224 + vm_name: "template-vm-small-04-4g-32g" + vm_cpu: "host" + vm_cores: 4 + vm_sockets: 1 + vm_memory: 4096 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-small-04-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9224 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-small-04-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9224 + vm_disk_size: "32g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-small-04-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9224 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.224" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG - template-vm-small-04-4g-32g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9224 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-02-8g-64g.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-02-8g-64g.yml new file mode 100644 index 00000000..e5e3be41 --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-02-8g-64g.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 9232 +# cpu core : 2 +# ram : 8g +# disk : 64g +# ip : 192.168.142.232 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-medium-02-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9232 + vm_name: "template-vm-medium-02-8g-64g" + vm_cpu: "host" + vm_cores: 2 + vm_sockets: 1 + vm_memory: 8192 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-medium-02-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9232 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-medium-02-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9232 + vm_disk_size: "64g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-medium-02-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9232 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.232" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG - template-vm-medium-02-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9232 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-04-8g-64g.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-04-8g-64g.yml new file mode 100644 index 00000000..dca6d00a --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-04-8g-64g.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 9234 +# cpu core : 4 +# ram : 8g +# disk : 64g +# ip : 192.168.142.234 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-medium-04-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9234 + vm_name: "template-vm-medium-04-8g-64g" + vm_cpu: "host" + vm_cores: 4 + vm_sockets: 1 + vm_memory: 8192 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-medium-04-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9234 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-medium-04-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9234 + vm_disk_size: "64g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-medium-04-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9234 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.234" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG - template-vm-medium-04-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9234 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-06-8g-64g.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-06-8g-64g.yml new file mode 100644 index 00000000..fb792d66 --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/03-template-vm-medium-06-8g-64g.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 9236 +# cpu core : 6 +# ram : 8g +# disk : 64g +# ip : 192.168.142.236 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-medium-06-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9236 + vm_name: "template-vm-medium-06-8g-64g" + vm_cpu: "host" + vm_cores: 6 + vm_sockets: 1 + vm_memory: 8192 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-medium-06-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9236 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-medium-06-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9236 + vm_disk_size: "64g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-medium-06-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9236 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.236" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG - template-vm-medium-06-8g-64g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9236 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-04-8g-64g.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-04-8g-64g.yml new file mode 100644 index 00000000..3e0928cd --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-04-8g-64g.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 9244 +# cpu core : 4 +# ram : 16g +# disk : 100g +# ip : 192.168.142.244 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-large-04-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9244 + vm_name: "template-vm-large-04-16g-100g" + vm_cpu: "host" + vm_cores: 4 + vm_sockets: 1 + vm_memory: 16384 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-large-04-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9244 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-large-04-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9244 + vm_disk_size: "100g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-large-04-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9244 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.244" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG - template-vm-large-04-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9244 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-06-8g-64g.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-06-8g-64g.yml new file mode 100644 index 00000000..0424c00e --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-06-8g-64g.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 9246 +# cpu core : 6 +# ram : 16g +# disk : 100g +# ip : 192.168.142.246 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-large-06-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9246 + vm_name: "template-vm-large-06-16g-100g" + vm_cpu: "host" + vm_cores: 6 + vm_sockets: 1 + vm_memory: 16384 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-large-06-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9246 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-large-06-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9246 + vm_disk_size: "100g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-large-06-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9246 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.246" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG - template-vm-large-06-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9246 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-08-8g-64g.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-08-8g-64g.yml new file mode 100644 index 00000000..4e2bdd23 --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/04-template-vm-large-08-8g-64g.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : 9248 +# cpu core : 8 +# ram : 16g +# disk : 100g +# ip : 192.168.142.248 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + ## + ## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - template-vm-large-08-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 9248 + vm_name: "template-vm-large-06-16g-100g" + vm_cpu: "host" + vm_cores: 8 + vm_sockets: 1 + vm_memory: 16384 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE - template-vm-large-08-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 9248 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK - template-vm-large-08-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 9248 + vm_disk_size: "100g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE - template-vm-large-08-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 9248 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.248" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG - template-vm-large-08-16g-100g + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 9248 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/_main_ubuntu_noble.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/_main_ubuntu_noble.yml new file mode 100644 index 00000000..486c5b44 --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/_main_ubuntu_noble.yml @@ -0,0 +1,15 @@ +## +## + +- import_playbook: ./00-template-vm-nano.yml +- import_playbook: ./01-template-vm-micro-01-2g-24g.yml +- import_playbook: ./01-template-vm-micro-02-2g-24g.yml +- import_playbook: ./02-template-vm-small-01-4g-32g.yml +- import_playbook: ./02-template-vm-small-02-4g-32g.yml +- import_playbook: ./02-template-vm-small-04-4g-32g.yml +- import_playbook: ./03-template-vm-medium-02-8g-64g.yml +- import_playbook: ./03-template-vm-medium-04-8g-64g.yml +- import_playbook: ./03-template-vm-medium-06-8g-64g.yml +- import_playbook: ./04-template-vm-large-04-8g-64g.yml +- import_playbook: ./04-template-vm-large-06-8g-64g.yml +- import_playbook: ./04-template-vm-large-08-8g-64g.yml diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/test_setup_templates.sh b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/test_setup_templates.sh new file mode 100755 index 00000000..fd1b2542 --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/test_setup_templates.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "test_setup_templates.yml" --ask-vault-pass diff --git a/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/test_setup_templates.yml b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/test_setup_templates.yml new file mode 100644 index 00000000..6e497a34 --- /dev/null +++ b/scenarios/demo_lab_network/01_init_proxmox/templates/ubuntu_noble/test_setup_templates.yml @@ -0,0 +1,99 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT - create ubuntu noble template : +# +# vm_id : +# cpu core : +# ram : +# disk : +# ip : 192.168.142. +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - CREATE TEMPLATE - XXXXXX + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_create" + # proxmox_node: "px-testing" + vm_id: 8001 + vm_name: "brol1" + vm_cpu: "host" + vm_cores: 1 + vm_sockets: 1 + vm_memory: 2048 + vm_net_virtio_bridge: "vmbr140" + + # + - name: PROMOX INIT - TEMPLATES - CONVERT VM TO TEMPLATE + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_convert_vm_to_template" + # proxmox_node: "px-testing" + proxmox_storage: "local" + vm_id: 8001 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox-cli + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - IMPORT CLOUD INIT DISK + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "template_cloudinit_import_disk" + proxmox_node: "px-testing-cli" + cloudinit_image_full_path: "/var/lib/vz/template/iso/noble-minimal-cloudimg-amd64.img" + vm_id: 8001 + vm_disk_size: "103g" + proxmox_dest_vm_storage_name: "local-lvm" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + tasks: + # + - name: PROMOX INIT - TEMPLATES - SET CLOUD DEFAULT INIT VARIABLE + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "cloudinit_set_variables" + # proxmox_node: "px-testing" + vm_id: 8001 + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_trainee_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_trainee_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "192.168.142.55" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + + # + - name: PROMOX INIT - TEMPLATES - SET PROMOX TAG + include_role: + name: range42-ansible_roles-proxmox_controller + vars: + proxmox_vm_action: "vm_set_tag" + # proxmox_node: "px-testing" + vm_id: 8001 + vm_tag_name: "template" diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/_main.reinstall.sh b/scenarios/demo_lab_network/02_admin_infrastructure/_main.reinstall.sh new file mode 100755 index 00000000..6e9083ef --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/_main.reinstall.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +## +## + +./stage_00/r42_admin_group.devkit/demo_lab_network.r42_admin_group.delete.sh + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "./_main.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/_main.yml b/scenarios/demo_lab_network/02_admin_infrastructure/_main.yml new file mode 100644 index 00000000..1c25e8b9 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/_main.yml @@ -0,0 +1,148 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### STAGE 00 #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# + +# disabled — wazuh client test VM, re-enable when wazuh config is validated +# - import_playbook: ./stage_00/_testing/wazuh_client_01.yml +# +# vars: +# global_vm_name: "testing-wazuh-client" +# global_vm_ssh_name: "r42.testing-wazuh-client" +# global_vm_id: 1111 +# global_vm_description: "wazuh - client test" +# global_vm_tag_name: "admin" +# global_vm_ci_ip: "192.168.142.111" +# global_template_vm_id: 9211 +# global_template_name: "01-template-vm-micro-01-2g-24g - id 9211" + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +- import_playbook: ./stage_00/mon_wazuh.yml + + vars: + global_vm_name: "admin-wazuh" + global_vm_ssh_name: "r42.admin-wazuh" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + + global_vm_id: 1000 + global_vm_description: "" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.142.100" + # + global_template_vm_id: 9232 + global_template_name: "03-template-vm-medium-02-8g-64g - 9232" # debug + # global_operator_ssh_config_known_hosts: "global_operator_ssh_config_known_hosts" # move this one to vault ! + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +# disabled — docker registry, re-enable when needed +# - import_playbook: ./stage_00/builder_docker_registry.yml +# +# vars: +# global_vm_name: "admin-builder-docker-registry" +# global_vm_ssh_name: "r42.admin-builder-docker-registry" +# global_vm_id: 1001 +# global_vm_description: "docker registry" +# global_vm_tag_name: "admin" +# global_vm_ci_ip: "192.168.142.101" +# global_template_vm_id: 9232 +# global_template_name: "03-template-vm-medium-02-8g-64g - 9232" + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +- import_playbook: ./stage_00/deployer_api_gateway.yml + + vars: + global_vm_name: "admin-deployer-api-gateway" + global_vm_ssh_name: "r42.admin-deployer-api-gateway" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 1020 + global_vm_description: "API gateway" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.142.120" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +- import_playbook: ./stage_00/deployer_api_backend.yml + + vars: + global_vm_name: "admin-deployer-api-backend" + global_vm_ssh_name: "r42.admin-deployer-api-backend" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 1021 + global_vm_description: "www - backend API " + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.142.121" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- import_playbook: ./stage_00/deployer_ui.yml + + vars: + global_vm_name: "admin-deployer-ui" + global_vm_ssh_name: "r42.admin-deployer-ui" + global_vm_id: 1023 + global_vm_description: "www - front end - r42 - deployer -ui" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.142.123" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### STAGE 01 #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# # + +- import_playbook: ./stage_01/_r42_admin.yml + vars: + OPERATOR_USER: alice + + INSTALL_VIM_DOTFILES: "YES" + INSTALL_ZSH_DOTFILES: "YES" + + INSTALL_PACKAGES_BASICS: "YES" + INSTALL_PACKAGES_FIREWALLS: "YES" + # + INSTALL_PACKAGES_DOCKER: "NO" + INSTALL_PACKAGES_DOCKER_COMPOSE: "NO" + # + INSTALL_PACKAGES_UTILS_JSON: "NO" + INSTALL_PACKAGES_UTILS_NETWORK: "YES" + + INSTALL_PACKAGES_NTP_AND_UPDATE_TIME: "YES" + # # + + INSTALL_TAILSCALE: "NO" + + # SPECIFIC_PACKAGES_CLEANING: "NO" + + firewall_rules: + - ip: "all" + port: 22 + protocol: "tcp" + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +- import_playbook: ./stage_01/deployer_ui.yml + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +- import_playbook: ./stage_01/mon_wazuh.yml +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/_r42_admin_group.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/_r42_admin_group.yml new file mode 100644 index 00000000..ffa141e9 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/_r42_admin_group.yml @@ -0,0 +1,131 @@ +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### STAGE 00 #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# + +## +## + +# - import_playbook: ./_testing/wazuh_client_01.yml + +# vars: +# global_vm_name: "testing-wazuh-client" +# global_vm_ssh_name: "r42.testing-wazuh-client" +# # global_vm_ssh_name: "r42.{{ global_vm_name }}" +# global_vm_id: 1111 +# global_vm_description: "wazuh - client test" +# global_vm_tag_name: "admin" +# global_vm_ci_ip: "192.168.142.111" +# # +# global_template_vm_id: 9211 +# global_template_name: "01-template-vm-micro-01-2g-24g - id 9211" # debug +# # + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +- import_playbook: ./mon_wazuh.yml + + vars: + global_vm_name: "admin-wazuh" + global_vm_ssh_name: "r42.admin-wazuh" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + + global_vm_id: 1000 + global_vm_description: "" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.142.100" + # + global_template_vm_id: 9232 + global_template_name: "03-template-vm-medium-02-8g-64g - 9232" # debug + # global_operator_ssh_config_known_hosts: "global_operator_ssh_config_known_hosts" # move this one to vault ! + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +# - import_playbook: ./builder_docker_registry.yml + +# vars: +# global_vm_name: "admin-builder-docker-registry" +# global_vm_ssh_name: "r42.admin-builder-docker-registry" +# # global_vm_ssh_name: "r42.{{ global_vm_name }}" + +# global_vm_id: 1001 +# global_vm_description: "docker registry" +# global_vm_tag_name: "admin" +# global_vm_ci_ip: "192.168.142.101" +# # +# global_template_vm_id: 9232 +# global_template_name: "03-template-vm-medium-02-8g-64g - 9232" # debug +# # + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +# - import_playbook: ./builder_api_devkit.yml + +# vars: +# global_vm_name: "admin-builder-api-devkit" +# global_vm_ssh_name: "r42.admin-builder-api-devkit" +# # global_vm_ssh_name: "r42.{{ global_vm_name }}" +# global_vm_id: 1002 +# global_vm_description: "devkit tooling" +# global_vm_tag_name: "admin" +# global_vm_ci_ip: "192.168.142.102" +# # +# global_template_vm_id: 9232 +# global_template_name: "01-template-vm-micro-01-2g-24g - id 9232" # debug +# # + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +- import_playbook: ./deployer_api_gateway.yml + + vars: + global_vm_name: "admin-deployer-api-gateway" + global_vm_ssh_name: "r42.admin-deployer-api-gateway" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 1020 + global_vm_description: "API gateway" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.142.120" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +- import_playbook: ./deployer_api_backend.yml + + vars: + global_vm_name: "admin-deployer-api-backend" + global_vm_ssh_name: "r42.admin-deployer-api-backend" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 1021 + global_vm_description: "www - backend API " + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.142.121" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- import_playbook: ./deployer_ui.yml + + vars: + global_vm_name: "admin-deployer-ui" + global_vm_ssh_name: "r42.admin-deployer-ui" + + global_vm_id: 1023 + global_vm_description: "www - front end - r42 - deployer -ui " + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.142.123" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + + + # diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/_testing/wazuh_client_01.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/_testing/wazuh_client_01.yml new file mode 100644 index 00000000..720644ab --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/_testing/wazuh_client_01.yml @@ -0,0 +1,116 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - ADMIN +# +# hostname : testing-wazuh-client +# vm_id : 1100 +# cpu core : 1 +# ram : 2g +# disk : 24g +# ip : 192.168.142.110 +# +# template : template-vm-micro-01-2g-24g - id 9211 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# TEST FILE - issue #10 + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + vars: + # global_vm_ssh_name: "cr42.testing-wazuh-client" + # # + # global_vm_id: 1110 + # global_vm_name: "testing-wazuh-client" + # global_vm_description: "" + # global_vm_tag_name: "admin" + # global_vm_ci_ip: "192.168.142.110" + # # + # global_template_vm_id: 9211 + # global_template_name: "01-template-vm-micro-01-2g-24g - id 9211" # debug + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + vm_net_virtio_bridge: "vmbr142" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../../secrets/default_vault.yml" + + vars: + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/builder_api_devkit.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/builder_api_devkit.yml new file mode 100644 index 00000000..215b0d00 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/builder_api_devkit.yml @@ -0,0 +1,118 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - ADMIN +# +# hostname : admin-builder-api-devkit +# vm_id : 1002 +# cpu core : 1 +# ram : 2g +# disk : 24g +# ip : 192.168.142.102 +# +# template : 01-template-vm-micro-01-2g-24g - id 9232 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # + # variables will be overwrited by values in _main.yml + # + # global_vm_id: 1002 + # global_vm_name: "admin-builder-api-devkit" + # global_vm_description: "" + # global_vm_tag_name: "admin" + # global_vm_ci_ip: "192.168.142.102" + # # + # global_template_vm_id: 9232 + # global_template_name: "01-template-vm-micro-01-2g-24g - id 9232" # debug + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + vm_net_virtio_bridge: "vmbr142" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/builder_docker_registry.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/builder_docker_registry.yml new file mode 100644 index 00000000..5fe5fcf1 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/builder_docker_registry.yml @@ -0,0 +1,118 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - ADMIN +# +# hostname : admin-builder-docker-registry +# vm_id : 1001 +# cpu core : 1 +# ram : 2g +# disk : 24g +# ip : 192.168.142.101 +# +# template : 03-template-vm-medium-02-8g-64g - 9232 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # + # variables will be overwrited by values in _main.yml + # + # global_vm_id: 1001 + # global_vm_name: "admin-builder-docker-registry" + # global_vm_description: "" + # global_vm_tag_name: "admin" + # global_vm_ci_ip: "192.168.142.101" + # # + # global_template_vm_id: 9232 + # global_template_name: "03-template-vm-medium-02-8g-64g - 9232" # debug + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + vm_net_virtio_bridge: "vmbr142" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_api_backend.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_api_backend.yml new file mode 100644 index 00000000..54299077 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_api_backend.yml @@ -0,0 +1,118 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - ADMIN +# +# hostname : admin-deployer-api-backend +# vm_id : 1021 +# cpu core : 1 +# ram : 4g +# disk : 32g +# ip : 192.168.142.121 +# +# template : template-vm-small-01-4g-32g - id : 9221 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # + # variables will be overwrited by values in _main.yml + # + # global_vm_id: 1021 + # global_vm_name: "admin-deployer-api-backend" + # global_vm_description: "" + # global_vm_tag_name: "admin" + # global_vm_ci_ip: "192.168.142.121" + # # + # global_template_vm_id: 9221 + # global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + vm_net_virtio_bridge: "vmbr142" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_api_gateway.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_api_gateway.yml new file mode 100644 index 00000000..f0827854 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_api_gateway.yml @@ -0,0 +1,123 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - ADMIN +# +# hostname : admin-deployer-api-gateway +# vm_id : 1020 +# cpu core : 1 +# ram : 4g +# disk : 32g +# ip : 192.168.142.120 +# +# template : template-vm-small-01-4g-32g - id : 9221 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # + # variables will be overwrited by values in _main.yml + # + # global_vm_id: 1020 + # global_vm_name: "admin-deployer-api-gateway" + # global_vm_description: "" + # global_vm_tag_name: "admin" + # global_vm_ci_ip: "192.168.142.120" + # # + # global_template_vm_id: 9221 + # global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + vm_net_virtio_bridge: "vmbr142" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_ui.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_ui.yml new file mode 100644 index 00000000..fb25a64d --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/deployer_ui.yml @@ -0,0 +1,118 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - ADMIN +# +# hostname : admin-deployer-ui +# vm_id : 1023 +# cpu core : 1 +# ram : 4g +# disk : 32g +# ip : 192.168.142.122 +# +# template : template-vm-small-01-4g-32g - id : 9221 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # + # variables will be overwrited by values in _main.yml + # + # global_vm_id: 1023 + # global_vm_name: "admin-deployer-ui" + # global_vm_description: "" + # global_vm_tag_name: "admin" + # global_vm_ci_ip: "192.168.142.123" + # # + # global_template_vm_id: 9221 + # global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + vm_net_virtio_bridge: "vmbr142" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/mon_wazuh.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/mon_wazuh.yml new file mode 100644 index 00000000..a918411f --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/mon_wazuh.yml @@ -0,0 +1,123 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - ADMIN +# +# hostname : admin-wazuh +# vm_id : 1000 +# cpu core : 1 +# ram : 2g +# disk : 24g +# ip : 192.168.142.100 +# +# template : 03-template-vm-medium-02-8g-64g - 9232 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # + # variables will be overwrited by values in _main.yml + # + # global_vm_id: 1000 + # global_vm_name: "admin-wazuh" + # global_vm_description: "" + # global_vm_tag_name: "admin" + # global_vm_ci_ip: "192.168.142.100" + # # + # global_template_vm_id: 9232 + # global_template_name: "03-template-vm-medium-02-8g-64g - 9232" # debug + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.142.1" + vm_net_virtio_bridge: "vmbr142" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/r42_admin_group.devkit/demo_lab_network.r42_admin_group.delete.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/r42_admin_group.devkit/demo_lab_network.r42_admin_group.delete.sh new file mode 100755 index 00000000..13ad6c00 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/r42_admin_group.devkit/demo_lab_network.r42_admin_group.delete.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +## +## + +VULN_BOX=( + + "192.168.142.111" # testing-wazuh-client + # + "192.168.142.102" # admin-builder-api-devkit + "192.168.142.101" # admin-builder-docker-registry + "192.168.142.100" # admin-wazuh + "192.168.142.120" # admin-deployer-api-gateway + "192.168.142.121" # admin-deployer-api-backend + "192.168.142.123" # admin-deployer-ui + # +) + +for ip in "${VULN_BOX[@]}"; do + echo ":: REMOVE SSH KEY FOR : $ip" + ssh-keygen -f "$HOME/.ssh/known_hosts" -R "$ip" +done + +for line in $(proxmox_vm.list.to.jsons.sh | grep -i -E "(admin|testing-wazuh)" | + jq -c "."); do + + printf "%s\n" "$line" | proxmox_vm.vm_id.stop.to.jsons.sh + + sleep 2 +done + +for line in $(proxmox_vm.list.to.jsons.sh | grep -i -E "(admin|testing-wazuh)" | + jq -c "."); do + + printf "%s\n" "$line" | proxmox_vm.vm_id.delete.to.jsons.sh + + sleep 1 +done diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/r42_admin_group.devkit/demo_lab_network.r42_admin_group.install.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/r42_admin_group.devkit/demo_lab_network.r42_admin_group.install.sh new file mode 100755 index 00000000..4d705468 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_00/r42_admin_group.devkit/demo_lab_network.r42_admin_group.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../_r42_admin_group.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.install.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.install.sh new file mode 100755 index 00000000..93a9ff00 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../_r42_admin.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.revert.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.revert.sh new file mode 100755 index 00000000..ab1bae52 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.revert.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +## +## + +# proxmox_vm.list.to.jsons.sh | +# grep -vi template | +# grep -vi group | +# grep -iE "(admin-)|(testing-)" | +# jq -c | +# proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh + +# proxmox_vm.list.to.jsons.sh | +# grep -vi template | +# grep -vi group | +# grep -iE "(admin-)|(testing-)" | +# jq -c | +# proxmox_vm.vm_id.start.to.jsons.sh + +for line in $(proxmox_vm.list.to.jsons.sh | grep -vi template | + grep -vi group | + grep -iE "(admin-)|(testing-)" | + jq -c "."); do + + printf "%s\n" "$line" | proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh + + sleep 2 +done + +for line in $(proxmox_vm.list.to.jsons.sh | grep -vi template | + grep -vi group | + grep -iE "(admin-)|(testing-)" | + jq -c "."); do + + printf "%s\n" "$line" | proxmox_vm.vm_id.start.to.jsons.sh + + sleep 1 +done diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.snapshot.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.snapshot.sh new file mode 100755 index 00000000..9bb5c485 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.devkit/demo_lab_network.r42_admin.snapshot.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +## +## + +# +# proxmox_vm.list.to.jsons.sh | +# grep -vi template | +# grep -vi group | +# grep -iE "(admin-)|(testing-)" | +# jq -c "." | +# proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh + +for line in $(proxmox_vm.list.to.jsons.sh | grep -vi template | + grep -vi group | + grep -iE "(admin-)|(testing-)" | + jq -c "."); do + + printf "%s\n" "$line" | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh + + sleep 2 +done diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.yml new file mode 100644 index 00000000..2de3bde7 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/_r42_admin.yml @@ -0,0 +1,120 @@ +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### STAGE 01 #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# + +## +## + +- name: install basics packages + hosts: r42_admin + become: true + roles: + - software.install.warmup.basic_packages + + # + # variables will be overwrited by values in _main.yml + # + + vars: + INSTALL_PACKAGES_BASICS: "YES" + INSTALL_PACKAGES_FIREWALLS: "YES" + # + INSTALL_PACKAGES_DOCKER: "NO" + INSTALL_PACKAGES_DOCKER_COMPOSE: "NO" + # + INSTALL_PACKAGES_UTILS_JSON: "NO" + INSTALL_PACKAGES_UTILS_NETWORK: "YES" + + INSTALL_PACKAGES_NTP_AND_UPDATE_TIME: "YES" + # + # SPECIFIC_PACKAGES_CLEANING: "NO" +# # +# #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# # + +- name: install dot files + hosts: r42_admin + become: true + roles: + - software.install.warmup.dot_files + + vars: + # + # variables will be overwrited by values in _main.yml + # + OPERATOR_USER: alice + INSTALL_VIM_DOTFILES: "YES" + INSTALL_ZSH_DOTFILES: "YES" + +- name: configure firewall - all + become: true + hosts: r42_admin + roles: + - software.configure.firewalls + + # + # variables will be overwrited by values in _main.yml + + vars: + firewall_rules: + - ip: "all" + port: 22 + protocol: "tcp" +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# + +## +## + +- hosts: r42.admin-wazuh + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + tailscale_hostnames: + - "admin-wazuh" + # - "admin-builder-docker-registry" # disabled — re-enable when needed + - "admin-deployer-api-gateway" + - "admin-deployer-api-backend" + - "admin-deployer-ui" + # - "testing-wazuh-client" # disabled — re-enable when wazuh config is validated + + requested_tasks: + - delete/tailscale_client.yml + # + tasks: + # + # + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils + loop: "{{ tailscale_hostnames }}" + loop_control: + loop_var: tailscale_hostname + when: INSTALL_TAILSCALE == "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: r42_admin + become: true + vars_files: + - "../../secrets/default_vault.yml" + vars: + # + # variables defined in vault + # + tailscale_authkey: "{{ infrastructure_tailscale_authkey }}" + INSTALL_TAILSCALE: "YES" # variables defined in main + + tasks: + - block: + - include_role: + name: software.install.tailscale + - include_role: + name: software.configure.tailscale_disable_nftables + + when: INSTALL_TAILSCALE == "YES" diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.install.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.install.sh new file mode 100755 index 00000000..5e9511c2 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../deployer_api_gateway.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.revert.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.revert.sh new file mode 100755 index 00000000..6aa5d622 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.revert.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +echo '{"proxmox_node":"px-testing","vm_id":1020 }' | proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh +echo '{"proxmox_node":"px-testing","vm_id":1020 }' | proxmox_vm.vm_id.start.to.jsons.sh diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.snapshot.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.snapshot.sh new file mode 100755 index 00000000..770251d6 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.devkit/demo_lab_network.deployer_api_gateway.snapshot.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +## +## + +echo '{"proxmox_node":"px-testing","vm_id":1020,"vm_snapshot_description":"base"}' | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.yml new file mode 100644 index 00000000..615c128f --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_api_gateway.yml @@ -0,0 +1,71 @@ +## +## +## + +- name: install basics packages + hosts: r42_admin + become: true + roles: + - software.install.warmup.basic_packages + + vars: + INSTALL_PACKAGES_BASICS: "YES" + INSTALL_PACKAGES_FIREWALLS: "YES" + # + INSTALL_PACKAGES_DOCKER: "YES" + INSTALL_PACKAGES_DOCKER_COMPOSE: "YES" + # + INSTALL_PACKAGES_UTILS_JSON: "NO" + INSTALL_PACKAGES_UTILS_NETWORK: "YES" + + INSTALL_PACKAGES_NTP_AND_UPDATE_TIME: "YES" + # # + + # SPECIFIC_PACKAGES_CLEANING: "NO" +# # +# #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# # + +- name: install dot files + hosts: r42_admin + become: true + roles: + - software.install.warmup.dot_files + + vars: + OPERATOR_USER: alice + + INSTALL_VIM_DOTFILES: "YES" + INSTALL_ZSH_DOTFILES: "YES" + +- name: configure firewall - all + become: true + hosts: r42_admin + roles: + - software.configure.firewalls + vars: + firewall_rules: + - ip: "all" + port: 22 + protocol: "tcp" +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# + +# - hosts: r42_admin +# become: true + +# vars_files: +# - "../../secrets/default_vault.yml" + +# vars: +# tailscale_authkey: "{{ infrastructure_tailscale_authkey }}" + +# roles: +# - software.install.tailscale + +# - hosts: r42_admin +# become: true +# roles: +# - software.configure.tailscale_disable_nftables +# vars: diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.install.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.install.sh new file mode 100755 index 00000000..889d32dd --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../deployer_ui.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.revert.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.revert.sh new file mode 100755 index 00000000..ff6147b2 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.revert.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +echo '{"proxmox_node":"px-testing","vm_id":1023 }' | proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh +echo '{"proxmox_node":"px-testing","vm_id":1023 }' | proxmox_vm.vm_id.start.to.jsons.sh diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.snapshot.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.snapshot.sh new file mode 100755 index 00000000..733e0c52 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.devkit/demo_lab_network.deployer_ui.snapshot.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +## +## + +echo '{"proxmox_node":"px-testing","vm_id":1023,"vm_snapshot_description":"base"}' | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.yml new file mode 100644 index 00000000..45fc3a10 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/deployer_ui.yml @@ -0,0 +1,31 @@ +# +# +# + +- name: + become: true + hosts: r42.admin-deployer-ui + roles: + - software.install.nodejs_app_systemd + vars_files: + - "../../secrets/default_vault.yml" + vars: + # + NODE_VERSION: "22" + # + LOCAL_CODE_PATH: "{{ lookup('env', 'RANGE42_GITDIR__ROOT_DIR') }}/range42-deployer-ui/" + + REMOTE_PROJECT_DIR: /var/www/range42_deployer_ui + # + NPM_COMMAND: dev + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + APP_USER: webapp_user + APP_USER_SHELL: "/usr/sbin/nologin" + APP_NAME_SYSTEMD: range42_deployer_ui + + # to avoid home dir warning in case of no login user + # ansible_remote_tmp: "/tmp/.ansible-{{ APP_USER }}/tmp" +# +# diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.install.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.install.sh new file mode 100755 index 00000000..bd26415d --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../mon_wazuh.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.revert.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.revert.sh new file mode 100755 index 00000000..97859219 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.revert.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +echo '{"proxmox_node":"px-testing","vm_id":1000 }' | proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh +echo '{"proxmox_node":"px-testing","vm_id":1000 }' | proxmox_vm.vm_id.start.to.jsons.sh diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.snapshot.sh b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.snapshot.sh new file mode 100755 index 00000000..60a34a6c --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.devkit/demo_lab_network.mon_wazuh.snapshot.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +## +## + +echo '{"proxmox_node":"px-testing","vm_id":1000,"vm_snapshot_description":"base"}' | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh diff --git a/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.yml b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.yml new file mode 100644 index 00000000..bc514291 --- /dev/null +++ b/scenarios/demo_lab_network/02_admin_infrastructure/stage_01/mon_wazuh.yml @@ -0,0 +1,195 @@ + +- name: configure firewall - r42.admin-wazuh + become: true + hosts: r42.admin-wazuh + roles: + - software.configure.firewalls + vars_files: + - "../../secrets/default_vault.yml" + vars: + firewall_rules: + - ip: "all" + port: 22 + protocol: "tcp" + - ip: "all" + port: 443 + protocol: "tcp" + - ip: "all" + port: 1515 + protocol: "tcp" + - ip: "all" + port: 1514 + protocol: "tcp" + +# disabled — testing-wazuh-client, re-enable when wazuh config is validated +# - name: configure firewall - r42.testing-wazuh-client +# become: true +# hosts: r42.testing-wazuh-client +# roles: +# - software.configure.firewalls +# vars: +# firewall_rules: +# - ip: "all" +# port: 22 +# protocol: "tcp" + +# #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: Install wazuh-indexer + hosts: r42.admin-wazuh + become: yes + become_user: root + roles: + - role: software.install.wazuh-indexer + + vars_files: + - "../../secrets/default_vault.yml" + + vars: + local_certs_path: "/tmp/certificates/" + WAZUH_SERVER_IP: 192.168.142.100 # MOVE TO VAULT ? + single_node: true + indexer_network_host: "{{ WAZUH_SERVER_IP }}" + indexer_cluster_nodes: + - "{{ WAZUH_SERVER_IP }}" + indexer_jvm_xms: 4096 + ansible_shell_allow_world_readable_temp: true + instances: + node1: + name: node-1 + ip: "{{ WAZUH_SERVER_IP }}" + role: indexer + +# # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: Wait for wazuh-indexer to be ready + hosts: r42.admin-wazuh + become: yes + tasks: + - name: Wait for wazuh-indexer API on port 9200 + ansible.builtin.wait_for: + port: 9200 + host: 192.168.142.100 + delay: 5 + timeout: 120 + +# # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: Install wazuh-dashboard + hosts: r42.admin-wazuh + become: yes + become_user: root + roles: + - role: software.install.wazuh-dashboard + + vars_files: + - "../../secrets/default_vault.yml" + + vars: + local_certs_path: "/tmp/certificates/" + WAZUH_SERVER_IP: 192.168.142.100 # MOVE TO VAULT ? + single_node: true + indexer_network_host: "{{ WAZUH_SERVER_IP }}" + indexer_cluster_nodes: + - "{{ WAZUH_SERVER_IP }}" + ansible_shell_allow_world_readable_temp: true + instances: + node1: + name: node-1 + ip: "{{ WAZUH_SERVER_IP }}" + role: indexer + +# # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: r42.admin-wazuh + become: yes + vars_files: + - "../../secrets/default_vault.yml" + vars: + WAZUH_SERVER_IP: 192.168.142.100 # MOVE TO VAULT ? + local_certs_path: "/tmp/certificates/" + + roles: + - role: software.install.wazuh-manager + - role: software.install.wazuh-filebeat-oss + filebeat_node_name: node-1 + filebeat_output_indexer_hosts: + - "{{ WAZUH_SERVER_IP }}:9200" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: Wait for wazuh-manager API before password change + hosts: r42.admin-wazuh + become: yes + tasks: + - name: Wait for wazuh-manager API on port 55000 + ansible.builtin.wait_for: + port: 55000 + host: 127.0.0.1 + delay: 10 + timeout: 120 + + - name: Wait for wazuh-manager daemons to be ready + ansible.builtin.command: /var/ossec/bin/wazuh-control status + register: _wazuh_status + failed_when: "'wazuh-modulesd is running' not in _wazuh_status.stdout" + until: "'wazuh-modulesd is running' in _wazuh_status.stdout" + retries: 12 + delay: 10 + changed_when: false + +# # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: EXEC wazuh-passwords-tool.sh - if CHANGE_WAZUH_PASSWORD is true + hosts: r42.admin-wazuh + become: yes + become_user: root + vars_files: + - "../../secrets/default_vault.yml" + + vars: + CHANGE_WAZUH_PASSWORD: true + WAZUH_ANSIBLE_HOSTNAME: "r42.admin-wazuh" ######### IF NOT IN TAILSCALE ! + # WAZUH_ANSIBLE_HOSTNAME: "xxx.yyyy.zzzz.edge.wazuh" # IF TAILSCALE ! + # WAZUH_ANSIBLE_HOSTNAME: "192.168.99.11" + + #### MOVED TO VAULT :: + #### + # WAZUH_PASSWORD: "***********" # moved to vault ! + tasks: + - name: Run wazuh-passwords-tool.sh + ansible.builtin.shell: > + /usr/share/wazuh-indexer/plugins/opensearch-security/tools/wazuh-passwords-tool.sh + -u admin -p "{{ infrastructure_wazuh_admin_password }}" + register: password_output + delegate_to: "{{ WAZUH_ANSIBLE_HOSTNAME }}" + when: CHANGE_WAZUH_PASSWORD + retries: 2 + delay: 15 + until: password_output.rc == 0 + # no_log: true + no_log: false + +############################### +# wazuh deploy agent + +- hosts: r42_admin_wazuh_clients + become: yes + become_user: root + roles: + - software.install.wazuh-agent + + vars: + WAZUH_SERVER_IP: 192.168.142.100 # MOVE TO VAULT !! + wazuh_managers: + - address: "{{ WAZUH_SERVER_IP }}" + port: 1514 + protocol: tcp + api_port: 55000 + api_proto: "https" + api_user: wazuh + max_retries: 5 + retry_interval: 5 +# +############################### + diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/_main.reinstall.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/_main.reinstall.sh new file mode 100755 index 00000000..7b1a2433 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/_main.reinstall.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +## +## + +./stage_00/r42_vuln_box_group.devkit/demo_lab_network.vuln_box_01.delete.sh + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "./_main.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/_main.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/_main.yml new file mode 100644 index 00000000..6884b038 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/_main.yml @@ -0,0 +1,159 @@ +## +## issue 13 +## + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### STAGE 00 #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# + +- import_playbook: ./stage_00/vuln_box_00.yml + + vars: + global_vm_name: "vuln-box-00" + global_vm_ssh_name: "r42.vuln-box-00" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 4000 + global_vm_description: "demo - vuln-box-00" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.144.170" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- import_playbook: ./stage_00/vuln_box_01.yml + + vars: + global_vm_name: "vuln-box-01" + global_vm_ssh_name: "r42.vuln-box-01" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 4001 + global_vm_description: "demo - vuln-box-01" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.144.171" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- import_playbook: ./stage_00/vuln_box_02.yml + + vars: + global_vm_name: "vuln-box-02" + global_vm_ssh_name: "r42.vuln-box-02" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 4002 + global_vm_description: "demo - vuln-box-02" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.144.172" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- import_playbook: ./stage_00/vuln_box_03.yml + + vars: + global_vm_name: "vuln-box-03" + global_vm_ssh_name: "r42.vuln-box-03" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 4003 + global_vm_description: "demo - vuln-box-03" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.144.173" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- import_playbook: ./stage_00/vuln_box_04.yml + + vars: + global_vm_name: "vuln-box-04" + global_vm_ssh_name: "r42.vuln-box-04" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 4004 + global_vm_description: "demo - vuln-box-04" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.144.174" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### STAGE 01 #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# + +- import_playbook: ./stage_01/_r42_vuln_box_group.yml + + vars: + OPERATOR_USER: alice + + INSTALL_VIM_DOTFILES: "YES" + INSTALL_ZSH_DOTFILES: "YES" + + INSTALL_PACKAGES_BASICS: "YES" + INSTALL_PACKAGES_FIREWALLS: "YES" + # + INSTALL_PACKAGES_DOCKER: "YES" + INSTALL_PACKAGES_DOCKER_COMPOSE: "YES" + # + INSTALL_PACKAGES_UTILS_JSON: "NO" + INSTALL_PACKAGES_UTILS_NETWORK: "NO" + + INSTALL_PACKAGES_NTP_AND_UPDATE_TIME: "YES" + # # + + INSTALL_TAILSCALE: "NO" + + # SPECIFIC_PACKAGES_CLEANING: "NO" + + firewall_rules: + - ip: "all" + port: 22 + protocol: "tcp" +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # +# - import_playbook: ./stage_01/vuln_box_00.yml +# vars: +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +# - import_playbook: ./stage_01/vuln_box_01.yml +# vars: + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +# - import_playbook: ./stage_01/vuln_box_02.yml +# vars: + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +# - import_playbook: ./stage_01/vuln_box_03.yml +# vars: + +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +# - import_playbook: ./stage_01/vuln_box_04.yml +# vars: +# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # + +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### STAGE 02 #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# +# - import_playbook: ./stage_02/_vuln_box.yml diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/_r42_vuln_box_group.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/_r42_vuln_box_group.yml new file mode 100644 index 00000000..ffdf912c --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/_r42_vuln_box_group.yml @@ -0,0 +1,90 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### STAGE 00 #### #### #### #### #### #### #### #### #### #### #### #### #### #### +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# + +## +## + +- import_playbook: ./vuln_box_00.yml + + vars: + global_vm_name: "vuln-box-00" + global_vm_ssh_name: "r42.vuln-box-00" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 4000 + global_vm_description: "demo - vuln-box-00" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.144.170" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- import_playbook: ./vuln_box_01.yml + + vars: + global_vm_name: "vuln-box-01" + global_vm_ssh_name: "r42.vuln-box-01" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 4001 + global_vm_description: "demo - vuln-box-01" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.144.171" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- import_playbook: ./vuln_box_02.yml + + vars: + global_vm_name: "vuln-box-02" + global_vm_ssh_name: "r42.vuln-box-02" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 4002 + global_vm_description: "demo - vuln-box-02" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.144.172" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- import_playbook: ./vuln_box_03.yml + + vars: + global_vm_name: "vuln-box-03" + global_vm_ssh_name: "r42.vuln-box-03" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 4003 + global_vm_description: "demo - vuln-box-03" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.144.173" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- import_playbook: ./vuln_box_04.yml + + vars: + global_vm_name: "vuln-box-04" + global_vm_ssh_name: "r42.vuln-box-04" + # global_vm_ssh_name: "r42.{{ global_vm_name }}" + global_vm_id: 4004 + global_vm_description: "demo - vuln-box-04" + global_vm_tag_name: "admin" + global_vm_ci_ip: "192.168.144.174" + # + global_template_vm_id: 9221 + global_template_name: "template-vm-small-01-4g-32g - id : 9221" # debug + # diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/r42_vuln_box_group.devkit/demo_lab_network.vuln_box_01.delete.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/r42_vuln_box_group.devkit/demo_lab_network.vuln_box_01.delete.sh new file mode 100755 index 00000000..e9898fd7 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/r42_vuln_box_group.devkit/demo_lab_network.vuln_box_01.delete.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +## +## + +VULN_BOX=( + + # + "192.168.144.170" # vuln-box-00 + "192.168.144.171" # vuln-box-01 + "192.168.144.172" # vuln-box-02 + "192.168.144.173" # vuln-box-03 + "192.168.144.174" # vuln-box-04 +) + +for ip in "${VULN_BOX[@]}"; do + echo ":: REMOVE SSH KEY FOR : $ip" + ssh-keygen -f "$HOME/.ssh/known_hosts" -R "$ip" +done + +for line in $(proxmox_vm.list.to.jsons.sh | grep -i "vuln-box" | + jq -c "."); do + + printf "%s\n" "$line" | proxmox_vm.vm_id.stop.to.jsons.sh + + sleep 2 +done + +for line in $(proxmox_vm.list.to.jsons.sh | grep -i "vuln-box" | + jq -c "."); do + + printf "%s\n" "$line" | proxmox_vm.vm_id.delete.to.jsons.sh + + sleep 1 +done diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/r42_vuln_box_group.devkit/demo_lab_network.vuln_box_01.install.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/r42_vuln_box_group.devkit/demo_lab_network.vuln_box_01.install.sh new file mode 100755 index 00000000..f1416832 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/r42_vuln_box_group.devkit/demo_lab_network.vuln_box_01.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../_r42_vuln_box_group.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_00.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_00.yml new file mode 100644 index 00000000..1a062cd2 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_00.yml @@ -0,0 +1,116 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - VULN-BOX +# +# hostname : vuln-box-00 +# vm_id : 4000 +# cpu core : 1 +# ram : 4g +# disk : 32g +# ip : +# +# template : template-vm-small-01-4g-32g - id : 9221 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + + # + # variables will be overwrited by values in _main.yml + # + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.144.1" + vm_net_virtio_bridge: "vmbr144" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_01.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_01.yml new file mode 100644 index 00000000..96bc7208 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_01.yml @@ -0,0 +1,116 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - VULN-BOX +# +# hostname : vuln-box-00 +# vm_id : 4000 +# cpu core : 1 +# ram : 4g +# disk : 32g +# ip : +# +# template : template-vm-small-01-4g-32g - id : 9221 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + + # + # variables will be overwrited by values in _main.yml + # + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.144.1" + vm_net_virtio_bridge: "vmbr144" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_02.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_02.yml new file mode 100644 index 00000000..96bc7208 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_02.yml @@ -0,0 +1,116 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - VULN-BOX +# +# hostname : vuln-box-00 +# vm_id : 4000 +# cpu core : 1 +# ram : 4g +# disk : 32g +# ip : +# +# template : template-vm-small-01-4g-32g - id : 9221 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + + # + # variables will be overwrited by values in _main.yml + # + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.144.1" + vm_net_virtio_bridge: "vmbr144" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_03.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_03.yml new file mode 100644 index 00000000..96bc7208 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_03.yml @@ -0,0 +1,116 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - VULN-BOX +# +# hostname : vuln-box-00 +# vm_id : 4000 +# cpu core : 1 +# ram : 4g +# disk : 32g +# ip : +# +# template : template-vm-small-01-4g-32g - id : 9221 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + + # + # variables will be overwrited by values in _main.yml + # + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.144.1" + vm_net_virtio_bridge: "vmbr144" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_04.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_04.yml new file mode 100644 index 00000000..96bc7208 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_00/vuln_box_04.yml @@ -0,0 +1,116 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# PROMOX INIT INFRASTRUCTURE - VULN-BOX +# +# hostname : vuln-box-00 +# vm_id : 4000 +# cpu core : 1 +# ram : 4g +# disk : 32g +# ip : +# +# template : template-vm-small-01-4g-32g - id : 9221 +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# +## + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + + # + # variables will be overwrited by values in _main.yml + # + + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - "{{ global_template_name }}" + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_clone" + vm_id: "{{ global_template_vm_id }}" # TEMPLATE VM_ID - vars should be rename to vm_id_src / vm_id_dst + vm_new_id: "{{ global_vm_id }}" + vm_name: "{{ global_vm_name }}" + vm_description: "{{ global_vm_description }}" + + #### + - name: INFRASTRUCTURE INIT - WAITING FOR PROXMOX UNLOCK TO CLEAR (slow setup) + ansible.builtin.pause: + seconds: 20 + prompt: "Waiting for 20 seconds during clone..." + #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET PROMOX TAG + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_set_tag" + vm_id: "{{ global_vm_id }}" + vm_tag_name: "{{ global_vm_tag_name }}" + + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - SET CLOUD DEFAULT INIT VARIABLE + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "cloudinit_set_variables" + + vm_id: "{{ global_vm_id }}" + vm_ci_user: "{{ default_admin_vm_ci_user | default('alice') }}" + vm_ci_password: "{{ default_admin_vm_ci_password | default('supersecret') }}" + vm_ci_ssh_key: "{{ default_admin_vm_ci_ssh_key }}" + vm_ci_dns_ips: "1.1.1.1" + vm_ci_ip: "{{ global_vm_ci_ip }}" + vm_ci_netmask: "24" + vm_ci_ip_gw: "192.168.144.1" + vm_net_virtio_bridge: "vmbr144" + + # + - name: ADMIN INFRASTRUCTURE INIT - "{{ global_vm_name }}" - CLONE TEMPLATE - START VM + + include_role: + name: range42-ansible_roles-proxmox_controller + + vars: + proxmox_vm_action: "vm_start" + vm_id: "{{ global_vm_id }}" + + # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: "{{ global_vm_ssh_name }}" + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + # ARG_global_operator_ssh_config_known_hosts: "{{ global_operator_ssh_config_known_hosts }}" + # ARG_global_vm_ssh_name: "{{ global_vm_ssh_name }}" + + deployer_cli_user_ssh_known_hosts: "{{ deployer_cli_user_ssh_known_hosts }}" + ARG_vm_ssh_name: "{{ global_vm_ssh_name }}" + + requested_tasks: + - wait/openssh_server/is_reachable.yml + - wait/cloudinit/is_boot_finished.yml + tasks: + #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.install.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.install.sh new file mode 100755 index 00000000..f1416832 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../_r42_vuln_box_group.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.revert.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.revert.sh new file mode 100755 index 00000000..3e89ba61 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.revert.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +## +## + +# proxmox_vm.list.to.jsons.sh | +# grep -vi template | +# grep -vi group | +# grep -iE "(admin-)|(testing-)" | +# jq -c | +# proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh + +# proxmox_vm.list.to.jsons.sh | +# grep -vi template | +# grep -vi group | +# grep -iE "(admin-)|(testing-)" | +# jq -c | +# proxmox_vm.vm_id.start.to.jsons.sh + +for line in $(proxmox_vm.list.to.jsons.sh | grep -i "vuln-box" | + jq -c "."); do + + printf "%s\n" "$line" | proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh + + sleep 2 +done + +for line in $(proxmox_vm.list.to.jsons.sh | grep -i "vuln-box" | + jq -c "."); do + + printf "%s\n" "$line" | proxmox_vm.vm_id.start.to.jsons.sh + + sleep 1 +done diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.snapshot.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.snapshot.sh new file mode 100755 index 00000000..86183aa3 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.devkit/demo_lab_network.r42_vuln_box.snapshot.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +## +## + +# +# proxmox_vm.list.to.jsons.sh | +# grep -vi template | +# grep -vi group | +# grep -iE "(admin-)|(testing-)" | +# jq -c "." | +# proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh + +for line in $(proxmox_vm.list.to.jsons.sh | grep -i "vuln-box" | + jq -c "."); do + + printf "%s\n" "$line" | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh + + sleep 2 +done diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.yml new file mode 100644 index 00000000..24cc62e5 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/_r42_vuln_box_group.yml @@ -0,0 +1,115 @@ +## +## issue 13 +## + +- name: install basics packages + hosts: r42_vuln_box_group + become: true + roles: + - software.install.warmup.basic_packages + + # + # variables will be overwrited by values in _main.yml + # + vars: + INSTALL_PACKAGES_BASICS: "YES" + INSTALL_PACKAGES_FIREWALLS: "YES" + # + INSTALL_PACKAGES_DOCKER: "YES" + INSTALL_PACKAGES_DOCKER_COMPOSE: "YES" + # + INSTALL_PACKAGES_UTILS_JSON: "NO" + INSTALL_PACKAGES_UTILS_NETWORK: "YES" + + INSTALL_PACKAGES_NTP_AND_UPDATE_TIME: "YES" + + SPECIFIC_PACKAGES_CLEANING: "NO" + +# #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: install dot files + hosts: r42_vuln_box_group + become: true + roles: + - software.install.warmup.dot_files + + vars: + # + # variables will be overwrited by values in _main.yml + # + OPERATOR_USER: alice + INSTALL_VIM_DOTFILES: "YES" + INSTALL_ZSH_DOTFILES: "YES" + +- name: configure firewall - all + become: true + hosts: r42_vuln_box_group + roles: + - software.configure.firewalls + + vars: + # + # variables will be overwrited by values in _main.yml + # + + firewall_rules: + - ip: "all" + port: 22 + protocol: "tcp" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: r42.admin-wazuh + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + tailscale_hostnames: + - "vuln-box-00" + - "vuln-box-01" + - "vuln-box-02" + - "vuln-box-03" + - "vuln-box-04" + + INSTALL_TAILSCALE: "NO" # variables defined in main + + requested_tasks: + - delete/tailscale_client.yml + # + tasks: + # + # + - name: TASK DYNAMIC INCLUDE FROM ansible.utils + include_role: + name: ansible.utils + loop: "{{ tailscale_hostnames }}" + loop_control: + loop_var: tailscale_hostname + when: INSTALL_TAILSCALE == "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +## +## issue 11 +## + +- hosts: r42_vuln_box_group + become: true + vars_files: + - "../../secrets/default_vault.yml" + vars: + # + # variables defined in vault + # + tailscale_authkey: "{{ infrastructure_tailscale_authkey }}" + INSTALL_TAILSCALE: "NO" # variables defined in main + + tasks: + - block: + - include_role: + name: software.install.tailscale + - include_role: + name: software.configure.tailscale_disable_nftables + + when: INSTALL_TAILSCALE == "YES" diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.install.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.install.sh new file mode 100755 index 00000000..af1511f8 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../vuln_box_00.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.revert.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.revert.sh new file mode 100755 index 00000000..777d2020 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.revert.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +proxmox_vm.list.to.jsons.sh | grep -i vuln-box-00 | proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh +proxmox_vm.list.to.jsons.sh | grep -i vuln-box-00 | proxmox_vm.vm_id.start.to.jsons.sh diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.snapshot.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.snapshot.sh new file mode 100755 index 00000000..78d6c18c --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.devkit/demo_lab_network.vuln_box_00.snapshot.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +# proxmox_vm.list.to.jsons.sh | grep -i vuln-box-00 | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh +echo '{"proxmox_node":"px-testing","vm_id":4000,"vm_snapshot_description":"base"}' | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.yml new file mode 100644 index 00000000..97bc22fd --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_00.yml @@ -0,0 +1,67 @@ +## +## + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: configure firewall - all + become: true + hosts: vuln-box-00 + roles: + - software.configure.firewalls + + vars: + # variables will be overwrited by values in _main.yml + firewall_rules: + - ip: "all" + port: 22 + protocol: "tcp" + - ip: "all" + port: 21 + protocol: "tcp" + - ip: "all" + port: 8080 + protocol: "tcp" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DOCKER - uwsgi-php-cve-2018-7490 - exposed_port:8080 + hosts: r42.vuln-box-00 + become: true + roles: + - software.configure.docker-compose + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "uwsgi-php-cve-2018-7490" + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/uwsg_php/CVE-2018-7490/" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "NO" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DOCKER FTP ANONYMOUS SERVER - exposed_port:21 + hosts: r42.vuln-box-00 + become: true + roles: + - software.configure.docker-compose + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "ftp_anon_server" + + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/misconfiguration/network/vsftpd/ftp_anon_server/" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "NO" diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.install.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.install.sh new file mode 100755 index 00000000..2ae455f5 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../vuln_box_01.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.revert.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.revert.sh new file mode 100755 index 00000000..51472a78 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.revert.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +proxmox_vm.list.to.jsons.sh | grep -i vuln-box-01 | proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh +proxmox_vm.list.to.jsons.sh | grep -i vuln-box-01 | proxmox_vm.vm_id.start.to.jsons.sh diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.snapshot.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.snapshot.sh new file mode 100755 index 00000000..040c92d1 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.devkit/demo_lab_network.vuln_box_01.snapshot.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +# proxmox_vm.list.to.jsons.sh | grep -i vuln-box-00 | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh +echo '{"proxmox_node":"px-testing","vm_id":4001,"vm_snapshot_description":"base"}' | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.yml new file mode 100644 index 00000000..2247611d --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_01.yml @@ -0,0 +1,145 @@ +## +## + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: configure firewall - all + become: true + hosts: r42.vuln-box-01 + roles: + - software.configure.firewalls + + vars: + # variables will be overwrited by values in _main.yml + firewall_rules: + - ip: "all" + port: 22 + protocol: "tcp" + - ip: "all" + port: 8443 + protocol: "tcp" + - ip: "all" + port: 9443 + protocol: "tcp" + - ip: "all" + port: 2218 + protocol: "tcp" + - ip: "all" + port: 2224 + protocol: "tcp" + - ip: "all" + port: 2225 + protocol: "tcp" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - cve/crypto/openssl/CVE-2014-0160/" - exposed_port:8443 + hosts: r42.vuln-box-01 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "crypto-openssl-cve-2014-0160" + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/crypto/openssl/CVE-2014-0160/" + # REMOTE_PROJECT_DIR: "/tmp/deploy" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" + +# ### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - crypto-openssl-cve-2022-0778 - exposed_port:9443 + hosts: r42.vuln-box-01 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "crypto-openssl-cve-2022-0778" + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/crypto/openssl/CVE-2022-0778/" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" + +# ### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - erlang-ssh-CVE-2025-32433 - exposed_port:2225 + hosts: r42.vuln-box-01 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "erlang-ssh-CVE-2025-32433" + # + + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/network/erlang-ssh/CVE-2025-32433" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - openssh-cve-2018-15473 - exposed_port:2218 + hosts: r42.vuln-box-01 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "openssh-cve-2018-15473" + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/network/openssh/CVE-2018-15473" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - openssh-cve-2024-6387 - exposed_port:2224 + hosts: r42.vuln-box-01 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "openssh-cve-2024-6387" + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/network/openssh/CVE-2024-6387" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.install.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.install.sh new file mode 100755 index 00000000..7972f8d8 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../vuln_box_02.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.revert.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.revert.sh new file mode 100755 index 00000000..6f3f1175 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.revert.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +proxmox_vm.list.to.jsons.sh | grep -i vuln-box-02 | proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh +proxmox_vm.list.to.jsons.sh | grep -i vuln-box-02 | proxmox_vm.vm_id.start.to.jsons.sh diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.snapshot.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.snapshot.sh new file mode 100755 index 00000000..e8d50d92 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.devkit/demo_lab_network.vuln_box_02.snapshot.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +## +## + +echo '{"proxmox_node":"px-testing","vm_id":4002,"vm_snapshot_description":"base"}' | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.yml new file mode 100644 index 00000000..afa2b6fe --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_02.yml @@ -0,0 +1,169 @@ +## +## + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: configure firewall - all + become: true + hosts: r42.vuln-box-02 + roles: + - software.configure.firewalls + + vars: + # variables will be overwrited by values in _main.yml + firewall_rules: + - ip: "all" + port: 22 + protocol: "tcp" + - ip: "all" + port: 21 + protocol: "tcp" + - ip: "all" + port: 8888 + protocol: "tcp" + - ip: "all" + port: 8080 + protocol: "tcp" + - ip: "all" + port: 8081 + protocol: "tcp" + - ip: "all" + port: 8082 + protocol: "tcp" + - ip: "all" + port: 4444 + protocol: "tcp" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DOCKER FTP ANONYMOUS SERVER - exposed_port:21 + hosts: r42.vuln-box-02 + become: true + roles: + - software.configure.docker-compose + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "ftp_anon_server" + + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/misconfiguration/network/vsftpd/ftp_anon_server/" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "NO" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - apache-cve-2021-42013 - exposed_port:8888 + hosts: r42.vuln-box-02 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "apache-cve-2021-42013" + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/apache/CVE-2021-42013/" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - pdfjs-cve-2024-4367 - exposed_port:4444 + hosts: r42.vuln-box-02 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "pdfjs-cve-2024-4367" + # + + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/pdfjs/CVE-2024-4367" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - tomcat-cve-2025-24813 - exposed_port:8081 + hosts: r42.vuln-box-02 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "tomcat-cve-2025-24813" + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/tomcat/CVE-2025-24813/" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DOCKER - uwsgi-php-cve-2018-7490 - exposed_port:8080 + hosts: r42.vuln-box-02 + become: true + roles: + - software.configure.docker-compose + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "uwsgi-php-cve-2018-7490" + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/uwsg_php/CVE-2018-7490/" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "NO" +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - php-CVE-2019-11043 - 8082 +# hosts: r42.vuln-box-02 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "php-cve-2019-11043" +# # +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/php/CVE-2019-11043" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.install.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.install.sh new file mode 100755 index 00000000..e9af8e78 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../vuln_box_03.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.revert.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.revert.sh new file mode 100755 index 00000000..1295a4bb --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.revert.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +proxmox_vm.list.to.jsons.sh | grep -i vuln-box-03 | proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh +proxmox_vm.list.to.jsons.sh | grep -i vuln-box-03 | proxmox_vm.vm_id.start.to.jsons.sh diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.snapshot.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.snapshot.sh new file mode 100755 index 00000000..f705d27b --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.devkit/demo_lab_network.vuln_box_03.snapshot.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +# proxmox_vm.list.to.jsons.sh | grep -i vuln-box-00 | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh +echo '{"proxmox_node":"px-testing","vm_id":4003,"vm_snapshot_description":"base"}' | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.yml new file mode 100644 index 00000000..379b0073 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_03.yml @@ -0,0 +1,178 @@ +## +## + +- name: configure firewall - all + become: true + hosts: r42.vuln-box-03 + roles: + - software.configure.firewalls + + vars: + # variables will be overwrited by values in _main.yml + firewall_rules: + - ip: "all" + port: 22 + protocol: "tcp" + - ip: "all" + port: 443 + protocol: "tcp" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DOCKER FTP ANONYMOUS SERVER + hosts: r42.vuln-box-03 + become: true + roles: + - software.configure.docker-compose + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "lpe-01" + + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/misconfiguration/system/lpe-01/" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "NO" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - sudo-cve-2025-32463 exposed_port:1113 + hosts: r42.vuln-box-03 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "sudo-cve-2025-32463" + # + # LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/system/sudo/CVE-2025-32463" + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/system/sudo/CVE-2025-32463" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" + +### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - sudo-cve-2025-32462 exposed_port:1112 + hosts: r42.vuln-box-03 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "sudo-cve-2025-32462" + # + + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/system/sudo/CVE-2025-32462" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" + +### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - sudo-cve-2023-22809 exposed_port:1111 + hosts: r42.vuln-box-03 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "sudo-cve-2023-22809" + # + + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/system/sudo/CVE-2023-22809" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" + +### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - vite-cve-2025-30208 - exposed_port:7001 + hosts: r42.vuln-box-03 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "vite-cve-2025-30208" + # + + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/vite/CVE-2025-30208" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" + +### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - vite-cve-2022-44615 - exposed_port:7000 + hosts: r42.vuln-box-03 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "vite-cve-2022-44615" + # + + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/vite/CVE-2022-44615" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - php-CVE-2019-11043 - 8082 + hosts: r42.vuln-box-03 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "php-cve-2019-11043" + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/php/CVE-2019-11043" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.install.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.install.sh new file mode 100755 index 00000000..c52d2133 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.install.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +## +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "../vuln_box_04.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.revert.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.revert.sh new file mode 100755 index 00000000..cb7ce4b0 --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.revert.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +proxmox_vm.list.to.jsons.sh | grep -i vuln-box-04 | proxmox_snapshot_vm.vm_id.revert_snapshot.to.jsons.sh +proxmox_vm.list.to.jsons.sh | grep -i vuln-box-04 | proxmox_vm.vm_id.start.to.jsons.sh diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.snapshot.sh b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.snapshot.sh new file mode 100755 index 00000000..17f6510e --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.devkit/demo_lab_network.vuln_box_04.snapshot.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +## +## + +# proxmox_vm.list.to.jsons.sh | grep -i vuln-box-00 | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh +echo '{"proxmox_node":"px-testing","vm_id":4004,"vm_snapshot_description":"base"}' | proxmox_snapshot_vm.vm_id.create_snapshot.to.jsons.sh diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.yml new file mode 100644 index 00000000..601b5c3d --- /dev/null +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/stage_01/vuln_box_04.yml @@ -0,0 +1,361 @@ +## +## + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: configure firewall - all +# become: true +# hosts: r42.vuln-box-04 +# roles: +# - software.configure.firewalls + +# vars: +# # variables will be overwrited by values in _main.yml +# firewall_rules: +# - ip: "all" +# port: 22 +# protocol: "tcp" +# - ip: "all" +# port: 443 +# protocol: "tcp" +# - ip: "all" +# port: 8082 # php cve-2019-11043 +# protocol: "tcp" +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- name: DEPLOY - cve/web/php/CVE-2019-11043/ + hosts: r42.vuln-box-04 + become: true + roles: + - software.configure.docker-compose + + vars_files: + - "../../secrets/default_vault.yml" + vars: + LABEL_PROJECT_TYPE: "CTF" + LABEL_PROJET_NAME: "php-cve-2019-11043" + # + LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/php/CVE-2019-11043/" + # REMOTE_PROJECT_DIR: "/tmp/deploy" + REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" + # + OPERATOR_USER: "{{ default_admin_vm_ci_user }}" + # + CLEAN_UP_DEPLOY_DIR: "NO" + SEND_POC_DIR: "YES" +# +# +# +# - name: DEPLOY - cve/crypto/openssl/CVE-2014-0160/" +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "crypto-openssl-cve-2014-0160" +# # +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/crypto/openssl/CVE-2014-0160/" +# # REMOTE_PROJECT_DIR: "/tmp/deploy" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" + +# ### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - crypto-openssl-cve-2022-0778 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "crypto-openssl-cve-2022-0778" +# # +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/crypto/openssl/CVE-2022-0778/" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" + +# ### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - tomcat-cve-2025-24813 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "tomcat-cve-2025-24813" +# # +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/tomcat/CVE-2025-24813/" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - apache-cve-2021-42013 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "apache-cve-2021-42013" +# # +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/apache/CVE-2021-42013/" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - openssh-cve-2018-15473 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "openssh-cve-2018-15473" +# # +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/network/openssh/CVE-2018-15473" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - sudo-cve-2025-32463 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "sudo-cve-2025-32463" +# # +# # LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/system/sudo/CVE-2025-32463" +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/system/sudo/CVE-2025-32463" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - sudo-cve-2025-32462 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "sudo-cve-2025-32462" +# # + +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/system/sudo/CVE-2025-32462" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - sudo-cve-2023-22809 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "sudo-cve-2023-22809" +# # + +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/system/sudo/CVE-2023-22809" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - pdfjs-cve-2024-4367 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "pdfjs-cve-2024-4367" +# # + +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/pdfjs/CVE-2024-4367" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - vite-cve-2025-30208 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "vite-cve-2025-30208" +# # + +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/vite/CVE-2025-30208" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" +# # +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - vite-cve-2022-44615 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "vite-cve-2022-44615" +# # + +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/vite/CVE-2022-44615" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" +# # +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - erlang-ssh-CVE-2025-32433 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "erlang-ssh-CVE-2025-32433" +# # + +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/network/erlang-ssh/CVE-2025-32433" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - openssh-cve-2024-6387 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "openssh-cve-2024-6387" +# # +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/network/openssh/CVE-2024-6387" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +# - name: DEPLOY - php-CVE-2019-11043 +# hosts: r42.vuln-box-04 +# become: true +# roles: +# - software.configure.docker-compose + +# vars_files: +# - "../../secrets/default_vault.yml" +# vars: +# LABEL_PROJECT_TYPE: "CTF" +# LABEL_PROJET_NAME: "php-cve-2019-11043" +# # +# LOCAL__PROJECT_DIR: "{{ lookup('env', 'RANGE42_INVENTORY__DOCKER__CTF') }}/cve/web/php/CVE-2019-11043" +# REMOTE_PROJECT_DIR: "/tmp/deploy-{{ LABEL_PROJET_NAME }}" +# # +# OPERATOR_USER: "{{ default_admin_vm_ci_user }}" +# # +# CLEAN_UP_DEPLOY_DIR: "NO" +# SEND_POC_DIR: "YES" diff --git a/scenarios/demo_lab_network/05_network_isolation/_main.yml b/scenarios/demo_lab_network/05_network_isolation/_main.yml new file mode 100644 index 00000000..1e88423a --- /dev/null +++ b/scenarios/demo_lab_network/05_network_isolation/_main.yml @@ -0,0 +1,8 @@ +## +## demo_lab_network — 05_network_isolation +## +## Apply FORWARD chain iptables rules on the Proxmox host to enforce +## zone isolation between the admin (vmbr142) and CTF (vmbr144) bridges. +## + +- import_playbook: ./stage_00/proxmox_forward_rules.yml diff --git a/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml b/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml new file mode 100644 index 00000000..dceba23f --- /dev/null +++ b/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml @@ -0,0 +1,111 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# NETWORK ISOLATION — PROXMOX HOST FORWARD RULES +# +# Enforces zone isolation between the admin and CTF bridges by installing +# iptables FORWARD chain rules directly on the Proxmox host. +# +# Rule table (evaluated in order): +# +# ESTABLISHED,RELATED → FORWARD ACCEPT (return traffic for existing flows) +# admin (vmbr142) → ctf ACCEPT (admin manages vuln boxes) +# ctf (vmbr144) → wazuh :1514 ACCEPT (agent event reporting) +# ctf (vmbr144) → wazuh :1515 ACCEPT (agent enrollment) +# ctf (vmbr144) → admin DROP (zone isolation) +# ctf (vmbr144) → WAN DROP (air-gap — no internet from vuln boxes) +# +# Rules are persisted via iptables-persistent / netfilter-persistent. +# Re-running this playbook is idempotent (ansible.builtin.iptables checks +# before inserting). +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +- hosts: proxmox + gather_facts: false + vars_files: + - "../../secrets/default_vault.yml" + + vars: + admin_subnet: "192.168.142.0/24" + ctf_subnet: "192.168.144.0/24" + wazuh_ip: "192.168.142.100" + ctf_bridge: "vmbr144" + wan_interface: "{{ infrastructure_proxmox_default_network_card_interface | default('vmbr0') }}" + + handlers: + - name: persist iptables rules + ansible.builtin.command: netfilter-persistent save + changed_when: true + + tasks: + + #### #### PREREQ #### + + - name: NETWORK ISOLATION - Install iptables-persistent + ansible.builtin.apt: + name: iptables-persistent + state: present + update_cache: false + + #### #### FORWARD ACCEPT rules (must precede DROP rules) #### + + - name: NETWORK ISOLATION - Allow established/related connections (FORWARD) + ansible.builtin.iptables: + chain: FORWARD + ctstate: ESTABLISHED,RELATED + jump: ACCEPT + action: insert + rule_num: 1 + comment: "r42: allow established" + notify: persist iptables rules + + - name: NETWORK ISOLATION - Allow Admin (vmbr142) → CTF (vmbr144) + ansible.builtin.iptables: + chain: FORWARD + source: "{{ admin_subnet }}" + destination: "{{ ctf_subnet }}" + jump: ACCEPT + comment: "r42: admin to ctf" + notify: persist iptables rules + + - name: NETWORK ISOLATION - Allow CTF → Wazuh TCP 1514 (agent events) + ansible.builtin.iptables: + chain: FORWARD + source: "{{ ctf_subnet }}" + destination: "{{ wazuh_ip }}" + protocol: tcp + destination_port: "1514" + jump: ACCEPT + comment: "r42: ctf wazuh events" + notify: persist iptables rules + + - name: NETWORK ISOLATION - Allow CTF → Wazuh TCP 1515 (agent enrollment) + ansible.builtin.iptables: + chain: FORWARD + source: "{{ ctf_subnet }}" + destination: "{{ wazuh_ip }}" + protocol: tcp + destination_port: "1515" + jump: ACCEPT + comment: "r42: ctf wazuh enrollment" + notify: persist iptables rules + + #### #### FORWARD DROP rules #### + + - name: NETWORK ISOLATION - Drop CTF (vmbr144) → Admin (vmbr142) — zone isolation + ansible.builtin.iptables: + chain: FORWARD + source: "{{ ctf_subnet }}" + destination: "{{ admin_subnet }}" + jump: DROP + comment: "r42: block ctf to admin" + notify: persist iptables rules + + - name: NETWORK ISOLATION - Drop CTF (vmbr144) → WAN — air-gap + ansible.builtin.iptables: + chain: FORWARD + in_interface: "{{ ctf_bridge }}" + out_interface: "{{ wan_interface }}" + jump: DROP + comment: "r42: ctf air-gap" + notify: persist iptables rules diff --git a/scenarios/demo_lab_network/README.md b/scenarios/demo_lab_network/README.md new file mode 100644 index 00000000..3e3d6149 --- /dev/null +++ b/scenarios/demo_lab_network/README.md @@ -0,0 +1,105 @@ +# demo_lab_network + +`demo_lab` + inter-bridge iptables isolation. Adds FORWARD chain firewall rules on the Proxmox host to enforce zone separation between the admin and CTF subnets. + +> Closes #20 + +## What's new vs demo_lab + +| | demo_lab | demo_lab_network | +|---|---|---| +| VM layout | identical | identical | +| Admin → CTF | allowed (default forward) | allowed (explicit ACCEPT) | +| CTF → Admin | allowed (default forward) | **DROPPED** | +| CTF → Internet | allowed (default forward) | **DROPPED** (air-gap) | +| CTF → Wazuh :1514/:1515 | allowed | **allowed** (explicit ACCEPT) | +| Proxmox iptables rules | none | `05_network_isolation` Ansible step | + +## Network architecture + +``` + ┌─────────────────────────────────────────────────┐ + │ Proxmox Host (pve01) │ + │ ip_forward=1 • iptables FORWARD │ + └───────────────────┬─────────────────┬────────────┘ + │ │ + ┌──────────┘ └──────────┐ + │ vmbr142 vmbr144 │ + ┌────────▼──────────────┐ ┌────────▼──────────────┐ + │ Admin / Deployment │ │ CTF / Vuln (air-gap) │ + │ 192.168.142.0/24 │ │ 192.168.144.0/24 │ + │ │ │ │ + │ admin-wazuh .100 │ │ vuln-box-00 .170 │ + │ api-gateway .120 │ │ vuln-box-01 .171 │ + │ api-backend .121 │ │ vuln-box-02 .172 │ + │ deployer-ui .123 │ │ vuln-box-03 .173 │ + └───────────────────────┘ │ vuln-box-04 .174 │ + └───────────────────────┘ +``` + +### iptables FORWARD rules (applied on Proxmox host) + +| From | To | Port | Action | Reason | +|------|----|------|--------|--------| +| any | any | — | ACCEPT (ESTABLISHED,RELATED) | return traffic | +| Admin (.142/24) | CTF (.144/24) | ALL | ACCEPT | admin manages vuln boxes | +| CTF (.144/24) | Wazuh (.142.100) | 1514 TCP | ACCEPT | Wazuh agent events | +| CTF (.144/24) | Wazuh (.142.100) | 1515 TCP | ACCEPT | Wazuh agent enrollment | +| CTF (.144/24) | Admin (.142/24) | ALL | **DROP** | zone isolation | +| CTF (vmbr144) | WAN (vmbr0) | ALL | **DROP** | air-gap | + +Rules are idempotent (`ansible.builtin.iptables` checks before inserting) and persist across reboots via `iptables-persistent`. + +## Deployed VMs + +### 02_admin_infrastructure (vmbr142) + +| VM | VM ID | IP | +|----|-------|----| +| admin-wazuh | 1000 | 192.168.142.100 | +| admin-deployer-api-gateway | 1020 | 192.168.142.120 | +| admin-deployer-api-backend | 1021 | 192.168.142.121 | +| admin-deployer-ui | 1023 | 192.168.142.123 | + +### 04_ctf_infrastructure (vmbr144) + +| VM | VM ID | IP | +|----|-------|----| +| vuln-box-00 | 4000 | 192.168.144.170 | +| vuln-box-01 | 4001 | 192.168.144.171 | +| vuln-box-02 | 4002 | 192.168.144.172 | +| vuln-box-03 | 4003 | 192.168.144.173 | +| vuln-box-04 | 4004 | 192.168.144.174 | + +## Stages + +| Stage | What it does | +|-------|-------------| +| `01_init_proxmox` | Download cloud-init images, create Ubuntu Noble VM templates | +| `02_admin_infrastructure` | Create + configure admin VMs on vmbr142 | +| `04_ctf_infrastructure` | Create + configure CTF/vuln VMs on vmbr144 | +| `05_network_isolation` | Install `iptables-persistent` on pve01, apply FORWARD rules | + +## Scripts + +| Script | What it does | +|--------|-------------| +| `demo_lab_network.setup.sh` | Full deploy (templates + VMs + network isolation) | +| `demo_lab_network.setup_vms_only.sh` | Fast redeploy (VMs only, skip templates) | +| `demo_lab_network.delete_vms_only.sh` | Destroy VMs, keep templates | +| `demo_lab_network.delete_all.sh` | Destroy everything + clean SSH known_hosts | +| `demo_lab_network.reset.setup.sh` | Delete all + redeploy from scratch | +| `demo_lab_network.reset.ssh_keys.sh` | Reset SSH known_hosts entries only | + +## Notes + +- **VM IDs are the same as demo_lab** — do not run both scenarios on the same Proxmox simultaneously. +- The `05_network_isolation` step runs on the `proxmox` host (not on VMs). Requires SSH root access to pve01. +- If your Proxmox WAN interface is not `vmbr0`, override with `-e wan_interface=` or set `infrastructure_proxmox_default_network_card_interface` in group_vars. +- iptables rules are NOT removed by the delete scripts. Use `iptables -D FORWARD ...` manually if needed. + +## Design notes + +Two-zone design (admin/ctf) was chosen over a three-zone design with a dedicated management bridge because vmbr142/vmbr144 already pre-exist in the `proxmox.init` bridge configuration and Wazuh agent traffic is adequately handled via iptables pinholes on ports 1514/1515. + +A three-zone design (with a dedicated `vmbr145` management bridge and dual NICs on vuln boxes) remains a valid future enhancement — it would eliminate the Wazuh iptables pinholes entirely and provide stronger monitoring isolation. diff --git a/scenarios/demo_lab_network/demo_lab_network.delete_all.sh b/scenarios/demo_lab_network/demo_lab_network.delete_all.sh new file mode 100755 index 00000000..a58b5dec --- /dev/null +++ b/scenarios/demo_lab_network/demo_lab_network.delete_all.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +## +## delete all — VMs + ubuntu_noble templates of THIS scenario +## +## VM IDs and IPs are read from the scenario manifest: +## manifest/scenario_vms.json +## +## WARNING: Templates (9xxx) are deleted by this script. They may be shared with +## other scenarios on the same Proxmox (demo_lab / blank_scenario_* all use the +## same template IDs). Run this only when no other scenario relies on them, or +## run delete_vms_only.sh to keep templates. +## +## NOTE: This script does NOT remove the iptables FORWARD rules applied to the +## Proxmox host by 05_network_isolation. Clean those up manually if needed. +## + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MANIFEST="$SCRIPT_DIR/manifest/scenario_vms.json" + +if [[ ! -f "$MANIFEST" ]]; then + echo "ERROR: manifest not found: $MANIFEST" >&2 + exit 1 +fi + +mapfile -t SCENARIO_VM_IDS < <(jq -r '.vms[].vm_id' "$MANIFEST") +mapfile -t TEMPLATE_VM_IDS < <(jq -r '.templates[].vm_id' "$MANIFEST") +mapfile -t INFRASTRUCTURE_IP < <(jq -r '.vms[].ip' "$MANIFEST") + +ALL_IDS=("${SCENARIO_VM_IDS[@]}" "${TEMPLATE_VM_IDS[@]}") +ID_REGEX=$(printf '|%s' "${ALL_IDS[@]}" | sed 's/^|//') + +echo ":: stopping and deleting VMs + templates" +echo ":: scenario VMs: ${SCENARIO_VM_IDS[*]}" +echo ":: templates : ${TEMPLATE_VM_IDS[*]}" +echo "" + +VM_LIST_JSON=$(proxmox_vm.list.to.jsons.sh 2>&1) +if ! echo "$VM_LIST_JSON" | jq -e . >/dev/null 2>&1; then + echo "ERROR: proxmox_vm.list.to.jsons.sh returned invalid JSON — aborting" >&2 + printf "output: %.200s\n" "$VM_LIST_JSON" >&2 + exit 1 +fi +echo "$VM_LIST_JSON" | jq -c | grep -E "\"vm_id\":($ID_REGEX)([^0-9]|\$)" | proxmox_vm.vm_id.stop_force.to.jsons.sh +echo "$VM_LIST_JSON" | jq -c | grep -E "\"vm_id\":($ID_REGEX)([^0-9]|\$)" | proxmox_vm.vm_id.delete.to.jsons.sh + +for ip in "${INFRASTRUCTURE_IP[@]}"; do + echo ":: REMOVE SSH KEY FOR : $ip" + ssh-keygen -f "$HOME/.ssh/known_hosts" -R "$ip" +done + +echo "" +echo ":: done — VMs and templates removed" +echo ":: redeploy from scratch with: range42-context deploy" +echo "" diff --git a/scenarios/demo_lab_network/demo_lab_network.delete_vms_only.sh b/scenarios/demo_lab_network/demo_lab_network.delete_vms_only.sh new file mode 100755 index 00000000..32c5aa00 --- /dev/null +++ b/scenarios/demo_lab_network/demo_lab_network.delete_vms_only.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +## +## delete VMs only — keep templates +## faster redeploy: skip 01_init_proxmox (templates already exist) +## + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MANIFEST="$SCRIPT_DIR/manifest/scenario_vms.json" + +if [[ ! -f "$MANIFEST" ]]; then + echo "ERROR: manifest not found: $MANIFEST" >&2 + exit 1 +fi + +mapfile -t SCENARIO_VM_IDS < <(jq -r '.vms[].vm_id' "$MANIFEST") +mapfile -t INFRASTRUCTURE_IP < <(jq -r '.vms[].ip' "$MANIFEST") + +ID_REGEX=$(printf '|%s' "${SCENARIO_VM_IDS[@]}" | sed 's/^|//') + +echo ":: stopping and deleting VMs (keeping templates)..." +echo ":: scenario VMs: ${SCENARIO_VM_IDS[*]}" +echo "" + +VM_LIST_JSON=$(proxmox_vm.list.to.jsons.sh 2>&1) +if ! echo "$VM_LIST_JSON" | jq -e . >/dev/null 2>&1; then + echo "ERROR: proxmox_vm.list.to.jsons.sh returned invalid JSON — aborting" >&2 + printf "output: %.200s\n" "$VM_LIST_JSON" >&2 + exit 1 +fi +echo "$VM_LIST_JSON" | jq -c | grep -E "\"vm_id\":($ID_REGEX)([^0-9]|\$)" | proxmox_vm.vm_id.stop_force.to.jsons.sh +echo "$VM_LIST_JSON" | jq -c | grep -E "\"vm_id\":($ID_REGEX)([^0-9]|\$)" | proxmox_vm.vm_id.delete.to.jsons.sh + +for ip in "${INFRASTRUCTURE_IP[@]}"; do + echo ":: REMOVE SSH KEY FOR : $ip" + ssh-keygen -f "$HOME/.ssh/known_hosts" -R "$ip" +done + +echo "" +echo ":: done — templates preserved" +echo ":: redeploy with: range42-context deploy" +echo "" diff --git a/scenarios/demo_lab_network/demo_lab_network.reset.setup.sh b/scenarios/demo_lab_network/demo_lab_network.reset.setup.sh new file mode 100755 index 00000000..53774a62 --- /dev/null +++ b/scenarios/demo_lab_network/demo_lab_network.reset.setup.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +## +## reset + full redeploy — delete everything then deploy from scratch +## + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo ":: reset — deleting all VMs and templates..." +"$SCRIPT_DIR/demo_lab_network.delete_all.sh" || exit 1 + +echo "" +echo ":: redeploying from scratch..." +"$SCRIPT_DIR/demo_lab_network.setup.sh" diff --git a/scenarios/demo_lab_network/demo_lab_network.reset.ssh_keys.sh b/scenarios/demo_lab_network/demo_lab_network.reset.ssh_keys.sh new file mode 100755 index 00000000..1f18b9b8 --- /dev/null +++ b/scenarios/demo_lab_network/demo_lab_network.reset.ssh_keys.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +## +## reset SSH known_hosts entries for all VMs in this scenario +## + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +MANIFEST="$SCRIPT_DIR/manifest/scenario_vms.json" + +if [[ ! -f "$MANIFEST" ]]; then + echo "ERROR: manifest not found: $MANIFEST" >&2 + exit 1 +fi + +mapfile -t INFRASTRUCTURE_IP < <(jq -r '.vms[].ip' "$MANIFEST") + +for ip in "${INFRASTRUCTURE_IP[@]}"; do + echo ":: REMOVE SSH KEY FOR : $ip" + ssh-keygen -f "$HOME/.ssh/known_hosts" -R "$ip" +done + +echo "" +echo ":: done — SSH known_hosts entries removed" +echo "" diff --git a/scenarios/demo_lab_network/demo_lab_network.setup.sh b/scenarios/demo_lab_network/demo_lab_network.setup.sh new file mode 100755 index 00000000..aff48748 --- /dev/null +++ b/scenarios/demo_lab_network/demo_lab_network.setup.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "./main.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/demo_lab_network.setup_vms_only.sh b/scenarios/demo_lab_network/demo_lab_network.setup_vms_only.sh new file mode 100755 index 00000000..afb9116a --- /dev/null +++ b/scenarios/demo_lab_network/demo_lab_network.setup_vms_only.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +## +## setup VMs only — skip template creation (templates already exist) +## + +ansible-playbook -i "${RANGE42_ANSIBLE_ROLES__INVENTORY_DIR}/inventory_default.yml" \ + -l "all" \ + "./main_vms_only.yml" --vault-password-file "${RANGE42_VAULT_PASSWORD_FILE:?RANGE42_VAULT_PASSWORD_FILE is not set — run: range42-context use }" diff --git a/scenarios/demo_lab_network/main.yml b/scenarios/demo_lab_network/main.yml new file mode 100644 index 00000000..5d4706be --- /dev/null +++ b/scenarios/demo_lab_network/main.yml @@ -0,0 +1,31 @@ +--- +## +## demo_lab_network — demo_lab VMs + inter-bridge iptables isolation +## +## Network zones: +## admin (vmbr142 · 192.168.142.0/24) — deployer platform + wazuh +## ctf (vmbr144 · 192.168.144.0/24) — vulnerable targets, air-gapped +## +## Adds on top of demo_lab: +## FORWARD DROP ctf → admin (zone isolation) +## FORWARD DROP ctf → internet (air-gap) +## FORWARD ACCEPT ctf → wazuh :1514/1515 (agent reporting) +## FORWARD ACCEPT admin → ctf (management access) +## + +- import_playbook: ./01_init_proxmox/templates/_main_download_cloudinit_files.yml +- import_playbook: ./01_init_proxmox/templates/ubuntu_noble/_main_ubuntu_noble.yml + +#### + +- import_playbook: ./02_admin_infrastructure/_main.yml + +#### + +#- import_playbook: ./03_student_infrastructure/_main.yml + +- import_playbook: ./04_ctf_infrastructure/_main.yml + +#### + +- import_playbook: ./05_network_isolation/_main.yml diff --git a/scenarios/demo_lab_network/main_vms_only.yml b/scenarios/demo_lab_network/main_vms_only.yml new file mode 100644 index 00000000..a9670bcc --- /dev/null +++ b/scenarios/demo_lab_network/main_vms_only.yml @@ -0,0 +1,17 @@ +--- +## +## demo_lab_network — VMs + isolation only (skip template creation) +## Use when templates already exist from a prior full deploy. +## + +- import_playbook: ./02_admin_infrastructure/_main.yml + +#### + +#- import_playbook: ./03_student_infrastructure/_main.yml + +- import_playbook: ./04_ctf_infrastructure/_main.yml + +#### + +- import_playbook: ./05_network_isolation/_main.yml diff --git a/scenarios/demo_lab_network/manifest/scenario_vms.json b/scenarios/demo_lab_network/manifest/scenario_vms.json new file mode 100644 index 00000000..aaa0687c --- /dev/null +++ b/scenarios/demo_lab_network/manifest/scenario_vms.json @@ -0,0 +1,30 @@ +{ + "scenario": "demo_lab_network", + "version": 1, + "description": "demo_lab + inter-bridge iptables isolation. Admin (vmbr142/192.168.142.0/24) and CTF (vmbr144/192.168.144.0/24) are network-isolated: CTF→admin is dropped, CTF→internet is dropped, Wazuh agent traffic (1514/1515) is allowed. Same VM IDs as demo_lab.", + "vms": [ + {"vm_id": 1000, "vm_name": "admin-wazuh", "ip": "192.168.142.100", "role": "admin", "bridge": "vmbr142"}, + {"vm_id": 1020, "vm_name": "admin-deployer-api-gateway", "ip": "192.168.142.120", "role": "admin", "bridge": "vmbr142"}, + {"vm_id": 1021, "vm_name": "admin-deployer-api-backend", "ip": "192.168.142.121", "role": "admin", "bridge": "vmbr142"}, + {"vm_id": 1023, "vm_name": "admin-deployer-ui", "ip": "192.168.142.123", "role": "admin", "bridge": "vmbr142"}, + {"vm_id": 4000, "vm_name": "vuln-box-00", "ip": "192.168.144.170", "role": "ctf", "bridge": "vmbr144"}, + {"vm_id": 4001, "vm_name": "vuln-box-01", "ip": "192.168.144.171", "role": "ctf", "bridge": "vmbr144"}, + {"vm_id": 4002, "vm_name": "vuln-box-02", "ip": "192.168.144.172", "role": "ctf", "bridge": "vmbr144"}, + {"vm_id": 4003, "vm_name": "vuln-box-03", "ip": "192.168.144.173", "role": "ctf", "bridge": "vmbr144"}, + {"vm_id": 4004, "vm_name": "vuln-box-04", "ip": "192.168.144.174", "role": "ctf", "bridge": "vmbr144"} + ], + "templates": [ + {"vm_id": 9901, "vm_name": "template-vm-nano", "spec": "1cpu/1gb/16gb", "ip": "192.168.140.201", "bridge": "vmbr140"}, + {"vm_id": 9211, "vm_name": "template-vm-micro-01-2g-24g", "spec": "1cpu/2gb/24gb", "ip": "192.168.140.211", "bridge": "vmbr140"}, + {"vm_id": 9212, "vm_name": "template-vm-micro-02-2g-24g", "spec": "1cpu/2gb/24gb", "ip": "192.168.140.212", "bridge": "vmbr140"}, + {"vm_id": 9221, "vm_name": "template-vm-small-01-4g-32g", "spec": "1cpu/4gb/32gb", "ip": "192.168.140.221", "bridge": "vmbr140"}, + {"vm_id": 9222, "vm_name": "template-vm-small-02-4g-32g", "spec": "1cpu/4gb/32gb", "ip": "192.168.140.222", "bridge": "vmbr140"}, + {"vm_id": 9224, "vm_name": "template-vm-small-04-4g-32g", "spec": "1cpu/4gb/32gb", "ip": "192.168.140.224", "bridge": "vmbr140"}, + {"vm_id": 9232, "vm_name": "template-vm-medium-02-8g-64g", "spec": "2cpu/8gb/64gb", "ip": "192.168.140.232", "bridge": "vmbr140"}, + {"vm_id": 9234, "vm_name": "template-vm-medium-04-8g-64g", "spec": "4cpu/8gb/64gb", "ip": "192.168.140.234", "bridge": "vmbr140"}, + {"vm_id": 9236, "vm_name": "template-vm-medium-06-8g-64g", "spec": "6cpu/8gb/64gb", "ip": "192.168.140.236", "bridge": "vmbr140"}, + {"vm_id": 9244, "vm_name": "template-vm-large-04-8g-64g", "spec": "4cpu/8gb/64gb", "ip": "192.168.140.244", "bridge": "vmbr140"}, + {"vm_id": 9246, "vm_name": "template-vm-large-06-8g-64g", "spec": "6cpu/8gb/64gb", "ip": "192.168.140.246", "bridge": "vmbr140"}, + {"vm_id": 9248, "vm_name": "template-vm-large-08-8g-64g", "spec": "8cpu/8gb/64gb", "ip": "192.168.140.248", "bridge": "vmbr140"} + ] +} diff --git a/scenarios/demo_lab_network/templates/ansible-inventory.j2 b/scenarios/demo_lab_network/templates/ansible-inventory.j2 new file mode 100644 index 00000000..1b2738ab --- /dev/null +++ b/scenarios/demo_lab_network/templates/ansible-inventory.j2 @@ -0,0 +1,62 @@ +all: + children: + range42_infrastructure: + children: + #### + + r42_admin: + hosts: + # r42.testing-wazuh-client: # disabled + # r42.admin-builder-api-devkit: # removed + # r42.admin-builder-docker-registry: # disabled — re-enable when needed + r42.admin-wazuh: + r42.admin-deployer-api-gateway: + r42.admin-deployer-api-backend: + r42.admin-deployer-ui: + + r42_admin_wazuh_clients: + hosts: + # r42.testing-wazuh-client: # disabled + # r42.admin-builder-api-devkit: # removed + # r42.admin-builder-docker-registry: # disabled — re-enable when needed + r42.admin-deployer-api-gateway: + r42.admin-deployer-api-backend: + r42.admin-deployer-ui: + + #### + + r42_student_box_group: + hosts: + r42.student-box-01: + + #### + + r42_vuln_box_group: + hosts: + r42.vuln-box-00: + r42.vuln-box-01: + r42.vuln-box-02: + r42.vuln-box-03: + r42.vuln-box-04: + + # TODO T57: move init_vm_group to scenario-specific config + r42_init_vm_group: + hosts: + r42.init-vm-00: + r42.init-vm-01: + r42.init-vm-02: + r42.init-vm-03: + r42.init-vm-04: + + #### + + proxmox: + hosts: + {{ INFRASTRUCTURE_CODENAME }}: + ansible_host: {{ INFRASTRUCTURE_PROXMOX_ADDRESS | mandatory }}:8006 + ansible_connection: local + ansible_python_interpreter: /usr/bin/python3 + + proxmox-cli: + hosts: + {{ INFRASTRUCTURE_CODENAME }}-cli: diff --git a/scenarios/demo_lab_network/templates/ansible-vars.yml b/scenarios/demo_lab_network/templates/ansible-vars.yml new file mode 100644 index 00000000..d2bcdd71 --- /dev/null +++ b/scenarios/demo_lab_network/templates/ansible-vars.yml @@ -0,0 +1,35 @@ +--- +################################################################################ +# range42 — scenario-specific variables (demo_lab_network) +# +# These variables are specific to this scenario and may differ +# between scenarios running on the same CODENAME infrastructure. +# +# For shared variables → group_vars/all/vars.yml +# For secrets → vault.yml (see vault.yml.example) +# +################################################################################ + + +#### SCENARIO IDENTIFICATION #### + +INFRASTRUCTURE_SCENARIO: "demo_lab_network" + + +#### CREDENTIAL GENERATION #### + +# auto-generate ssh keys and passwords (YES/NO) +context_auto_generate_ssh_keys: "YES" +context_auto_generate_vm_passwords: "YES" + +# number of additional student ssh keys (bob_1 .. bob_N) +student_additionnal_keys_count: 5 + + +#### CLOUD-INIT USERNAMES #### + +# admin vm user (not secret — password and ssh key are in vault) +default_admin_vm_ci_user: "alice" + +# student vm user +default_trainee_vm_ci_user: "bob" diff --git a/scenarios/demo_lab_network/templates/ssh-config.j2 b/scenarios/demo_lab_network/templates/ssh-config.j2 new file mode 100644 index 00000000..92a014c1 --- /dev/null +++ b/scenarios/demo_lab_network/templates/ssh-config.j2 @@ -0,0 +1,84 @@ +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# +# infrastructure code name : {{ INFRASTRUCTURE_CODENAME }} +# infrastructure proxmox address : {{ INFRASTRUCTURE_PROXMOX_ADDRESS }} +# scenario : {{ INFRASTRUCTURE_SCENARIO }} +# +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# PROXMOX WEB UI (port forward) +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +Host px.{{ INFRASTRUCTURE_CODENAME }}.redirect_www.proxmox + RequestTTY no + RemoteCommand none + localforward localhost:18042 {{ INFRASTRUCTURE_PROXMOX_ADDRESS | mandatory }}:8006 + localcommand sh -c '/usr/bin/chromium-browser --incognito --new-window --disable-translate https://127.0.0.1:18042 &' + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# PX ROOT USER SSH ACCESS +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +Host px.{{ INFRASTRUCTURE_CODENAME }}-ssh_cli.root {{ INFRASTRUCTURE_CODENAME }}-cli + Hostname {{ INFRASTRUCTURE_PROXMOX_ADDRESS | mandatory }} + User root + IdentityFile {{ DEPLOYER_CLI__DST_SSH_KEYS_JUMP_DEST_DIR }}/px.{{ INFRASTRUCTURE_CODENAME }}-{{ INFRASTRUCTURE_SCENARIO }}-ssh_cli.root + Port 22 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# SSH JUMPER +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +Host px.{{ INFRASTRUCTURE_CODENAME }}.jumper + Hostname {{ INFRASTRUCTURE_PROXMOX_ADDRESS | mandatory }} + User jump_user + IdentityFile {{ DEPLOYER_CLI__DST_SSH_KEYS_JUMP_DEST_DIR }}/px.{{ INFRASTRUCTURE_CODENAME }}-{{ INFRASTRUCTURE_SCENARIO }}-ssh_cli.jump_user + Port 22 + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# ADMIN VMs (vmbr142 · 192.168.142.0/24) +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +Host r42.admin-wazuh + Hostname 192.168.142.100 + +Host r42.admin-deployer-api-gateway + Hostname 192.168.142.120 + +Host r42.admin-deployer-api-backend + Hostname 192.168.142.121 + +Host r42.admin-deployer-ui + Hostname 192.168.142.123 + +Host r42.admin* r42.testing* + User alice + IdentityFile {{ DEPLOYER_CLI__DST_SSH_KEYS_BACKEND_DEST_DIR }}/r42.{{ INFRASTRUCTURE_CODENAME }}-{{ INFRASTRUCTURE_SCENARIO }}-deployer-key_alice + Port 22 + ProxyJump px.{{ INFRASTRUCTURE_CODENAME }}.jumper + +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### +# CTF / VULN VMs (vmbr144 · 192.168.144.0/24) +#### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### + +Host r42.vuln-box-00 + Hostname 192.168.144.170 + +Host r42.vuln-box-01 + Hostname 192.168.144.171 + +Host r42.vuln-box-02 + Hostname 192.168.144.172 + +Host r42.vuln-box-03 + Hostname 192.168.144.173 + +Host r42.vuln-box-04 + Hostname 192.168.144.174 + +Host r42.vuln-box-* + User alice + IdentityFile {{ DEPLOYER_CLI__DST_SSH_KEYS_BACKEND_DEST_DIR }}/r42.{{ INFRASTRUCTURE_CODENAME }}-{{ INFRASTRUCTURE_SCENARIO }}-deployer-key_alice + Port 22 + ProxyJump px.{{ INFRASTRUCTURE_CODENAME }}.jumper diff --git a/scenarios/demo_lab_network/templates/vault-example.yml b/scenarios/demo_lab_network/templates/vault-example.yml new file mode 100644 index 00000000..87c9daef --- /dev/null +++ b/scenarios/demo_lab_network/templates/vault-example.yml @@ -0,0 +1,65 @@ +--- +################################################################################ +# range42 — vault secrets (demo_lab) +# +# This file contains ALL secret values for this CODENAME-SCENARIO. +# +# Usage: +# 1. Copy this file: cp vault.yml.example vault.yml +# 2. Fill in real values +# 3. Encrypt: ansible-vault encrypt vault.yml +# 4. Never commit the unencrypted version +# +# Password format: use only a-z A-Z 0-9 - _ (20+ characters recommended) +# +################################################################################ + + +#### PROXMOX API SECRET #### + +# api token secret — generated by proxmox.api-token or imported from existing +proxmox_api_token_secret: "REPLACE_ME" + + +#### JUMP HOST #### + +# jump user password (initial password, used for user creation) +jump_password: "REPLACE_ME" + + +#### CLOUD-INIT PASSWORDS #### + +# admin vm (alice) password +default_admin_vm_ci_password: "REPLACE_ME" + +# admin vm (alice) ssh public key — populated by credentials.generate +default_admin_vm_ci_ssh_key: "ssh-ed25519 AAAA... alice CODENAME-SCENARIO" + +# student vm (bob) password +default_trainee_vm_ci_password: "REPLACE_ME" + +# student vm (bob) ssh public key — populated by credentials.generate +default_trainee_vm_ci_ssh_key: "ssh-ed25519 AAAA... bob CODENAME-SCENARIO" + + +#### TAILSCALE #### + +# tailscale auth key (for joining the tailnet) +infrastructure_tailscale_authkey: "tskey-auth-REPLACE_ME" + +# tailscale api key (for managing devices) +infrastructure_tailscale_apikey: "tskey-api-REPLACE_ME" + + +#### WAZUH #### + +# wazuh admin password +# WAZUH_PASSWORD is consumed by credentials.vault template +# and mapped to infrastructure_wazuh_admin_password in the generated vault +WAZUH_PASSWORD: "REPLACE_ME" + + +#### MISC #### + +# deployer-cli known_hosts path (not really secret, but stored in vault historically) +deployer_cli_user_ssh_known_hosts: "/home/your_deployer_cli_username/.ssh/known_hosts" From fa1c75fa6813b292c9612edb1ca658d153473111 Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Fri, 15 May 2026 10:57:28 +0200 Subject: [PATCH 002/121] fix(demo_lab_network): use proxmox-cli (SSH) host for iptables tasks ansible_connection: local on the proxmox host targets the deployer machine, which doesn't have iptables. Switch to proxmox-cli (SSH to pve01 as root) where iptables is available. --- .../05_network_isolation/stage_00/proxmox_forward_rules.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml b/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml index dceba23f..bcff9a9c 100644 --- a/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml +++ b/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml @@ -3,7 +3,7 @@ # NETWORK ISOLATION — PROXMOX HOST FORWARD RULES # # Enforces zone isolation between the admin and CTF bridges by installing -# iptables FORWARD chain rules directly on the Proxmox host. +# iptables FORWARD chain rules directly on the Proxmox host via SSH (proxmox-cli). # # Rule table (evaluated in order): # @@ -20,7 +20,7 @@ # #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### #### -- hosts: proxmox +- hosts: proxmox-cli gather_facts: false vars_files: - "../../secrets/default_vault.yml" From 8f506e0763a028818a92f71cd641601e6a836d3a Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Fri, 15 May 2026 10:58:29 +0200 Subject: [PATCH 003/121] fix(demo_lab_network): pin python3 interpreter on proxmox-cli to suppress discovery warning --- .../05_network_isolation/stage_00/proxmox_forward_rules.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml b/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml index bcff9a9c..8cf5486a 100644 --- a/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml +++ b/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml @@ -22,6 +22,8 @@ - hosts: proxmox-cli gather_facts: false + vars: + ansible_python_interpreter: /usr/bin/python3 vars_files: - "../../secrets/default_vault.yml" From 7c88272223bfeb4e2945df83f1e41d258eb80b2c Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Fri, 15 May 2026 14:20:30 +0200 Subject: [PATCH 004/121] fix(demo_lab_network): lift CTF air-gap during setup so cloud-init and packages can reach internet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this fix, the CTF→WAN FORWARD DROP rule was already in place when the CTF VMs were deployed, blocking DNS and NTP during cloud-init and package installation. This caused the deployment to hang (30-60s DNS timeouts) and ultimately fail at the NTP sync step. New lift/restore pattern: 1. lift_ctf_airgap.yml removes the DROP rule before CTF infrastructure runs 2. 04_ctf_infrastructure/_main.yml runs with internet access 3. 05_network_isolation/_main.yml re-installs the DROP rule after setup Both main.yml and main_vms_only.yml updated. lift_ctf_airgap.yml handles both DROP and REJECT variants (idempotent, state=absent). --- .../stage_00/proxmox_forward_rules.yml | 2 +- .../stage_pre/lift_ctf_airgap.yml | 49 +++++++++++++++++++ scenarios/demo_lab_network/main.yml | 6 ++- scenarios/demo_lab_network/main_vms_only.yml | 6 ++- 4 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 scenarios/demo_lab_network/05_network_isolation/stage_pre/lift_ctf_airgap.yml diff --git a/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml b/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml index 8cf5486a..2d1e5ad9 100644 --- a/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml +++ b/scenarios/demo_lab_network/05_network_isolation/stage_00/proxmox_forward_rules.yml @@ -12,7 +12,7 @@ # ctf (vmbr144) → wazuh :1514 ACCEPT (agent event reporting) # ctf (vmbr144) → wazuh :1515 ACCEPT (agent enrollment) # ctf (vmbr144) → admin DROP (zone isolation) -# ctf (vmbr144) → WAN DROP (air-gap — no internet from vuln boxes) +# ctf (vmbr144) → WAN DROP (air-gap — no internet from vuln boxes during lab) # # Rules are persisted via iptables-persistent / netfilter-persistent. # Re-running this playbook is idempotent (ansible.builtin.iptables checks diff --git a/scenarios/demo_lab_network/05_network_isolation/stage_pre/lift_ctf_airgap.yml b/scenarios/demo_lab_network/05_network_isolation/stage_pre/lift_ctf_airgap.yml new file mode 100644 index 00000000..040147b5 --- /dev/null +++ b/scenarios/demo_lab_network/05_network_isolation/stage_pre/lift_ctf_airgap.yml @@ -0,0 +1,49 @@ +## +## demo_lab_network — lift CTF air-gap before setup +## +## Removes the CTF (vmbr144) → WAN FORWARD blocking rule so CTF VMs can +## reach the internet during cloud-init and package installation. +## +## Called at the start of the CTF infrastructure deployment. +## The full air-gap is restored afterwards by 05_network_isolation/_main.yml. +## +## Idempotent: uses state=absent — no-op if the rule is not present. +## + +- hosts: proxmox-cli + gather_facts: false + vars: + ansible_python_interpreter: /usr/bin/python3 + vars_files: + - "../../secrets/default_vault.yml" + + vars: + ctf_bridge: "vmbr144" + wan_interface: "{{ infrastructure_proxmox_default_network_card_interface | default('vmbr0') }}" + + tasks: + + - name: NETWORK ISOLATION - Lift CTF air-gap (REJECT variant) + ansible.builtin.iptables: + chain: FORWARD + in_interface: "{{ ctf_bridge }}" + out_interface: "{{ wan_interface }}" + jump: REJECT + reject_with: icmp-net-unreachable + comment: "r42: ctf air-gap" + state: absent + ignore_errors: true + + - name: NETWORK ISOLATION - Lift CTF air-gap (DROP variant — legacy) + ansible.builtin.iptables: + chain: FORWARD + in_interface: "{{ ctf_bridge }}" + out_interface: "{{ wan_interface }}" + jump: DROP + comment: "r42: ctf air-gap" + state: absent + ignore_errors: true + + - name: NETWORK ISOLATION - Persist (air-gap lifted) + ansible.builtin.command: netfilter-persistent save + changed_when: true diff --git a/scenarios/demo_lab_network/main.yml b/scenarios/demo_lab_network/main.yml index 5d4706be..eecc00c3 100644 --- a/scenarios/demo_lab_network/main.yml +++ b/scenarios/demo_lab_network/main.yml @@ -24,8 +24,10 @@ #- import_playbook: ./03_student_infrastructure/_main.yml -- import_playbook: ./04_ctf_infrastructure/_main.yml +# Lift air-gap so CTF VMs can reach the internet during cloud-init + package install +- import_playbook: ./05_network_isolation/stage_pre/lift_ctf_airgap.yml -#### +- import_playbook: ./04_ctf_infrastructure/_main.yml +# Restore air-gap after CTF setup is complete - import_playbook: ./05_network_isolation/_main.yml diff --git a/scenarios/demo_lab_network/main_vms_only.yml b/scenarios/demo_lab_network/main_vms_only.yml index a9670bcc..27f27774 100644 --- a/scenarios/demo_lab_network/main_vms_only.yml +++ b/scenarios/demo_lab_network/main_vms_only.yml @@ -10,8 +10,10 @@ #- import_playbook: ./03_student_infrastructure/_main.yml -- import_playbook: ./04_ctf_infrastructure/_main.yml +# Lift air-gap so CTF VMs can reach the internet during cloud-init + package install +- import_playbook: ./05_network_isolation/stage_pre/lift_ctf_airgap.yml -#### +- import_playbook: ./04_ctf_infrastructure/_main.yml +# Restore air-gap after CTF setup is complete - import_playbook: ./05_network_isolation/_main.yml From e4b7658c641b23f785640f1c6b2d246c8782ec1d Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Fri, 15 May 2026 15:28:57 +0200 Subject: [PATCH 005/121] fix(demo_lab_network): enable network utils on vuln-boxes (ping, nmap, tcpdump) INSTALL_PACKAGES_UTILS_NETWORK was hardcoded to NO for the vuln-box stage, blocking installation of nmap, tcpdump, net-tools and iputils-ping even though iputils-ping was added to the role. Set to YES so all network diagnostic tools are available on vuln-boxes. --- scenarios/demo_lab_network/04_ctf_infrastructure/_main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenarios/demo_lab_network/04_ctf_infrastructure/_main.yml b/scenarios/demo_lab_network/04_ctf_infrastructure/_main.yml index 6884b038..9207cdea 100644 --- a/scenarios/demo_lab_network/04_ctf_infrastructure/_main.yml +++ b/scenarios/demo_lab_network/04_ctf_infrastructure/_main.yml @@ -113,7 +113,7 @@ INSTALL_PACKAGES_DOCKER_COMPOSE: "YES" # INSTALL_PACKAGES_UTILS_JSON: "NO" - INSTALL_PACKAGES_UTILS_NETWORK: "NO" + INSTALL_PACKAGES_UTILS_NETWORK: "YES" INSTALL_PACKAGES_NTP_AND_UPDATE_TIME: "YES" # # From 594fc95b357481435f7a1917566df295340796ed Mon Sep 17 00:00:00 2001 From: t0kubetsu Date: Fri, 15 May 2026 16:12:05 +0200 Subject: [PATCH 006/121] =?UTF-8?q?fix(demo=5Flab=5Fnetwork):=20lift=5Fctf?= =?UTF-8?q?=5Fairgap=20=E2=80=94=20handle=20duplicate/malformed=20air-gap?= =?UTF-8?q?=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit netfilter-persistent can save rules with a newline embedded in the comment ("r42: ctf\n air-gap"), which ansible.builtin.iptables state=absent cannot match. Add a second comment-based pass for duplicates, then a shell fallback that loops iptables -D until no more DROP rules on vmbr144→vmbr0 remain. --- .../stage_pre/lift_ctf_airgap.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scenarios/demo_lab_network/05_network_isolation/stage_pre/lift_ctf_airgap.yml b/scenarios/demo_lab_network/05_network_isolation/stage_pre/lift_ctf_airgap.yml index 040147b5..24cdd53a 100644 --- a/scenarios/demo_lab_network/05_network_isolation/stage_pre/lift_ctf_airgap.yml +++ b/scenarios/demo_lab_network/05_network_isolation/stage_pre/lift_ctf_airgap.yml @@ -44,6 +44,22 @@ state: absent ignore_errors: true + - name: NETWORK ISOLATION - Lift CTF air-gap (DROP variant — second pass, handles duplicates) + ansible.builtin.iptables: + chain: FORWARD + in_interface: "{{ ctf_bridge }}" + out_interface: "{{ wan_interface }}" + jump: DROP + comment: "r42: ctf air-gap" + state: absent + ignore_errors: true + + - name: NETWORK ISOLATION - Lift CTF air-gap (fallback — flush any remaining DROP on ctf→wan by interface) + ansible.builtin.shell: | + while iptables -D FORWARD -i {{ ctf_bridge }} -o {{ wan_interface }} -j DROP 2>/dev/null; do :; done + exit 0 + changed_when: false + - name: NETWORK ISOLATION - Persist (air-gap lifted) ansible.builtin.command: netfilter-persistent save changed_when: true From 19377842f2c95ff24a97e6b6a131d09a2f204a96 Mon Sep 17 00:00:00 2001 From: xinshen Date: Wed, 3 Jun 2026 13:04:29 +0200 Subject: [PATCH 007/121] =?UTF-8?q?feat(r42topo):=20P1=20foundation=20?= =?UTF-8?q?=E2=80=94=20topology=20core=20models,=20IO,=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the r42topo package skeleton in the playbooks repo: a pure, framework-agnostic pydantic v2 core consumed by the backend API, r42topo's own CLI/TUI, and the (rewrite-in-progress) range42 deployment CLI/TUI. - core/models.py: Topology schema tree (Subnet/Zone/Box/Attachment/ NetworkPolicyRef), extra=forbid, security deny-list guards - core/constants.py: scenario-name regex (matches backend resolver, no dots), catalog-ref/bridge/IP patterns, vm_id bounds, octet rule, injection deny-list - core/io.py: deterministic atomic JSON load/dump (sorted keys, round-trip) - core/errors.py: framework-free exception hierarchy - pyproject.toml: pydantic+pyyaml core deps; typer/textual/dev as extras - tests: 14 tests, RED->GREEN, 94% coverage on core - docs/r42topo-plan.md: full architecture + phased build order Deploy path is _universal-only (rewrite-aligned): the compiler emits topology.json + artifacts; deployment runs via _universal Plan B (external prerequisite), not a materialized scenario dir. --- .gitignore | 10 ++ docs/r42topo-plan.md | 261 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 30 +++++ r42topo/README.md | 33 +++++ r42topo/__init__.py | 11 ++ r42topo/core/__init__.py | 6 + r42topo/core/constants.py | 62 +++++++++ r42topo/core/errors.py | 22 ++++ r42topo/core/io.py | 53 ++++++++ r42topo/core/models.py | 96 ++++++++++++++ tests/conftest.py | 47 +++++++ tests/test_io.py | 32 +++++ tests/test_models.py | 62 +++++++++ 13 files changed, 725 insertions(+) create mode 100644 docs/r42topo-plan.md create mode 100644 pyproject.toml create mode 100644 r42topo/README.md create mode 100644 r42topo/__init__.py create mode 100644 r42topo/core/__init__.py create mode 100644 r42topo/core/constants.py create mode 100644 r42topo/core/errors.py create mode 100644 r42topo/core/io.py create mode 100644 r42topo/core/models.py create mode 100644 tests/conftest.py create mode 100644 tests/test_io.py create mode 100644 tests/test_models.py diff --git a/.gitignore b/.gitignore index 9b718e0c..d3250f82 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,13 @@ **/secret/ CLAUDE.md **/CLAUDE.md + +# Python (r42topo module) +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.coverage +dist/ +build/ diff --git a/docs/r42topo-plan.md b/docs/r42topo-plan.md new file mode 100644 index 00000000..a003e8e3 --- /dev/null +++ b/docs/r42topo-plan.md @@ -0,0 +1,261 @@ +# r42topo — Scenario Authoring & Topology Compiler — Implementation Plan + +> Status: **proposed** · Target repo: `range42-playbooks` · Branch: `feat/r42topo-scenario-authoring` +> Companion repos: `range42-catalog` (templates), `range42-backend-api` (FastAPI consumer) + +## 1. Goal + +A Python package, **`r42topo`**, living in `range42-playbooks`, that turns a declarative, +pydantic-validated **`topology.json`** into the Ansible artifacts a deploy needs, then hands +them to the existing backend runner. One **pure core**; thin frontends (Typer **CLI**, +Textual **TUI**, and an importable **API adapter** the FastAPI backend calls). + +### Consumers of the core (drives the no-frontend-in-core rule) + +`r42topo.core` / `r42topo.api` are framework-agnostic and imported by **three** independent +consumers — so the core must stay consumer-neutral (no FastAPI/Typer/Textual types in its +signatures; errors are the plain `TopologyError` hierarchy, not `HTTPException`/exit codes): + +1. **range42-backend-api** (FastAPI) — authors/compiles, then runs `_universal`. +2. **r42topo's own CLI/TUI** (Typer + Textual). +3. **the range42 deployment CLI/TUI** — rewrite in progress; will import the same core + instead of re-implementing scenario logic. + +CLI/TUI dependencies are packaged as **extras** (`[cli]`, `[tui]`) so backend and deployment +consumers can install the core alone. Any shared interactive logic (validation messages, +"next free vm_id/IP", topology diff) lives in `core`/`api` so all three frontends reuse it. + +It generalizes the hardcoded `05_network_isolation` (iptables FORWARD rules) from +`feat/demo_lab_network-scenario` into reusable, parametric **catalog templates**, and feeds the +`_universal` scenario stub from `feature/gamenet-authoring-v1`. + +## 2. Locked decisions + +| # | Decision | +|---|----------| +| 1 | Output = pydantic-validated `topology.json` (source of truth) **+ a compiler** that expands it into inventory, `scenario_vms.json`, network policy, stage wiring. | +| 2 | CLI = **Typer**, TUI = **Textual**, both thin over a shared **pydantic v2 pure core** (no Typer/Textual/FastAPI imports in `core/`). | +| 3 | Catalog gains **network/isolation policy templates** AND **box/topology templates** (new `05_topology_layer/`). | +| 4 | Must **match the backend contract**: scenario resolves at `scenarios//main.yml`, name regex `^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*$` (no dots); API calls `run_playbook_core(playbook, inventory, extravars=…)`. | +| 5 | **Trust model = operator-only** (trusted-but-fallible). Authoring is behind an authenticated admin path; full validation still enforced, but the threat model does not assume end-user-supplied input. | +| 6 | **Format**: YAML for authored catalog templates; JSON for `topology.json` and all compiled artifacts (the latter cross the process boundary into `extravars`). | + +| 7 | **Deploy path = `_universal` only** (rewrite-aligned). The compiler emits `topology.json` + artifacts + extravars; deployment runs via `_universal` Plan B, driven by the rewritten deployment CLI/TUI and the backend API. The compiler does **not** materialize a `scenarios//` dir, and today's `range42-context`/`range42-init.py` selector is **not** retrofitted (it is superseded by the rewrite). | + +Defaults for still-open items (revisit before networked exposure): +- **Integration target**: the `_universal` playbook on `feature/gamenet-authoring-v1` (prerequisite for the P3 green slice; does not block P1–P2). +- **Backend wiring**: editable pip-install of the playbooks repo into the FastAPI venv (preferred over `PYTHONPATH`). + +### Deployment boundaries (what r42topo is NOT) + +r42topo **authors, validates, and compiles** — it produces *data*, never touches Proxmox. +The actual deploy (create VMs, apply the `R42-FORWARD` iptables policy, install catalog +roles/docker stacks onto each box) is done by **Ansible**. The catalog is consumed at two +distinct moments: **compile time** r42topo reads catalog *templates* (`box_templates`, +`network_policies`); **deploy time** Ansible pulls catalog *roles/stacks* (box `attachments`) +via `ANSIBLE_ROLES_PATH`. + +Two prerequisites live **outside this module** and gate the first real Proxmox deploy: + +1. **`_universal` Plan B** — `scenarios/_universal/main.yml` is currently a *stub* (asserts + `topology.json` exists, no-ops). The real "load topology → create VMs → apply network → + dispatch attachments" playbook must be implemented. Tracked as an external dependency. +2. **Deployment CLI/TUI rewrite** — today's `range42-init.py::list_deployable_scenarios` + requires a `templates/` dir (4 files) and skips `_`-prefixed names (so `_universal` is + excluded), and `range42-context deploy` runs `.setup.sh` from a materialized + scenario dir with no notion of `topology.json`/extravars. The rewrite teaches the + topology→`_universal` flow; r42topo's importable core is what it will call. + +P1–P4 (author/validate/compile/CLI) are fully testable **without** either prerequisite — the +P3 green slice runs against the existing `_universal` *stub* (asserts the compiled topology is +well-formed). End-to-end Proxmox deploy depends on Plan B landing. + +## 3. Verified contract (from `range42-backend-api`) + +- `run_playbook_core(playbook: Path, inventory: Path, limit, tags, cmdline, extravars: dict, quiet)` — single + ansible-runner entry point; isolated `private_data_dir`; reads `ANSIBLE_ROLES_PATH` / `ANSIBLE_CONFIG` / vault env. +- `resolve_scenarios_playbook(name, "public_github")` → `/scenarios//main.yml`; name regex above; `_universal` is valid. +- `_universal/main.yml` consumes extravars: `r42_topology_path`, `r42_inventory_dir`, `r42_deployment_id`, + `r42_attempt_id`, `r42_scope`, `r42_team_id`. +- Stack: FastAPI 0.115, **pydantic v2**, ansible-core 2.19.1, ansible-runner 2.4.1. Schemas use `{rc, result:[…]}` + envelopes, fields `proxmox_node`, `vm_id` (str), `iface_bridge`, IP regex patterns. +- Path-traversal posture to reuse (`app/utils/checks_playbooks.py`): regex + `resolve(strict=True)` + `is_relative_to` + symlink rejection. + +## 4. Data flow + +``` +operator ─┬─ Typer CLI ─┐ + ├─ Textual TUI ─┼─► r42topo.core (pydantic, pure) ─► topology.json (source of truth) + └─ FastAPI ┘ │ compile_topology() + ┌───────────────────────────────────────────────────────────────────────┐ + │ inventory/hosts.yml · manifest/scenario_vms.json · │ + │ network_policy.json (ordered FORWARD rules) · stages.json · topology.json │ + └───────────────────────────────────────────────────────────────────────┘ + │ resolve_universal_extravars() + ▼ + backend: run_playbook_core(scenarios/_universal/main.yml, hosts.yml, extravars={r42_*}) + ▼ + ansible-runner ─► Proxmox host (iptables R42-FORWARD chain + VM lifecycle, as root) +``` + +## 5. Package layout (`range42-playbooks/r42topo/`) + +Flat top-level package (repo is otherwise pure Ansible). `setuptools` `include = ["r42topo*"]` +so build tooling never touches `scenarios/`/`bundles/`. Files kept < 400 lines. + +``` +r42topo/ + __init__.py + core/ # PURE — pydantic v2 + pyyaml + stdlib only + constants.py # SCENARIO_NAME_RE, IPV4/CIDR/bridge regexes, deny-list tokens, octet rule + errors.py # TopologyError, ValidationError, CatalogNotFoundError, CompileError + models.py # Topology → Subnet / Zone / Box / Attachment / NetworkPolicyRef + catalog_models.py # BoxTemplate, NetworkPolicyTemplate (symbolic), SubnetLayout + catalog.py # CatalogLoader: id@semver → validated template (+ recorded hash) + idalloc.py # vm_id/IP octet-rule + _reserved.json uniqueness (atomic, locked) + io.py # load/dump topology, atomic write, sorted JSON/YAML + compiler/ + __init__.py # compile_topology() → CompileResult + inventory.py # → hosts.yml (nested groups; force_valid_group_names=never safe) + scenario_vms.py # → manifest/scenario_vms.json (demo_lab shape) + network_policy.py # → ordered FORWARD rule table (weight bands) + segmentation linter + stages.py # → per-zone box/attachment dispatch list for _universal Plan B + extravars.py # resolve_universal_extravars() → {r42_*} (typed allow-list) + api.py # load_catalog / author_topology / validate_topology / compile_topology / resolve_universal_extravars + cli.py # Typer: author / validate / compile / show + tui/ + app.py widgets.py # Textual, thin over core +pyproject.toml # core deps unconditional; [cli]/[tui]/[dev] extras +tests/ # golden-file compiler + schema/idalloc/security tests +docs/r42topo-plan.md # this document +``` + +**Importable API (pure — backend imports this):** + +```python +load_catalog(catalog_root: Path) -> Catalog +author_topology(spec: dict, *, catalog: Catalog) -> Topology +validate_topology(t: Topology, *, catalog: Catalog, reserved: ReservedIndex) -> list[str] +compile_topology(t: Topology, *, workspace: Path, catalog: Catalog, reserved: ReservedIndex) -> CompileResult +resolve_universal_extravars(result: CompileResult, *, deployment_id, attempt_id, scope, team_id=None) -> dict +``` + +## 6. topology.json schema (pydantic v2, `extra="forbid"`) + +``` +Topology(schema_version:int=1, scenario:str[regex,no-dots], description:str, proxmox_node:str, + subnets:[Subnet], zones:[Zone], boxes:[Box], network_policy:NetworkPolicyRef) +Subnet(name, cidr[CIDR], bridge[^vmbr\d+$], gateway?:IPv4) +Zone(name, subnet→Subnet.name, role:Literal[admin|ctf|team|student|template]) +Box(vm_name[^[a-z0-9-]+$], vm_id:int[1000..9999], ip:IPv4, zone→Zone.name, + box_template→catalog id, inventory_group[^[a-z0-9_]+$], attachments:[Attachment]) +Attachment(kind:Literal[role|container|gamification], catalog_ref:str[dots allowed], params:dict) +NetworkPolicyRef(template→catalog id@semver-range, overrides:dict) +``` + +Note: `scenario` is dot-free (backend rule); `Attachment.catalog_ref` **allows dots** (role naming +`software.install.wazuh`) — different fields, different regexes. + +## 7. Catalog templates (`range42-catalog/05_topology_layer/`) + +Templates carry **symbolic structure only — zero concrete IPs/CIDRs/bridges**; the topology binds +symbols to concrete values. Directory-per-version; `id@semver-range` references; resolved exact +version + content hash recorded into compiled output (lockfile pattern). Three independent version +axes: catalog-repo version · template SemVer · policy-schema `api_version`. `manifest.json` gains a +`topology` layer entry. + +``` +05_topology_layer/ + network_policies/air-gap-ctf/v1.0.0/template.yml # symbolic zones + allow/deny matrix + airgap + wazuh exceptions + box_templates/{admin-wazuh,deployer,vuln-box,student-box}/v1.0.0/template.yml + subnet_layouts/default-3zone/v1.0.0/template.yml +``` + +### Network model (the core generalization) + +Three layers: **Policy template (intent, symbolic)** → **Topology binding (concrete)** → +**Compiled rule table (execution)**. + +- Template declares symbolic `zones`, `services` (e.g. `siem`), a **sparse allow/deny matrix**, + and `airgap_zones`. The author writes only exceptions; `default_action: drop` + `allow_intra_zone` + cover the rest. Scales to n zones with no schema change. +- Topology supplies `zone_bindings` (zone → CIDR + bridge + optional VLAN) and `service_bindings` + (siem → wazuh IP). **The hardcoded wazuh IP disappears from the playbook.** +- Compiler emits a **deterministically ordered** rule table via fixed **weight bands** + (`ESTABLISHED,RELATED` → service-ACCEPT → zone-ACCEPT → intra-zone → zone-DROP → air-gap → + default-deny), stable-sorted → byte-reproducible. Order is *correctness* (iptables is + first-match-wins) and is decided in the testable compiler, never at Ansible runtime. +- The generalized `proxmox_forward_rules.yml` becomes a **data-driven loop** that + **flush-and-rebuilds a dedicated `R42-FORWARD` chain** (not the live `FORWARD`), then ensures a + single jump into it — naturally idempotent, order-exact, and cannot lock out host SSH (FORWARD-only). +- Edge cases: reject two zones on the same bridge with non-disjoint subnets (CIDR ambiguity); + optional `match_mode: cidr|iface|both` for CIDR reuse across labs. + +## 8. Source-of-truth hierarchy + +`_reserved.json` (what's allowed/forbidden: reserved subnets, vm_id bands, service IPs) +→ `topology.json` (what this scenario binds) +→ `scenario_vms.json` (concrete per-VM: vm_id↔ip↔bridge↔role) +→ compiled artifacts (derived, never hand-edited). +Catalog templates sit orthogonal as reusable symbolic intent. The compiler **cross-validates**: +every box IP must fall in exactly one bound zone subnet; bridges must match. + +**Octet rule** (`vm_id % 1000 == ip last octet`): enforced as an **error for newly authored boxes**, +**warning for legacy `_reserved.json` rows** (existing `demo_lab_network` data violates it: `4000 → .170`). + +## 9. Security requirements (operator-only model; baked into phases) + +- **Structured emit only** (`yaml.safe_dump` / `json.dump`) — never interpolate topology strings into + templates → no SSTI. Deny-list rejects fields containing `{{ }}`, `{% %}`, `${`, backtick, `;`, `|`, + `&`, newline, null byte, `..`, leading `-`, absolute paths. +- **iptables fields as typed argv/rule-data**, never shell concatenation: strict CIDR + (`ip_network(strict=True)`), ports 1–65535, bridge `^vmbr\d+$`, action ∈ {ACCEPT,DROP,REJECT}. +- **Segmentation linter** post-compile (fail closed): ESTABLISHED-ACCEPT precedes any DROP; + no `ctf → admin`; air-gap intact; default-deny last; **Proxmox mgmt SSH never blocked**. +- **Atomic, locked reservation** vs `_reserved.json` (TOCTOU-safe); reject cross-scenario / admin / + template-band vm_id/IP claims. +- **Path posture** (`resolve(strict=True)` + `is_relative_to` + regex + symlink rejection) for template + ids and every compiler read/write path. +- **extravars = typed allow-list** (strip any `ansible_*`/`ANSIBLE_*`); **no secrets** in any emitted + artifact (vault stays the only secret source; generated artifacts on gitignored paths). +- Honor `force_valid_group_names=never`: validate host `^r42\.[a-z0-9-]+$` / group `^[a-z0-9_]+$` names. +- **Fail-closed compile**: build in temp, validate fully (incl. linter), atomic-rename; never run on a + partially-validated artifact. Size/count caps to prevent compile-time DoS. + +## 10. Packaging + +```toml +[build-system] requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" +[project] name="r42topo" version="0.1.0" requires-python=">=3.11" + dependencies=["pydantic>=2,<3","pyyaml>=6"] +[project.optional-dependencies] cli=["typer>=0.12"] tui=["textual>=0.60"] dev=["pytest>=8.3","pytest-cov"] +[project.scripts] r42topo="r42topo.cli:app" +[tool.setuptools.packages.find] include=["r42topo*"] +[tool.pytest.ini_options] testpaths=["tests"] +``` + +`typer`/`textual` are **extras** so the backend installs core only (keeps the no-frontend-in-core +boundary at the dependency level too). `.gitignore` gains `__pycache__/`, `*.egg-info/`, +`.pytest_cache/`, `dist/`, workspace output dirs. + +## 11. Build order (TDD — RED first; each phase independently testable) + +| Phase | Delivers | Test gate | +|------|----------|-----------| +| **P1 Foundation** | `pyproject.toml`, `core/{constants,errors,models,io}.py`, `__init__.py`, `.gitignore` | topology round-trip + schema + deny-list unit tests | +| **P2 Catalog + idalloc** | `05_topology_layer/*` templates + `manifest.json` change; `catalog{,_models}.py`, `idalloc.py` | ref resolution, octet rule, locked uniqueness vs `_reserved.json` | +| **P3 Compiler + adapter** ◄ first end-to-end slice | `compiler/*`, `extravars.py`, `api.py`, segmentation linter | author→compile→`_universal` stub runs **green**; golden-file inventory/rules; security-linter tests | +| **P4 CLI** | `cli.py` (Typer) | `typer.testing.CliRunner` | +| **P5 TUI** | `tui/*` (Textual) | Textual pilot tests | +| **P6 Docs** | module README + catalog README + backend integration note | — | + +Dependency: P1 → P2 → P3 → {P4, P5 parallel} → P6. **Demoable milestone = end of P3.** +Coverage target ≥ 80% (project rule). + +## 12. Prerequisites / risks + +1. `_universal/main.yml` is on `feature/gamenet-authoring-v1`, not `main` — needed for the P3 green slice. +2. `r42topo` must be importable from the FastAPI venv (editable pip-install recommended). +3. Octet-rule conflict with legacy `_reserved.json` data — enforce on new boxes only. +4. Resolver drift between CLI and backend — single packaged core, version-pinned, shared golden tests. +5. Stale on-disk catalog clone — loader records resolved version + hash; surface mismatch. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..f804f942 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "r42topo" +version = "0.1.0" +description = "range42 scenario authoring & topology compiler" +readme = "r42topo/README.md" +requires-python = ">=3.11" +license = { text = "GPL-3.0-only" } +dependencies = [ + "pydantic>=2,<3", + "pyyaml>=6", +] + +[project.optional-dependencies] +cli = ["typer>=0.12"] +tui = ["textual>=0.60"] +dev = ["pytest>=8.3", "pytest-cov"] + +[project.scripts] +r42topo = "r42topo.cli:app" + +[tool.setuptools.packages.find] +include = ["r42topo*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/r42topo/README.md b/r42topo/README.md new file mode 100644 index 00000000..db61ec4f --- /dev/null +++ b/r42topo/README.md @@ -0,0 +1,33 @@ +# r42topo + +Scenario authoring & topology compiler for range42. + +`r42topo` turns a declarative, pydantic-validated **`topology.json`** into the Ansible +artifacts a deploy needs (inventory, `scenario_vms.json`, network-isolation policy, stage +wiring), then resolves the extravars the `_universal` scenario consumes. + +## Consumers + +The **pure core** (`r42topo.core`, `r42topo.api`) is framework-agnostic and imported by: + +- **range42-backend-api** (FastAPI) — authors/compiles topologies, then runs `_universal`. +- **r42topo's own CLI/TUI** — Typer CLI (`r42topo …`) and Textual TUI. +- **the range42 deployment CLI/TUI** (rewrite in progress) — same core, no duplication. + +`core/` imports only pydantic, pyyaml, and the stdlib. CLI/TUI deps are optional extras +(`pip install r42topo[cli]`, `[tui]`) so the backend installs the core alone. + +## Status + +Early development. See [`docs/r42topo-plan.md`](../docs/r42topo-plan.md) for the full plan +and build order. Phase 1 (core models + IO) is implemented and tested. + +## Develop + +```bash +python3 -m venv .venv && . .venv/bin/activate +pip install -e ".[dev]" +PYTHONPATH=. pytest --cov=r42topo.core +``` + +GPL-3.0. diff --git a/r42topo/__init__.py b/r42topo/__init__.py new file mode 100644 index 00000000..7a421b2e --- /dev/null +++ b/r42topo/__init__.py @@ -0,0 +1,11 @@ +"""r42topo — range42 scenario authoring & topology compiler. + +Pure, framework-agnostic core (``r42topo.core``) consumed by multiple frontends: +the range42-backend-api (FastAPI), this package's own Typer CLI / Textual TUI, +and the range42 deployment CLI/TUI. No frontend imports belong in ``core``. +""" + +from r42topo.core.models import Topology + +__all__ = ["Topology", "__version__"] +__version__ = "0.1.0" diff --git a/r42topo/core/__init__.py b/r42topo/core/__init__.py new file mode 100644 index 00000000..9a1b2c11 --- /dev/null +++ b/r42topo/core/__init__.py @@ -0,0 +1,6 @@ +"""Pure core for r42topo: pydantic models, validation, IO, compiler. + +This sub-package imports only pydantic, pyyaml, and the standard library. +It must remain free of CLI/TUI/web-framework imports so every consumer +(backend-api, CLI, TUI, deployment tooling) can import it cheaply. +""" diff --git a/r42topo/core/constants.py b/r42topo/core/constants.py new file mode 100644 index 00000000..0829ff6a --- /dev/null +++ b/r42topo/core/constants.py @@ -0,0 +1,62 @@ +"""Regexes, limits, and naming rules shared across the core. + +Single source of truth for the patterns that several modules validate against, +so the scenario-name rule (must match the backend resolver) and the security +deny-list never drift between models, the compiler, and the catalog loader. +""" + +import re + +# Scenario name — mirrors range42-backend-api resolve_scenarios_playbook(): +# /scenarios//main.yml, name segments are [A-Za-z0-9_-], +# slash-joined, NO dots, no leading/trailing slash. +SCENARIO_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*$") + +# vm_name / inventory host leaf — lowercase kebab, bounded length. +VM_NAME_RE = re.compile(r"^[a-z0-9-]{1,40}$") + +# Ansible inventory group — lowercase snake. Hyphenated groups (e.g. proxmox-cli) +# are intentional and hard-coded by the compiler, never sourced from a topology, +# because ansible.cfg sets force_valid_group_names=never (see workspace CLAUDE.md). +INVENTORY_GROUP_RE = re.compile(r"^[a-z0-9_]+$") + +# Catalog reference — role/stack ids MAY contain dots (e.g. software.install.wazuh), +# unlike scenario names. Kept deliberately separate from SCENARIO_NAME_RE. +CATALOG_REF_RE = re.compile(r"^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$") + +# Proxmox bridge interface. +BRIDGE_RE = re.compile(r"^vmbr[0-9]+$") + +# IPv4 address and CIDR (syntactic; semantic checks live in the compiler). +IPV4_RE = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$") +IPV4_CIDR_RE = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$") + +# Proxmox node name — mirrors backend schema pattern. +PROXMOX_NODE_RE = re.compile(r"^[A-Za-z0-9-]*$") + +# vm_id bounds: 4-digit ids in the project's allocated band. +VM_ID_MIN = 1000 +VM_ID_MAX = 9999 + +# Security deny-list: substrings that must never appear in a free-text topology +# field. Blocks Jinja/SSTI (`{{ }}`, `{% %}`, `${`), shell metacharacters, +# path traversal, and argv-flag injection. Fields are rejected, never sanitized. +DENYLIST_SUBSTRINGS: tuple[str, ...] = ( + "{{", "}}", "{%", "%}", "${", "`", ";", "|", "&", "\n", "\r", "\x00", "..", +) + + +def violates_denylist(value: str) -> bool: + """Return True if *value* contains any denied substring or a leading dash.""" + if value.startswith("-"): + return True + return any(token in value for token in DENYLIST_SUBSTRINGS) + + +def octet_matches_vm_id(vm_id: int, ip: str) -> bool: + """Project rule: a single-subnet VM's vm_id last 3 digits == IP last octet. + + Enforced as an error for newly authored boxes; legacy ``_reserved.json`` + rows that predate the rule are only warned about (handled in idalloc). + """ + return vm_id % 1000 == int(ip.rsplit(".", 1)[-1]) diff --git a/r42topo/core/errors.py b/r42topo/core/errors.py new file mode 100644 index 00000000..29be7a6f --- /dev/null +++ b/r42topo/core/errors.py @@ -0,0 +1,22 @@ +"""Framework-free exception hierarchy for the r42topo core. + +Consumers (backend-api, CLI, TUI) translate these into their own surface +(HTTP envelopes, exit codes, dialog text). The core never raises framework +exceptions such as fastapi.HTTPException. +""" + + +class TopologyError(Exception): + """Base class for all r42topo core errors.""" + + +class ValidationError(TopologyError): + """A topology or template failed semantic validation (beyond schema).""" + + +class CatalogNotFoundError(TopologyError): + """A referenced catalog template id/version could not be resolved.""" + + +class CompileError(TopologyError): + """Compilation failed (reservation conflict, segmentation invariant, etc.).""" diff --git a/r42topo/core/io.py b/r42topo/core/io.py new file mode 100644 index 00000000..6cb6737f --- /dev/null +++ b/r42topo/core/io.py @@ -0,0 +1,53 @@ +"""Topology load/dump helpers — deterministic, atomic JSON IO. + +``dump_topology`` writes sorted-key, stable JSON so re-compiling an unchanged +topology yields byte-identical output (clean diffs, reproducible builds). +Writes are atomic (temp file + os.replace) to avoid half-written artifacts. +""" + +import json +import os +import tempfile +from pathlib import Path + +from r42topo.core.errors import TopologyError +from r42topo.core.models import Topology + + +def load_topology(path: Path) -> Topology: + """Load and validate a topology.json from *path*. + + :raises TopologyError: if the file is missing or not valid JSON. + """ + path = Path(path) + try: + raw = path.read_text(encoding="utf-8") + except OSError as exc: + raise TopologyError(f"cannot read topology file: {path}") from exc + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise TopologyError(f"invalid JSON in topology file: {path}") from exc + return Topology.model_validate(data) + + +def dumps_topology(topology: Topology) -> str: + """Serialize a Topology to canonical, sorted, newline-terminated JSON.""" + payload = topology.model_dump(mode="json") + return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" + + +def dump_topology(topology: Topology, path: Path) -> Path: + """Atomically write *topology* to *path* as canonical JSON. Returns the path.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + text = dumps_topology(topology) + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".topology-", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + os.replace(tmp, path) + finally: + if os.path.exists(tmp): + os.unlink(tmp) + return path diff --git a/r42topo/core/models.py b/r42topo/core/models.py new file mode 100644 index 00000000..7d5b29ba --- /dev/null +++ b/r42topo/core/models.py @@ -0,0 +1,96 @@ +"""Pydantic v2 models for topology.json — the authored source of truth. + +Schema-level validation only (shapes, patterns, the security deny-list). +Cross-field semantics (zone/subnet referential integrity, IP-in-subnet, +reservation uniqueness) live in the compiler/idalloc layer, not here. +""" + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from r42topo.core import constants as C + +_STRICT = ConfigDict(extra="forbid") + + +def _no_injection(value: str) -> str: + """Reject free-text values containing deny-listed tokens (SSTI/shell/path).""" + if C.violates_denylist(value): + raise ValueError("value contains a forbidden character or pattern") + return value + + +class Attachment(BaseModel): + """A catalog item dispatched onto a box (role / container / gamification).""" + + model_config = _STRICT + + kind: Literal["role", "container", "gamification"] + catalog_ref: str = Field(pattern=C.CATALOG_REF_RE.pattern) + params: dict[str, Any] = Field(default_factory=dict) + + +class Subnet(BaseModel): + """A concrete L3 subnet bound to a Proxmox bridge.""" + + model_config = _STRICT + + name: str = Field(pattern=C.VM_NAME_RE.pattern) + cidr: str = Field(pattern=C.IPV4_CIDR_RE.pattern) + bridge: str = Field(pattern=C.BRIDGE_RE.pattern) + gateway: str | None = Field(default=None, pattern=C.IPV4_RE.pattern) + + _guard_name = field_validator("name")(_no_injection) + + +class Zone(BaseModel): + """A logical isolation zone mapped onto one subnet.""" + + model_config = _STRICT + + name: str = Field(pattern=C.VM_NAME_RE.pattern) + subnet: str = Field(pattern=C.VM_NAME_RE.pattern) # FK -> Subnet.name + role: Literal["admin", "ctf", "team", "student", "template"] + + _guard_name = field_validator("name")(_no_injection) + + +class Box(BaseModel): + """A single VM to create and configure.""" + + model_config = _STRICT + + vm_name: str = Field(pattern=C.VM_NAME_RE.pattern) + vm_id: int = Field(ge=C.VM_ID_MIN, le=C.VM_ID_MAX) + ip: str = Field(pattern=C.IPV4_RE.pattern) + zone: str = Field(pattern=C.VM_NAME_RE.pattern) # FK -> Zone.name + box_template: str = Field(pattern=C.CATALOG_REF_RE.pattern) + inventory_group: str = Field(pattern=C.INVENTORY_GROUP_RE.pattern) + attachments: list[Attachment] = Field(default_factory=list) + + _guard_vm_name = field_validator("vm_name")(_no_injection) + + +class NetworkPolicyRef(BaseModel): + """Reference to a catalog network-isolation policy template + overrides.""" + + model_config = _STRICT + + template: str = Field(pattern=C.CATALOG_REF_RE.pattern) + overrides: dict[str, Any] = Field(default_factory=dict) + + +class Topology(BaseModel): + """Top-level authored topology — the source of truth the compiler expands.""" + + model_config = _STRICT + + schema_version: int = 1 + scenario: str = Field(pattern=C.SCENARIO_NAME_RE.pattern) + description: str = "" + proxmox_node: str = Field(pattern=C.PROXMOX_NODE_RE.pattern) + subnets: list[Subnet] = Field(min_length=1) + zones: list[Zone] = Field(min_length=1) + boxes: list[Box] = Field(min_length=1) + network_policy: NetworkPolicyRef diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..0c1d2ca5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,47 @@ +"""Shared test fixtures for r42topo.""" + +import copy + +import pytest + + +@pytest.fixture +def valid_topology_dict() -> dict: + """A minimal, valid topology spec (admin + ctf zones, one box each).""" + return { + "schema_version": 1, + "scenario": "demo_lab_network", + "description": "test topology", + "proxmox_node": "px-testing", + "subnets": [ + {"name": "admin", "cidr": "192.168.142.0/24", "bridge": "vmbr142", + "gateway": "192.168.142.1"}, + {"name": "ctf", "cidr": "192.168.144.0/24", "bridge": "vmbr144"}, + ], + "zones": [ + {"name": "admin", "subnet": "admin", "role": "admin"}, + {"name": "ctf", "subnet": "ctf", "role": "ctf"}, + ], + "boxes": [ + {"vm_name": "admin-wazuh", "vm_id": 1100, "ip": "192.168.142.100", + "zone": "admin", "box_template": "admin-wazuh", + "inventory_group": "r42_admin_group", + "attachments": [ + {"kind": "role", "catalog_ref": "software.install.wazuh", "params": {}}, + ]}, + {"vm_name": "vuln-box-00", "vm_id": 1170, "ip": "192.168.144.170", + "zone": "ctf", "box_template": "vuln-box", + "inventory_group": "r42_vuln_box_group", "attachments": []}, + ], + "network_policy": {"template": "air-gap-ctf", "overrides": {}}, + } + + +@pytest.fixture +def topology_factory(valid_topology_dict): + """Return a deep-copy mutator so tests can tweak one field in isolation.""" + def _make(**overrides) -> dict: + spec = copy.deepcopy(valid_topology_dict) + spec.update(overrides) + return spec + return _make diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 00000000..196d45f7 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,32 @@ +"""P1 IO round-trip tests for r42topo.core.io — RED before GREEN.""" + +import json + +from r42topo.core.io import dump_topology, load_topology +from r42topo.core.models import Topology + + +def test_round_trip_preserves_topology(tmp_path, valid_topology_dict): + t = Topology.model_validate(valid_topology_dict) + path = tmp_path / "topology.json" + dump_topology(t, path) + loaded = load_topology(path) + assert loaded == t + + +def test_dump_writes_sorted_deterministic_json(tmp_path, valid_topology_dict): + t = Topology.model_validate(valid_topology_dict) + p1, p2 = tmp_path / "a.json", tmp_path / "b.json" + dump_topology(t, p1) + dump_topology(t, p2) + assert p1.read_text() == p2.read_text() # byte-identical + top = json.loads(p1.read_text()) + assert list(top.keys()) == sorted(top.keys()) # sorted keys + + +def test_load_rejects_unknown_path(tmp_path): + import pytest + + from r42topo.core.errors import TopologyError + with pytest.raises(TopologyError): + load_topology(tmp_path / "does-not-exist.json") diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 00000000..97139a59 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,62 @@ +"""P1 schema tests for r42topo.core.models — RED before GREEN.""" + +import pytest +from pydantic import ValidationError + +from r42topo.core.models import Topology + + +def test_valid_topology_parses(valid_topology_dict): + t = Topology.model_validate(valid_topology_dict) + assert t.scenario == "demo_lab_network" + assert len(t.boxes) == 2 + assert t.network_policy.template == "air-gap-ctf" + + +def test_scenario_name_rejects_dots(topology_factory): + # backend resolver regex forbids dots in scenario names + with pytest.raises(ValidationError): + Topology.model_validate(topology_factory(scenario="demo.lab")) + + +def test_scenario_name_rejects_leading_slash(topology_factory): + with pytest.raises(ValidationError): + Topology.model_validate(topology_factory(scenario="/demo_lab")) + + +def test_bridge_pattern_enforced(topology_factory): + spec = topology_factory() + spec["subnets"][0]["bridge"] = "br0" # not vmbrN + with pytest.raises(ValidationError): + Topology.model_validate(spec) + + +def test_vm_id_range_enforced(topology_factory): + spec = topology_factory() + spec["boxes"][0]["vm_id"] = 99 # below 1000 + with pytest.raises(ValidationError): + Topology.model_validate(spec) + + +def test_extra_fields_forbidden(topology_factory): + with pytest.raises(ValidationError): + Topology.model_validate(topology_factory(unexpected="x")) + + +def test_attachment_catalog_ref_allows_dots(valid_topology_dict): + # role names like software.install.wazuh contain dots (unlike scenario names) + t = Topology.model_validate(valid_topology_dict) + assert t.boxes[0].attachments[0].catalog_ref == "software.install.wazuh" + + +@pytest.mark.parametrize("bad", [ + "{{ lookup('pipe','id') }}", + "name;rm -rf /", + "a|b", + "../escape", +]) +def test_denylist_rejects_injection_in_vm_name(topology_factory, bad): + spec = topology_factory() + spec["boxes"][0]["vm_name"] = bad + with pytest.raises(ValidationError): + Topology.model_validate(spec) From 3ff5b45fdb2c10f7f576caf874effe70ef9c23a4 Mon Sep 17 00:00:00 2001 From: xinshen Date: Wed, 3 Jun 2026 13:09:56 +0200 Subject: [PATCH 008/121] feat(r42topo): P2 catalog loader + reservation checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the catalog-template layer and global vm_id/IP allocation validation. - core/catalog_models.py: BoxTemplate, NetworkPolicyTemplate (symbolic zones, services, allow/deny matrix, defaults), SubnetLayout — all extra=forbid - core/catalog.py: load_catalog() resolves //vX.Y.Z/template.yml, picks highest version, validates, records version+sha256; path posture mirrors backend checks_playbooks.py (regex + strict resolve + is_relative_to + symlink guard) - core/idalloc.py: ReservedIndex parses JSONL _reserved.json; validate_allocation() enforces octet rule, intra-topology dup vm_id/IP, and cross-scenario collisions (own-scenario re-deploy allowed). Read-only/pure. - core/constants.py: TEMPLATE_ID_RE, VERSION_DIR_RE, topology-layer dir/category names - tests: +16 (catalog loader incl. shipped-template validation; idalloc rules) - suite: 30 passing, 93% core coverage --- r42topo/core/catalog.py | 162 +++++++++++++++++++++++++++++++++ r42topo/core/catalog_models.py | 119 ++++++++++++++++++++++++ r42topo/core/constants.py | 13 +++ r42topo/core/idalloc.py | 126 +++++++++++++++++++++++++ tests/conftest.py | 87 ++++++++++++++++++ tests/test_catalog.py | 72 +++++++++++++++ tests/test_idalloc.py | 64 +++++++++++++ 7 files changed, 643 insertions(+) create mode 100644 r42topo/core/catalog.py create mode 100644 r42topo/core/catalog_models.py create mode 100644 r42topo/core/idalloc.py create mode 100644 tests/test_catalog.py create mode 100644 tests/test_idalloc.py diff --git a/r42topo/core/catalog.py b/r42topo/core/catalog.py new file mode 100644 index 00000000..fdc92803 --- /dev/null +++ b/r42topo/core/catalog.py @@ -0,0 +1,162 @@ +"""Catalog loader: resolve + validate 05_topology_layer templates from disk. + +Layout (directory-per-version): + /05_topology_layer///v../template.yml + +For each (category, id) the highest version is selected, parsed, validated +against its pydantic model, and content-hashed (sha256 of the raw file) so a +compile can record exactly which template version produced an artifact. + +Security posture mirrors the backend's checks_playbooks.py: template ids are +regex-validated, resolved with strict=True, asserted to stay inside the layer +root, and symlinks escaping the root are rejected. +""" + +import hashlib +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +from r42topo.core import constants as C +from r42topo.core.catalog_models import ( + BoxTemplate, + NetworkPolicyTemplate, + SubnetLayout, +) +from r42topo.core.errors import CatalogNotFoundError, ValidationError + +_CATEGORY_MODEL = { + C.CATEGORY_BOX_TEMPLATES: BoxTemplate, + C.CATEGORY_NETWORK_POLICIES: NetworkPolicyTemplate, + C.CATEGORY_SUBNET_LAYOUTS: SubnetLayout, +} + + +@dataclass(frozen=True) +class _Resolved: + """A loaded template plus the version + content hash it came from.""" + + model: object + version: str + sha256: str + + +@dataclass +class Catalog: + """In-memory index of validated topology-layer templates.""" + + box_templates: dict[str, BoxTemplate] = field(default_factory=dict) + network_policies: dict[str, NetworkPolicyTemplate] = field(default_factory=dict) + subnet_layouts: dict[str, SubnetLayout] = field(default_factory=dict) + _resolved: dict[tuple[str, str], _Resolved] = field(default_factory=dict) + + # -- resolution helpers (raise CatalogNotFoundError on miss) -- + + def resolve_box_template(self, ref: str) -> BoxTemplate: + return self._resolve(C.CATEGORY_BOX_TEMPLATES, ref, self.box_templates) + + def resolve_network_policy(self, ref: str) -> NetworkPolicyTemplate: + return self._resolve(C.CATEGORY_NETWORK_POLICIES, ref, self.network_policies) + + def resolve_subnet_layout(self, ref: str) -> SubnetLayout: + return self._resolve(C.CATEGORY_SUBNET_LAYOUTS, ref, self.subnet_layouts) + + def resolved_version(self, category: str, template_id: str) -> str: + return self._resolved[(category, template_id)].version + + def resolved_hash(self, category: str, template_id: str) -> str: + return self._resolved[(category, template_id)].sha256 + + def _resolve(self, category: str, ref: str, index: dict): + # ref is a bare template id (version pinning via @range is a later refinement) + if not C.TEMPLATE_ID_RE.fullmatch(ref): + raise CatalogNotFoundError(f"invalid template id: {ref!r}") + try: + return index[ref] + except KeyError: + raise CatalogNotFoundError( + f"{category} template not found: {ref!r}" + ) from None + + +def _highest_version_dir(template_dir: Path) -> tuple[Path, tuple[int, int, int]]: + """Return (version_dir, parsed_semver) for the highest vX.Y.Z under a template.""" + best: tuple[tuple[int, int, int], Path] | None = None + for child in template_dir.iterdir(): + if not child.is_dir(): + continue + m = C.VERSION_DIR_RE.fullmatch(child.name) + if not m: + continue + sem = (int(m.group(1)), int(m.group(2)), int(m.group(3))) + if best is None or sem > best[0]: + best = (sem, child) + if best is None: + raise CatalogNotFoundError(f"no versioned template under {template_dir}") + return best[1], best[0] + + +def _load_category(layer_root: Path, category: str, catalog: Catalog) -> None: + """Load every template id under a category into the catalog index.""" + category_dir = (layer_root / category).resolve() + if not category_dir.is_dir(): + return # category optional + if not category_dir.is_relative_to(layer_root): # symlink escape guard + raise CatalogNotFoundError(f"category escapes layer root: {category}") + + model_cls = _CATEGORY_MODEL[category] + index = { + C.CATEGORY_BOX_TEMPLATES: catalog.box_templates, + C.CATEGORY_NETWORK_POLICIES: catalog.network_policies, + C.CATEGORY_SUBNET_LAYOUTS: catalog.subnet_layouts, + }[category] + + for template_dir in sorted(category_dir.iterdir()): + if not template_dir.is_dir(): + continue + template_id = template_dir.name + if not C.TEMPLATE_ID_RE.fullmatch(template_id): + raise ValidationError(f"invalid template id directory: {template_id!r}") + + version_dir, sem = _highest_version_dir(template_dir) + template_file = (version_dir / "template.yml").resolve(strict=True) + if not template_file.is_relative_to(layer_root): + raise CatalogNotFoundError(f"template escapes layer root: {template_file}") + + raw = template_file.read_bytes() + sha = hashlib.sha256(raw).hexdigest() + data = yaml.safe_load(raw.decode("utf-8")) + try: + model = model_cls.model_validate(data) + except Exception as exc: # pydantic ValidationError -> our ValidationError + raise ValidationError( + f"invalid {category} template {template_id!r}: {exc}" + ) from exc + + if model.id != template_id: + raise ValidationError( + f"template id {model.id!r} does not match directory {template_id!r}" + ) + + index[template_id] = model + catalog._resolved[(category, template_id)] = _Resolved( + model=model, version=f"{sem[0]}.{sem[1]}.{sem[2]}", sha256=sha + ) + + +def load_catalog(catalog_root: Path) -> Catalog: + """Load + validate all topology-layer templates from a catalog checkout. + + :param catalog_root: path that contains ``05_topology_layer/``. + :raises CatalogNotFoundError: if the layer dir is absent. + :raises ValidationError: if any template file fails schema validation. + """ + layer_root = (Path(catalog_root) / C.TOPOLOGY_LAYER_DIR).resolve() + if not layer_root.is_dir(): + raise CatalogNotFoundError(f"missing {C.TOPOLOGY_LAYER_DIR} under {catalog_root}") + + catalog = Catalog() + for category in _CATEGORY_MODEL: + _load_category(layer_root, category, catalog) + return catalog diff --git a/r42topo/core/catalog_models.py b/r42topo/core/catalog_models.py new file mode 100644 index 00000000..694826ea --- /dev/null +++ b/r42topo/core/catalog_models.py @@ -0,0 +1,119 @@ +"""Pydantic models for the catalog templates under 05_topology_layer/. + +Three template kinds, all authored as YAML, validated here: + - BoxTemplate (box_templates/) VM/box archetypes + - NetworkPolicyTemplate (network_policies/) symbolic isolation policies + - SubnetLayout (subnet_layouts/) subnet/bridge layouts + +Templates carry *symbolic* structure only (zone names, service ports) — no +concrete per-scenario IPs beyond declared params/defaults. The compiler (P3) +binds symbols to a topology's concrete subnets/IPs. +""" + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from r42topo.core import constants as C +from r42topo.core.models import Attachment, Subnet + +_STRICT = ConfigDict(extra="forbid") + + +# --- box templates --------------------------------------------------------- + +class BoxTemplate(BaseModel): + """A VM/box archetype: role, default inventory group, spec, attachments.""" + + model_config = _STRICT + + id: str = Field(pattern=C.TEMPLATE_ID_RE.pattern) + api_version: int = 1 + description: str = "" + role: Literal["admin", "ctf", "team", "student", "template"] + default_inventory_group: str = Field(pattern=C.INVENTORY_GROUP_RE.pattern) + spec: str = Field(min_length=1) + default_attachments: list[Attachment] = Field(default_factory=list) + + +# --- subnet layouts -------------------------------------------------------- + +class SubnetLayout(BaseModel): + """A reusable set of subnet/bridge declarations a topology can adopt.""" + + model_config = _STRICT + + id: str = Field(pattern=C.TEMPLATE_ID_RE.pattern) + api_version: int = 1 + description: str = "" + subnets: list[Subnet] = Field(min_length=1) + + +# --- network policy templates --------------------------------------------- + +class PortSpec(BaseModel): + """A protocol + optional port (range). port is None for icmp/all-ports.""" + + model_config = _STRICT + + proto: Literal["tcp", "udp", "icmp"] + port: int | None = Field(default=None, ge=1, le=65535) + port_end: int | None = Field(default=None, ge=1, le=65535) + + +class ZoneDecl(BaseModel): + """A symbolic zone; `wan: true` marks the uplink/internet pseudo-zone.""" + + model_config = _STRICT + + name: str = Field(pattern=C.VM_NAME_RE.pattern) + wan: bool = False + + +class ServiceEndpoint(BaseModel): + """A named service inside a zone, addressable in the matrix as `svc:`.""" + + model_config = _STRICT + + name: str = Field(pattern=C.VM_NAME_RE.pattern) + zone: str = Field(pattern=C.VM_NAME_RE.pattern) + ports: list[PortSpec] = Field(min_length=1) + + +class MatrixRule(BaseModel): + """One intent cell: src zone -> dst (zone | `svc:`) with an action.""" + + model_config = _STRICT + + src: str + dst: str + action: Literal["accept", "drop", "reject"] + ports: list[PortSpec] = Field(default_factory=list) + comment: str | None = None + + +class PolicyDefaults(BaseModel): + """Catch-all behaviour the compiler synthesizes around explicit matrix rules.""" + + model_config = _STRICT + + default_action: Literal["drop", "reject", "accept"] = "drop" + accept_established_related: bool = True + allow_intra_zone: bool = True + airgap_zones: list[str] = Field(default_factory=list) + + +class NetworkPolicyTemplate(BaseModel): + """A symbolic, parametric isolation policy (generalizes 05_network_isolation).""" + + model_config = _STRICT + + id: str = Field(pattern=C.TEMPLATE_ID_RE.pattern) + api_version: int = 1 + kind: Literal["isolation-policy"] + description: str = "" + params: dict[str, Any] = Field(default_factory=dict) + zones: list[ZoneDecl] = Field(min_length=1) + services: list[ServiceEndpoint] = Field(default_factory=list) + matrix: list[MatrixRule] = Field(default_factory=list) + defaults: PolicyDefaults = Field(default_factory=PolicyDefaults) diff --git a/r42topo/core/constants.py b/r42topo/core/constants.py index 0829ff6a..326e9e96 100644 --- a/r42topo/core/constants.py +++ b/r42topo/core/constants.py @@ -38,6 +38,19 @@ VM_ID_MIN = 1000 VM_ID_MAX = 9999 +# Catalog template id — dotless kebab (distinct from dotted role refs and from +# the scenario-name rule). Used for directory names under 05_topology_layer/. +TEMPLATE_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + +# Version directory under a template id, e.g. "v1.0.0". +VERSION_DIR_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + +# Catalog layout: the new topology layer and its categories. +TOPOLOGY_LAYER_DIR = "05_topology_layer" +CATEGORY_BOX_TEMPLATES = "box_templates" +CATEGORY_NETWORK_POLICIES = "network_policies" +CATEGORY_SUBNET_LAYOUTS = "subnet_layouts" + # Security deny-list: substrings that must never appear in a free-text topology # field. Blocks Jinja/SSTI (`{{ }}`, `{% %}`, `${`), shell metacharacters, # path traversal, and argv-flag injection. Fields are rejected, never sanitized. diff --git a/r42topo/core/idalloc.py b/r42topo/core/idalloc.py new file mode 100644 index 00000000..8372014b --- /dev/null +++ b/r42topo/core/idalloc.py @@ -0,0 +1,126 @@ +"""vm_id / IP allocation checks against the global reservation registry. + +`scenarios/_reserved.json` is a JSONL file (one JSON object per line) that +records every vm_id/IP claimed across all scenarios. This module validates a +topology's boxes against it and against the project's allocation rules: + + - octet rule: a box's vm_id last 3 digits must equal its IP last octet + (enforced as an error for newly authored boxes); + - no duplicate vm_id / IP within the topology; + - no vm_id / IP collision with a *different* scenario already in the registry + (re-deploying the topology's own scenario is fine). + +Read-only and pure: claiming/writing reservations (with a file lock) is a +separate concern handled by the deploy side, not here. +""" + +import json +from dataclasses import dataclass, field +from pathlib import Path + +from r42topo.core import constants as C +from r42topo.core.errors import TopologyError +from r42topo.core.models import Topology + + +@dataclass(frozen=True) +class ReservedIndex: + """Parsed view of a JSONL ``_reserved.json`` reservation registry.""" + + entries: tuple[dict, ...] + + @classmethod + def from_file(cls, path: Path) -> "ReservedIndex": + path = Path(path) + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise TopologyError(f"cannot read reservation file: {path}") from exc + rows: list[dict] = [] + for lineno, line in enumerate(text.splitlines(), start=1): + line = line.strip() + if not line: + continue + try: + rows.append(json.loads(line)) + except json.JSONDecodeError as exc: + raise TopologyError( + f"invalid JSON on line {lineno} of {path}" + ) from exc + return cls(entries=tuple(rows)) + + def used_vm_ids(self) -> set[int]: + return {int(e["vm_id"]) for e in self.entries if "vm_id" in e} + + def used_ips(self) -> set[str]: + return {str(e["ip"]) for e in self.entries if "ip" in e} + + def owner_of_vm_id(self, vm_id: int) -> str | None: + for e in self.entries: + if int(e.get("vm_id", -1)) == vm_id: + return e.get("scenario") + return None + + def owner_of_ip(self, ip: str) -> str | None: + for e in self.entries: + if str(e.get("ip", "")) == ip: + return e.get("scenario") + return None + + +@dataclass +class AllocationReport: + """Result of validate_allocation: human-readable errors + warnings.""" + + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.errors + + +def validate_allocation(topology: Topology, reserved: ReservedIndex) -> AllocationReport: + """Validate a topology's vm_id/IP allocation. Returns a report (never raises).""" + report = AllocationReport() + scenario = topology.scenario + + seen_vm_ids: dict[int, str] = {} + seen_ips: dict[str, str] = {} + + for box in topology.boxes: + # octet rule (new boxes must comply) + if not C.octet_matches_vm_id(box.vm_id, box.ip): + report.errors.append( + f"box {box.vm_name}: octet rule violated — vm_id {box.vm_id} " + f"last 3 digits != IP last octet ({box.ip})" + ) + + # intra-topology duplicates + if box.vm_id in seen_vm_ids: + report.errors.append( + f"box {box.vm_name}: duplicate vm_id {box.vm_id} " + f"(also {seen_vm_ids[box.vm_id]})" + ) + else: + seen_vm_ids[box.vm_id] = box.vm_name + if box.ip in seen_ips: + report.errors.append( + f"box {box.vm_name}: duplicate IP {box.ip} (also {seen_ips[box.ip]})" + ) + else: + seen_ips[box.ip] = box.vm_name + + # cross-scenario collisions in the registry + owner = reserved.owner_of_vm_id(box.vm_id) + if owner is not None and owner != scenario: + report.errors.append( + f"box {box.vm_name}: vm_id {box.vm_id} reserved by scenario {owner!r}" + ) + ip_owner = reserved.owner_of_ip(box.ip) + if ip_owner is not None and ip_owner != scenario: + report.errors.append( + f"box {box.vm_name}: IP {box.ip} reserved by scenario {ip_owner!r}" + ) + + return report diff --git a/tests/conftest.py b/tests/conftest.py index 0c1d2ca5..d5d063f5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -45,3 +45,90 @@ def _make(**overrides) -> dict: spec.update(overrides) return spec return _make + + +def _write(path, text: str): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +@pytest.fixture +def fake_catalog(tmp_path): + """Materialize a minimal, valid 05_topology_layer catalog under tmp_path. + + Returns the catalog_root (the dir that *contains* 05_topology_layer), + mirroring how range42-catalog is laid out on disk. + """ + root = tmp_path / "range42-catalog" + layer = root / "05_topology_layer" + + _write(layer / "subnet_layouts" / "default-3zone" / "v1.0.0" / "template.yml", """ +id: default-3zone +api_version: 1 +description: admin + ctf + student subnets +subnets: + - {name: admin, cidr: "192.168.142.0/24", bridge: vmbr142, gateway: "192.168.142.1"} + - {name: ctf, cidr: "192.168.144.0/24", bridge: vmbr144} + - {name: student, cidr: "192.168.143.0/24", bridge: vmbr143} +""".lstrip()) + + _write(layer / "box_templates" / "vuln-box" / "v1.0.0" / "template.yml", """ +id: vuln-box +api_version: 1 +description: CTF vulnerable target +role: ctf +default_inventory_group: r42_vuln_box_group +spec: "1cpu/4gb/32gb" +default_attachments: + - {kind: role, catalog_ref: software.install.wazuh-agent, params: {}} +""".lstrip()) + + _write(layer / "box_templates" / "admin-wazuh" / "v1.0.0" / "template.yml", """ +id: admin-wazuh +api_version: 1 +description: Wazuh SIEM admin box +role: admin +default_inventory_group: r42_admin_group +spec: "4cpu/8gb/64gb" +""".lstrip()) + + # two versions of a policy — loader must pick the highest (1.1.0) + for ver, comment in (("v1.0.0", "v1"), ("v1.1.0", "v1.1")): + _write(layer / "network_policies" / "air-gap-ctf" / ver / "template.yml", f""" +id: air-gap-ctf +api_version: 1 +kind: isolation-policy +description: admin/ctf isolation + ctf air-gap ({comment}) +params: + siem_ip: "192.168.142.100" +zones: + - {{name: admin}} + - {{name: ctf}} + - {{name: wan, wan: true}} +services: + - {{name: siem, zone: admin, ports: [{{proto: tcp, port: 1514}}, {{proto: tcp, port: 1515}}]}} +defaults: + default_action: drop + accept_established_related: true + allow_intra_zone: true + airgap_zones: [ctf] +matrix: + - {{src: admin, dst: ctf, action: accept, comment: "admin manages vuln boxes"}} + - {{src: ctf, dst: "svc:siem", action: accept, comment: "wazuh agent"}} + - {{src: ctf, dst: admin, action: drop, comment: "zone isolation"}} +""".lstrip()) + + return root + + +@pytest.fixture +def reserved_factory(tmp_path): + """Write a JSONL _reserved.json (one object per line) from a list of dicts.""" + import json + + def _make(entries: list[dict]) -> "object": + from pathlib import Path + path = Path(tmp_path) / "_reserved.json" + path.write_text("\n".join(json.dumps(e) for e in entries) + "\n", encoding="utf-8") + return path + return _make diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 00000000..7d189027 --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,72 @@ +"""P2 catalog loader tests — RED before GREEN.""" + +import pytest + +from r42topo.core.catalog import load_catalog +from r42topo.core.errors import CatalogNotFoundError + + +def test_load_catalog_indexes_all_categories(fake_catalog): + cat = load_catalog(fake_catalog) + assert set(cat.box_templates) == {"vuln-box", "admin-wazuh"} + assert set(cat.network_policies) == {"air-gap-ctf"} + assert set(cat.subnet_layouts) == {"default-3zone"} + + +def test_box_template_fields_parsed(fake_catalog): + cat = load_catalog(fake_catalog) + vb = cat.box_templates["vuln-box"] + assert vb.role == "ctf" + assert vb.default_inventory_group == "r42_vuln_box_group" + assert vb.default_attachments[0].catalog_ref == "software.install.wazuh-agent" + + +def test_loader_picks_highest_version(fake_catalog): + cat = load_catalog(fake_catalog) + pol = cat.network_policies["air-gap-ctf"] + assert cat.resolved_version("network_policies", "air-gap-ctf") == "1.1.0" + # content hash is recorded for reproducibility + assert len(cat.resolved_hash("network_policies", "air-gap-ctf")) == 64 + + +def test_network_policy_structure(fake_catalog): + cat = load_catalog(fake_catalog) + pol = cat.network_policies["air-gap-ctf"] + assert pol.kind == "isolation-policy" + assert {z.name for z in pol.zones} == {"admin", "ctf", "wan"} + assert any(z.wan for z in pol.zones) + assert pol.defaults.airgap_zones == ["ctf"] + assert any(r.dst == "svc:siem" for r in pol.matrix) + + +def test_resolve_helpers(fake_catalog): + cat = load_catalog(fake_catalog) + assert cat.resolve_box_template("vuln-box").id == "vuln-box" + assert cat.resolve_network_policy("air-gap-ctf").id == "air-gap-ctf" + + +def test_missing_template_raises(fake_catalog): + cat = load_catalog(fake_catalog) + with pytest.raises(CatalogNotFoundError): + cat.resolve_box_template("does-not-exist") + + +def test_missing_layer_raises(tmp_path): + with pytest.raises(CatalogNotFoundError): + load_catalog(tmp_path / "empty") + + +def test_template_id_traversal_rejected(fake_catalog): + cat = load_catalog(fake_catalog) + with pytest.raises(CatalogNotFoundError): + cat.resolve_box_template("../../etc/passwd") + + +def test_shipped_catalog_validates(): + """The real range42-catalog/05_topology_layer (sibling repo) must load if present.""" + from pathlib import Path + sibling = Path(__file__).resolve().parents[2] / "range42-catalog" + if not (sibling / "05_topology_layer").is_dir(): + pytest.skip("range42-catalog sibling not checked out") + cat = load_catalog(sibling) + assert cat.box_templates and cat.network_policies and cat.subnet_layouts diff --git a/tests/test_idalloc.py b/tests/test_idalloc.py new file mode 100644 index 00000000..b36bad4a --- /dev/null +++ b/tests/test_idalloc.py @@ -0,0 +1,64 @@ +"""P2 id/IP allocation + reservation tests — RED before GREEN.""" + +from r42topo.core.idalloc import ReservedIndex, validate_allocation +from r42topo.core.models import Topology + + +def test_valid_topology_has_no_allocation_errors(valid_topology_dict, reserved_factory): + t = Topology.model_validate(valid_topology_dict) + reserved = ReservedIndex.from_file(reserved_factory([])) + report = validate_allocation(t, reserved) + assert report.errors == [] + + +def test_octet_rule_violation_is_error(topology_factory, reserved_factory): + spec = topology_factory() + spec["boxes"][0]["vm_id"] = 1101 # ip ends .100 -> 101 != 100 + t = Topology.model_validate(spec) + report = validate_allocation(t, ReservedIndex.from_file(reserved_factory([]))) + assert any("octet" in e.lower() for e in report.errors) + + +def test_cross_scenario_vm_id_collision_is_error(valid_topology_dict, reserved_factory): + t = Topology.model_validate(valid_topology_dict) + reserved = ReservedIndex.from_file(reserved_factory([ + {"vm_id": 1100, "ip": "192.168.1.1", "scenario": "other_lab", "role": "admin"}, + ])) + report = validate_allocation(t, reserved) + assert any("1100" in e and "other_lab" in e for e in report.errors) + + +def test_same_scenario_reservation_is_not_collision(valid_topology_dict, reserved_factory): + t = Topology.model_validate(valid_topology_dict) # scenario demo_lab_network + reserved = ReservedIndex.from_file(reserved_factory([ + {"vm_id": 1100, "ip": "192.168.142.100", "scenario": "demo_lab_network", "role": "admin"}, + ])) + report = validate_allocation(t, reserved) + assert report.errors == [] # re-deploy of own scenario is fine + + +def test_cross_scenario_ip_collision_is_error(valid_topology_dict, reserved_factory): + t = Topology.model_validate(valid_topology_dict) + reserved = ReservedIndex.from_file(reserved_factory([ + {"vm_id": 5555, "ip": "192.168.142.100", "scenario": "other_lab", "role": "admin"}, + ])) + report = validate_allocation(t, reserved) + assert any("192.168.142.100" in e and "other_lab" in e for e in report.errors) + + +def test_intra_topology_duplicate_vm_id_is_error(topology_factory, reserved_factory): + spec = topology_factory() + spec["boxes"][1]["vm_id"] = spec["boxes"][0]["vm_id"] # dup within topology + spec["boxes"][1]["ip"] = "192.168.144." + str(spec["boxes"][0]["vm_id"] % 1000) + t = Topology.model_validate(spec) + report = validate_allocation(t, ReservedIndex.from_file(reserved_factory([]))) + assert any("duplicate" in e.lower() for e in report.errors) + + +def test_reserved_index_parses_jsonl(reserved_factory): + reserved = ReservedIndex.from_file(reserved_factory([ + {"vm_id": 2001, "ip": "192.168.143.200", "scenario": "a", "role": "team"}, + {"vm_id": 2002, "ip": "192.168.143.201", "scenario": "a", "role": "team"}, + ])) + assert reserved.used_vm_ids() == {2001, 2002} + assert "192.168.143.200" in reserved.used_ips() From e9c132a1f2f104380e6ecd595deb721c348a13c6 Mon Sep 17 00:00:00 2001 From: xinshen Date: Wed, 3 Jun 2026 13:18:04 +0200 Subject: [PATCH 009/121] feat(r42topo): P3 compiler + adapter + segmentation linter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First end-to-end vertical slice: author -> compile -> _universal. - core/validate.py: semantic checks (zone/subnet refs, IP-in-subnet, dangling catalog refs) - core/compiler/network_policy.py: compile symbolic policy + topology bindings into deterministically ordered FORWARD rules via weight bands (ESTABLISHED -> service -> zone-accept -> intra -> zone-drop -> air-gap -> default); plus lint_segmentation() fail-closed checks (FORWARD-only/host-SSH-safe, established precedes drops, no ACCEPT shadows a deny, air-gap present, terminal default-deny) - core/compiler/{inventory,scenario_vms,stages}.py: hosts.yml (range42 nested groups), scenario_vms.json (demo_lab shape), per-zone attachment dispatch (template defaults + box attachments, deduped) - core/compiler/__init__.py: compile_topology() orchestrator -> CompileResult - core/extravars.py: resolve_universal_extravars() — typed r42_* allow-list - api.py: load_catalog/author_topology/validate_topology/compile_topology/ resolve_universal_extravars — pure adapter for backend + CLI/TUI consumers - tests: +26 (56 total), 93% coverage Verified: compiling against the REAL range42-catalog 05_topology_layer and running the actual _universal stub (origin/feature/gamenet-authoring-v1) passes green; generated FORWARD table reproduces demo_lab_network's isolation rules. --- r42topo/api.py | 54 ++++++ r42topo/core/compiler/__init__.py | 102 +++++++++++ r42topo/core/compiler/inventory.py | 49 ++++++ r42topo/core/compiler/network_policy.py | 220 ++++++++++++++++++++++++ r42topo/core/compiler/scenario_vms.py | 42 +++++ r42topo/core/compiler/stages.py | 71 ++++++++ r42topo/core/extravars.py | 46 +++++ r42topo/core/validate.py | 79 +++++++++ tests/test_api.py | 55 ++++++ tests/test_compiler.py | 140 +++++++++++++++ tests/test_extravars.py | 47 +++++ tests/test_validate.py | 55 ++++++ 12 files changed, 960 insertions(+) create mode 100644 r42topo/api.py create mode 100644 r42topo/core/compiler/__init__.py create mode 100644 r42topo/core/compiler/inventory.py create mode 100644 r42topo/core/compiler/network_policy.py create mode 100644 r42topo/core/compiler/scenario_vms.py create mode 100644 r42topo/core/compiler/stages.py create mode 100644 r42topo/core/extravars.py create mode 100644 r42topo/core/validate.py create mode 100644 tests/test_api.py create mode 100644 tests/test_compiler.py create mode 100644 tests/test_extravars.py create mode 100644 tests/test_validate.py diff --git a/r42topo/api.py b/r42topo/api.py new file mode 100644 index 00000000..fa934f8e --- /dev/null +++ b/r42topo/api.py @@ -0,0 +1,54 @@ +"""Importable adapter — the surface every frontend calls. + +Consumed by the range42-backend-api (FastAPI), r42topo's own CLI/TUI, and the +range42 deployment CLI/TUI. Pure: returns plain models / dicts / lists and +raises the ``r42topo.core.errors`` hierarchy — never framework types. Each +consumer maps these into its own surface (HTTP envelopes, exit codes, dialogs). +""" + +from pathlib import Path + +from r42topo.core.catalog import Catalog, load_catalog +from r42topo.core.compiler import CompileResult, compile_topology +from r42topo.core.errors import ValidationError +from r42topo.core.extravars import resolve_universal_extravars +from r42topo.core.idalloc import ReservedIndex, validate_allocation +from r42topo.core.models import Topology +from r42topo.core.validate import semantic_problems + +__all__ = [ + "load_catalog", + "author_topology", + "validate_topology", + "compile_topology", + "resolve_universal_extravars", + "Catalog", + "CompileResult", + "Topology", + "ReservedIndex", +] + + +def author_topology(spec: dict, *, catalog: Catalog) -> Topology: + """Validate a raw author spec into a Topology, rejecting dangling catalog refs. + + :raises ValidationError: on schema failure or a reference the catalog can't resolve. + """ + try: + topology = Topology.model_validate(spec) + except Exception as exc: # pydantic ValidationError -> our ValidationError + raise ValidationError(f"invalid topology: {exc}") from exc + + problems = semantic_problems(topology, catalog) + if problems: + raise ValidationError("topology references unresolved items: " + "; ".join(problems)) + return topology + + +def validate_topology( + topology: Topology, *, catalog: Catalog, reserved: ReservedIndex +) -> list[str]: + """Return all problems (semantic + allocation) with a topology ([] == valid).""" + problems = list(semantic_problems(topology, catalog)) + problems.extend(validate_allocation(topology, reserved).errors) + return problems diff --git a/r42topo/core/compiler/__init__.py b/r42topo/core/compiler/__init__.py new file mode 100644 index 00000000..e692d072 --- /dev/null +++ b/r42topo/core/compiler/__init__.py @@ -0,0 +1,102 @@ +"""Topology compiler: expand a validated Topology into deploy artifacts. + +``compile_topology`` is the orchestrator. It validates (semantics + allocation), +resolves catalog templates, builds the artifacts in an isolated workspace, runs +the network-segmentation linter, and returns a :class:`CompileResult` of paths. +Artifacts are written under:: + + /project/topology.json + /project/manifest/scenario_vms.json + /project/network_policy.json + /project/stages.json + /inventory/hosts.yml +""" + +from dataclasses import dataclass +from pathlib import Path + +from r42topo.core.catalog import Catalog +from r42topo.core.compiler import inventory as _inventory +from r42topo.core.compiler import network_policy as _netpol +from r42topo.core.compiler import scenario_vms as _scenario_vms +from r42topo.core.compiler import stages as _stages +from r42topo.core.errors import CompileError +from r42topo.core.idalloc import ReservedIndex, validate_allocation +from r42topo.core.io import dump_topology +from r42topo.core.models import Topology +from r42topo.core.validate import semantic_problems + + +@dataclass(frozen=True) +class CompileResult: + """Absolute paths to the artifacts produced by ``compile_topology``.""" + + workspace: Path + topology_path: Path + inventory_path: Path + scenario_vms_path: Path + network_policy_path: Path + stages_path: Path + + +def _write_json(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def compile_topology( + topology: Topology, + *, + workspace: Path, + catalog: Catalog, + reserved: ReservedIndex, +) -> CompileResult: + """Compile *topology* into deploy artifacts under *workspace*. + + :raises CompileError: on any semantic, allocation, or segmentation failure. + """ + # 1. semantics + problems = semantic_problems(topology, catalog) + if problems: + raise CompileError("topology has semantic errors: " + "; ".join(problems)) + + # 2. allocation (octet rule, duplicates, cross-scenario collisions) + report = validate_allocation(topology, reserved) + if report.errors: + raise CompileError("allocation errors: " + "; ".join(report.errors)) + + # 3. resolve the network policy + compile its rule table + policy = catalog.resolve_network_policy(topology.network_policy.template) + version = catalog.resolved_version("network_policies", policy.id) + compiled_policy = _netpol.compile_network_policy(topology, policy, version=version) + + # 4. segmentation linter — fail closed + lint = _netpol.lint_segmentation(compiled_policy, policy, topology) + if lint: + raise CompileError("network segmentation linter: " + "; ".join(lint)) + + # 5. build artifacts + workspace = Path(workspace) + project = workspace / "project" + inv_dir = workspace / "inventory" + + topology_path = project / "topology.json" + inventory_path = inv_dir / "hosts.yml" + scenario_vms_path = project / "manifest" / "scenario_vms.json" + network_policy_path = project / "network_policy.json" + stages_path = project / "stages.json" + + dump_topology(topology, topology_path) + _write_json(inventory_path, _inventory.build_inventory_yaml(topology)) + _write_json(scenario_vms_path, _scenario_vms.build_scenario_vms_json(topology)) + _write_json(network_policy_path, compiled_policy.model_dump_json(indent=2) + "\n") + _write_json(stages_path, _stages.build_stages_json(topology, catalog)) + + return CompileResult( + workspace=workspace, + topology_path=topology_path, + inventory_path=inventory_path, + scenario_vms_path=scenario_vms_path, + network_policy_path=network_policy_path, + stages_path=stages_path, + ) diff --git a/r42topo/core/compiler/inventory.py b/r42topo/core/compiler/inventory.py new file mode 100644 index 00000000..05429365 --- /dev/null +++ b/r42topo/core/compiler/inventory.py @@ -0,0 +1,49 @@ +"""Compile a topology into an Ansible YAML inventory (hosts.yml). + +Reproduces range42's nested layout:: + + all.children.range42_infrastructure.children..hosts.r42. + +Only the scenario's VM groups are emitted; the ``proxmox`` / ``proxmox-cli`` +host groups are workspace-level (provided by the deployer base inventory), so +the host-level network-policy play targets those, merged at deploy time. + +Group names come from each box's validated ``inventory_group`` (snake_case); +host keys are ``r42.``. Emitted via ``yaml.safe_dump`` — topology +strings are data, never templated, so there is no SSTI surface. +""" + +import yaml + +from r42topo.core.models import Topology + + +def build_inventory(topology: Topology) -> dict: + """Return the inventory as a plain dict (groups -> hosts -> host vars).""" + groups: dict[str, dict] = {} + zone_role = {z.name: z.role for z in topology.zones} + + for box in topology.boxes: + group = groups.setdefault(box.inventory_group, {"hosts": {}}) + group["hosts"][f"r42.{box.vm_name}"] = { + "ansible_host": box.ip, + "r42_vm_id": box.vm_id, + "r42_vm_name": box.vm_name, + "r42_zone": box.zone, + "r42_role": zone_role.get(box.zone), + } + + return { + "all": { + "children": { + "range42_infrastructure": { + "children": {name: groups[name] for name in sorted(groups)}, + }, + }, + }, + } + + +def build_inventory_yaml(topology: Topology) -> str: + """Serialize the inventory as deterministic YAML.""" + return yaml.safe_dump(build_inventory(topology), sort_keys=True, default_flow_style=False) diff --git a/r42topo/core/compiler/network_policy.py b/r42topo/core/compiler/network_policy.py new file mode 100644 index 00000000..c2e39864 --- /dev/null +++ b/r42topo/core/compiler/network_policy.py @@ -0,0 +1,220 @@ +"""Compile a symbolic network policy + topology bindings into ordered FORWARD rules. + +iptables FORWARD is first-match-wins, so rule ORDER is correctness, not style. +Each emitted rule gets a deterministic ``weight`` from a fixed band table; the +final list is stable-sorted by (weight, source, destination, proto, port, jump) +so identical inputs yield byte-identical output. The deploy playbook (Plan B) +flush-rebuilds a dedicated ``R42-FORWARD`` chain from this list in order, which +is naturally idempotent and never touches the host INPUT chain (SSH stays up). +""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from r42topo.core.catalog_models import MatrixRule, NetworkPolicyTemplate, PortSpec +from r42topo.core.models import Topology +from r42topo.core.validate import zone_bridge_map, zone_subnet_map + +# weight bands — lower weight is evaluated earlier in FORWARD +W_ESTABLISHED = 0 +W_SERVICE_ACCEPT = 100 +W_ZONE_ACCEPT = 200 +W_INTRA = 300 +W_ZONE_DROP = 500 +W_AIRGAP = 600 +W_DEFAULT = 900 + +_ACTION_JUMP = {"accept": "ACCEPT", "drop": "DROP", "reject": "REJECT"} +_SVC = "svc:" + + +class CompiledRule(BaseModel): + """A single iptables FORWARD rule, fully resolved to concrete values.""" + + model_config = ConfigDict(extra="forbid") + + weight: int + chain: Literal["FORWARD"] = "FORWARD" + proto: Literal["tcp", "udp", "icmp", "all"] = "all" + source: str | None = None + destination: str | None = None + destination_port: str | None = None + in_interface: str | None = None + out_interface: str | None = None + ctstate: str | None = None + jump: Literal["ACCEPT", "DROP", "REJECT"] + comment: str + + +class CompiledNetworkPolicy(BaseModel): + """The ordered FORWARD rule table produced from a policy + topology.""" + + model_config = ConfigDict(extra="forbid") + + chain: Literal["FORWARD"] = "FORWARD" + policy_id: str + policy_version: str + wan_interface: str + rules: list[CompiledRule] = Field(default_factory=list) + + +def _sort_key(r: CompiledRule) -> tuple: + return (r.weight, r.source or "", r.destination or "", + r.in_interface or "", r.proto, r.destination_port or "", r.jump) + + +def _port_str(p: PortSpec) -> str | None: + if p.port is None: + return None + return f"{p.port}:{p.port_end}" if p.port_end else str(p.port) + + +def _param(policy: NetworkPolicyTemplate, topology: Topology, key: str, default): + """Resolve a policy param: topology override wins over template default.""" + return topology.network_policy.overrides.get(key, policy.params.get(key, default)) + + +def compile_network_policy( + topology: Topology, policy: NetworkPolicyTemplate, *, version: str +) -> CompiledNetworkPolicy: + """Compile *policy* against *topology*'s zone/subnet bindings into FORWARD rules.""" + zsubnet = zone_subnet_map(topology) + zbridge = zone_bridge_map(topology) + services = {s.name: s for s in policy.services} + wan_interface = _param(policy, topology, "wan_interface", "vmbr0") + defaults = policy.defaults + + rules: list[CompiledRule] = [] + + # band 0 — return traffic + if defaults.accept_established_related: + rules.append(CompiledRule( + weight=W_ESTABLISHED, ctstate="ESTABLISHED,RELATED", jump="ACCEPT", + comment="r42: allow established", + )) + + # explicit matrix rules + for mr in policy.matrix: + rules.extend(_compile_matrix_rule(mr, zsubnet, services, topology, policy)) + + # band 300 — intra-zone accept + if defaults.allow_intra_zone: + for zname, cidr in sorted(zsubnet.items()): + rules.append(CompiledRule( + weight=W_INTRA, source=cidr, destination=cidr, jump="ACCEPT", + comment=f"r42: intra-zone {zname}", + )) + + # band 600 — air-gap (zone bridge -> wan drop) + for zname in defaults.airgap_zones: + bridge = zbridge.get(zname) + if bridge is None: + continue + rules.append(CompiledRule( + weight=W_AIRGAP, in_interface=bridge, out_interface=wan_interface, + source=zsubnet.get(zname), jump="DROP", + comment=f"r42: air-gap {zname}", + )) + + # band 900 — terminal default + rules.append(CompiledRule( + weight=W_DEFAULT, jump=_ACTION_JUMP[defaults.default_action], + comment=f"r42: default {defaults.default_action}", + )) + + rules.sort(key=_sort_key) + return CompiledNetworkPolicy( + policy_id=policy.id, policy_version=version, + wan_interface=wan_interface, rules=rules, + ) + + +def _compile_matrix_rule(mr, zsubnet, services, topology, policy) -> list[CompiledRule]: + jump = _ACTION_JUMP[mr.action] + src = None if mr.src == "*" else zsubnet.get(mr.src) + comment = f"r42: {mr.comment}" if mr.comment else f"r42: {mr.src}->{mr.dst}" + + # service destination (svc:) -> one rule per service port + if mr.dst.startswith(_SVC): + svc_name = mr.dst[len(_SVC):] + svc = services.get(svc_name) + if svc is None: + return [] + svc_ip = _param(policy, topology, f"{svc_name}_ip", None) + out: list[CompiledRule] = [] + for p in (mr.ports or svc.ports): + out.append(CompiledRule( + weight=W_SERVICE_ACCEPT if mr.action == "accept" else W_ZONE_DROP, + proto=p.proto, source=src, destination=svc_ip, + destination_port=_port_str(p), jump=jump, comment=comment, + )) + return out + + # zone destination + dst = zsubnet.get(mr.dst) + weight = W_ZONE_ACCEPT if mr.action == "accept" else W_ZONE_DROP + if mr.ports: + return [CompiledRule(weight=weight, proto=p.proto, source=src, destination=dst, + destination_port=_port_str(p), jump=jump, comment=comment) + for p in mr.ports] + return [CompiledRule(weight=weight, source=src, destination=dst, jump=jump, + comment=comment)] + + +def lint_segmentation( + compiled: CompiledNetworkPolicy, policy: NetworkPolicyTemplate, topology: Topology +) -> list[str]: + """Assert the compiled rule table is safe. Returns problems ([] == safe).""" + problems: list[str] = [] + rules = compiled.rules + + # 1. FORWARD only — host INPUT (SSH/mgmt) must never be touched + if any(r.chain != "FORWARD" for r in rules): + problems.append("non-FORWARD rule present (host INPUT must not be modified)") + + drop_weights = [r.weight for r in rules if r.jump in ("DROP", "REJECT")] + first_drop = min(drop_weights) if drop_weights else None + + # 2. established accept must exist and precede any drop + if policy.defaults.accept_established_related: + est = [r for r in rules if r.ctstate and "ESTABLISHED" in r.ctstate and r.jump == "ACCEPT"] + if not est: + problems.append("missing ESTABLISHED,RELATED ACCEPT") + elif first_drop is not None and min(r.weight for r in est) > first_drop: + problems.append("ESTABLISHED accept does not precede DROP rules") + + zsubnet = zone_subnet_map(topology) + + # 3. each explicit deny pair must not be shadowed by an earlier accept + for mr in policy.matrix: + if mr.action not in ("drop", "reject"): + continue + if mr.dst.startswith(_SVC): + continue + src, dst = zsubnet.get(mr.src), zsubnet.get(mr.dst) + if src is None or dst is None: + continue + drop_w = min((r.weight for r in rules + if r.source == src and r.destination == dst and r.jump in ("DROP", "REJECT")), + default=None) + if drop_w is None: + problems.append(f"deny {mr.src}->{mr.dst} produced no DROP rule") + continue + if any(r.source == src and r.destination == dst and r.jump == "ACCEPT" and r.weight < drop_w + for r in rules): + problems.append(f"ACCEPT shadows deny {mr.src}->{mr.dst} (ordering hazard)") + + # 4. each air-gap zone must have a wan-drop rule + zbridge = zone_bridge_map(topology) + for zname in policy.defaults.airgap_zones: + bridge = zbridge.get(zname) + if bridge and not any(r.in_interface == bridge and r.out_interface and r.jump == "DROP" + for r in rules): + problems.append(f"air-gap zone {zname!r} missing wan DROP") + + # 5. default-deny must be the terminal rule + if policy.defaults.default_action == "drop" and rules and rules[-1].jump != "DROP": + problems.append("default-deny is not the terminal rule") + + return problems diff --git a/r42topo/core/compiler/scenario_vms.py b/r42topo/core/compiler/scenario_vms.py new file mode 100644 index 00000000..17b3b121 --- /dev/null +++ b/r42topo/core/compiler/scenario_vms.py @@ -0,0 +1,42 @@ +"""Compile a topology into manifest/scenario_vms.json (the demo_lab shape). + +range42-context reads this manifest to know a scenario's VMs (e.g. to flush +their SSH known_hosts on redeploy). Role comes from the box's zone; bridge +comes from the zone's subnet. Templates (the 9xxx clone sources) are not part +of a topology and are emitted as an empty list for now. +""" + +import json + +from r42topo.core.models import Topology +from r42topo.core.validate import zone_bridge_map + + +def build_scenario_vms(topology: Topology) -> dict: + """Return the scenario_vms manifest as a plain dict.""" + zone_role = {z.name: z.role for z in topology.zones} + zbridge = zone_bridge_map(topology) + + vms = [ + { + "vm_id": box.vm_id, + "vm_name": box.vm_name, + "ip": box.ip, + "role": zone_role.get(box.zone), + "bridge": zbridge.get(box.zone), + } + for box in topology.boxes + ] + + return { + "scenario": topology.scenario, + "version": 2, + "description": topology.description, + "vms": vms, + "templates": [], + } + + +def build_scenario_vms_json(topology: Topology) -> str: + """Serialize the manifest as deterministic JSON.""" + return json.dumps(build_scenario_vms(topology), indent=2, sort_keys=True) + "\n" diff --git a/r42topo/core/compiler/stages.py b/r42topo/core/compiler/stages.py new file mode 100644 index 00000000..bb5d72e1 --- /dev/null +++ b/r42topo/core/compiler/stages.py @@ -0,0 +1,71 @@ +"""Compile a topology into stages.json — the per-zone box/attachment dispatch. + +This is the data the ``_universal`` Plan B playbook iterates: for each zone (in +deploy order), the boxes to create and, per box, the ordered catalog +attachments to apply. Each box's effective attachments are the box_template's +``default_attachments`` first, then the box's own ``attachments``, de-duplicated +by (kind, catalog_ref) keeping first occurrence. +""" + +import json + +from r42topo.core.catalog import Catalog +from r42topo.core.models import Box, Topology + +# deploy order for zone roles (admin infra before students before ctf targets) +_ROLE_ORDER = {"template": 0, "admin": 1, "student": 2, "team": 3, "ctf": 4} + + +def _effective_attachments(box: Box, catalog: Catalog) -> list[dict]: + template = catalog.box_templates.get(box.box_template) + merged: list[dict] = [] + seen: set[tuple[str, str]] = set() + source = list(template.default_attachments) if template else [] + source += list(box.attachments) + for att in source: + key = (att.kind, att.catalog_ref) + if key in seen: + continue + seen.add(key) + merged.append({"kind": att.kind, "catalog_ref": att.catalog_ref, "params": att.params}) + return merged + + +def build_stages(topology: Topology, catalog: Catalog) -> dict: + """Return the stages dispatch as a plain dict.""" + zones_by_name = {z.name: z for z in topology.zones} + boxes_by_zone: dict[str, list[Box]] = {} + for box in topology.boxes: + boxes_by_zone.setdefault(box.zone, []).append(box) + + ordered_zone_names = sorted( + boxes_by_zone, + key=lambda zn: (_ROLE_ORDER.get(zones_by_name[zn].role, 99), zn) + if zn in zones_by_name else (99, zn), + ) + + zones_out = [] + for zname in ordered_zone_names: + zone = zones_by_name.get(zname) + zones_out.append({ + "name": zname, + "role": zone.role if zone else None, + "boxes": [ + { + "vm_name": box.vm_name, + "vm_id": box.vm_id, + "ip": box.ip, + "box_template": box.box_template, + "inventory_group": box.inventory_group, + "attachments": _effective_attachments(box, catalog), + } + for box in sorted(boxes_by_zone[zname], key=lambda b: b.vm_id) + ], + }) + + return {"scenario": topology.scenario, "zones": zones_out} + + +def build_stages_json(topology: Topology, catalog: Catalog) -> str: + """Serialize the stages dispatch as deterministic JSON.""" + return json.dumps(build_stages(topology, catalog), indent=2, sort_keys=True) + "\n" diff --git a/r42topo/core/extravars.py b/r42topo/core/extravars.py new file mode 100644 index 00000000..4c655e71 --- /dev/null +++ b/r42topo/core/extravars.py @@ -0,0 +1,46 @@ +"""Build the extravars dict the ``_universal`` scenario consumes. + +The backend calls ``run_playbook_core(scenarios/_universal/main.yml, inventory, +extravars=...)``; ``_universal`` asserts ``r42_topology_path`` is set and points +at a real file. This builder emits ONLY the allow-listed ``r42_*`` keys — no +``ansible_*`` or arbitrary keys can leak into the playbook's variable space. +Identifier values are deny-list checked (no injection into templated vars). +""" + +from r42topo.core import constants as C +from r42topo.core.compiler import CompileResult +from r42topo.core.errors import ValidationError + +# the exact contract keys _universal/main.yml expects +_ALLOWED_KEYS = ( + "r42_topology_path", "r42_inventory_dir", "r42_deployment_id", + "r42_attempt_id", "r42_scope", "r42_team_id", +) + + +def _safe_id(name: str, value: str) -> str: + if C.violates_denylist(value): + raise ValidationError(f"{name} contains a forbidden character or pattern") + return value + + +def resolve_universal_extravars( + result: CompileResult, + *, + deployment_id: str, + attempt_id: str, + scope: str, + team_id: str | None = None, +) -> dict: + """Return the typed, allow-listed extravars for the ``_universal`` playbook.""" + extravars = { + "r42_topology_path": str(result.topology_path), + "r42_inventory_dir": str(result.workspace / "inventory"), + "r42_deployment_id": _safe_id("deployment_id", deployment_id), + "r42_attempt_id": _safe_id("attempt_id", attempt_id), + "r42_scope": _safe_id("scope", scope), + "r42_team_id": _safe_id("team_id", team_id) if team_id else "", + } + # defensive: guarantee no key escaped the allow-list + assert set(extravars) == set(_ALLOWED_KEYS) + return extravars diff --git a/r42topo/core/validate.py b/r42topo/core/validate.py new file mode 100644 index 00000000..87247354 --- /dev/null +++ b/r42topo/core/validate.py @@ -0,0 +1,79 @@ +"""Semantic validation of a topology against itself and the catalog. + +Schema-level constraints (patterns, ranges) are handled by the pydantic models. +This module covers cross-field semantics that pydantic cannot express alone: +referential integrity (zone->subnet, box->zone), IP-in-subnet, and dangling +catalog references. Returns a list of human-readable problems ([] == valid); +never raises. +""" + +import ipaddress + +from r42topo.core.catalog import Catalog +from r42topo.core.models import Topology + + +def zone_subnet_map(topology: Topology) -> dict[str, str]: + """Map zone name -> subnet cidr (only for zones whose subnet exists).""" + subnet_cidr = {s.name: s.cidr for s in topology.subnets} + return { + z.name: subnet_cidr[z.subnet] + for z in topology.zones + if z.subnet in subnet_cidr + } + + +def zone_bridge_map(topology: Topology) -> dict[str, str]: + """Map zone name -> bridge (only for zones whose subnet exists).""" + subnet_bridge = {s.name: s.bridge for s in topology.subnets} + return { + z.name: subnet_bridge[z.subnet] + for z in topology.zones + if z.subnet in subnet_bridge + } + + +def semantic_problems(topology: Topology, catalog: Catalog) -> list[str]: + """Return human-readable semantic problems with a topology ([] if clean).""" + problems: list[str] = [] + + subnet_names = {s.name for s in topology.subnets} + zone_names = {z.name for z in topology.zones} + + # zones must reference an existing subnet + for z in topology.zones: + if z.subnet not in subnet_names: + problems.append(f"zone {z.name!r} references unknown subnet {z.subnet!r}") + + zsubnet = zone_subnet_map(topology) + + # boxes: zone must exist; ip must fall inside the zone's subnet + for box in topology.boxes: + if box.zone not in zone_names: + problems.append(f"box {box.vm_name!r} references unknown zone {box.zone!r}") + continue + cidr = zsubnet.get(box.zone) + if cidr is None: + continue # zone's subnet already reported missing + try: + net = ipaddress.ip_network(cidr, strict=True) + if ipaddress.ip_address(box.ip) not in net: + problems.append( + f"box {box.vm_name!r} ip {box.ip} not in zone {box.zone!r} subnet {cidr}" + ) + except ValueError as exc: + problems.append(f"box {box.vm_name!r}: {exc}") + + # box_template must resolve in the catalog + if box.box_template not in catalog.box_templates: + problems.append( + f"box {box.vm_name!r} references unknown box_template {box.box_template!r}" + ) + + # network policy template must resolve + if topology.network_policy.template not in catalog.network_policies: + problems.append( + f"unknown network_policy template {topology.network_policy.template!r}" + ) + + return problems diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000..ae0d4de4 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,55 @@ +"""P3 importable-API adapter tests (the surface FastAPI / deployment CLI call).""" + +import pytest + +from r42topo import api +from r42topo.core.errors import ValidationError +from r42topo.core.idalloc import ReservedIndex +from r42topo.core.models import Topology + + +def test_load_catalog_reexported(fake_catalog): + cat = api.load_catalog(fake_catalog) + assert "vuln-box" in cat.box_templates + + +def test_author_topology_returns_model(topology_factory, fake_catalog): + cat = api.load_catalog(fake_catalog) + t = api.author_topology(topology_factory(), catalog=cat) + assert isinstance(t, Topology) + + +def test_author_topology_rejects_dangling_ref(topology_factory, fake_catalog): + cat = api.load_catalog(fake_catalog) + spec = topology_factory() + spec["boxes"][0]["box_template"] = "missing-template" + with pytest.raises(ValidationError): + api.author_topology(spec, catalog=cat) + + +def test_validate_topology_clean(topology_factory, fake_catalog, reserved_factory): + cat = api.load_catalog(fake_catalog) + t = Topology.model_validate(topology_factory()) + reserved = ReservedIndex.from_file(reserved_factory([])) + assert api.validate_topology(t, catalog=cat, reserved=reserved) == [] + + +def test_validate_topology_reports_problems(topology_factory, fake_catalog, reserved_factory): + cat = api.load_catalog(fake_catalog) + spec = topology_factory() + spec["boxes"][0]["ip"] = "10.0.0.100" + t = Topology.model_validate(spec) + reserved = ReservedIndex.from_file(reserved_factory([])) + assert api.validate_topology(t, catalog=cat, reserved=reserved) + + +def test_full_author_compile_extravars(topology_factory, fake_catalog, reserved_factory, tmp_path): + cat = api.load_catalog(fake_catalog) + reserved = ReservedIndex.from_file(reserved_factory([])) + t = api.author_topology(topology_factory(), catalog=cat) + assert api.validate_topology(t, catalog=cat, reserved=reserved) == [] + result = api.compile_topology(t, workspace=tmp_path / "ws", catalog=cat, reserved=reserved) + ev = api.resolve_universal_extravars(result, deployment_id="d", attempt_id="a", scope="global") + # the _universal stub asserts this path exists and is a regular file + from pathlib import Path + assert Path(ev["r42_topology_path"]).is_file() diff --git a/tests/test_compiler.py b/tests/test_compiler.py new file mode 100644 index 00000000..37c832d8 --- /dev/null +++ b/tests/test_compiler.py @@ -0,0 +1,140 @@ +"""P3 compiler tests (inventory, scenario_vms, network policy, stages, e2e).""" + +import json + +import pytest +import yaml + +from r42topo.core.catalog import load_catalog +from r42topo.core.compiler import compile_topology +from r42topo.core.compiler.network_policy import compile_network_policy, lint_segmentation +from r42topo.core.idalloc import ReservedIndex +from r42topo.core.models import Topology + + +@pytest.fixture +def built(topology_factory, fake_catalog, reserved_factory, tmp_path): + t = Topology.model_validate(topology_factory()) + cat = load_catalog(fake_catalog) + reserved = ReservedIndex.from_file(reserved_factory([])) + ws = tmp_path / "ws" + result = compile_topology(t, workspace=ws, catalog=cat, reserved=reserved) + return t, cat, result + + +# --- inventory --- + +def test_inventory_structure(built): + _, _, result = built + inv = yaml.safe_load(result.inventory_path.read_text()) + groups = inv["all"]["children"]["range42_infrastructure"]["children"] + assert "r42_admin_group" in groups + assert "r42_vuln_box_group" in groups + admin_hosts = groups["r42_admin_group"]["hosts"] + assert "r42.admin-wazuh" in admin_hosts + assert admin_hosts["r42.admin-wazuh"]["ansible_host"] == "192.168.142.100" + + +# --- scenario_vms manifest --- + +def test_scenario_vms_manifest(built): + _, _, result = built + man = json.loads(result.scenario_vms_path.read_text()) + assert man["scenario"] == "demo_lab_network" + by_name = {v["vm_name"]: v for v in man["vms"]} + assert by_name["admin-wazuh"]["role"] == "admin" + assert by_name["admin-wazuh"]["bridge"] == "vmbr142" + assert by_name["vuln-box-00"]["role"] == "ctf" + + +# --- network policy compilation --- + +def test_network_policy_ordered_rules(built): + t, cat, _ = built + pol = cat.network_policies["air-gap-ctf"] + compiled = compile_network_policy(t, pol, version="1.1.0") + weights = [r.weight for r in compiled.rules] + assert weights == sorted(weights) # emitted in ascending order + + # established first + assert compiled.rules[0].ctstate and "ESTABLISHED" in compiled.rules[0].ctstate + # admin -> ctf accept + assert any(r.source == "192.168.142.0/24" and r.destination == "192.168.144.0/24" + and r.jump == "ACCEPT" for r in compiled.rules) + # ctf -> siem 1514/1515 accept + siem = [r for r in compiled.rules if r.destination == "192.168.142.100" and r.jump == "ACCEPT"] + assert {r.destination_port for r in siem} == {"1514", "1515"} + # ctf -> admin drop + assert any(r.source == "192.168.144.0/24" and r.destination == "192.168.142.0/24" + and r.jump == "DROP" for r in compiled.rules) + # air-gap: ctf bridge -> wan drop + assert any(r.in_interface == "vmbr144" and r.jump == "DROP" and r.out_interface + for r in compiled.rules) + # terminal default-deny + assert compiled.rules[-1].jump == "DROP" + + +def test_network_policy_deterministic(built): + t, cat, _ = built + pol = cat.network_policies["air-gap-ctf"] + a = compile_network_policy(t, pol, version="1.1.0").model_dump_json() + b = compile_network_policy(t, pol, version="1.1.0").model_dump_json() + assert a == b + + +def test_segmentation_linter_passes_for_air_gap(built): + t, cat, _ = built + pol = cat.network_policies["air-gap-ctf"] + compiled = compile_network_policy(t, pol, version="1.1.0") + assert lint_segmentation(compiled, pol, t) == [] + + +def test_segmentation_linter_flags_shadowed_drop(built): + """An ACCEPT that precedes a same-pair DROP must be flagged (ordering hazard).""" + t, cat, _ = built + pol = cat.network_policies["air-gap-ctf"].model_copy(deep=True) + # add ctf->admin ACCEPT before the existing ctf->admin DROP + from r42topo.core.catalog_models import MatrixRule + pol.matrix.insert(0, MatrixRule(src="ctf", dst="admin", action="accept", + comment="bad: opens ctf->admin")) + compiled = compile_network_policy(t, pol, version="1.1.0") + assert lint_segmentation(compiled, pol, t) != [] + + +# --- stages --- + +def test_stages_merge_template_and_box_attachments(built): + _, _, result = built + stages = json.loads(result.stages_path.read_text()) + by_zone = {z["name"]: z for z in stages["zones"]} + ctf_boxes = by_zone["ctf"]["boxes"] + refs = [a["catalog_ref"] for a in ctf_boxes[0]["attachments"]] + # template default for vuln-box includes the wazuh agent + assert "software.install.wazuh-agent" in refs + + +# --- compile_topology orchestration --- + +def test_compile_writes_all_artifacts(built): + _, _, result = built + for p in (result.topology_path, result.inventory_path, + result.scenario_vms_path, result.network_policy_path, result.stages_path): + assert p.exists() + + +def test_compile_topology_roundtrips_topology(built): + t, _, result = built + from r42topo.core.io import load_topology + assert load_topology(result.topology_path) == t + + +def test_compile_rejects_allocation_conflict(topology_factory, fake_catalog, + reserved_factory, tmp_path): + from r42topo.core.errors import CompileError + t = Topology.model_validate(topology_factory()) + cat = load_catalog(fake_catalog) + reserved = ReservedIndex.from_file(reserved_factory([ + {"vm_id": 1100, "ip": "192.168.142.100", "scenario": "other", "role": "admin"}, + ])) + with pytest.raises(CompileError): + compile_topology(t, workspace=tmp_path / "ws2", catalog=cat, reserved=reserved) diff --git a/tests/test_extravars.py b/tests/test_extravars.py new file mode 100644 index 00000000..0364898d --- /dev/null +++ b/tests/test_extravars.py @@ -0,0 +1,47 @@ +"""P3 extravars tests — RED before GREEN.""" + +import pytest + +from r42topo.core.catalog import load_catalog +from r42topo.core.compiler import compile_topology +from r42topo.core.errors import ValidationError +from r42topo.core.extravars import resolve_universal_extravars +from r42topo.core.idalloc import ReservedIndex +from r42topo.core.models import Topology + + +@pytest.fixture +def result(topology_factory, fake_catalog, reserved_factory, tmp_path): + t = Topology.model_validate(topology_factory()) + cat = load_catalog(fake_catalog) + reserved = ReservedIndex.from_file(reserved_factory([])) + return compile_topology(t, workspace=tmp_path / "ws", catalog=cat, reserved=reserved) + + +def test_extravars_has_universal_contract_keys(result): + ev = resolve_universal_extravars(result, deployment_id="dep-1", attempt_id="att-1", + scope="team", team_id="team-a") + assert set(ev) == { + "r42_topology_path", "r42_inventory_dir", "r42_deployment_id", + "r42_attempt_id", "r42_scope", "r42_team_id", + } + assert ev["r42_topology_path"] == str(result.topology_path) + assert ev["r42_inventory_dir"] == str(result.workspace / "inventory") + assert ev["r42_team_id"] == "team-a" + + +def test_extravars_team_id_optional(result): + ev = resolve_universal_extravars(result, deployment_id="d", attempt_id="a", scope="global") + assert ev["r42_team_id"] == "" + + +def test_extravars_rejects_injection_in_ids(result): + with pytest.raises(ValidationError): + resolve_universal_extravars(result, deployment_id="d;rm -rf /", attempt_id="a", + scope="global") + + +def test_extravars_only_allowlisted_keys(result): + # no ansible_* or arbitrary keys can leak through + ev = resolve_universal_extravars(result, deployment_id="d", attempt_id="a", scope="global") + assert not any(k.lower().startswith("ansible") for k in ev) diff --git a/tests/test_validate.py b/tests/test_validate.py new file mode 100644 index 00000000..86c980e6 --- /dev/null +++ b/tests/test_validate.py @@ -0,0 +1,55 @@ +"""P3 semantic validation tests — RED before GREEN.""" + +from r42topo.core.catalog import load_catalog +from r42topo.core.models import Topology +from r42topo.core.validate import semantic_problems + + +def _valid(topology_factory, fake_catalog): + return Topology.model_validate(topology_factory()), load_catalog(fake_catalog) + + +def test_valid_topology_has_no_semantic_problems(topology_factory, fake_catalog): + t, cat = _valid(topology_factory, fake_catalog) + assert semantic_problems(t, cat) == [] + + +def test_box_zone_must_exist(topology_factory, fake_catalog): + spec = topology_factory() + spec["boxes"][0]["zone"] = "ghost" + t = Topology.model_validate(spec) + probs = semantic_problems(t, load_catalog(fake_catalog)) + assert any("ghost" in p for p in probs) + + +def test_zone_subnet_must_exist(topology_factory, fake_catalog): + spec = topology_factory() + spec["zones"][0]["subnet"] = "nope" + t = Topology.model_validate(spec) + probs = semantic_problems(t, load_catalog(fake_catalog)) + assert any("nope" in p for p in probs) + + +def test_box_ip_must_be_in_zone_subnet(topology_factory, fake_catalog): + spec = topology_factory() + spec["boxes"][0]["ip"] = "10.0.0.100" # not in 192.168.142.0/24 + spec["boxes"][0]["vm_id"] = 1100 # keep octet rule happy (.100) + t = Topology.model_validate(spec) + probs = semantic_problems(t, load_catalog(fake_catalog)) + assert any("10.0.0.100" in p for p in probs) + + +def test_dangling_box_template_is_problem(topology_factory, fake_catalog): + spec = topology_factory() + spec["boxes"][0]["box_template"] = "missing-template" + t = Topology.model_validate(spec) + probs = semantic_problems(t, load_catalog(fake_catalog)) + assert any("missing-template" in p for p in probs) + + +def test_dangling_network_policy_is_problem(topology_factory, fake_catalog): + spec = topology_factory() + spec["network_policy"]["template"] = "no-such-policy" + t = Topology.model_validate(spec) + probs = semantic_problems(t, load_catalog(fake_catalog)) + assert any("no-such-policy" in p for p in probs) From 89f342e24588e708e7bbea2c58de3465bf02c8de Mon Sep 17 00:00:00 2001 From: xinshen Date: Wed, 3 Jun 2026 13:23:36 +0200 Subject: [PATCH 010/121] feat(r42topo): P4 Typer CLI + P5 Textual TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin frontends over the pure core, plus a shared scaffolding helper. - core/scaffold.py: scaffold_topology() builds a minimal valid topology from a subnet_layout + policy (one box per role-matched zone, octet-rule vm_id/IP) — pure, reused by both CLI and TUI - cli.py: Typer app — author / validate / compile / show (--rules); maps core errors to exit codes; console_script `r42topo` - tui/controller.py: pure, framework-free façade (choices, scaffold, validate, save, summary, rules) — unit-tested without the event loop - tui/app.py: Textual view over the controller; console_script `r42topo-tui` - pyproject: add r42topo-tui entry point - tests: +12 (68 total, 90% coverage) incl. CliRunner + headless Textual mount Verified: `r42topo author --layout default-3zone --policy air-gap-ctf` against the real catalog scaffolds a 3-box topology; `show --rules` renders the compiled FORWARD table. --- pyproject.toml | 1 + r42topo/cli.py | 141 ++++++++++++++++++++++++++++++++++++++ r42topo/core/scaffold.py | 89 ++++++++++++++++++++++++ r42topo/tui/__init__.py | 6 ++ r42topo/tui/app.py | 94 +++++++++++++++++++++++++ r42topo/tui/controller.py | 83 ++++++++++++++++++++++ tests/test_cli.py | 60 ++++++++++++++++ tests/test_scaffold.py | 32 +++++++++ tests/test_tui.py | 42 ++++++++++++ 9 files changed, 548 insertions(+) create mode 100644 r42topo/cli.py create mode 100644 r42topo/core/scaffold.py create mode 100644 r42topo/tui/__init__.py create mode 100644 r42topo/tui/app.py create mode 100644 r42topo/tui/controller.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_scaffold.py create mode 100644 tests/test_tui.py diff --git a/pyproject.toml b/pyproject.toml index f804f942..b2dc36df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dev = ["pytest>=8.3", "pytest-cov"] [project.scripts] r42topo = "r42topo.cli:app" +r42topo-tui = "r42topo.tui.app:main" [tool.setuptools.packages.find] include = ["r42topo*"] diff --git a/r42topo/cli.py b/r42topo/cli.py new file mode 100644 index 00000000..9e512765 --- /dev/null +++ b/r42topo/cli.py @@ -0,0 +1,141 @@ +"""Typer CLI — a thin frontend over r42topo.api / core. + +Commands: author (scaffold a starter topology), validate, compile, show. +All real logic lives in the pure core; this module only parses args, prints, +and maps core errors to exit codes. +""" + +from pathlib import Path + +import typer + +from r42topo import api +from r42topo.core.catalog import load_catalog +from r42topo.core.compiler.network_policy import compile_network_policy +from r42topo.core.errors import TopologyError +from r42topo.core.idalloc import ReservedIndex +from r42topo.core.io import dumps_topology, load_topology +from r42topo.core.scaffold import scaffold_topology + +app = typer.Typer(help="range42 scenario authoring & topology compiler", no_args_is_help=True) + +_CatalogOpt = typer.Option(..., "--catalog", help="Path to the range42-catalog checkout") +_ReservedOpt = typer.Option(None, "--reserved", help="Path to scenarios/_reserved.json") + + +def _reserved(path: Path | None) -> ReservedIndex: + return ReservedIndex.from_file(path) if path else ReservedIndex(entries=()) + + +def _fail(message: str) -> None: + typer.secho(message, fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) + + +@app.command() +def validate( + topology: Path = typer.Argument(..., help="Path to topology.json"), + catalog: Path = _CatalogOpt, + reserved: Path = _ReservedOpt, +) -> None: + """Validate a topology against schema, catalog, and the reservation registry.""" + try: + cat = load_catalog(catalog) + topo = load_topology(topology) + except TopologyError as exc: + _fail(f"error: {exc}") + problems = api.validate_topology(topo, catalog=cat, reserved=_reserved(reserved)) + if problems: + for p in problems: + typer.secho(f" ✗ {p}", fg=typer.colors.RED) + _fail(f"{len(problems)} problem(s) found") + typer.secho("✓ topology is valid", fg=typer.colors.GREEN) + + +@app.command("compile") +def compile_cmd( + topology: Path = typer.Argument(..., help="Path to topology.json"), + workspace: Path = typer.Option(..., "--workspace", help="Output workspace dir"), + catalog: Path = _CatalogOpt, + reserved: Path = _ReservedOpt, +) -> None: + """Compile a topology into deploy artifacts under a workspace.""" + try: + cat = load_catalog(catalog) + topo = load_topology(topology) + result = api.compile_topology(topo, workspace=workspace, catalog=cat, + reserved=_reserved(reserved)) + except TopologyError as exc: + _fail(f"compile failed: {exc}") + typer.secho("✓ compiled", fg=typer.colors.GREEN) + for label, path in ( + ("topology", result.topology_path), + ("inventory", result.inventory_path), + ("scenario_vms", result.scenario_vms_path), + ("network_policy", result.network_policy_path), + ("stages", result.stages_path), + ): + typer.echo(f" {label:<14} {path}") + + +@app.command() +def author( + scenario: str = typer.Option(..., "--scenario", help="Scenario name (no dots)"), + layout: str = typer.Option(..., "--layout", help="subnet_layout template id"), + policy: str = typer.Option(..., "--policy", help="network_policy template id"), + catalog: Path = _CatalogOpt, + output: Path = typer.Option(None, "-o", "--output", help="Write to file (default: stdout)"), +) -> None: + """Scaffold a starter topology.json from a subnet layout + network policy.""" + try: + cat = load_catalog(catalog) + topo = scaffold_topology(cat, scenario=scenario, layout_id=layout, policy_id=policy) + except TopologyError as exc: + _fail(f"author failed: {exc}") + text = dumps_topology(topo) + if output: + output.write_text(text, encoding="utf-8") + typer.secho(f"✓ wrote {output}", fg=typer.colors.GREEN) + else: + typer.echo(text) + + +@app.command() +def show( + topology: Path = typer.Argument(..., help="Path to topology.json"), + catalog: Path = typer.Option(None, "--catalog", help="Catalog (required for --rules)"), + rules: bool = typer.Option(False, "--rules", help="Also show compiled FORWARD rules"), +) -> None: + """Print a topology summary; with --rules, also the compiled FORWARD table.""" + try: + topo = load_topology(topology) + except TopologyError as exc: + _fail(f"error: {exc}") + + typer.secho(f"scenario: {topo.scenario}", bold=True) + typer.echo(f" subnets: {', '.join(f'{s.name}={s.cidr}@{s.bridge}' for s in topo.subnets)}") + typer.echo(f" zones: {', '.join(f'{z.name}({z.role})' for z in topo.zones)}") + typer.echo(f" boxes: {len(topo.boxes)}") + for b in topo.boxes: + typer.echo(f" - {b.vm_name} id={b.vm_id} ip={b.ip} zone={b.zone} tmpl={b.box_template}") + typer.echo(f" policy: {topo.network_policy.template}") + + if rules: + if catalog is None: + _fail("--rules requires --catalog") + try: + cat = load_catalog(catalog) + pol = cat.resolve_network_policy(topo.network_policy.template) + ver = cat.resolved_version("network_policies", pol.id) + compiled = compile_network_policy(topo, pol, version=ver) + except TopologyError as exc: + _fail(f"error: {exc}") + typer.secho(f"\nFORWARD rules ({pol.id}@{ver}):", bold=True) + for r in compiled.rules: + dst = r.destination or r.out_interface or "-" + port = f":{r.destination_port}" if r.destination_port else "" + typer.echo(f" w{r.weight:<3} {r.jump:<6} {r.source or '-':<18} -> {dst}{port}") + + +if __name__ == "__main__": + app() diff --git a/r42topo/core/scaffold.py b/r42topo/core/scaffold.py new file mode 100644 index 00000000..23e90d7d --- /dev/null +++ b/r42topo/core/scaffold.py @@ -0,0 +1,89 @@ +"""Generate a starter Topology from catalog templates (pure, reused by CLI + TUI). + +``scaffold_topology`` turns a subnet-layout + policy choice into a minimal, +valid, deployable topology: one zone per subnet and one box per zone whose role +has a matching box_template. vm_id/IP are auto-assigned to satisfy the project +octet rule (vm_id last 3 digits == IP last octet). The result is a starting +point an operator refines in the CLI/TUI. +""" + +import ipaddress + +from r42topo.core.catalog import Catalog +from r42topo.core.errors import ValidationError +from r42topo.core.models import Box, NetworkPolicyRef, Topology, Zone + +# subnet/zone name -> role (anything unrecognized becomes a team zone) +_NAME_ROLE = {"admin": "admin", "ctf": "ctf", "student": "student", "template": "template"} +# role -> default last-octet for the first box in that zone +_ROLE_OCTET = {"admin": 100, "ctf": 170, "student": 160, "team": 200} + + +def _role_for(subnet_name: str) -> str: + return _NAME_ROLE.get(subnet_name, "team") + + +def _first_template_for_role(catalog: Catalog, role: str): + for template in catalog.box_templates.values(): + if template.role == role: + return template + return None + + +def _ip_with_octet(cidr: str, octet: int) -> str: + net = ipaddress.ip_network(cidr, strict=True) + base = str(net.network_address).rsplit(".", 1)[0] + return f"{base}.{octet}" + + +def scaffold_topology( + catalog: Catalog, + *, + scenario: str, + layout_id: str, + policy_id: str, + proxmox_node: str = "px-testing", + description: str = "", +) -> Topology: + """Build a minimal valid Topology from a subnet layout + network policy. + + :raises ValidationError: if no box template matches any zone role. + """ + layout = catalog.resolve_subnet_layout(layout_id) + catalog.resolve_network_policy(policy_id) # fail fast if missing + + subnets = [s.model_dump() for s in layout.subnets] + zones, boxes = [], [] + + for subnet in layout.subnets: + role = _role_for(subnet.name) + zones.append(Zone(name=subnet.name, subnet=subnet.name, role=role)) + if role == "template": + continue + template = _first_template_for_role(catalog, role) + if template is None: + continue # no archetype for this role — leave the zone box-less + octet = _ROLE_OCTET.get(role, 200) + boxes.append(Box( + vm_name=template.id, + vm_id=1000 + octet, + ip=_ip_with_octet(subnet.cidr, octet), + zone=subnet.name, + box_template=template.id, + inventory_group=template.default_inventory_group, + )) + + if not boxes: + raise ValidationError( + f"layout {layout_id!r} produced no boxes — no box template matched any zone role" + ) + + return Topology( + scenario=scenario, + description=description, + proxmox_node=proxmox_node, + subnets=subnets, + zones=zones, + boxes=boxes, + network_policy=NetworkPolicyRef(template=policy_id), + ) diff --git a/r42topo/tui/__init__.py b/r42topo/tui/__init__.py new file mode 100644 index 00000000..0054834e --- /dev/null +++ b/r42topo/tui/__init__.py @@ -0,0 +1,6 @@ +"""Textual TUI for r42topo. + +The interactive authoring frontend. All non-view logic lives in +``TuiController`` (pure, framework-light) so it can be unit-tested without the +event loop and shared with the range42 deployment TUI rewrite. +""" diff --git a/r42topo/tui/app.py b/r42topo/tui/app.py new file mode 100644 index 00000000..2bd84c50 --- /dev/null +++ b/r42topo/tui/app.py @@ -0,0 +1,94 @@ +"""Textual TUI view — thin shell over TuiController. + +Pick a scenario name, subnet layout, and network policy; scaffold a starter +topology; see its summary, validation result, and compiled FORWARD rules; save +it to disk. All logic is delegated to TuiController (tested separately). +""" + +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Button, Footer, Header, Input, Label, Select, Static + +from r42topo.tui.controller import TuiController + + +class TopologyAuthorApp(App): + """Interactive authoring app for r42topo topologies.""" + + CSS = """ + #form { height: auto; padding: 1; } + #output { padding: 1; border: round $accent; height: 1fr; } + Select, Input { width: 1fr; } + """ + BINDINGS = [("q", "quit", "Quit")] + + def __init__(self, controller: TuiController, *, out_path: Path | None = None) -> None: + super().__init__() + self.controller = controller + self.out_path = out_path or Path("topology.json") + + def compose(self) -> ComposeResult: + yield Header() + with Vertical(id="form"): + yield Label("Scenario name") + yield Input(placeholder="my_lab", id="scenario") + yield Label("Subnet layout") + yield Select([(x, x) for x in self.controller.layouts()], id="layout") + yield Label("Network policy") + yield Select([(x, x) for x in self.controller.policies()], id="policy") + with Horizontal(): + yield Button("Scaffold", id="scaffold", variant="primary") + yield Button("Save", id="save", variant="success") + yield Static("(author a topology to begin)", id="output") + yield Footer() + + def _set_output(self, text: str) -> None: + self.query_one("#output", Static).update(text) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "scaffold": + self._do_scaffold() + elif event.button.id == "save": + self._do_save() + + def _selected(self, widget_id: str) -> str | None: + value = self.query_one(f"#{widget_id}", Select).value + return None if value is Select.BLANK else str(value) + + def _do_scaffold(self) -> None: + scenario = self.query_one("#scenario", Input).value.strip() + layout = self._selected("layout") + policy = self._selected("policy") + if not (scenario and layout and policy): + self._set_output("⚠ fill scenario, layout, and policy first") + return + try: + self.controller.scaffold(scenario=scenario, layout_id=layout, policy_id=policy) + except Exception as exc: # surface authoring errors in the view + self._set_output(f"✗ {exc}") + return + problems = self.controller.validate() + verdict = "✓ valid" if not problems else "✗ " + "; ".join(problems) + self._set_output( + f"{self.controller.summary()}\n\n{verdict}\n\n{self.controller.rules_text()}" + ) + + def _do_save(self) -> None: + try: + path = self.controller.save(self.out_path) + except Exception as exc: + self._set_output(f"✗ {exc}") + return + self._set_output(f"✓ saved {path}") + + +def main() -> None: # pragma: no cover - manual entry point + import sys + catalog = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("../range42-catalog") + TopologyAuthorApp(TuiController(catalog)).run() + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/r42topo/tui/controller.py b/r42topo/tui/controller.py new file mode 100644 index 00000000..d2fbf50d --- /dev/null +++ b/r42topo/tui/controller.py @@ -0,0 +1,83 @@ +"""Pure controller behind the Textual TUI — no Textual imports here. + +Wraps the api/core so the view stays a thin shell and the logic is unit-testable +(and reusable by the range42 deployment TUI). Holds the in-progress Topology. +""" + +from pathlib import Path + +from r42topo import api +from r42topo.core.catalog import load_catalog +from r42topo.core.compiler.network_policy import compile_network_policy +from r42topo.core.idalloc import ReservedIndex +from r42topo.core.io import dump_topology +from r42topo.core.models import Topology +from r42topo.core.scaffold import scaffold_topology + + +class TuiController: + """Stateful façade the TUI drives: choose templates, scaffold, validate, save.""" + + def __init__(self, catalog_root: Path, reserved_path: Path | None = None) -> None: + self.catalog = load_catalog(catalog_root) + self.reserved = ( + ReservedIndex.from_file(reserved_path) + if reserved_path else ReservedIndex(entries=()) + ) + self.topology: Topology | None = None + + # -- catalog choices -- + + def layouts(self) -> list[str]: + return sorted(self.catalog.subnet_layouts) + + def policies(self) -> list[str]: + return sorted(self.catalog.network_policies) + + # -- authoring -- + + def scaffold(self, *, scenario: str, layout_id: str, policy_id: str) -> Topology: + self.topology = scaffold_topology( + self.catalog, scenario=scenario, layout_id=layout_id, policy_id=policy_id + ) + return self.topology + + def validate(self) -> list[str]: + if self.topology is None: + return ["no topology authored yet"] + return api.validate_topology(self.topology, catalog=self.catalog, reserved=self.reserved) + + def save(self, path: Path) -> Path: + if self.topology is None: + raise ValueError("nothing to save — scaffold a topology first") + return dump_topology(self.topology, Path(path)) + + # -- rendering helpers (plain strings; the view decides styling) -- + + def summary(self) -> str: + t = self.topology + if t is None: + return "(no topology)" + lines = [ + f"scenario: {t.scenario}", + f"subnets: {', '.join(f'{s.name}={s.cidr}@{s.bridge}' for s in t.subnets)}", + f"zones: {', '.join(f'{z.name}({z.role})' for z in t.zones)}", + f"boxes: {len(t.boxes)}", + ] + lines += [f" - {b.vm_name} id={b.vm_id} ip={b.ip} zone={b.zone}" for b in t.boxes] + lines.append(f"policy: {t.network_policy.template}") + return "\n".join(lines) + + def rules_text(self) -> str: + t = self.topology + if t is None: + return "" + pol = self.catalog.resolve_network_policy(t.network_policy.template) + ver = self.catalog.resolved_version("network_policies", pol.id) + compiled = compile_network_policy(t, pol, version=ver) + out = [f"FORWARD rules ({pol.id}@{ver}):"] + for r in compiled.rules: + dst = r.destination or r.out_interface or "-" + port = f":{r.destination_port}" if r.destination_port else "" + out.append(f" w{r.weight:<3} {r.jump:<6} {r.source or '-':<18} -> {dst}{port}") + return "\n".join(out) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..63ea3f1d --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,60 @@ +"""P4 Typer CLI tests via CliRunner — thin frontend over api.py.""" + +import json + +from typer.testing import CliRunner + +from r42topo.cli import app + +runner = CliRunner() + + +def _write_topology(path, valid_topology_dict): + path.write_text(json.dumps(valid_topology_dict), encoding="utf-8") + return path + + +def test_validate_ok(tmp_path, valid_topology_dict, fake_catalog): + top = _write_topology(tmp_path / "t.json", valid_topology_dict) + res = runner.invoke(app, ["validate", str(top), "--catalog", str(fake_catalog)]) + assert res.exit_code == 0, res.output + assert "valid" in res.output.lower() + + +def test_validate_reports_problem(tmp_path, valid_topology_dict, fake_catalog): + bad = dict(valid_topology_dict) + bad["boxes"] = [dict(b) for b in bad["boxes"]] + bad["boxes"][0]["ip"] = "10.9.9.9" + top = _write_topology(tmp_path / "bad.json", bad) + res = runner.invoke(app, ["validate", str(top), "--catalog", str(fake_catalog)]) + assert res.exit_code == 1 + assert "10.9.9.9" in res.output + + +def test_compile_writes_artifacts(tmp_path, valid_topology_dict, fake_catalog): + top = _write_topology(tmp_path / "t.json", valid_topology_dict) + ws = tmp_path / "ws" + res = runner.invoke(app, ["compile", str(top), "--workspace", str(ws), + "--catalog", str(fake_catalog)]) + assert res.exit_code == 0, res.output + assert (ws / "project" / "topology.json").exists() + assert (ws / "inventory" / "hosts.yml").exists() + + +def test_author_scaffolds_valid_topology(tmp_path, fake_catalog): + out = tmp_path / "scaffold.json" + res = runner.invoke(app, ["author", "--scenario", "lab1", "--layout", "default-3zone", + "--policy", "air-gap-ctf", "--catalog", str(fake_catalog), + "-o", str(out)]) + assert res.exit_code == 0, res.output + # the scaffold must itself validate + res2 = runner.invoke(app, ["validate", str(out), "--catalog", str(fake_catalog)]) + assert res2.exit_code == 0, res2.output + + +def test_show_summary_and_rules(tmp_path, valid_topology_dict, fake_catalog): + top = _write_topology(tmp_path / "t.json", valid_topology_dict) + res = runner.invoke(app, ["show", str(top), "--catalog", str(fake_catalog), "--rules"]) + assert res.exit_code == 0, res.output + assert "demo_lab_network" in res.output + assert "DROP" in res.output # compiled FORWARD rules shown diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py new file mode 100644 index 00000000..5debfe35 --- /dev/null +++ b/tests/test_scaffold.py @@ -0,0 +1,32 @@ +"""P4 scaffold tests — pure starter-topology generation (shared by CLI + TUI).""" + +from r42topo.core import constants as C +from r42topo.core.catalog import load_catalog +from r42topo.core.scaffold import scaffold_topology +from r42topo.core.validate import semantic_problems + + +def test_scaffold_produces_valid_topology(fake_catalog): + cat = load_catalog(fake_catalog) + t = scaffold_topology(cat, scenario="lab1", layout_id="default-3zone", + policy_id="air-gap-ctf") + assert t.scenario == "lab1" + assert semantic_problems(t, cat) == [] + assert t.network_policy.template == "air-gap-ctf" + + +def test_scaffold_picks_box_template_by_role(fake_catalog): + cat = load_catalog(fake_catalog) + t = scaffold_topology(cat, scenario="lab1", layout_id="default-3zone", + policy_id="air-gap-ctf") + templates = {b.box_template for b in t.boxes} + assert "admin-wazuh" in templates # admin zone + assert "vuln-box" in templates # ctf zone + + +def test_scaffold_respects_octet_rule(fake_catalog): + cat = load_catalog(fake_catalog) + t = scaffold_topology(cat, scenario="lab1", layout_id="default-3zone", + policy_id="air-gap-ctf") + for box in t.boxes: + assert C.octet_matches_vm_id(box.vm_id, box.ip), box diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 00000000..262c6d65 --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,42 @@ +"""P5 TUI tests — pure controller (sync) + a Textual mount smoke test.""" + +import asyncio + +from r42topo.core.io import load_topology +from r42topo.tui.controller import TuiController + + +def test_controller_lists_catalog_choices(fake_catalog): + ctl = TuiController(fake_catalog) + assert "default-3zone" in ctl.layouts() + assert "air-gap-ctf" in ctl.policies() + + +def test_controller_scaffold_and_validate(fake_catalog): + ctl = TuiController(fake_catalog) + ctl.scaffold(scenario="tui_lab", layout_id="default-3zone", policy_id="air-gap-ctf") + assert ctl.topology is not None + assert ctl.validate() == [] + assert "tui_lab" in ctl.summary() + assert "DROP" in ctl.rules_text() + + +def test_controller_save_roundtrip(fake_catalog, tmp_path): + ctl = TuiController(fake_catalog) + ctl.scaffold(scenario="tui_lab", layout_id="default-3zone", policy_id="air-gap-ctf") + out = ctl.save(tmp_path / "topology.json") + assert load_topology(out) == ctl.topology + + +def test_app_mounts(fake_catalog): + """The Textual app composes and mounts headlessly without error.""" + from r42topo.tui.app import TopologyAuthorApp + + async def _go(): + app = TopologyAuthorApp(TuiController(fake_catalog)) + async with app.run_test() as pilot: + await pilot.pause() + assert app.query_one("#scenario") is not None + assert app.query_one("#output") is not None + + asyncio.run(_go()) From 0d8ddbbf9f3e4402e55084b22b427b59d07ff4a6 Mon Sep 17 00:00:00 2001 From: xinshen Date: Wed, 3 Jun 2026 13:49:23 +0200 Subject: [PATCH 011/121] fix(r42topo): address ECC python + security review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security (from security-reviewer): - C3 (CRITICAL): close the segmentation-linter bypass — wildcard-source DROP rules were skipped, so `accept ctf->admin` + `drop *->admin` passed clean. Rewrote lint_segmentation to check every compiled DROP (interface-aware, so air-gap rules don't false-positive) against earlier ACCEPTs; excludes the terminal catch-all. Added regression test (bypass caught, air-gap still clean). - C1: deny-list now applied recursively to Attachment.params and NetworkPolicyRef.overrides (Jinja/SSTI surface) via reject_injection_nested - C2: validate wan_interface (IFACE_RE) and service *_ip (ipaddress) overrides; missing/invalid service IP now fails closed instead of broadening the rule to "any destination". CompiledRule interface/port fields gain patterns. - H1/H2/H3: injection guards on Topology.scenario/description/proxmox_node; PROXMOX_NODE_RE requires leading alnum (no empty/leading-dash) - H4: extravars allow-list uses explicit raise, not assert (-O strips asserts) - H5: air-gap zone absent from topology is now flagged (was silently skipped) - M1/M2: MatrixRule src/dst patterns + comment guard; linter terminal-rule check covers reject as well as drop - M4: CLI warns when --reserved is omitted (collision checks disabled) Python quality (from python-reviewer): - load_topology + api.author_topology wrap pydantic.ValidationError as TopologyError (no raw traceback leaks to the CLI) - _fail typed NoReturn; schema_version pinned Literal[1] - scenario_vms.json vms sorted by vm_id (full determinism) - scaffold avoids dup vm_id/IP for repeated roles; drops redundant model_dump - TUI handlers catch TopologyError/ValueError, not bare Exception - idalloc docstring documents the TOCTOU window Suite: 78 tests, 90% coverage. --- r42topo/api.py | 4 +- r42topo/cli.py | 11 ++- r42topo/core/catalog_models.py | 12 ++- r42topo/core/compiler/network_policy.py | 104 ++++++++++++++++++------ r42topo/core/compiler/scenario_vms.py | 23 +++--- r42topo/core/constants.py | 41 +++++++++- r42topo/core/extravars.py | 8 +- r42topo/core/idalloc.py | 6 ++ r42topo/core/io.py | 7 +- r42topo/core/models.py | 20 +++-- r42topo/core/scaffold.py | 10 ++- r42topo/tui/app.py | 5 +- tests/test_security_fixes.py | 98 ++++++++++++++++++++++ 13 files changed, 286 insertions(+), 63 deletions(-) create mode 100644 tests/test_security_fixes.py diff --git a/r42topo/api.py b/r42topo/api.py index fa934f8e..b12f5e4b 100644 --- a/r42topo/api.py +++ b/r42topo/api.py @@ -6,7 +6,7 @@ consumer maps these into its own surface (HTTP envelopes, exit codes, dialogs). """ -from pathlib import Path +from pydantic import ValidationError as _PydanticValidationError from r42topo.core.catalog import Catalog, load_catalog from r42topo.core.compiler import CompileResult, compile_topology @@ -36,7 +36,7 @@ def author_topology(spec: dict, *, catalog: Catalog) -> Topology: """ try: topology = Topology.model_validate(spec) - except Exception as exc: # pydantic ValidationError -> our ValidationError + except _PydanticValidationError as exc: raise ValidationError(f"invalid topology: {exc}") from exc problems = semantic_problems(topology, catalog) diff --git a/r42topo/cli.py b/r42topo/cli.py index 9e512765..35d4c7fb 100644 --- a/r42topo/cli.py +++ b/r42topo/cli.py @@ -6,6 +6,7 @@ """ from pathlib import Path +from typing import NoReturn import typer @@ -24,10 +25,16 @@ def _reserved(path: Path | None) -> ReservedIndex: - return ReservedIndex.from_file(path) if path else ReservedIndex(entries=()) + if path: + return ReservedIndex.from_file(path) + typer.secho( + "⚠ no --reserved file: cross-scenario vm_id/IP collision checks are disabled", + fg=typer.colors.YELLOW, err=True, + ) + return ReservedIndex(entries=()) -def _fail(message: str) -> None: +def _fail(message: str) -> NoReturn: typer.secho(message, fg=typer.colors.RED, err=True) raise typer.Exit(code=1) diff --git a/r42topo/core/catalog_models.py b/r42topo/core/catalog_models.py index 694826ea..0fd5b12e 100644 --- a/r42topo/core/catalog_models.py +++ b/r42topo/core/catalog_models.py @@ -12,7 +12,7 @@ from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from r42topo.core import constants as C from r42topo.core.models import Attachment, Subnet @@ -85,11 +85,15 @@ class MatrixRule(BaseModel): model_config = _STRICT - src: str - dst: str + src: str = Field(pattern=C.MATRIX_SRC_RE.pattern) + dst: str = Field(pattern=C.MATRIX_DST_RE.pattern) action: Literal["accept", "drop", "reject"] ports: list[PortSpec] = Field(default_factory=list) - comment: str | None = None + comment: str | None = Field(default=None, max_length=200) + + _guard_comment = field_validator("comment")( + lambda v: C.reject_injection(v) if v is not None else v + ) class PolicyDefaults(BaseModel): diff --git a/r42topo/core/compiler/network_policy.py b/r42topo/core/compiler/network_policy.py index c2e39864..856a3d0c 100644 --- a/r42topo/core/compiler/network_policy.py +++ b/r42topo/core/compiler/network_policy.py @@ -8,11 +8,14 @@ is naturally idempotent and never touches the host INPUT chain (SSH stays up). """ +import ipaddress from typing import Literal from pydantic import BaseModel, ConfigDict, Field +from r42topo.core import constants as C from r42topo.core.catalog_models import MatrixRule, NetworkPolicyTemplate, PortSpec +from r42topo.core.errors import CompileError from r42topo.core.models import Topology from r42topo.core.validate import zone_bridge_map, zone_subnet_map @@ -39,12 +42,12 @@ class CompiledRule(BaseModel): proto: Literal["tcp", "udp", "icmp", "all"] = "all" source: str | None = None destination: str | None = None - destination_port: str | None = None - in_interface: str | None = None - out_interface: str | None = None + destination_port: str | None = Field(default=None, pattern=C.PORT_SPEC_RE.pattern) + in_interface: str | None = Field(default=None, pattern=C.IFACE_RE.pattern) + out_interface: str | None = Field(default=None, pattern=C.IFACE_RE.pattern) ctstate: str | None = None jump: Literal["ACCEPT", "DROP", "REJECT"] - comment: str + comment: str = Field(max_length=200) class CompiledNetworkPolicy(BaseModel): @@ -75,6 +78,15 @@ def _param(policy: NetworkPolicyTemplate, topology: Topology, key: str, default) return topology.network_policy.overrides.get(key, policy.params.get(key, default)) +def _require_ip(value, what: str) -> str: + """Validate that *value* is a concrete IPv4/IPv6 address, else CompileError.""" + try: + ipaddress.ip_address(str(value)) + except ValueError: + raise CompileError(f"{what} must be a valid IP address, got {value!r}") from None + return str(value) + + def compile_network_policy( topology: Topology, policy: NetworkPolicyTemplate, *, version: str ) -> CompiledNetworkPolicy: @@ -82,7 +94,9 @@ def compile_network_policy( zsubnet = zone_subnet_map(topology) zbridge = zone_bridge_map(topology) services = {s.name: s for s in policy.services} - wan_interface = _param(policy, topology, "wan_interface", "vmbr0") + wan_interface = str(_param(policy, topology, "wan_interface", "vmbr0")) + if not C.IFACE_RE.fullmatch(wan_interface): + raise CompileError(f"invalid wan_interface: {wan_interface!r}") defaults = policy.defaults rules: list[CompiledRule] = [] @@ -140,8 +154,10 @@ def _compile_matrix_rule(mr, zsubnet, services, topology, policy) -> list[Compil svc_name = mr.dst[len(_SVC):] svc = services.get(svc_name) if svc is None: - return [] - svc_ip = _param(policy, topology, f"{svc_name}_ip", None) + raise CompileError(f"matrix references unknown service {svc_name!r}") + # a missing/invalid service IP would silently broaden the rule to "any dest" + svc_ip = _require_ip(_param(policy, topology, f"{svc_name}_ip", None), + f"service {svc_name!r} ip ({svc_name}_ip)") out: list[CompiledRule] = [] for p in (mr.ports or svc.ports): out.append(CompiledRule( @@ -184,37 +200,73 @@ def lint_segmentation( elif first_drop is not None and min(r.weight for r in est) > first_drop: problems.append("ESTABLISHED accept does not precede DROP rules") + # 3. no ACCEPT may shadow an earlier-evaluated DROP/REJECT for overlapping + # traffic. Considers ALL compiled drops (including wildcard-source ones), + # not just exact matrix zone-pairs — this closes the src="*" bypass. + # The terminal default catch-all (no match fields) is excluded: specific + # ACCEPTs are *supposed* to precede it. Interface-scoped rules (air-gap) + # only conflict with other interface-scoped rules, so they are matched + # on interface too, avoiding false positives against CIDR/service rules. + accepts = [r for r in rules if r.jump == "ACCEPT"] + for d in rules: + if d.jump not in ("DROP", "REJECT") or _is_catch_all(d): + continue + for a in accepts: + if a.weight < d.weight and _shadows(a, d): + problems.append( + f"ACCEPT (w{a.weight}) shadows DROP (w{d.weight}) for " + f"{a.source or '*'}->{a.destination or a.out_interface or '*'} " + f"({d.comment})" + ) + break + + # also assert every explicit zone->zone deny actually produced a DROP rule zsubnet = zone_subnet_map(topology) - - # 3. each explicit deny pair must not be shadowed by an earlier accept for mr in policy.matrix: - if mr.action not in ("drop", "reject"): - continue - if mr.dst.startswith(_SVC): + if mr.action not in ("drop", "reject") or mr.dst.startswith(_SVC) or mr.src == "*": continue src, dst = zsubnet.get(mr.src), zsubnet.get(mr.dst) if src is None or dst is None: continue - drop_w = min((r.weight for r in rules - if r.source == src and r.destination == dst and r.jump in ("DROP", "REJECT")), - default=None) - if drop_w is None: + if not any(r.source == src and r.destination == dst and r.jump in ("DROP", "REJECT") + for r in rules): problems.append(f"deny {mr.src}->{mr.dst} produced no DROP rule") - continue - if any(r.source == src and r.destination == dst and r.jump == "ACCEPT" and r.weight < drop_w - for r in rules): - problems.append(f"ACCEPT shadows deny {mr.src}->{mr.dst} (ordering hazard)") - # 4. each air-gap zone must have a wan-drop rule + # 4. each air-gap zone must exist in the topology AND have a wan-drop rule zbridge = zone_bridge_map(topology) for zname in policy.defaults.airgap_zones: bridge = zbridge.get(zname) - if bridge and not any(r.in_interface == bridge and r.out_interface and r.jump == "DROP" - for r in rules): + if bridge is None: + problems.append(f"air-gap zone {zname!r} not bound in topology — air-gap absent") + elif not any(r.in_interface == bridge and r.out_interface and r.jump == "DROP" + for r in rules): problems.append(f"air-gap zone {zname!r} missing wan DROP") - # 5. default-deny must be the terminal rule - if policy.defaults.default_action == "drop" and rules and rules[-1].jump != "DROP": - problems.append("default-deny is not the terminal rule") + # 5. terminal rule must be the deny catch-all when default denies + if policy.defaults.default_action in ("drop", "reject"): + if not rules or rules[-1].jump not in ("DROP", "REJECT"): + problems.append("default-deny is not the terminal rule") return problems + + +def _is_catch_all(rule: CompiledRule) -> bool: + """True for a rule with no match fields — the intended terminal default.""" + return (rule.source is None and rule.destination is None + and rule.in_interface is None and rule.out_interface is None + and rule.destination_port is None and rule.ctstate is None) + + +def _shadows(accept: CompiledRule, drop: CompiledRule) -> bool: + """True if *accept* would match traffic the later *drop* intends to block. + + A None match field means "any" for that dimension. Interface-scoped drops + (air-gap) only conflict with interface-scoped accepts on the same interface, + so they never false-positive against CIDR/service ACCEPT rules. + """ + if drop.in_interface or drop.out_interface: + return (accept.in_interface == drop.in_interface + and accept.out_interface == drop.out_interface) + src_overlap = drop.source is None or accept.source == drop.source + dst_overlap = drop.destination is None or accept.destination == drop.destination + return src_overlap and dst_overlap diff --git a/r42topo/core/compiler/scenario_vms.py b/r42topo/core/compiler/scenario_vms.py index 17b3b121..9efd954e 100644 --- a/r42topo/core/compiler/scenario_vms.py +++ b/r42topo/core/compiler/scenario_vms.py @@ -17,16 +17,19 @@ def build_scenario_vms(topology: Topology) -> dict: zone_role = {z.name: z.role for z in topology.zones} zbridge = zone_bridge_map(topology) - vms = [ - { - "vm_id": box.vm_id, - "vm_name": box.vm_name, - "ip": box.ip, - "role": zone_role.get(box.zone), - "bridge": zbridge.get(box.zone), - } - for box in topology.boxes - ] + vms = sorted( + ( + { + "vm_id": box.vm_id, + "vm_name": box.vm_name, + "ip": box.ip, + "role": zone_role.get(box.zone), + "bridge": zbridge.get(box.zone), + } + for box in topology.boxes + ), + key=lambda v: v["vm_id"], + ) return { "scenario": topology.scenario, diff --git a/r42topo/core/constants.py b/r42topo/core/constants.py index 326e9e96..a5abe17b 100644 --- a/r42topo/core/constants.py +++ b/r42topo/core/constants.py @@ -31,8 +31,19 @@ IPV4_RE = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$") IPV4_CIDR_RE = re.compile(r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$") -# Proxmox node name — mirrors backend schema pattern. -PROXMOX_NODE_RE = re.compile(r"^[A-Za-z0-9-]*$") +# Proxmox node name — requires a leading alphanumeric (no empty, no leading dash +# which a subprocess could read as a flag), bounded length per RFC-952/Proxmox. +PROXMOX_NODE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9-]{0,62}$") + +# Network-policy matrix endpoints (catalog-authored): src is a zone name or "*"; +# dst is a zone name, "svc:", or "*". +MATRIX_SRC_RE = re.compile(r"^(\*|[a-z0-9-]{1,40})$") +MATRIX_DST_RE = re.compile(r"^(\*|svc:[a-z0-9-]{1,40}|[a-z0-9-]{1,40})$") + +# Interface name for compiled iptables rules (bridges, uplink NICs). +IFACE_RE = re.compile(r"^[A-Za-z0-9._-]{1,32}$") +# iptables destination port or inclusive range, e.g. "1514" or "1514:1515". +PORT_SPEC_RE = re.compile(r"^[0-9]{1,5}(:[0-9]{1,5})?$") # vm_id bounds: 4-digit ids in the project's allocated band. VM_ID_MIN = 1000 @@ -66,6 +77,32 @@ def violates_denylist(value: str) -> bool: return any(token in value for token in DENYLIST_SUBSTRINGS) +def reject_injection(value: str) -> str: + """Field-validator helper: raise ValueError if *value* is deny-listed.""" + if violates_denylist(value): + raise ValueError("value contains a forbidden character or pattern") + return value + + +def reject_injection_nested(obj): + """Recursively deny-list-check every string key/value in a dict/list/scalar. + + Used for free-form ``params`` / ``overrides`` dicts whose values can flow + into Ansible variables (Jinja2 render surface) or compiled rule fields. + """ + if isinstance(obj, str): + reject_injection(obj) + elif isinstance(obj, dict): + for key, val in obj.items(): + if isinstance(key, str): + reject_injection(key) + reject_injection_nested(val) + elif isinstance(obj, (list, tuple)): + for item in obj: + reject_injection_nested(item) + return obj + + def octet_matches_vm_id(vm_id: int, ip: str) -> bool: """Project rule: a single-subnet VM's vm_id last 3 digits == IP last octet. diff --git a/r42topo/core/extravars.py b/r42topo/core/extravars.py index 4c655e71..9ab51ebc 100644 --- a/r42topo/core/extravars.py +++ b/r42topo/core/extravars.py @@ -41,6 +41,10 @@ def resolve_universal_extravars( "r42_scope": _safe_id("scope", scope), "r42_team_id": _safe_id("team_id", team_id) if team_id else "", } - # defensive: guarantee no key escaped the allow-list - assert set(extravars) == set(_ALLOWED_KEYS) + # defensive: guarantee no key escaped the allow-list (explicit, not assert — + # assert is stripped under `python -O`) + if set(extravars) != set(_ALLOWED_KEYS): + raise ValidationError( + f"extravars key mismatch: {set(extravars) ^ set(_ALLOWED_KEYS)}" + ) return extravars diff --git a/r42topo/core/idalloc.py b/r42topo/core/idalloc.py index 8372014b..8ceb3d8a 100644 --- a/r42topo/core/idalloc.py +++ b/r42topo/core/idalloc.py @@ -12,6 +12,12 @@ Read-only and pure: claiming/writing reservations (with a file lock) is a separate concern handled by the deploy side, not here. + +TOCTOU: ``validate_allocation`` reads the registry at compile time; there is an +inherent race between this check and the deploy-side reservation write. Callers +that compile concurrently must serialize the validate→deploy→record sequence +with an external lock (file lock / DB advisory lock) to avoid two scenarios +claiming the same vm_id/IP. """ import json diff --git a/r42topo/core/io.py b/r42topo/core/io.py index 6cb6737f..76dd8d15 100644 --- a/r42topo/core/io.py +++ b/r42topo/core/io.py @@ -10,6 +10,8 @@ import tempfile from pathlib import Path +from pydantic import ValidationError as _PydanticValidationError + from r42topo.core.errors import TopologyError from r42topo.core.models import Topology @@ -28,7 +30,10 @@ def load_topology(path: Path) -> Topology: data = json.loads(raw) except json.JSONDecodeError as exc: raise TopologyError(f"invalid JSON in topology file: {path}") from exc - return Topology.model_validate(data) + try: + return Topology.model_validate(data) + except _PydanticValidationError as exc: + raise TopologyError(f"topology schema error in {path}: {exc}") from exc def dumps_topology(topology: Topology) -> str: diff --git a/r42topo/core/models.py b/r42topo/core/models.py index 7d5b29ba..4d2393b8 100644 --- a/r42topo/core/models.py +++ b/r42topo/core/models.py @@ -13,12 +13,8 @@ _STRICT = ConfigDict(extra="forbid") - -def _no_injection(value: str) -> str: - """Reject free-text values containing deny-listed tokens (SSTI/shell/path).""" - if C.violates_denylist(value): - raise ValueError("value contains a forbidden character or pattern") - return value +# free-text injection guard (shared with catalog_models via constants) +_no_injection = C.reject_injection class Attachment(BaseModel): @@ -30,6 +26,8 @@ class Attachment(BaseModel): catalog_ref: str = Field(pattern=C.CATALOG_REF_RE.pattern) params: dict[str, Any] = Field(default_factory=dict) + _guard_params = field_validator("params")(C.reject_injection_nested) + class Subnet(BaseModel): """A concrete L3 subnet bound to a Proxmox bridge.""" @@ -80,17 +78,23 @@ class NetworkPolicyRef(BaseModel): template: str = Field(pattern=C.CATALOG_REF_RE.pattern) overrides: dict[str, Any] = Field(default_factory=dict) + _guard_overrides = field_validator("overrides")(C.reject_injection_nested) + class Topology(BaseModel): """Top-level authored topology — the source of truth the compiler expands.""" model_config = _STRICT - schema_version: int = 1 + schema_version: Literal[1] = 1 scenario: str = Field(pattern=C.SCENARIO_NAME_RE.pattern) - description: str = "" + description: str = Field(default="", max_length=255) proxmox_node: str = Field(pattern=C.PROXMOX_NODE_RE.pattern) subnets: list[Subnet] = Field(min_length=1) zones: list[Zone] = Field(min_length=1) boxes: list[Box] = Field(min_length=1) network_policy: NetworkPolicyRef + + _guard_scenario = field_validator("scenario")(_no_injection) + _guard_description = field_validator("description")(_no_injection) + _guard_proxmox_node = field_validator("proxmox_node")(_no_injection) diff --git a/r42topo/core/scaffold.py b/r42topo/core/scaffold.py index 23e90d7d..d55285fa 100644 --- a/r42topo/core/scaffold.py +++ b/r42topo/core/scaffold.py @@ -52,8 +52,8 @@ def scaffold_topology( layout = catalog.resolve_subnet_layout(layout_id) catalog.resolve_network_policy(policy_id) # fail fast if missing - subnets = [s.model_dump() for s in layout.subnets] zones, boxes = [], [] + role_seen: dict[str, int] = {} # per-role count -> distinct octet/vm_id per box for subnet in layout.subnets: role = _role_for(subnet.name) @@ -63,9 +63,11 @@ def scaffold_topology( template = _first_template_for_role(catalog, role) if template is None: continue # no archetype for this role — leave the zone box-less - octet = _ROLE_OCTET.get(role, 200) + nth = role_seen.get(role, 0) + role_seen[role] = nth + 1 + octet = _ROLE_OCTET.get(role, 200) + nth # avoid dup vm_id/IP per role boxes.append(Box( - vm_name=template.id, + vm_name=f"{template.id}-{nth:02d}" if nth else template.id, vm_id=1000 + octet, ip=_ip_with_octet(subnet.cidr, octet), zone=subnet.name, @@ -82,7 +84,7 @@ def scaffold_topology( scenario=scenario, description=description, proxmox_node=proxmox_node, - subnets=subnets, + subnets=list(layout.subnets), # pydantic accepts model instances directly zones=zones, boxes=boxes, network_policy=NetworkPolicyRef(template=policy_id), diff --git a/r42topo/tui/app.py b/r42topo/tui/app.py index 2bd84c50..281672db 100644 --- a/r42topo/tui/app.py +++ b/r42topo/tui/app.py @@ -11,6 +11,7 @@ from textual.containers import Horizontal, Vertical from textual.widgets import Button, Footer, Header, Input, Label, Select, Static +from r42topo.core.errors import TopologyError from r42topo.tui.controller import TuiController @@ -66,7 +67,7 @@ def _do_scaffold(self) -> None: return try: self.controller.scaffold(scenario=scenario, layout_id=layout, policy_id=policy) - except Exception as exc: # surface authoring errors in the view + except TopologyError as exc: # surface authoring errors in the view self._set_output(f"✗ {exc}") return problems = self.controller.validate() @@ -78,7 +79,7 @@ def _do_scaffold(self) -> None: def _do_save(self) -> None: try: path = self.controller.save(self.out_path) - except Exception as exc: + except (TopologyError, ValueError) as exc: self._set_output(f"✗ {exc}") return self._set_output(f"✓ saved {path}") diff --git a/tests/test_security_fixes.py b/tests/test_security_fixes.py new file mode 100644 index 00000000..4ac4d6bf --- /dev/null +++ b/tests/test_security_fixes.py @@ -0,0 +1,98 @@ +"""Regression tests for the ECC review findings (security + correctness).""" + +import json + +import pytest +from pydantic import ValidationError as PydErr + +from r42topo.core.catalog import load_catalog +from r42topo.core.catalog_models import MatrixRule, NetworkPolicyTemplate, ZoneDecl +from r42topo.core.compiler.network_policy import compile_network_policy, lint_segmentation +from r42topo.core.errors import CompileError, TopologyError +from r42topo.core.extravars import resolve_universal_extravars +from r42topo.core.models import Topology + + +# --- C3: wildcard-source DROP must not be shadowed undetected --- + +def test_linter_catches_wildcard_drop_bypass(topology_factory, fake_catalog): + """accept ctf->admin (w200) + drop *->admin (w500) must be flagged, not pass.""" + t = Topology.model_validate(topology_factory()) + pol = load_catalog(fake_catalog).network_policies["air-gap-ctf"].model_copy(deep=True) + pol.matrix = [ + MatrixRule(src="ctf", dst="admin", action="accept", comment="opens ctf->admin"), + MatrixRule(src="*", dst="admin", action="drop", comment="drop all to admin"), + ] + compiled = compile_network_policy(t, pol, version="1.1.0") + assert lint_segmentation(compiled, pol, t) != [] # bypass is now caught + + +def test_air_gap_reference_policy_still_clean(topology_factory, fake_catalog): + """The legitimate air-gap-ctf policy must NOT trip the stricter linter.""" + t = Topology.model_validate(topology_factory()) + pol = load_catalog(fake_catalog).network_policies["air-gap-ctf"] + compiled = compile_network_policy(t, pol, version="1.1.0") + assert lint_segmentation(compiled, pol, t) == [] + + +# --- C1: params / overrides injection --- + +def test_attachment_params_reject_injection(topology_factory): + spec = topology_factory() + spec["boxes"][0]["attachments"] = [ + {"kind": "role", "catalog_ref": "software.install.x", + "params": {"cmd": "{{ lookup('pipe','id') }}"}}, + ] + with pytest.raises(PydErr): + Topology.model_validate(spec) + + +def test_network_policy_overrides_reject_injection(topology_factory): + spec = topology_factory() + spec["network_policy"]["overrides"] = {"wan_interface": "eth0; iptables -F"} + with pytest.raises(PydErr): + Topology.model_validate(spec) + + +# --- C2: missing/invalid service IP must fail closed, not broaden the rule --- + +def test_missing_service_ip_raises(topology_factory, fake_catalog): + t = Topology.model_validate(topology_factory()) + pol = load_catalog(fake_catalog).network_policies["air-gap-ctf"].model_copy(deep=True) + pol.params = {} # remove siem_ip default + with pytest.raises(CompileError): + compile_network_policy(t, pol, version="1.1.0") + + +# --- H1/H2/H3: field guards --- + +def test_description_rejects_injection(topology_factory): + with pytest.raises(PydErr): + Topology.model_validate(topology_factory(description="{{ evil }}")) + + +def test_proxmox_node_rejects_empty(topology_factory): + with pytest.raises(PydErr): + Topology.model_validate(topology_factory(proxmox_node="")) + + +def test_scenario_rejects_leading_dash(topology_factory): + with pytest.raises(PydErr): + Topology.model_validate(topology_factory(scenario="-evil")) + + +def test_schema_version_pinned(topology_factory): + with pytest.raises(PydErr): + Topology.model_validate(topology_factory(schema_version=2)) + + +# --- CRITICAL(py): load_topology wraps pydantic errors as TopologyError --- + +def test_load_topology_wraps_schema_error(tmp_path, valid_topology_dict): + from r42topo.core.io import load_topology + bad = dict(valid_topology_dict) + bad["proxmox_node"] = "" # schema-invalid but valid JSON + p = tmp_path / "bad.json" + p.write_text(json.dumps(bad), encoding="utf-8") + with pytest.raises(TopologyError): + load_topology(p) From 855273d6580bda1cc0f743ca53632b5f8e1e4b5a Mon Sep 17 00:00:00 2001 From: xinshen Date: Wed, 3 Jun 2026 19:57:31 +0200 Subject: [PATCH 012/121] docs(r42playbooks): construction blueprint (multi-session handoff) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ECC blueprint skill output — a cold-start plan for r42playbooks, the msfvenom-style scenario generator (list catalog modules -> compose -> generate a demo_lab-shaped scenarios//). 10 steps (S0 housekeeping, S1 rename r42topo->r42playbooks, S2 spec model, S3 catalog pick/validate, S4 alloc+manifest, S5b verbatim tree, S5a generated-from-manifest + API freeze, S6 CLI, S7 TUI, S8 importable API, S9 ECC review). Adversarially reviewed (architect/opus); CRITICAL+HIGH folded in: secrets/ symlink contract, generated-vs-copied templates/*.j2, populate manifest templates[], resolved template-selection + role->section decisions, name-reference (not copy) catalog contract. --- docs/r42playbooks-plan.md | 320 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 docs/r42playbooks-plan.md diff --git a/docs/r42playbooks-plan.md b/docs/r42playbooks-plan.md new file mode 100644 index 00000000..3cf73d1e --- /dev/null +++ b/docs/r42playbooks-plan.md @@ -0,0 +1,320 @@ +# r42playbooks — construction blueprint (multi-session handoff) + +> **Generated by the ECC `blueprint` skill.** Self-contained: any fresh agent can pick up a step +> cold without re-reading the conversation. Pairs with the memory files +> `r42playbooks-scenario-generator` and `avoid-r42-engine-divergence`. +> +> **Branch:** `feat/r42playbooks-generator` (based on the original scenario-authoring foundation). +> **Base/PR target:** `dev` (repo uses a `dev → main` PR flow). **License:** GPL-3.0. +> **Status:** not started — Step 1 is the entry point. + +## 0. Objective (the one line) + +`r42playbooks` is an **msfvenom-style scenario generator**: it *lists* composable `range42-catalog` +modules, lets a user *compose* a lab (flags or a `scenario.r42.yml` spec), and *generates* a real +`scenarios//` directory in the **existing demo_lab format** — deployable through the normal +range42 flow with no changes elsewhere. + +## 1. Why this exists / what it is NOT + +- It **continues** `range42-init`/`range42-context`'s "choose a scenario", upgrading it to + "**compose a new** scenario from catalog items". +- It is a clean **importable package**: future `r42deploy`/`r42runtime` will `import r42playbooks`; + the CLI and TUI are thin frontends over a pure API. +- It is **NOT** the canonical topology engine (compose/expand/`inventory_writer` → `hosts.yml` for + the gamenet "universal" playbook). That convergence work is **parked** on + `feat/r42topo-canonical-schema` (issue #67, being closed). Do not pull it in here. + +## 2. The contract that drives everything: catalog ↔ playbooks is name-reference, NOT copy + +A generated scenario references catalog content **by name only**; it never vendors role code. +- **Ansible roles:** `stage_01/.yml` lists `roles: [software.install.wazuh-indexer]`. At deploy + time these resolve via `ANSIBLE_ROLES_PATH` into `range42-catalog/02_ansible_layer/.../roles/` + (wired by the workspace tooling: `range42-init.py`, roles `deployer.repos` + `workspace.symlinks`). + `range42/roles/` holds only org-bootstrap roles — the `software.*` names are catalog-resolved. +- **Containers:** resolve via env vars `RANGE42_INVENTORY__DOCKER__*` into + `range42-catalog/03_container_layer/docker/_ctf/…`, applied through `software.configure.docker-compose`. + +**Implication for r42playbooks:** it *reads* the catalog to (a) let you pick and (b) validate that a +ref name exists — then *writes name references*. The generated tree stays a thin recipe of names. + +## 3. What already exists on this branch (REUSE — do not rebuild) + +| Asset | Path | Use | +|---|---|---| +| Catalog loader | `r42topo/core/catalog.py`, `catalog_models.py` | reads `05_topology_layer` box_templates / subnet_layouts / network_policies | +| Topology model | `r42topo/core/models.py` | the `subnets/zones/boxes` authoring model + scaffold target | +| Scaffold | `r42topo/core/scaffold.py` | scaffolds a topology from catalog picks | +| Reservation/alloc | `r42topo/core/idalloc.py` | `_reserved.json` index, vm_id/IP allocation, octet rule | +| Compiler | `r42topo/core/compiler/*` | emits a **flat workspace** (topology.json, hosts.yml, scenario_vms.json, network_policy.json, stages.json) — NOT the staged scenario dir | +| Typer CLI | `r42topo/cli.py` | `validate` / `compile` / `author` / `show` (re-point/extend) | +| Textual TUI | `r42topo/tui/` (`app.py`, `controller.py`) | interactive composer (re-point/extend) | +| Deny-list + atomic IO | `r42topo/core/constants.py`, `io.py` | security + safe writes | + +## 4. The net-new gap + +The current compiler writes a **flat workspace**; the demo_lab format is a **staged directory tree**. +The core new work is a **renderer**: composed lab → `scenarios//` mirroring `_init_lab`/`demo_lab`: +``` +scenarios// + NN_
/ (01_init_proxmox, 02_admin_infrastructure, 03_student…, 04_ctf…) + _main.yml, _main.reinstall.sh + stage_00/.yml (clone Proxmox template → create VM; globals from _main.yml) + stage_01/.yml (roles: []) + .devkit/ + manifest/scenario_vms.json (vm_id, vm_name, ip, role, bridge — allocated, octet rule) + templates/ (ansible-inventory.j2, ansible-vars.yml, ssh-config.j2, vault-example.yml) + .setup.sh, .delete_all.sh, reset scripts, main.yml, README.md +``` +Reference skeletons to mirror byte-for-shape: `scenarios/_init_lab/`, `scenarios/demo_lab/`. + +### 4.1 Renderer file CLASSES (critical — two different kinds of output) +A cold agent must not "copy demo_lab verbatim" wholesale. Emitted files fall in two classes: + +- **(A) Generated FROM the manifest** (must reflect *this* composition, never copied): + `templates/ansible-inventory.j2` (groups + member hosts), `templates/ssh-config.j2` (a `Host` block + per VM), `manifest/scenario_vms.json`, each section's `_main.yml` (import + `global_*` vars list), and + the top-level `main.yml` / `main_vms_only.yml` import lists. Copying demo_lab's versions verbatim + would target demo_lab's 10 VMs, not yours → wrong/nonexistent hosts at deploy. +- **(B) Verbatim-with-param boilerplate** (copy from `_init_lab`/`demo_lab`, fill placeholders): + `stage_00/.yml` clone playbooks, `stage_01/.yml` (only the `roles:` list + `hosts:` vary), + devkit scripts, `_main.reinstall.sh`, `_activate.sh`, `templates/ansible-vars.yml`, + `templates/vault-example.yml`, the top-level `*.setup.sh` / `*.delete_all.sh` / `*_vms_only.sh` / + reset scripts, and the `01_init_proxmox/templates/` subtree (see Step 5b / H3). + +### 4.2 `secrets/` is NOT generated (deploy-time symlink) +Every stage references `vars_files: ["../../secrets/default_vault.yml"]`. `scenarios//secrets/` +is a **symlink to `~/range42.config/-/secrets/`**, created by `range42-context use` +(future r42deploy/r42runtime), **gitignored, never committed**. The renderer emits the +`../../secrets/default_vault.yml` references (matching demo_lab depth) but **must not create `secrets/`**. + +### 4.3 Baseline = the real demo_lab file list +The S5 golden test asserts the generated tree against an **explicit `find scenarios/demo_lab` baseline** +(every top-level script incl. `_vms_only` variants, every section `_main.yml`, `manifest/`, `templates/`, +`01_init_proxmox/templates/`), not a hand-summarized "scripts". Generate that baseline first. + +## 5. Dependency graph & ordering + +``` +S1 rename ──┬─► S2 spec model ──┐ + └─► S3 catalog pick/validate ──┤ + ▼ + S4 alloc+manifest ─► S5b verbatim tree ─► S5a generated-from-manifest + │ (freezes the public API surface) + ├─► S6 CLI ──┐ + └─► S7 TUI ──┤ + ▼ + S8 importable API+docs ─► S9 ECC review +S0 housekeeping (independent, anytime) +``` +Parallel: **S2 ∥ S3** (after S1); **S6 ∥ S7** — but only because **S5a freezes the +`render_scenario`/`list_*` API signatures** (its exit criterion). S6/S7 both consume that frozen +contract; do not begin them until S5a fixes the signatures. Everything else serial. (S5 is split into +**S5b** verbatim-with-param boilerplate → **S5a** manifest-derived generated artifacts: S5b's structure +feeds S5a's import lists.) + +--- + +## STEP 0 — Housekeeping (independent; do anytime) + +**Context.** Loose ends from the pivot, none blocking code. +**Tasks.** +1. Post the #67 close comment: `gh issue close 67 --repo range42/range42-playbooks --comment "$(cat /tmp/issue-67-close.md)"` (if `/tmp` is gone, re-draft from §1–§2 here). +2. Open a fresh tracking issue "r42playbooks — scenario generator" linking this plan + the kanban. +3. Re-add scratch ignores to `.gitignore` (`/topology.json`, `/hosts.yml`, `/expanded.json`) — they exist on `feat/r42topo-canonical-schema` but not on this branch. +**Verify.** Issue #67 shows closed; new issue exists; `git status` clean of scratch. +**Exit.** Tracking in place. **Rollback:** reopen #67. **Model:** default. + +## STEP 1 — Rename `r42topo` → `r42playbooks` (FOUNDATION; blocks S2–S9) + +**Context.** The package's real identity is `r42playbooks` (what r42deploy/r42runtime will import). Pure +mechanical rename of the existing scenario-authoring package; behaviour unchanged. +**Tasks.** +- `git mv r42topo r42playbooks`; rename `tests` imports `r42topo` → `r42playbooks`. +- `pyproject.toml`: `name`, `[project.scripts]` (`r42playbooks = "r42playbooks.cli:app"`, + `r42playbooks-tui = "r42playbooks.tui.app:main"`), `[tool.setuptools.packages.find] include`. +- Update every `from r42topo…` / `import r42topo` across package + tests + docstrings. +- Keep CLI/TUI command names as `r42playbooks` / `r42playbooks-tui`. +**Verify.** `.venv/bin/python -m pytest -q` all green; `python -c "import r42playbooks"` works; +`grep -rn "r42topo" . --exclude-dir=.git --exclude-dir=scenarios` returns nothing (catches READMEs, +docstrings, and any CI workflow referencing the package/script names). +**Exit.** Suite green under the new name. **Rollback:** revert the rename commit. **Model:** default. + +## STEP 2 — Composition spec model `scenario.r42.yml` (pure core) + +**Context.** The reproducible artifact a user composes (msfvenom "options"). Pydantic model + loader, +written into every generated scenario so `new` is re-runnable. Depends S1. +**Tasks.** +- `r42playbooks/core/spec.py`: `ScenarioSpec` (name, subnet_layout, network_policy, boxes[]: + `{template, count?, attachments_add?[], vars?}`, proxmox_node?, notes?). `extra="forbid"`, + deny-list free-text via existing `constants.violates_denylist`. +- `load_spec(path)` / `dump_spec_atomic(spec, path)` reusing `core/io.py`. +**Verify.** TDD `tests/test_spec.py`: valid spec round-trips; bad field rejected; injection rejected. +**Exit.** Spec loads/validates/dumps deterministically. **Rollback:** drop `spec.py`. **Model:** default. + +## STEP 3 — Catalog pick/validate API (pure core) — ∥ S2 + +**Context.** `list`/`show`/validation source of truth. Extend the existing `catalog.py` (which already +loads `05_topology_layer`) to also surface role/container names from `02_/03_`. Depends S1. +**Tasks.** +- `r42playbooks/core/catalog.py`: keep `05` loaders; add `list_roles()` (scan + `02_ansible_layer/**/roles/` names) and `list_containers()` (scan `03_container_layer/docker/_ctf/`). +- `validate_refs(spec, catalog)` → list of unknown box/subnet/policy/role/container refs (typo guard). +- Pure read-only; **catalog root is an explicit input** (a `--catalog` path / env var, same as the + existing `cli.py` `_CatalogOpt`). NOTE: `range42-catalog/` is a **separate repo, not in this checkout** — + so S3/S4/S5 tests MUST use a `fake_catalog` tmp fixture (extend the existing conftest), never the real path. +**Verify.** `tests/test_catalog_pick.py` with a `fake_catalog` fixture (extend existing conftest): +known refs pass, an unknown role/container is reported. +**Exit.** Can enumerate + validate every pickable module. **Rollback:** revert catalog.py additions. **Model:** default. + +## STEP 4 — Renderer A: allocation + `manifest/scenario_vms.json` + +**Context.** Turn a `ScenarioSpec` into the concrete VM list with allocated `vm_id`/IP, honouring the +**project rule (`vm_id` last 3 digits = IP last octet`)** and **global uniqueness via +`scenarios/_reserved.json`** (`_check_reserved.sh` / `_regenerate_reserved.sh`). Depends S2,S3. +Uses the **resolved decisions** in §7.1 (template selection, role→section). +**Tasks.** +- `r42playbooks/core/allocate.py`: place each box in its subnet (from the layout); resolve box `spec` + → Proxmox template `vm_id` via §7.1 rule; allocate `vm_id`+IP via `idalloc` respecting the octet rule + and `_reserved.json`; expand `count>1` boxes (e.g. `vuln-box:count=5` → `vuln-box-00..04`). +- **`_reserved.json` has TEMPLATE rows (9xxx, `role:"template"`)** — skip already-claimed template ids + rather than reallocating, and **scope the octet-rule assertion to non-template boxes** (mirror + `idalloc`'s intent; templates like `9901`→`.201` do not satisfy the octet rule). +- Emit `manifest/scenario_vms.json` matching `demo_lab` shape. **H1: the existing + `compiler/scenario_vms.py` hard-codes `"templates": []`** — you MUST populate `templates[]` from the + template table (§7.1), not leave it empty; stage_00 `global_template_vm_id` depends on it. +**Verify.** TDD: octet rule holds for non-template boxes; template rows preserved/skipped, not +reallocated; `templates[]` populated; manifest matches demo_lab schema; no collision vs a reserved fixture. +**Exit.** Deterministic manifest (vms[] + populated templates[]) from a spec. **Rollback:** revert allocate.py. **Model:** strongest. + +## STEP 5b — Renderer: verbatim-with-param boilerplate (class B; do BEFORE 5a) + +**Context.** The net-new renderer, part 1: the **class-(B)** files (§4.1) — boilerplate copied from +`_init_lab`/`demo_lab` with placeholders filled. Emit name-referencing playbooks per §2. Atomic writes +(`core/io.py`). Depends S4. Uses §7.1 role→section table. +**Tasks.** +- `r42playbooks/core/render.py` (part 1): from spec+manifest, create `NN_
/` dirs by box role + per the **§7.1 role→section table** (admin→`02_admin_infrastructure`, student→`03_student_infrastructure`, + ctf→`04_ctf_infrastructure`, team→decided slot, template→`01_init_proxmox`). +- `stage_00/.yml`: copy demo_lab's clone boilerplate, fill the **`global_*` var set** (L3, enumerate + from `02_admin_infrastructure/_main.yml` + `stage_00/mon_wazuh.yml`): `global_vm_name`, + `global_vm_ssh_name` (= `r42.` — see M5 naming contract), `global_vm_id`, + `global_vm_description`, `global_vm_tag_name`, `global_vm_ci_ip`, `global_template_vm_id`, + `global_template_name`, plus cloud-init `vm_ci_ip_gw` / `vm_net_virtio_bridge` / `vm_ci_netmask` + (gateway/bridge come from the **subnet layout**, not the box). +- `stage_01/.yml`: copy boilerplate; only `hosts:` and the `roles:` list vary — + `roles: []` (§2: names, never copied role code). +- **H3 — `01_init_proxmox/templates/` subtree**: copy it verbatim into each scenario (it is + scenario-independent — creates the 9xxx templates the clones need) UNLESS the team decides templates + are created once by `_init_lab`; if so the generated `main.yml` omits the `01_init_proxmox` imports. + Make the choice explicit and keep `main.yml` imports consistent with emitted dirs. +- **C1 — `secrets/`**: emit `vars_files: ["../../secrets/default_vault.yml"]` refs; **do NOT create + `secrets/`** (range42-context symlinks it at deploy). Per §4.2. +- devkits, `_main.reinstall.sh`, `_activate.sh`, `templates/ansible-vars.yml`, `templates/vault-example.yml`, + top-level `*.setup.sh` / `*.delete_all.sh` / `*_vms_only.sh` / reset scripts (the full demo_lab list, §4.3). +- Write the originating `scenario.r42.yml` into the scenario for reproducibility. +**Verify.** Golden test vs the explicit `find scenarios/demo_lab` baseline (§4.3): every class-(B) file +present by name; a generated `stage_01` lists expected role NAMES (assert no role *code* copied); +`secrets/` absent. +**Exit.** Class-(B) tree generated, structurally a sibling of demo_lab. **Rollback:** revert render.py part 1. **Model:** strongest. + +## STEP 5a — Renderer: manifest-derived generated artifacts (class A) + FREEZE API + +**Context.** Part 2: the **class-(A)** files (§4.1) that must reflect *this* composition — generated +from the manifest, never copied. **This step also freezes the public API surface** consumed by S6/S7/S8. +Depends S5b. +**Tasks.** +- `templates/ansible-inventory.j2`: generate groups + member hosts from the manifest (NOT copied — + C2). **M5 naming contract** (single source, shared with stage `hosts:`): SSH host = `r42.`; + inventory group = `r42_` (or the box's `default_inventory_group`). Every `hosts:` line in + generated stages MUST exist as a host/group here (golden-assert this consistency). +- `templates/ssh-config.j2`: one `Host`/`Hostname` block per VM from the manifest. +- each section `_main.yml`: the import list of its stage files + the per-VM `global_*` vars overrides. +- top-level `main.yml` + `main_vms_only.yml`: import lists consistent with the emitted section dirs + (H3: never import a section that wasn't emitted). +- Define + **freeze** `api.render_scenario(spec, *, catalog, dest) -> Path` and `list_*`/`validate_refs` + signatures here (S8 only documents/exports them). +**Verify.** Golden test: a **2-box** composition yields an inventory + ssh-config containing exactly +those 2 boxes (not demo_lab's 10); `main.yml` imports match emitted dirs; every stage `hosts:` resolves +in the generated inventory. +**Exit.** A generated scenario is fully deployable + the API surface is frozen. **Rollback:** revert render.py part 2. **Model:** strongest. + +## STEP 6 — CLI `r42playbooks list | show | new` (msfvenom feel) — ∥ S7 + +**Context.** Thin Typer frontend over the frozen S5a API. Depends S3, S5a (frozen API). +**Tasks.** +- `list boxes|subnets|policies|roles|containers|scenarios`; `show `; `new + --subnet … --policy … --box admin-wazuh --box vuln-box:count=5 [--spec scenario.r42.yml] [-o scenarios/]`. +- Flags build a `ScenarioSpec` (or load `--spec`); writes the tree via the renderer; prints the path. +- Errors → exit codes (map `TopologyError`); no business logic in the CLI. +**Verify.** `tests/test_cli.py` via `CliRunner`: `list` shows modules; `new` writes a valid tree that +re-`validate`s; bad ref exits non-zero. +**Exit.** Full compose→generate from the CLI. **Rollback:** revert cli.py. **Model:** default. + +## STEP 7 — TUI composer (Textual) — ∥ S6 + +**Context.** Interactive composer over the same frozen S5a API (keep the initial CLI+TUI requirement). +Depends S5a (frozen API) — runs parallel to S6 since both consume the frozen surface. +**Tasks.** +- Re-point `r42playbooks/tui/` at: pick subnet layout → policy → add boxes (from `list`) → set + count/attachments → preview → generate. Controller stays Textual-free and calls the core API. +**Verify.** `tests/test_tui.py` drives the pure controller (compose + generate) headless; assert the +tree is produced. (Widget rendering not unit-tested; controller is.) +**Exit.** TUI composes + generates a scenario. **Rollback:** revert tui changes. **Model:** default. + +## STEP 8 — Importable API + packaging + docs + +**Context.** Make `import r42playbooks` clean for r42deploy/r42runtime; document. Depends S5a,S6 +(the API was frozen in S5a; S8 exports + documents it, does not re-shape it). +**Tasks.** +- `r42playbooks/api.py`: stable surface — `load_catalog`, `list_*`, `validate_refs`, `load_spec`, + `allocate`, `render_scenario(spec, *, catalog, dest) -> Path`. Pure; raises `core.errors`. +- `r42playbooks/__init__.py` exports the API; `r42playbooks/README.md` rewritten (generator, not + compiler); update `pyproject` extras (`cli`, `tui`). +**Verify.** `python -c "import r42playbooks as r; r.render_scenario"`; README examples run. +**Exit.** A downstream tool can drive generation by import alone. **Rollback:** revert api.py. **Model:** default. + +## STEP 9 — ECC review + fixes + +**Context.** Quality gate before declaring done. +**Tasks.** Run ECC `python-reviewer` + `code-reviewer` over S1–S8 (focus: renderer correctness, +allocation invariants, name-reference contract, no catalog code copied, deny-list coverage, test +quality). Address CRITICAL/HIGH. Re-run suite. +**Verify.** Reviews clean of CRITICAL/HIGH; suite green; `r42playbooks new` produces a deployable tree. +**Exit.** Convergence: a user composes from the catalog and gets a demo_lab-equivalent scenario. +**Rollback:** n/a. **Model:** strongest (review). + +## 6. Invariants to re-check after EVERY step +- Full test suite green (`.venv/bin/python -m pytest -q`). +- `core/` imports only pydantic + pyyaml + stdlib (no FastAPI/httpx/asyncio); CLI/TUI deps stay optional. +- Generated scenarios reference catalog content **by name only** (never copy role code). +- `vm_id` last 3 digits == IP last octet for single-subnet VMs; no `_reserved.json` collisions. +- No edits to `range42-backend-api` / `range42-deployer-ui`; canonical engine stays on its parked branch. + +## 7.1 Resolved decisions (settle these BEFORE S4 — they are inputs, not open) +- **Role → `NN_
` (H4):** `admin → 02_admin_infrastructure`, `student → 03_student_infrastructure`, + `ctf → 04_ctf_infrastructure`, `template → 01_init_proxmox` (not a placed box). **`team`** has no demo_lab + section — DEFAULT it to `03_student_infrastructure` (student-equivalent) unless the maintainer says + otherwise; record the choice in S5b's context as a `MUTATED` line if changed. Confirm the exact section + numbering by reading `demo_lab`/`_init_lab` before emitting. +- **Template selection (H2):** box `spec` (e.g. `4cpu/8gb/64gb`) → template `vm_id` is **not unique** + (e.g. `9234` and `9244` are both `4cpu/8gb/64gb`). Rule: **pick the lowest matching `vm_id`** from the + template table; allow an explicit `template_vm_id` override field on the box in `scenario.r42.yml`. +- **Template table source:** the available 9xxx Proxmox templates (vm_id, spec, ip, bridge) come from the + `01_init_proxmox/templates/ubuntu_noble/*.yml` set / the reference `demo_lab` `templates[]`. Treat it as + an explicit constant table or a small loadable file — it is NOT in `05_topology_layer`. +- **`_main.yml` globals:** copy the `global_vm_*` contract verbatim from `_init_lab`/`demo_lab` and + parametrize (full var set enumerated in S5b/L3). Do not invent var names. +- **Host/group naming (M5):** SSH host = `r42.`; inventory group = `r42_` (or the box's + `default_inventory_group`). Single source shared by the inventory generator and stage `hosts:` lines. + +## 7.2 Open questions / risks (genuinely open) +- **`01_init_proxmox` ownership (H3):** copy the template-creation subtree into every scenario, or create + templates once via `_init_lab` and omit the import? Decide in S5b; keep `main.yml` imports consistent. +- **Scope vs the canonical engine:** keep them separate; if a shared schema is ever wanted, that's a + later reconciliation, not this plan. +- **Real catalog absent in-repo:** `range42-catalog/` is a sibling repo, gitignored — all tests use a + `fake_catalog` fixture; never hardcode the real path. + +## 8. Plan mutation protocol +Steps may be split/inserted/reordered. If you change the plan, edit this file in the same PR and note +the change in the step's context (one line: `MUTATED : …`). Keep the dependency graph (§5) in sync. From 72102340471d984f67cb7a6a6826f159c2099b95 Mon Sep 17 00:00:00 2001 From: xinshen Date: Wed, 3 Jun 2026 20:23:32 +0200 Subject: [PATCH 013/121] refactor: rename r42topo package to r42playbooks Mechanical rename of the scenario-authoring package to its real identity (what r42deploy/r42runtime will import). Behaviour unchanged. - git mv r42topo -> r42playbooks (all modules + tui + tests imports) - pyproject: name, readme, [project.scripts] (r42playbooks / r42playbooks-tui), packages.find include - update docstrings, README, and the plan's REUSE table paths - .gitignore module comment Suite green (78 passed); import r42playbooks + cli/tui/api entrypoints OK. Step 1 of docs/r42playbooks-plan.md. --- .gitignore | 2 +- docs/r42playbooks-plan.md | 16 +++++++-------- pyproject.toml | 10 +++++----- {r42topo => r42playbooks}/README.md | 14 ++++++------- {r42topo => r42playbooks}/__init__.py | 6 +++--- {r42topo => r42playbooks}/api.py | 18 ++++++++--------- {r42topo => r42playbooks}/cli.py | 16 +++++++-------- {r42topo => r42playbooks}/core/__init__.py | 2 +- {r42topo => r42playbooks}/core/catalog.py | 6 +++--- .../core/catalog_models.py | 4 ++-- .../core/compiler/__init__.py | 20 +++++++++---------- .../core/compiler/inventory.py | 2 +- .../core/compiler/network_policy.py | 10 +++++----- .../core/compiler/scenario_vms.py | 4 ++-- .../core/compiler/stages.py | 4 ++-- {r42topo => r42playbooks}/core/constants.py | 0 {r42topo => r42playbooks}/core/errors.py | 4 ++-- {r42topo => r42playbooks}/core/extravars.py | 6 +++--- {r42topo => r42playbooks}/core/idalloc.py | 6 +++--- {r42topo => r42playbooks}/core/io.py | 4 ++-- {r42topo => r42playbooks}/core/models.py | 2 +- {r42topo => r42playbooks}/core/scaffold.py | 6 +++--- {r42topo => r42playbooks}/core/validate.py | 4 ++-- {r42topo => r42playbooks}/tui/__init__.py | 2 +- {r42topo => r42playbooks}/tui/app.py | 6 +++--- {r42topo => r42playbooks}/tui/controller.py | 14 ++++++------- tests/conftest.py | 2 +- tests/test_api.py | 8 ++++---- tests/test_catalog.py | 4 ++-- tests/test_cli.py | 2 +- tests/test_compiler.py | 16 +++++++-------- tests/test_extravars.py | 12 +++++------ tests/test_idalloc.py | 4 ++-- tests/test_io.py | 8 ++++---- tests/test_models.py | 4 ++-- tests/test_scaffold.py | 8 ++++---- tests/test_security_fixes.py | 14 ++++++------- tests/test_tui.py | 6 +++--- tests/test_validate.py | 6 +++--- 39 files changed, 141 insertions(+), 141 deletions(-) rename {r42topo => r42playbooks}/README.md (57%) rename {r42topo => r42playbooks}/__init__.py (52%) rename {r42topo => r42playbooks}/api.py (70%) rename {r42topo => r42playbooks}/cli.py (92%) rename {r42topo => r42playbooks}/core/__init__.py (74%) rename {r42topo => r42playbooks}/core/catalog.py (97%) rename {r42topo => r42playbooks}/core/catalog_models.py (97%) rename {r42topo => r42playbooks}/core/compiler/__init__.py (85%) rename {r42topo => r42playbooks}/core/compiler/inventory.py (97%) rename {r42topo => r42playbooks}/core/compiler/network_policy.py (97%) rename {r42topo => r42playbooks}/core/compiler/scenario_vms.py (92%) rename {r42topo => r42playbooks}/core/compiler/stages.py (96%) rename {r42topo => r42playbooks}/core/constants.py (100%) rename {r42topo => r42playbooks}/core/errors.py (82%) rename {r42topo => r42playbooks}/core/extravars.py (92%) rename {r42topo => r42playbooks}/core/idalloc.py (97%) rename {r42topo => r42playbooks}/core/io.py (95%) rename {r42topo => r42playbooks}/core/models.py (98%) rename {r42topo => r42playbooks}/core/scaffold.py (94%) rename {r42topo => r42playbooks}/core/validate.py (96%) rename {r42topo => r42playbooks}/tui/__init__.py (86%) rename {r42topo => r42playbooks}/tui/app.py (95%) rename {r42topo => r42playbooks}/tui/controller.py (89%) diff --git a/.gitignore b/.gitignore index d3250f82..e9d3ad0e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ CLAUDE.md **/CLAUDE.md -# Python (r42topo module) +# Python (r42playbooks module) .venv/ __pycache__/ *.py[cod] diff --git a/docs/r42playbooks-plan.md b/docs/r42playbooks-plan.md index 3cf73d1e..c1d0ee94 100644 --- a/docs/r42playbooks-plan.md +++ b/docs/r42playbooks-plan.md @@ -42,14 +42,14 @@ ref name exists — then *writes name references*. The generated tree stays a th | Asset | Path | Use | |---|---|---| -| Catalog loader | `r42topo/core/catalog.py`, `catalog_models.py` | reads `05_topology_layer` box_templates / subnet_layouts / network_policies | -| Topology model | `r42topo/core/models.py` | the `subnets/zones/boxes` authoring model + scaffold target | -| Scaffold | `r42topo/core/scaffold.py` | scaffolds a topology from catalog picks | -| Reservation/alloc | `r42topo/core/idalloc.py` | `_reserved.json` index, vm_id/IP allocation, octet rule | -| Compiler | `r42topo/core/compiler/*` | emits a **flat workspace** (topology.json, hosts.yml, scenario_vms.json, network_policy.json, stages.json) — NOT the staged scenario dir | -| Typer CLI | `r42topo/cli.py` | `validate` / `compile` / `author` / `show` (re-point/extend) | -| Textual TUI | `r42topo/tui/` (`app.py`, `controller.py`) | interactive composer (re-point/extend) | -| Deny-list + atomic IO | `r42topo/core/constants.py`, `io.py` | security + safe writes | +| Catalog loader | `r42playbooks/core/catalog.py`, `catalog_models.py` | reads `05_topology_layer` box_templates / subnet_layouts / network_policies | +| Topology model | `r42playbooks/core/models.py` | the `subnets/zones/boxes` authoring model + scaffold target | +| Scaffold | `r42playbooks/core/scaffold.py` | scaffolds a topology from catalog picks | +| Reservation/alloc | `r42playbooks/core/idalloc.py` | `_reserved.json` index, vm_id/IP allocation, octet rule | +| Compiler | `r42playbooks/core/compiler/*` | emits a **flat workspace** (topology.json, hosts.yml, scenario_vms.json, network_policy.json, stages.json) — NOT the staged scenario dir | +| Typer CLI | `r42playbooks/cli.py` | `validate` / `compile` / `author` / `show` (re-point/extend) | +| Textual TUI | `r42playbooks/tui/` (`app.py`, `controller.py`) | interactive composer (re-point/extend) | +| Deny-list + atomic IO | `r42playbooks/core/constants.py`, `io.py` | security + safe writes | ## 4. The net-new gap diff --git a/pyproject.toml b/pyproject.toml index b2dc36df..08c95595 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,10 +3,10 @@ requires = ["setuptools>=68"] build-backend = "setuptools.build_meta" [project] -name = "r42topo" +name = "r42playbooks" version = "0.1.0" description = "range42 scenario authoring & topology compiler" -readme = "r42topo/README.md" +readme = "r42playbooks/README.md" requires-python = ">=3.11" license = { text = "GPL-3.0-only" } dependencies = [ @@ -20,11 +20,11 @@ tui = ["textual>=0.60"] dev = ["pytest>=8.3", "pytest-cov"] [project.scripts] -r42topo = "r42topo.cli:app" -r42topo-tui = "r42topo.tui.app:main" +r42playbooks = "r42playbooks.cli:app" +r42playbooks-tui = "r42playbooks.tui.app:main" [tool.setuptools.packages.find] -include = ["r42topo*"] +include = ["r42playbooks*"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/r42topo/README.md b/r42playbooks/README.md similarity index 57% rename from r42topo/README.md rename to r42playbooks/README.md index db61ec4f..f26f51a0 100644 --- a/r42topo/README.md +++ b/r42playbooks/README.md @@ -1,25 +1,25 @@ -# r42topo +# r42playbooks Scenario authoring & topology compiler for range42. -`r42topo` turns a declarative, pydantic-validated **`topology.json`** into the Ansible +`r42playbooks` turns a declarative, pydantic-validated **`topology.json`** into the Ansible artifacts a deploy needs (inventory, `scenario_vms.json`, network-isolation policy, stage wiring), then resolves the extravars the `_universal` scenario consumes. ## Consumers -The **pure core** (`r42topo.core`, `r42topo.api`) is framework-agnostic and imported by: +The **pure core** (`r42playbooks.core`, `r42playbooks.api`) is framework-agnostic and imported by: - **range42-backend-api** (FastAPI) — authors/compiles topologies, then runs `_universal`. -- **r42topo's own CLI/TUI** — Typer CLI (`r42topo …`) and Textual TUI. +- **r42playbooks's own CLI/TUI** — Typer CLI (`r42playbooks …`) and Textual TUI. - **the range42 deployment CLI/TUI** (rewrite in progress) — same core, no duplication. `core/` imports only pydantic, pyyaml, and the stdlib. CLI/TUI deps are optional extras -(`pip install r42topo[cli]`, `[tui]`) so the backend installs the core alone. +(`pip install r42playbooks[cli]`, `[tui]`) so the backend installs the core alone. ## Status -Early development. See [`docs/r42topo-plan.md`](../docs/r42topo-plan.md) for the full plan +Early development. See [`docs/r42playbooks-plan.md`](../docs/r42playbooks-plan.md) for the full plan and build order. Phase 1 (core models + IO) is implemented and tested. ## Develop @@ -27,7 +27,7 @@ and build order. Phase 1 (core models + IO) is implemented and tested. ```bash python3 -m venv .venv && . .venv/bin/activate pip install -e ".[dev]" -PYTHONPATH=. pytest --cov=r42topo.core +PYTHONPATH=. pytest --cov=r42playbooks.core ``` GPL-3.0. diff --git a/r42topo/__init__.py b/r42playbooks/__init__.py similarity index 52% rename from r42topo/__init__.py rename to r42playbooks/__init__.py index 7a421b2e..3bb29c38 100644 --- a/r42topo/__init__.py +++ b/r42playbooks/__init__.py @@ -1,11 +1,11 @@ -"""r42topo — range42 scenario authoring & topology compiler. +"""r42playbooks — range42 scenario authoring & topology compiler. -Pure, framework-agnostic core (``r42topo.core``) consumed by multiple frontends: +Pure, framework-agnostic core (``r42playbooks.core``) consumed by multiple frontends: the range42-backend-api (FastAPI), this package's own Typer CLI / Textual TUI, and the range42 deployment CLI/TUI. No frontend imports belong in ``core``. """ -from r42topo.core.models import Topology +from r42playbooks.core.models import Topology __all__ = ["Topology", "__version__"] __version__ = "0.1.0" diff --git a/r42topo/api.py b/r42playbooks/api.py similarity index 70% rename from r42topo/api.py rename to r42playbooks/api.py index b12f5e4b..21c1dd2d 100644 --- a/r42topo/api.py +++ b/r42playbooks/api.py @@ -1,20 +1,20 @@ """Importable adapter — the surface every frontend calls. -Consumed by the range42-backend-api (FastAPI), r42topo's own CLI/TUI, and the +Consumed by the range42-backend-api (FastAPI), r42playbooks's own CLI/TUI, and the range42 deployment CLI/TUI. Pure: returns plain models / dicts / lists and -raises the ``r42topo.core.errors`` hierarchy — never framework types. Each +raises the ``r42playbooks.core.errors`` hierarchy — never framework types. Each consumer maps these into its own surface (HTTP envelopes, exit codes, dialogs). """ from pydantic import ValidationError as _PydanticValidationError -from r42topo.core.catalog import Catalog, load_catalog -from r42topo.core.compiler import CompileResult, compile_topology -from r42topo.core.errors import ValidationError -from r42topo.core.extravars import resolve_universal_extravars -from r42topo.core.idalloc import ReservedIndex, validate_allocation -from r42topo.core.models import Topology -from r42topo.core.validate import semantic_problems +from r42playbooks.core.catalog import Catalog, load_catalog +from r42playbooks.core.compiler import CompileResult, compile_topology +from r42playbooks.core.errors import ValidationError +from r42playbooks.core.extravars import resolve_universal_extravars +from r42playbooks.core.idalloc import ReservedIndex, validate_allocation +from r42playbooks.core.models import Topology +from r42playbooks.core.validate import semantic_problems __all__ = [ "load_catalog", diff --git a/r42topo/cli.py b/r42playbooks/cli.py similarity index 92% rename from r42topo/cli.py rename to r42playbooks/cli.py index 35d4c7fb..ecb6fee5 100644 --- a/r42topo/cli.py +++ b/r42playbooks/cli.py @@ -1,4 +1,4 @@ -"""Typer CLI — a thin frontend over r42topo.api / core. +"""Typer CLI — a thin frontend over r42playbooks.api / core. Commands: author (scaffold a starter topology), validate, compile, show. All real logic lives in the pure core; this module only parses args, prints, @@ -10,13 +10,13 @@ import typer -from r42topo import api -from r42topo.core.catalog import load_catalog -from r42topo.core.compiler.network_policy import compile_network_policy -from r42topo.core.errors import TopologyError -from r42topo.core.idalloc import ReservedIndex -from r42topo.core.io import dumps_topology, load_topology -from r42topo.core.scaffold import scaffold_topology +from r42playbooks import api +from r42playbooks.core.catalog import load_catalog +from r42playbooks.core.compiler.network_policy import compile_network_policy +from r42playbooks.core.errors import TopologyError +from r42playbooks.core.idalloc import ReservedIndex +from r42playbooks.core.io import dumps_topology, load_topology +from r42playbooks.core.scaffold import scaffold_topology app = typer.Typer(help="range42 scenario authoring & topology compiler", no_args_is_help=True) diff --git a/r42topo/core/__init__.py b/r42playbooks/core/__init__.py similarity index 74% rename from r42topo/core/__init__.py rename to r42playbooks/core/__init__.py index 9a1b2c11..c1ad7b9a 100644 --- a/r42topo/core/__init__.py +++ b/r42playbooks/core/__init__.py @@ -1,4 +1,4 @@ -"""Pure core for r42topo: pydantic models, validation, IO, compiler. +"""Pure core for r42playbooks: pydantic models, validation, IO, compiler. This sub-package imports only pydantic, pyyaml, and the standard library. It must remain free of CLI/TUI/web-framework imports so every consumer diff --git a/r42topo/core/catalog.py b/r42playbooks/core/catalog.py similarity index 97% rename from r42topo/core/catalog.py rename to r42playbooks/core/catalog.py index fdc92803..aab30311 100644 --- a/r42topo/core/catalog.py +++ b/r42playbooks/core/catalog.py @@ -18,13 +18,13 @@ import yaml -from r42topo.core import constants as C -from r42topo.core.catalog_models import ( +from r42playbooks.core import constants as C +from r42playbooks.core.catalog_models import ( BoxTemplate, NetworkPolicyTemplate, SubnetLayout, ) -from r42topo.core.errors import CatalogNotFoundError, ValidationError +from r42playbooks.core.errors import CatalogNotFoundError, ValidationError _CATEGORY_MODEL = { C.CATEGORY_BOX_TEMPLATES: BoxTemplate, diff --git a/r42topo/core/catalog_models.py b/r42playbooks/core/catalog_models.py similarity index 97% rename from r42topo/core/catalog_models.py rename to r42playbooks/core/catalog_models.py index 0fd5b12e..3f0d9109 100644 --- a/r42topo/core/catalog_models.py +++ b/r42playbooks/core/catalog_models.py @@ -14,8 +14,8 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -from r42topo.core import constants as C -from r42topo.core.models import Attachment, Subnet +from r42playbooks.core import constants as C +from r42playbooks.core.models import Attachment, Subnet _STRICT = ConfigDict(extra="forbid") diff --git a/r42topo/core/compiler/__init__.py b/r42playbooks/core/compiler/__init__.py similarity index 85% rename from r42topo/core/compiler/__init__.py rename to r42playbooks/core/compiler/__init__.py index e692d072..5fe38946 100644 --- a/r42topo/core/compiler/__init__.py +++ b/r42playbooks/core/compiler/__init__.py @@ -15,16 +15,16 @@ from dataclasses import dataclass from pathlib import Path -from r42topo.core.catalog import Catalog -from r42topo.core.compiler import inventory as _inventory -from r42topo.core.compiler import network_policy as _netpol -from r42topo.core.compiler import scenario_vms as _scenario_vms -from r42topo.core.compiler import stages as _stages -from r42topo.core.errors import CompileError -from r42topo.core.idalloc import ReservedIndex, validate_allocation -from r42topo.core.io import dump_topology -from r42topo.core.models import Topology -from r42topo.core.validate import semantic_problems +from r42playbooks.core.catalog import Catalog +from r42playbooks.core.compiler import inventory as _inventory +from r42playbooks.core.compiler import network_policy as _netpol +from r42playbooks.core.compiler import scenario_vms as _scenario_vms +from r42playbooks.core.compiler import stages as _stages +from r42playbooks.core.errors import CompileError +from r42playbooks.core.idalloc import ReservedIndex, validate_allocation +from r42playbooks.core.io import dump_topology +from r42playbooks.core.models import Topology +from r42playbooks.core.validate import semantic_problems @dataclass(frozen=True) diff --git a/r42topo/core/compiler/inventory.py b/r42playbooks/core/compiler/inventory.py similarity index 97% rename from r42topo/core/compiler/inventory.py rename to r42playbooks/core/compiler/inventory.py index 05429365..b138b3cc 100644 --- a/r42topo/core/compiler/inventory.py +++ b/r42playbooks/core/compiler/inventory.py @@ -15,7 +15,7 @@ import yaml -from r42topo.core.models import Topology +from r42playbooks.core.models import Topology def build_inventory(topology: Topology) -> dict: diff --git a/r42topo/core/compiler/network_policy.py b/r42playbooks/core/compiler/network_policy.py similarity index 97% rename from r42topo/core/compiler/network_policy.py rename to r42playbooks/core/compiler/network_policy.py index 856a3d0c..15d03e32 100644 --- a/r42topo/core/compiler/network_policy.py +++ b/r42playbooks/core/compiler/network_policy.py @@ -13,11 +13,11 @@ from pydantic import BaseModel, ConfigDict, Field -from r42topo.core import constants as C -from r42topo.core.catalog_models import MatrixRule, NetworkPolicyTemplate, PortSpec -from r42topo.core.errors import CompileError -from r42topo.core.models import Topology -from r42topo.core.validate import zone_bridge_map, zone_subnet_map +from r42playbooks.core import constants as C +from r42playbooks.core.catalog_models import MatrixRule, NetworkPolicyTemplate, PortSpec +from r42playbooks.core.errors import CompileError +from r42playbooks.core.models import Topology +from r42playbooks.core.validate import zone_bridge_map, zone_subnet_map # weight bands — lower weight is evaluated earlier in FORWARD W_ESTABLISHED = 0 diff --git a/r42topo/core/compiler/scenario_vms.py b/r42playbooks/core/compiler/scenario_vms.py similarity index 92% rename from r42topo/core/compiler/scenario_vms.py rename to r42playbooks/core/compiler/scenario_vms.py index 9efd954e..3de3f499 100644 --- a/r42topo/core/compiler/scenario_vms.py +++ b/r42playbooks/core/compiler/scenario_vms.py @@ -8,8 +8,8 @@ import json -from r42topo.core.models import Topology -from r42topo.core.validate import zone_bridge_map +from r42playbooks.core.models import Topology +from r42playbooks.core.validate import zone_bridge_map def build_scenario_vms(topology: Topology) -> dict: diff --git a/r42topo/core/compiler/stages.py b/r42playbooks/core/compiler/stages.py similarity index 96% rename from r42topo/core/compiler/stages.py rename to r42playbooks/core/compiler/stages.py index bb5d72e1..b87709b9 100644 --- a/r42topo/core/compiler/stages.py +++ b/r42playbooks/core/compiler/stages.py @@ -9,8 +9,8 @@ import json -from r42topo.core.catalog import Catalog -from r42topo.core.models import Box, Topology +from r42playbooks.core.catalog import Catalog +from r42playbooks.core.models import Box, Topology # deploy order for zone roles (admin infra before students before ctf targets) _ROLE_ORDER = {"template": 0, "admin": 1, "student": 2, "team": 3, "ctf": 4} diff --git a/r42topo/core/constants.py b/r42playbooks/core/constants.py similarity index 100% rename from r42topo/core/constants.py rename to r42playbooks/core/constants.py diff --git a/r42topo/core/errors.py b/r42playbooks/core/errors.py similarity index 82% rename from r42topo/core/errors.py rename to r42playbooks/core/errors.py index 29be7a6f..6690f18c 100644 --- a/r42topo/core/errors.py +++ b/r42playbooks/core/errors.py @@ -1,4 +1,4 @@ -"""Framework-free exception hierarchy for the r42topo core. +"""Framework-free exception hierarchy for the r42playbooks core. Consumers (backend-api, CLI, TUI) translate these into their own surface (HTTP envelopes, exit codes, dialog text). The core never raises framework @@ -7,7 +7,7 @@ class TopologyError(Exception): - """Base class for all r42topo core errors.""" + """Base class for all r42playbooks core errors.""" class ValidationError(TopologyError): diff --git a/r42topo/core/extravars.py b/r42playbooks/core/extravars.py similarity index 92% rename from r42topo/core/extravars.py rename to r42playbooks/core/extravars.py index 9ab51ebc..764eab9e 100644 --- a/r42topo/core/extravars.py +++ b/r42playbooks/core/extravars.py @@ -7,9 +7,9 @@ Identifier values are deny-list checked (no injection into templated vars). """ -from r42topo.core import constants as C -from r42topo.core.compiler import CompileResult -from r42topo.core.errors import ValidationError +from r42playbooks.core import constants as C +from r42playbooks.core.compiler import CompileResult +from r42playbooks.core.errors import ValidationError # the exact contract keys _universal/main.yml expects _ALLOWED_KEYS = ( diff --git a/r42topo/core/idalloc.py b/r42playbooks/core/idalloc.py similarity index 97% rename from r42topo/core/idalloc.py rename to r42playbooks/core/idalloc.py index 8ceb3d8a..4647ee67 100644 --- a/r42topo/core/idalloc.py +++ b/r42playbooks/core/idalloc.py @@ -24,9 +24,9 @@ from dataclasses import dataclass, field from pathlib import Path -from r42topo.core import constants as C -from r42topo.core.errors import TopologyError -from r42topo.core.models import Topology +from r42playbooks.core import constants as C +from r42playbooks.core.errors import TopologyError +from r42playbooks.core.models import Topology @dataclass(frozen=True) diff --git a/r42topo/core/io.py b/r42playbooks/core/io.py similarity index 95% rename from r42topo/core/io.py rename to r42playbooks/core/io.py index 76dd8d15..c3e53940 100644 --- a/r42topo/core/io.py +++ b/r42playbooks/core/io.py @@ -12,8 +12,8 @@ from pydantic import ValidationError as _PydanticValidationError -from r42topo.core.errors import TopologyError -from r42topo.core.models import Topology +from r42playbooks.core.errors import TopologyError +from r42playbooks.core.models import Topology def load_topology(path: Path) -> Topology: diff --git a/r42topo/core/models.py b/r42playbooks/core/models.py similarity index 98% rename from r42topo/core/models.py rename to r42playbooks/core/models.py index 4d2393b8..2826f1d8 100644 --- a/r42topo/core/models.py +++ b/r42playbooks/core/models.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -from r42topo.core import constants as C +from r42playbooks.core import constants as C _STRICT = ConfigDict(extra="forbid") diff --git a/r42topo/core/scaffold.py b/r42playbooks/core/scaffold.py similarity index 94% rename from r42topo/core/scaffold.py rename to r42playbooks/core/scaffold.py index d55285fa..f0bbd969 100644 --- a/r42topo/core/scaffold.py +++ b/r42playbooks/core/scaffold.py @@ -9,9 +9,9 @@ import ipaddress -from r42topo.core.catalog import Catalog -from r42topo.core.errors import ValidationError -from r42topo.core.models import Box, NetworkPolicyRef, Topology, Zone +from r42playbooks.core.catalog import Catalog +from r42playbooks.core.errors import ValidationError +from r42playbooks.core.models import Box, NetworkPolicyRef, Topology, Zone # subnet/zone name -> role (anything unrecognized becomes a team zone) _NAME_ROLE = {"admin": "admin", "ctf": "ctf", "student": "student", "template": "template"} diff --git a/r42topo/core/validate.py b/r42playbooks/core/validate.py similarity index 96% rename from r42topo/core/validate.py rename to r42playbooks/core/validate.py index 87247354..b81f6dc7 100644 --- a/r42topo/core/validate.py +++ b/r42playbooks/core/validate.py @@ -9,8 +9,8 @@ import ipaddress -from r42topo.core.catalog import Catalog -from r42topo.core.models import Topology +from r42playbooks.core.catalog import Catalog +from r42playbooks.core.models import Topology def zone_subnet_map(topology: Topology) -> dict[str, str]: diff --git a/r42topo/tui/__init__.py b/r42playbooks/tui/__init__.py similarity index 86% rename from r42topo/tui/__init__.py rename to r42playbooks/tui/__init__.py index 0054834e..b735a8eb 100644 --- a/r42topo/tui/__init__.py +++ b/r42playbooks/tui/__init__.py @@ -1,4 +1,4 @@ -"""Textual TUI for r42topo. +"""Textual TUI for r42playbooks. The interactive authoring frontend. All non-view logic lives in ``TuiController`` (pure, framework-light) so it can be unit-tested without the diff --git a/r42topo/tui/app.py b/r42playbooks/tui/app.py similarity index 95% rename from r42topo/tui/app.py rename to r42playbooks/tui/app.py index 281672db..cacc0059 100644 --- a/r42topo/tui/app.py +++ b/r42playbooks/tui/app.py @@ -11,12 +11,12 @@ from textual.containers import Horizontal, Vertical from textual.widgets import Button, Footer, Header, Input, Label, Select, Static -from r42topo.core.errors import TopologyError -from r42topo.tui.controller import TuiController +from r42playbooks.core.errors import TopologyError +from r42playbooks.tui.controller import TuiController class TopologyAuthorApp(App): - """Interactive authoring app for r42topo topologies.""" + """Interactive authoring app for r42playbooks topologies.""" CSS = """ #form { height: auto; padding: 1; } diff --git a/r42topo/tui/controller.py b/r42playbooks/tui/controller.py similarity index 89% rename from r42topo/tui/controller.py rename to r42playbooks/tui/controller.py index d2fbf50d..18dfe631 100644 --- a/r42topo/tui/controller.py +++ b/r42playbooks/tui/controller.py @@ -6,13 +6,13 @@ from pathlib import Path -from r42topo import api -from r42topo.core.catalog import load_catalog -from r42topo.core.compiler.network_policy import compile_network_policy -from r42topo.core.idalloc import ReservedIndex -from r42topo.core.io import dump_topology -from r42topo.core.models import Topology -from r42topo.core.scaffold import scaffold_topology +from r42playbooks import api +from r42playbooks.core.catalog import load_catalog +from r42playbooks.core.compiler.network_policy import compile_network_policy +from r42playbooks.core.idalloc import ReservedIndex +from r42playbooks.core.io import dump_topology +from r42playbooks.core.models import Topology +from r42playbooks.core.scaffold import scaffold_topology class TuiController: diff --git a/tests/conftest.py b/tests/conftest.py index d5d063f5..6b327f39 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -"""Shared test fixtures for r42topo.""" +"""Shared test fixtures for r42playbooks.""" import copy diff --git a/tests/test_api.py b/tests/test_api.py index ae0d4de4..548672e7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -2,10 +2,10 @@ import pytest -from r42topo import api -from r42topo.core.errors import ValidationError -from r42topo.core.idalloc import ReservedIndex -from r42topo.core.models import Topology +from r42playbooks import api +from r42playbooks.core.errors import ValidationError +from r42playbooks.core.idalloc import ReservedIndex +from r42playbooks.core.models import Topology def test_load_catalog_reexported(fake_catalog): diff --git a/tests/test_catalog.py b/tests/test_catalog.py index 7d189027..8d972297 100644 --- a/tests/test_catalog.py +++ b/tests/test_catalog.py @@ -2,8 +2,8 @@ import pytest -from r42topo.core.catalog import load_catalog -from r42topo.core.errors import CatalogNotFoundError +from r42playbooks.core.catalog import load_catalog +from r42playbooks.core.errors import CatalogNotFoundError def test_load_catalog_indexes_all_categories(fake_catalog): diff --git a/tests/test_cli.py b/tests/test_cli.py index 63ea3f1d..d5f44cb8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,7 +4,7 @@ from typer.testing import CliRunner -from r42topo.cli import app +from r42playbooks.cli import app runner = CliRunner() diff --git a/tests/test_compiler.py b/tests/test_compiler.py index 37c832d8..e8b51852 100644 --- a/tests/test_compiler.py +++ b/tests/test_compiler.py @@ -5,11 +5,11 @@ import pytest import yaml -from r42topo.core.catalog import load_catalog -from r42topo.core.compiler import compile_topology -from r42topo.core.compiler.network_policy import compile_network_policy, lint_segmentation -from r42topo.core.idalloc import ReservedIndex -from r42topo.core.models import Topology +from r42playbooks.core.catalog import load_catalog +from r42playbooks.core.compiler import compile_topology +from r42playbooks.core.compiler.network_policy import compile_network_policy, lint_segmentation +from r42playbooks.core.idalloc import ReservedIndex +from r42playbooks.core.models import Topology @pytest.fixture @@ -94,7 +94,7 @@ def test_segmentation_linter_flags_shadowed_drop(built): t, cat, _ = built pol = cat.network_policies["air-gap-ctf"].model_copy(deep=True) # add ctf->admin ACCEPT before the existing ctf->admin DROP - from r42topo.core.catalog_models import MatrixRule + from r42playbooks.core.catalog_models import MatrixRule pol.matrix.insert(0, MatrixRule(src="ctf", dst="admin", action="accept", comment="bad: opens ctf->admin")) compiled = compile_network_policy(t, pol, version="1.1.0") @@ -124,13 +124,13 @@ def test_compile_writes_all_artifacts(built): def test_compile_topology_roundtrips_topology(built): t, _, result = built - from r42topo.core.io import load_topology + from r42playbooks.core.io import load_topology assert load_topology(result.topology_path) == t def test_compile_rejects_allocation_conflict(topology_factory, fake_catalog, reserved_factory, tmp_path): - from r42topo.core.errors import CompileError + from r42playbooks.core.errors import CompileError t = Topology.model_validate(topology_factory()) cat = load_catalog(fake_catalog) reserved = ReservedIndex.from_file(reserved_factory([ diff --git a/tests/test_extravars.py b/tests/test_extravars.py index 0364898d..c6480f4c 100644 --- a/tests/test_extravars.py +++ b/tests/test_extravars.py @@ -2,12 +2,12 @@ import pytest -from r42topo.core.catalog import load_catalog -from r42topo.core.compiler import compile_topology -from r42topo.core.errors import ValidationError -from r42topo.core.extravars import resolve_universal_extravars -from r42topo.core.idalloc import ReservedIndex -from r42topo.core.models import Topology +from r42playbooks.core.catalog import load_catalog +from r42playbooks.core.compiler import compile_topology +from r42playbooks.core.errors import ValidationError +from r42playbooks.core.extravars import resolve_universal_extravars +from r42playbooks.core.idalloc import ReservedIndex +from r42playbooks.core.models import Topology @pytest.fixture diff --git a/tests/test_idalloc.py b/tests/test_idalloc.py index b36bad4a..fdf20f35 100644 --- a/tests/test_idalloc.py +++ b/tests/test_idalloc.py @@ -1,7 +1,7 @@ """P2 id/IP allocation + reservation tests — RED before GREEN.""" -from r42topo.core.idalloc import ReservedIndex, validate_allocation -from r42topo.core.models import Topology +from r42playbooks.core.idalloc import ReservedIndex, validate_allocation +from r42playbooks.core.models import Topology def test_valid_topology_has_no_allocation_errors(valid_topology_dict, reserved_factory): diff --git a/tests/test_io.py b/tests/test_io.py index 196d45f7..db3ac2fb 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,9 +1,9 @@ -"""P1 IO round-trip tests for r42topo.core.io — RED before GREEN.""" +"""P1 IO round-trip tests for r42playbooks.core.io — RED before GREEN.""" import json -from r42topo.core.io import dump_topology, load_topology -from r42topo.core.models import Topology +from r42playbooks.core.io import dump_topology, load_topology +from r42playbooks.core.models import Topology def test_round_trip_preserves_topology(tmp_path, valid_topology_dict): @@ -27,6 +27,6 @@ def test_dump_writes_sorted_deterministic_json(tmp_path, valid_topology_dict): def test_load_rejects_unknown_path(tmp_path): import pytest - from r42topo.core.errors import TopologyError + from r42playbooks.core.errors import TopologyError with pytest.raises(TopologyError): load_topology(tmp_path / "does-not-exist.json") diff --git a/tests/test_models.py b/tests/test_models.py index 97139a59..46fb5d02 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,9 +1,9 @@ -"""P1 schema tests for r42topo.core.models — RED before GREEN.""" +"""P1 schema tests for r42playbooks.core.models — RED before GREEN.""" import pytest from pydantic import ValidationError -from r42topo.core.models import Topology +from r42playbooks.core.models import Topology def test_valid_topology_parses(valid_topology_dict): diff --git a/tests/test_scaffold.py b/tests/test_scaffold.py index 5debfe35..03eb90ae 100644 --- a/tests/test_scaffold.py +++ b/tests/test_scaffold.py @@ -1,9 +1,9 @@ """P4 scaffold tests — pure starter-topology generation (shared by CLI + TUI).""" -from r42topo.core import constants as C -from r42topo.core.catalog import load_catalog -from r42topo.core.scaffold import scaffold_topology -from r42topo.core.validate import semantic_problems +from r42playbooks.core import constants as C +from r42playbooks.core.catalog import load_catalog +from r42playbooks.core.scaffold import scaffold_topology +from r42playbooks.core.validate import semantic_problems def test_scaffold_produces_valid_topology(fake_catalog): diff --git a/tests/test_security_fixes.py b/tests/test_security_fixes.py index 4ac4d6bf..4db5b390 100644 --- a/tests/test_security_fixes.py +++ b/tests/test_security_fixes.py @@ -5,12 +5,12 @@ import pytest from pydantic import ValidationError as PydErr -from r42topo.core.catalog import load_catalog -from r42topo.core.catalog_models import MatrixRule, NetworkPolicyTemplate, ZoneDecl -from r42topo.core.compiler.network_policy import compile_network_policy, lint_segmentation -from r42topo.core.errors import CompileError, TopologyError -from r42topo.core.extravars import resolve_universal_extravars -from r42topo.core.models import Topology +from r42playbooks.core.catalog import load_catalog +from r42playbooks.core.catalog_models import MatrixRule, NetworkPolicyTemplate, ZoneDecl +from r42playbooks.core.compiler.network_policy import compile_network_policy, lint_segmentation +from r42playbooks.core.errors import CompileError, TopologyError +from r42playbooks.core.extravars import resolve_universal_extravars +from r42playbooks.core.models import Topology # --- C3: wildcard-source DROP must not be shadowed undetected --- @@ -89,7 +89,7 @@ def test_schema_version_pinned(topology_factory): # --- CRITICAL(py): load_topology wraps pydantic errors as TopologyError --- def test_load_topology_wraps_schema_error(tmp_path, valid_topology_dict): - from r42topo.core.io import load_topology + from r42playbooks.core.io import load_topology bad = dict(valid_topology_dict) bad["proxmox_node"] = "" # schema-invalid but valid JSON p = tmp_path / "bad.json" diff --git a/tests/test_tui.py b/tests/test_tui.py index 262c6d65..c2c98c0b 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -2,8 +2,8 @@ import asyncio -from r42topo.core.io import load_topology -from r42topo.tui.controller import TuiController +from r42playbooks.core.io import load_topology +from r42playbooks.tui.controller import TuiController def test_controller_lists_catalog_choices(fake_catalog): @@ -30,7 +30,7 @@ def test_controller_save_roundtrip(fake_catalog, tmp_path): def test_app_mounts(fake_catalog): """The Textual app composes and mounts headlessly without error.""" - from r42topo.tui.app import TopologyAuthorApp + from r42playbooks.tui.app import TopologyAuthorApp async def _go(): app = TopologyAuthorApp(TuiController(fake_catalog)) diff --git a/tests/test_validate.py b/tests/test_validate.py index 86c980e6..c36d4ea5 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -1,8 +1,8 @@ """P3 semantic validation tests — RED before GREEN.""" -from r42topo.core.catalog import load_catalog -from r42topo.core.models import Topology -from r42topo.core.validate import semantic_problems +from r42playbooks.core.catalog import load_catalog +from r42playbooks.core.models import Topology +from r42playbooks.core.validate import semantic_problems def _valid(topology_factory, fake_catalog): From 17bc6dcacb790b07322e914898bf0630b6e0838e Mon Sep 17 00:00:00 2001 From: xinshen Date: Wed, 3 Jun 2026 20:30:11 +0200 Subject: [PATCH 014/121] feat(spec): add scenario.r42.yml composition spec model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 of docs/r42playbooks-plan.md. The reproducible msfvenom-style 'options' artifact a user composes, written verbatim into every generated scenario. - core/spec.py: ScenarioSpec + BoxSpec (extra='forbid', deny-list guards on free-text name/notes/vars), load_spec / dump_spec_atomic (YAML, deterministic). - core/io.py: extract atomic_write_text helper (reused by spec IO); dump_topology now delegates to it (byte-identical output preserved). - constants.py: BOX_COUNT_MAX bound on count expansion. - BoxSpec.template_vm_id optional override (per plan §7.1, unblocks S4). - tests/test_spec.py (15 cases) + conftest valid_spec_dict/spec_factory fixtures. Full suite green. --- r42playbooks/core/constants.py | 4 ++ r42playbooks/core/io.py | 16 +++-- r42playbooks/core/spec.py | 104 ++++++++++++++++++++++++++++++ tests/conftest.py | 34 ++++++++++ tests/test_spec.py | 114 +++++++++++++++++++++++++++++++++ 5 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 r42playbooks/core/spec.py create mode 100644 tests/test_spec.py diff --git a/r42playbooks/core/constants.py b/r42playbooks/core/constants.py index a5abe17b..aa22cfd5 100644 --- a/r42playbooks/core/constants.py +++ b/r42playbooks/core/constants.py @@ -49,6 +49,10 @@ VM_ID_MIN = 1000 VM_ID_MAX = 9999 +# Upper bound on how many VMs a single composed box may expand to (count>1). +# Bounds runaway allocation from a hostile/typo'd scenario.r42.yml. +BOX_COUNT_MAX = 99 + # Catalog template id — dotless kebab (distinct from dotted role refs and from # the scenario-name rule). Used for directory names under 05_topology_layer/. TEMPLATE_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") diff --git a/r42playbooks/core/io.py b/r42playbooks/core/io.py index c3e53940..5f66a857 100644 --- a/r42playbooks/core/io.py +++ b/r42playbooks/core/io.py @@ -42,12 +42,15 @@ def dumps_topology(topology: Topology) -> str: return json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False) + "\n" -def dump_topology(topology: Topology, path: Path) -> Path: - """Atomically write *topology* to *path* as canonical JSON. Returns the path.""" +def atomic_write_text(text: str, path: Path) -> Path: + """Atomically write *text* to *path* (temp file + os.replace). Returns the path. + + Shared by every artifact writer in the core (topology JSON, scenario specs, + rendered scenario files) so atomicity lives in exactly one place. + """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) - text = dumps_topology(topology) - fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".topology-", suffix=".tmp") + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".r42-", suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as fh: fh.write(text) @@ -56,3 +59,8 @@ def dump_topology(topology: Topology, path: Path) -> Path: if os.path.exists(tmp): os.unlink(tmp) return path + + +def dump_topology(topology: Topology, path: Path) -> Path: + """Atomically write *topology* to *path* as canonical JSON. Returns the path.""" + return atomic_write_text(dumps_topology(topology), Path(path)) diff --git a/r42playbooks/core/spec.py b/r42playbooks/core/spec.py new file mode 100644 index 00000000..c841ec89 --- /dev/null +++ b/r42playbooks/core/spec.py @@ -0,0 +1,104 @@ +"""The ``scenario.r42.yml`` composition spec — the msfvenom-style "options". + +A ``ScenarioSpec`` is the reproducible artifact a user composes: which catalog +modules (subnet layout, network policy, box templates) make up a lab. It is +written verbatim into every generated ``scenarios//`` so ``new`` is +re-runnable. + +Schema-level validation only (shapes, patterns, the security deny-list). Whether +a referenced module *exists* in the catalog is a separate concern (``catalog.py`` +``validate_refs``); allocation/rendering happen later still. This layer never +touches the filesystem beyond the load/dump helpers, which reuse ``core.io``'s +atomic writer. +""" + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic import ValidationError as _PydanticValidationError + +import yaml + +from r42playbooks.core import constants as C +from r42playbooks.core import io +from r42playbooks.core.errors import TopologyError +from r42playbooks.core.models import Attachment + +_STRICT = ConfigDict(extra="forbid") +_no_injection = C.reject_injection + + +class BoxSpec(BaseModel): + """One composed box: a catalog box-template pick plus optional tweaks.""" + + model_config = _STRICT + + # box_template id (dotless kebab dir under 05_topology_layer/box_templates/). + template: str = Field(pattern=C.TEMPLATE_ID_RE.pattern) + # how many VMs this box expands to (vuln-box:count=5 -> vuln-box-00..04). + count: int = Field(default=1, ge=1, le=C.BOX_COUNT_MAX) + # extra catalog attachments layered on top of the template's defaults. + attachments_add: list[Attachment] = Field(default_factory=list) + # free-form Ansible vars merged into the box (Jinja render surface -> guarded). + vars: dict[str, Any] = Field(default_factory=dict) + # §7.1 override: pin the Proxmox template vm_id instead of auto-selecting it. + template_vm_id: int | None = Field(default=None, ge=C.VM_ID_MIN, le=C.VM_ID_MAX) + + _guard_vars = field_validator("vars")(C.reject_injection_nested) + + +class ScenarioSpec(BaseModel): + """The composed lab: catalog picks the renderer turns into a scenario tree.""" + + model_config = _STRICT + + schema_version: Literal[1] = 1 + name: str = Field(pattern=C.SCENARIO_NAME_RE.pattern) + subnet_layout: str = Field(pattern=C.TEMPLATE_ID_RE.pattern) + network_policy: str = Field(pattern=C.TEMPLATE_ID_RE.pattern) + boxes: list[BoxSpec] = Field(min_length=1) + proxmox_node: str | None = Field(default=None, pattern=C.PROXMOX_NODE_RE.pattern) + notes: str = Field(default="", max_length=255) + + _guard_name = field_validator("name")(_no_injection) + _guard_notes = field_validator("notes")(_no_injection) + + +def dumps_spec(spec: ScenarioSpec) -> str: + """Serialize *spec* to canonical, sorted, newline-terminated YAML.""" + payload = spec.model_dump(mode="json") + return yaml.safe_dump( + payload, + sort_keys=True, + default_flow_style=False, + allow_unicode=True, + ) + + +def dump_spec_atomic(spec: ScenarioSpec, path) -> "object": + """Atomically write *spec* to *path* as canonical YAML. Returns the path.""" + return io.atomic_write_text(dumps_spec(spec), path) + + +def load_spec(path) -> ScenarioSpec: + """Load and validate a ``scenario.r42.yml`` from *path*. + + :raises TopologyError: if the file is missing, not valid YAML, or fails schema. + """ + from pathlib import Path + + path = Path(path) + try: + raw = path.read_text(encoding="utf-8") + except OSError as exc: + raise TopologyError(f"cannot read scenario spec: {path}") from exc + try: + data = yaml.safe_load(raw) + except yaml.YAMLError as exc: + raise TopologyError(f"invalid YAML in scenario spec: {path}") from exc + if not isinstance(data, dict): + raise TopologyError(f"scenario spec must be a mapping: {path}") + try: + return ScenarioSpec.model_validate(data) + except _PydanticValidationError as exc: + raise TopologyError(f"scenario spec schema error in {path}: {exc}") from exc diff --git a/tests/conftest.py b/tests/conftest.py index 6b327f39..86bc64dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -37,6 +37,40 @@ def valid_topology_dict() -> dict: } +@pytest.fixture +def valid_spec_dict() -> dict: + """A minimal, valid scenario.r42.yml composition spec (msfvenom 'options').""" + return { + "schema_version": 1, + "name": "my_lab", + "subnet_layout": "default-3zone", + "network_policy": "air-gap-ctf", + "proxmox_node": "px-testing", + "notes": "demo composition", + "boxes": [ + {"template": "admin-wazuh"}, + { + "template": "vuln-box", + "count": 5, + "attachments_add": [ + {"kind": "role", "catalog_ref": "software.install.extra", "params": {}}, + ], + "vars": {"difficulty": "hard"}, + }, + ], + } + + +@pytest.fixture +def spec_factory(valid_spec_dict): + """Return a deep-copy mutator so tests can tweak one spec field in isolation.""" + def _make(**overrides) -> dict: + spec = copy.deepcopy(valid_spec_dict) + spec.update(overrides) + return spec + return _make + + @pytest.fixture def topology_factory(valid_topology_dict): """Return a deep-copy mutator so tests can tweak one field in isolation.""" diff --git a/tests/test_spec.py b/tests/test_spec.py new file mode 100644 index 00000000..72fc25fe --- /dev/null +++ b/tests/test_spec.py @@ -0,0 +1,114 @@ +"""Step 2 — scenario.r42.yml composition spec tests (RED before GREEN). + +Covers: valid spec round-trips through YAML; unknown/bad fields are rejected +(extra="forbid"); deny-listed free-text (injection) is rejected; IO error paths. +""" + +import pytest +from pydantic import ValidationError as PydanticValidationError + +from r42playbooks.core.errors import TopologyError +from r42playbooks.core.spec import ScenarioSpec, dump_spec_atomic, load_spec + + +def test_valid_spec_round_trips(tmp_path, valid_spec_dict): + # Arrange + spec = ScenarioSpec.model_validate(valid_spec_dict) + path = tmp_path / "scenario.r42.yml" + + # Act + dump_spec_atomic(spec, path) + loaded = load_spec(path) + + # Assert + assert loaded == spec + + +def test_dump_is_deterministic(tmp_path, valid_spec_dict): + spec = ScenarioSpec.model_validate(valid_spec_dict) + p1, p2 = tmp_path / "a.yml", tmp_path / "b.yml" + dump_spec_atomic(spec, p1) + dump_spec_atomic(spec, p2) + assert p1.read_text() == p2.read_text() # byte-identical + + +def test_count_defaults_to_one(valid_spec_dict): + spec = ScenarioSpec.model_validate(valid_spec_dict) + assert spec.boxes[0].count == 1 # admin-wazuh has no explicit count + + +def test_optional_fields_may_be_omitted(spec_factory): + data = spec_factory() + del data["proxmox_node"] + del data["notes"] + spec = ScenarioSpec.model_validate(data) + assert spec.proxmox_node is None + assert spec.notes == "" + + +def test_unknown_top_level_field_rejected(spec_factory): + data = spec_factory(unexpected="boom") + with pytest.raises(PydanticValidationError): + ScenarioSpec.model_validate(data) + + +def test_unknown_box_field_rejected(valid_spec_dict): + valid_spec_dict["boxes"][0]["junk"] = 1 + with pytest.raises(PydanticValidationError): + ScenarioSpec.model_validate(valid_spec_dict) + + +def test_bad_name_rejected(spec_factory): + data = spec_factory(name="has spaces") + with pytest.raises(PydanticValidationError): + ScenarioSpec.model_validate(data) + + +def test_empty_boxes_rejected(spec_factory): + data = spec_factory(boxes=[]) + with pytest.raises(PydanticValidationError): + ScenarioSpec.model_validate(data) + + +def test_injection_in_notes_rejected(spec_factory): + data = spec_factory(notes="{{ malicious }}") + with pytest.raises(PydanticValidationError): + ScenarioSpec.model_validate(data) + + +def test_injection_in_box_vars_rejected(valid_spec_dict): + valid_spec_dict["boxes"][0]["vars"] = {"x": "${SECRET}"} + with pytest.raises(PydanticValidationError): + ScenarioSpec.model_validate(valid_spec_dict) + + +def test_count_must_be_positive(valid_spec_dict): + valid_spec_dict["boxes"][0]["count"] = 0 + with pytest.raises(PydanticValidationError): + ScenarioSpec.model_validate(valid_spec_dict) + + +def test_template_vm_id_override_accepted(valid_spec_dict): + # §7.1 resolved decision: a box may pin its template via template_vm_id. + valid_spec_dict["boxes"][0]["template_vm_id"] = 9234 + spec = ScenarioSpec.model_validate(valid_spec_dict) + assert spec.boxes[0].template_vm_id == 9234 + + +def test_load_spec_rejects_missing_file(tmp_path): + with pytest.raises(TopologyError): + load_spec(tmp_path / "nope.r42.yml") + + +def test_load_spec_rejects_invalid_yaml(tmp_path): + bad = tmp_path / "bad.r42.yml" + bad.write_text("name: [unclosed\n", encoding="utf-8") + with pytest.raises(TopologyError): + load_spec(bad) + + +def test_load_spec_rejects_schema_violation(tmp_path): + bad = tmp_path / "bad.r42.yml" + bad.write_text("name: my_lab\n", encoding="utf-8") # missing required fields + with pytest.raises(TopologyError): + load_spec(bad) From 93b70c5b0d77a645a8c1e9fbe15b5b8f8e88d88c Mon Sep 17 00:00:00 2001 From: xinshen Date: Wed, 3 Jun 2026 20:36:17 +0200 Subject: [PATCH 015/121] feat(catalog): add role/container enumeration + spec ref validation Step 3 of docs/r42playbooks-plan.md. The list/validate source of truth. - catalog.py: list_roles() scans 02_ansible_layer/**/roles/ names; list_containers() scans 03_container_layer/docker/_ctf/ for compose-bearing dirs (ref = path under _ctf/). Both read-only with symlink-escape guards; empty when a layer is absent. - Catalog gains roles/containers sets, populated by load_catalog. - validate_refs(spec, catalog): typo guard reporting unknown subnet_layout / network_policy / box template / role / container refs (gamification skipped). - constants.py: 02/03 layer + compose-filename constants. - tests/test_catalog_pick.py (10 cases) + fake_catalog fixture extended with 02/03 layers (real range42-catalog is a separate gitignored repo, never in checkout). Full suite green (103). --- r42playbooks/core/catalog.py | 77 +++++++++++++++++++++++++++- r42playbooks/core/constants.py | 11 ++++ tests/conftest.py | 10 ++++ tests/test_catalog_pick.py | 93 ++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 tests/test_catalog_pick.py diff --git a/r42playbooks/core/catalog.py b/r42playbooks/core/catalog.py index aab30311..697b0e93 100644 --- a/r42playbooks/core/catalog.py +++ b/r42playbooks/core/catalog.py @@ -15,6 +15,7 @@ import hashlib from dataclasses import dataclass, field from pathlib import Path +from typing import TYPE_CHECKING import yaml @@ -26,6 +27,9 @@ ) from r42playbooks.core.errors import CatalogNotFoundError, ValidationError +if TYPE_CHECKING: + from r42playbooks.core.spec import ScenarioSpec + _CATEGORY_MODEL = { C.CATEGORY_BOX_TEMPLATES: BoxTemplate, C.CATEGORY_NETWORK_POLICIES: NetworkPolicyTemplate, @@ -44,11 +48,14 @@ class _Resolved: @dataclass class Catalog: - """In-memory index of validated topology-layer templates.""" + """In-memory index of validated topology-layer templates + pickable refs.""" box_templates: dict[str, BoxTemplate] = field(default_factory=dict) network_policies: dict[str, NetworkPolicyTemplate] = field(default_factory=dict) subnet_layouts: dict[str, SubnetLayout] = field(default_factory=dict) + # name-referenced modules from 02_/03_ (not pydantic templates) — see S3. + roles: set[str] = field(default_factory=set) + containers: set[str] = field(default_factory=set) _resolved: dict[tuple[str, str], _Resolved] = field(default_factory=dict) # -- resolution helpers (raise CatalogNotFoundError on miss) -- @@ -145,6 +152,48 @@ def _load_category(layer_root: Path, category: str, catalog: Catalog) -> None: ) +def list_roles(catalog_root: Path) -> list[str]: + """Enumerate reusable Ansible role names under ``02_ansible_layer/**/roles/``. + + Roles are referenced by name (``..``) and resolve at + deploy time via ``ANSIBLE_ROLES_PATH`` — this is a read-only name scan, never a + copy. Returns a sorted, de-duplicated list. Empty if the layer is absent. + """ + layer_root = (Path(catalog_root) / C.ANSIBLE_LAYER_DIR).resolve() + if not layer_root.is_dir(): + return [] + names: set[str] = set() + for roles_dir in layer_root.rglob(C.ROLES_DIR_NAME): + if not roles_dir.is_dir() or not roles_dir.resolve().is_relative_to(layer_root): + continue # skip symlink escapes + for child in roles_dir.iterdir(): + if child.is_dir() and C.CATALOG_REF_RE.fullmatch(child.name): + names.add(child.name) + return sorted(names) + + +def list_containers(catalog_root: Path) -> list[str]: + """Enumerate CTF docker stacks under ``03_container_layer/docker/_ctf/``. + + A container ref is the POSIX path, relative to ``_ctf/``, of a directory that + holds a compose file (e.g. ``cve/web/dvwa``). Returns a sorted list. Empty if + the layer is absent. + """ + ctf_root = (Path(catalog_root) / C.CONTAINER_LAYER_DIR / C.CTF_REL_DIR).resolve() + if not ctf_root.is_dir(): + return [] + refs: set[str] = set() + for filename in C.COMPOSE_FILENAMES: + for compose in ctf_root.rglob(filename): + stack_dir = compose.parent.resolve() + if not stack_dir.is_relative_to(ctf_root): + continue # skip symlink escapes + rel = stack_dir.relative_to(ctf_root).as_posix() + if rel and rel != ".": + refs.add(rel) + return sorted(refs) + + def load_catalog(catalog_root: Path) -> Catalog: """Load + validate all topology-layer templates from a catalog checkout. @@ -159,4 +208,30 @@ def load_catalog(catalog_root: Path) -> Catalog: catalog = Catalog() for category in _CATEGORY_MODEL: _load_category(layer_root, category, catalog) + catalog.roles = set(list_roles(catalog_root)) + catalog.containers = set(list_containers(catalog_root)) return catalog + + +def validate_refs(spec: "ScenarioSpec", catalog: Catalog) -> list[str]: + """Return human-readable messages for every spec ref missing from *catalog*. + + A typo guard for ``scenario.r42.yml``: checks the subnet layout, network + policy, each box template, and each added role/container attachment. An empty + list means every referenced module exists. ``gamification`` attachments are + not enumerated here and are skipped (cannot be validated by name yet). + """ + problems: list[str] = [] + if spec.subnet_layout not in catalog.subnet_layouts: + problems.append(f"unknown subnet_layout: {spec.subnet_layout!r}") + if spec.network_policy not in catalog.network_policies: + problems.append(f"unknown network_policy: {spec.network_policy!r}") + for box in spec.boxes: + if box.template not in catalog.box_templates: + problems.append(f"unknown box template: {box.template!r}") + for att in box.attachments_add: + if att.kind == "role" and att.catalog_ref not in catalog.roles: + problems.append(f"unknown role: {att.catalog_ref!r}") + elif att.kind == "container" and att.catalog_ref not in catalog.containers: + problems.append(f"unknown container: {att.catalog_ref!r}") + return problems diff --git a/r42playbooks/core/constants.py b/r42playbooks/core/constants.py index aa22cfd5..f943f946 100644 --- a/r42playbooks/core/constants.py +++ b/r42playbooks/core/constants.py @@ -66,6 +66,17 @@ CATEGORY_NETWORK_POLICIES = "network_policies" CATEGORY_SUBNET_LAYOUTS = "subnet_layouts" +# Catalog layers scanned for pickable, name-referenced modules (S3). +# roles -> 02_ansible_layer/**/roles/../ +# containers -> 03_container_layer/docker/_ctf/**/ (dir holding a compose file) +ANSIBLE_LAYER_DIR = "02_ansible_layer" +CONTAINER_LAYER_DIR = "03_container_layer" +CTF_REL_DIR = "docker/_ctf" +ROLES_DIR_NAME = "roles" +COMPOSE_FILENAMES: tuple[str, ...] = ( + "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml", +) + # Security deny-list: substrings that must never appear in a free-text topology # field. Blocks Jinja/SSTI (`{{ }}`, `{% %}`, `${`), shell metacharacters, # path traversal, and argv-flag injection. Fields are rejected, never sanitized. diff --git a/tests/conftest.py b/tests/conftest.py index 86bc64dd..c3052bbe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -126,6 +126,16 @@ def fake_catalog(tmp_path): spec: "4cpu/8gb/64gb" """.lstrip()) + # 02_ansible_layer: reusable roles, referenced by name (..). + for role in ("software.install.wazuh", "software.install.wazuh-agent", "software.install.extra"): + _write(root / "02_ansible_layer" / "admin" / "roles" / role / "tasks" / "main.yml", "---\n[]\n") + + # 03_container_layer: CTF docker stacks, referenced by path under _ctf/. + _write(root / "03_container_layer" / "docker" / "_ctf" / "cve" / "web" / "dvwa" / "docker-compose.yml", + "services: {}\n") + _write(root / "03_container_layer" / "docker" / "_ctf" / "misconfiguration" / "network" / "open-smb" / "compose.yml", + "services: {}\n") + # two versions of a policy — loader must pick the highest (1.1.0) for ver, comment in (("v1.0.0", "v1"), ("v1.1.0", "v1.1")): _write(layer / "network_policies" / "air-gap-ctf" / ver / "template.yml", f""" diff --git a/tests/test_catalog_pick.py b/tests/test_catalog_pick.py new file mode 100644 index 00000000..7dfd9495 --- /dev/null +++ b/tests/test_catalog_pick.py @@ -0,0 +1,93 @@ +"""Step 3 — catalog pick/validate API (RED before GREEN). + +Enumerate pickable roles (02_ansible_layer) and containers (03_container_layer), +and validate that every ref in a ScenarioSpec resolves in the catalog. +All tests use the fake_catalog fixture — the real range42-catalog is a separate, +gitignored repo never present in this checkout. +""" + +from r42playbooks.core.catalog import ( + list_containers, + list_roles, + load_catalog, + validate_refs, +) +from r42playbooks.core.spec import ScenarioSpec + + +def test_list_roles_enumerates_role_dir_names(fake_catalog): + roles = list_roles(fake_catalog) + assert "software.install.wazuh" in roles + assert "software.install.wazuh-agent" in roles + assert roles == sorted(roles) # deterministic ordering + + +def test_list_containers_enumerates_ctf_paths(fake_catalog): + containers = list_containers(fake_catalog) + assert "cve/web/dvwa" in containers + assert "misconfiguration/network/open-smb" in containers + + +def test_list_is_empty_when_layers_absent(tmp_path): + # a catalog_root without 02/03 layers yields empty lists, not an error + assert list_roles(tmp_path) == [] + assert list_containers(tmp_path) == [] + + +def test_load_catalog_populates_roles_and_containers(fake_catalog): + catalog = load_catalog(fake_catalog) + assert "software.install.wazuh" in catalog.roles + assert "cve/web/dvwa" in catalog.containers + + +def test_validate_refs_all_known_returns_empty(fake_catalog, valid_spec_dict): + catalog = load_catalog(fake_catalog) + spec = ScenarioSpec.model_validate(valid_spec_dict) + assert validate_refs(spec, catalog) == [] + + +def test_validate_refs_reports_unknown_box_template(fake_catalog, spec_factory): + catalog = load_catalog(fake_catalog) + data = spec_factory(boxes=[{"template": "ghost-box"}]) + spec = ScenarioSpec.model_validate(data) + problems = validate_refs(spec, catalog) + assert any("ghost-box" in p for p in problems) + + +def test_validate_refs_reports_unknown_subnet_and_policy(fake_catalog, spec_factory): + catalog = load_catalog(fake_catalog) + data = spec_factory(subnet_layout="nope-layout", network_policy="nope-policy") + spec = ScenarioSpec.model_validate(data) + problems = validate_refs(spec, catalog) + assert any("nope-layout" in p for p in problems) + assert any("nope-policy" in p for p in problems) + + +def test_validate_refs_reports_unknown_role(fake_catalog, valid_spec_dict): + catalog = load_catalog(fake_catalog) + valid_spec_dict["boxes"][0]["attachments_add"] = [ + {"kind": "role", "catalog_ref": "software.install.ghost", "params": {}}, + ] + spec = ScenarioSpec.model_validate(valid_spec_dict) + problems = validate_refs(spec, catalog) + assert any("software.install.ghost" in p for p in problems) + + +def test_validate_refs_reports_unknown_container(fake_catalog, valid_spec_dict): + catalog = load_catalog(fake_catalog) + valid_spec_dict["boxes"][0]["attachments_add"] = [ + {"kind": "container", "catalog_ref": "cve/web/ghost", "params": {}}, + ] + spec = ScenarioSpec.model_validate(valid_spec_dict) + problems = validate_refs(spec, catalog) + assert any("cve/web/ghost" in p for p in problems) + + +def test_validate_refs_accepts_known_role_and_container(fake_catalog, valid_spec_dict): + catalog = load_catalog(fake_catalog) + valid_spec_dict["boxes"][0]["attachments_add"] = [ + {"kind": "role", "catalog_ref": "software.install.wazuh", "params": {}}, + {"kind": "container", "catalog_ref": "cve/web/dvwa", "params": {}}, + ] + spec = ScenarioSpec.model_validate(valid_spec_dict) + assert validate_refs(spec, catalog) == [] From c0e786c57f67119e4a870f24492e9f5bc670019a Mon Sep 17 00:00:00 2001 From: xinshen Date: Wed, 3 Jun 2026 20:46:23 +0200 Subject: [PATCH 016/121] feat(allocate): deterministic VM allocation + scenario_vms manifest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 of docs/r42playbooks-plan.md (Renderer A). Turn a ScenarioSpec into a concrete VM list honouring the project invariants. - core/templates_table.py: the fixed 12-row 9xxx Proxmox template table (verbatim from demo_lab manifest templates[]); select_template() picks lowest matching vm_id for a box spec, with explicit template_vm_id override (§7.1 / H2). - core/allocate.py: place each box in its role's subnet, fill octets from a per-role base, assign vm_id=band*1000+octet (bands 1..8; 9xxx reserved for templates) so the octet rule holds by construction; expand count>1 to