[
  {
    "path": ".ansible-lint",
    "content": "# Ansible-lint configuration\nexclude_paths:\n  - .cache/\n  - .github/\n  - tests/\n  - files/cloud-init/  # Cloud-init files have special format requirements\n  - playbooks/  # These are task files included by other playbooks, not standalone playbooks\n  - roles/cloud-ec2/files/  # AWS CloudFormation templates use YAML tags ansible-lint can't parse\n  - roles/cloud-lightsail/files/  # AWS CloudFormation templates use YAML tags ansible-lint can't parse\n\nskip_list:\n  - 'package-latest'  # Package installs should not use latest - needed for updates\n  - 'experimental'  # Experimental rules\n  - 'fqcn[action]'  # Use FQCN for module actions - gradual migration\n  - 'fqcn[action-core]'  # Use FQCN for builtin actions - gradual migration\n  - 'var-naming[no-role-prefix]'  # Variable naming\n  - 'var-naming[pattern]'  # Variable naming patterns\n  - 'no-free-form'  # Avoid free-form syntax - some legacy usage\n  - 'name[casing]'  # Name casing\n  - 'yaml[document-start]'  # YAML document start\n  - 'role-name'  # Role naming convention - too many cloud-* roles\n  - 'no-handler'  # Handler usage - some legitimate non-handler use cases\n  - 'name[missing]'  # All tasks should be named - 113 issues to fix (temporary)\n\n# Enable additional rules\nenable_list:\n  - no-log-password\n  - no-same-owner\n  - partial-become\n  - name[play]  # All plays should be named\n  - yaml[new-line-at-end-of-file]  # Files should end with newline\n  - jinja[invalid]  # Invalid Jinja2 syntax (catches template errors)\n  - jinja[spacing]  # Proper spacing in Jinja2 expressions\n  - no-changed-when  # Commands should declare changed_when\n  - risky-file-permissions  # File tasks must have explicit mode\n\nverbosity: 1\n\n# Mock custom modules in library/ that ansible-lint can't auto-discover\n# These modules exist and work at runtime, but need to be declared for static analysis\nmock_modules:\n  - gcp_compute_location_info\n  - lightsail_region_facts\n  - x25519_pubkey\n  - scaleway_compute\n\n# vim: ft=yaml\n"
  },
  {
    "path": ".dockerignore",
    "content": "# Version control and CI\n.git/\n.github/\n.gitignore\n\n# Development environment\n.env\n.venv/\n.ruff_cache/\n__pycache__/\n*.pyc\n*.pyo\n*.pyd\n\n# Documentation and metadata\ndocs/\ntests/\nREADME.md\nCHANGELOG.md\nCONTRIBUTING.md\nPULL_REQUEST_TEMPLATE.md\nSECURITY.md\nlogo.png\n.travis.yml\n\n# Build artifacts and configs\nconfigs/\nDockerfile\n.dockerignore\nVagrantfile\n\n# User configuration (should be bind-mounted)\nconfig.cfg\n\n# IDE and editor files\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS generated files\n.DS_Store\nThumbs.db\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "---\n# These are supported funding model platforms\n\ngithub: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]\npatreon: algovpn\nopen_collective: # Replace with a single Open Collective username\nko_fi: # Replace with a single Ko-fi username\ntidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel\ncommunity_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry\nliberapay: # Replace with a single Liberapay username\nissuehunt: # Replace with a single IssueHunt username\notechie: # Replace with a single Otechie username\ncustom: # Replace with a single custom sponsorship URL\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Report a problem with Algo\n---\n\n**What happened?**\n\n\n**Environment** (cloud provider, OS, WireGuard or IPsec)\n\n\n**Output**\n```\nPaste any error messages or relevant output here\n```\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "---\nblank_issues_enabled: true\ncontact_links:\n  - name: Troubleshooting Guide\n    url: https://trailofbits.github.io/algo/troubleshooting.html\n    about: Check common issues and solutions before filing\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/actions/setup-algo/action.yml",
    "content": "---\nname: 'Setup Algo Environment'\ndescription: 'Setup Python, uv, and dependencies for Algo VPN CI'\ninputs:\n  python-version:\n    description: 'Python version to use'\n    required: false\n    default: '3.11'\n  install-shellcheck:\n    description: 'Install shellcheck for shell script linting'\n    required: false\n    default: 'false'\n  install-ansible-collections:\n    description: 'Install Ansible Galaxy collections'\n    required: false\n    default: 'false'\nruns:\n  using: composite\n  steps:\n    - name: Setup Python\n      uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548  # v6.1.0\n      with:\n        python-version: ${{ inputs.python-version }}\n\n    - name: Setup uv environment\n      uses: ./.github/actions/setup-uv\n\n    - name: Install shellcheck\n      if: inputs.install-shellcheck == 'true'\n      run: sudo apt-get update && sudo apt-get install -y shellcheck\n      shell: bash\n\n    - name: Install Ansible collections\n      if: inputs.install-ansible-collections == 'true'\n      run: uv run ansible-galaxy collection install -r requirements.yml\n      shell: bash\n"
  },
  {
    "path": ".github/actions/setup-uv/action.yml",
    "content": "---\nname: 'Setup uv Environment'\ndescription: 'Install uv and sync dependencies for Algo VPN project'\noutputs:\n  uv-version:\n    description: 'The version of uv that was installed'\n    value: ${{ steps.setup.outputs.uv-version }}\nruns:\n  using: composite\n  steps:\n    - name: Install uv\n      id: setup\n      uses: astral-sh/setup-uv@1ddb97e5078301c0bec13b38151f8664ed04edc8  # v6\n      with:\n        enable-cache: true\n    - name: Sync dependencies\n      run: uv sync\n      shell: bash\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "---\nversion: 2\nupdates:\n  # Maintain dependencies for GitHub Actions\n  - package-ecosystem: \"github-actions\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    cooldown:\n      default-days: 7\n    groups:\n      github-actions:\n        patterns:\n          - \"*\"\n\n  # Maintain dependencies for Python using uv\n  # Using \"uv\" ecosystem ensures both pyproject.toml AND uv.lock are updated together\n  # This prevents Docker build failures from lockfile mismatches\n  - package-ecosystem: \"uv\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    cooldown:\n      default-days: 7\n    groups:\n      python:\n        patterns:\n          - \"*\"\n\n  # Maintain Docker base image (python:3.12-alpine)\n  - package-ecosystem: \"docker\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    cooldown:\n      default-days: 7\n"
  },
  {
    "path": ".github/workflows/docker-image.yaml",
    "content": "---\nname: Create and publish a Docker image\n\n'on':\n  push:\n    branches: ['master']\n\nenv:\n  REGISTRY: ghcr.io\n  IMAGE_NAME: ${{ github.repository }}\n\njobs:\n  build-and-push-image:\n    runs-on: ubuntu-latest\n    permissions:\n      contents: read\n      packages: write\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a  # v4.0.0\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd  # v4.0.0\n\n      - name: Log in to the Container registry\n        uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2  # v4.0.0\n        with:\n          registry: ${{ env.REGISTRY }}\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Extract metadata (tags, labels) for Docker\n        id: meta\n        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf  # v6.0.0\n        with:\n          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}\n          tags: |\n            # set latest tag for master branch\n            type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}\n\n      - name: Build and push Docker image\n        uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294  # v7.0.0\n        with:\n          context: .\n          push: true\n          platforms: linux/amd64,linux/arm64\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n"
  },
  {
    "path": ".github/workflows/integration-tests.yml",
    "content": "---\nname: Integration Tests\n\n'on':\n  pull_request:\n    types: [opened, synchronize, reopened]\n    paths:\n      - 'main.yml'\n      - 'roles/**'\n      - 'playbooks/**'\n      - 'library/**'\n  workflow_dispatch:\n  schedule:\n    - cron: '0 2 * * 1'  # Weekly on Monday at 2 AM\n\npermissions:\n  contents: read\n\njobs:\n  localhost-deployment:\n    name: Localhost VPN Deployment Test\n    runs-on: ubuntu-22.04\n    timeout-minutes: 30\n    strategy:\n      fail-fast: false\n      matrix:\n        vpn_type: ['wireguard', 'ipsec', 'both']\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n          # Note: No pip cache - we use uv for dependency management\n\n      - name: Install system dependencies\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y \\\n            wireguard \\\n            wireguard-tools \\\n            strongswan \\\n            libstrongswan-standard-plugins \\\n            dnsmasq \\\n            qrencode \\\n            openssl \\\n            \"linux-headers-$(uname -r)\" \\\n            libxml2-utils \\\n            dnsutils\n\n      - name: Install uv\n        run: curl -LsSf https://astral.sh/uv/install.sh | sh\n\n      - name: Install Python dependencies\n        run: uv sync\n\n      - name: Install Ansible collections\n        run: uv run ansible-galaxy collection install -r requirements.yml\n\n      - name: Create test configuration\n        run: |\n          cat > integration-test.cfg << EOF\n          users:\n            - alice\n            - bob\n          cloud_providers:\n            local:\n              server: localhost\n              endpoint: 127.0.0.1\n          wireguard_enabled: ${{ matrix.vpn_type == 'wireguard' || matrix.vpn_type == 'both' }}\n          ipsec_enabled: ${{ matrix.vpn_type == 'ipsec' || matrix.vpn_type == 'both' }}\n          dns_adblocking: true\n          ssh_tunneling: false\n          store_pki: true\n          algo_provider: local\n          algo_server_name: github-ci-test\n          server: localhost\n          algo_ssh_port: 22\n          CA_password: \"test-ca-password-${{ github.run_id }}\"\n          p12_export_password: \"test-p12-password-${{ github.run_id }}\"\n          tests: true\n          no_log: false\n          ansible_connection: local\n          dns_encryption: true\n          algo_dns_adblocking: true\n          algo_ssh_tunneling: false\n          BetweenClients_DROP: true\n          block_smb: true\n          block_netbios: true\n          pki_in_tmpfs: true\n          endpoint: 127.0.0.1\n          ssh_port: 4160\n          local_service_ip: 172.16.0.1\n          local_service_ipv6: \"fd00::1\"\n          EOF\n\n      - name: Run Algo deployment\n        run: |\n          # Run ansible-playbook via uv - become: true in playbook handles root\n          # GitHub runners have passwordless sudo for become escalation\n          uv run ansible-playbook main.yml \\\n            -i \"localhost,\" \\\n            -c local \\\n            -e @integration-test.cfg \\\n            -e \"provider=local\" \\\n            -vv\n\n      - name: Verify services are running\n        run: |\n          # Check WireGuard\n          if [[ \"${{ matrix.vpn_type }}\" == \"wireguard\" || \"${{ matrix.vpn_type }}\" == \"both\" ]]; then\n            echo \"Checking WireGuard...\"\n            sudo wg show\n            if ! sudo systemctl is-active --quiet wg-quick@wg0; then\n              echo \"✗ WireGuard service not running\"\n              exit 1\n            fi\n            echo \"✓ WireGuard is running\"\n          fi\n\n          # Check StrongSwan (service name is strongswan-starter on Ubuntu 20.04+)\n          if [[ \"${{ matrix.vpn_type }}\" == \"ipsec\" || \"${{ matrix.vpn_type }}\" == \"both\" ]]; then\n            echo \"Checking StrongSwan...\"\n            sudo ipsec statusall\n            if ! sudo systemctl is-active --quiet strongswan-starter; then\n              echo \"✗ StrongSwan service not running\"\n              exit 1\n            fi\n            echo \"✓ StrongSwan is running\"\n          fi\n\n          # Check dnsmasq\n          if ! sudo systemctl is-active --quiet dnsmasq; then\n            echo \"⚠️  dnsmasq not running (may be expected)\"\n          else\n            echo \"✓ dnsmasq is running\"\n          fi\n\n          # Check dnscrypt-proxy\n          if sudo systemctl is-active --quiet dnscrypt-proxy; then\n            echo \"✓ dnscrypt-proxy is running\"\n          else\n            echo \"⚠️  dnscrypt-proxy not running\"\n          fi\n\n          # DNS health check - verify DNS resolution works\n          echo \"Testing DNS resolution via local_service_ip (172.16.0.1)...\"\n          if dig @172.16.0.1 google.com +short +timeout=5 | grep -q .; then\n            echo \"✓ DNS resolution working\"\n          else\n            echo \"⚠️  DNS resolution failed (service may still be starting)\"\n          fi\n\n      - name: Verify generated configs\n        run: |\n          echo \"Checking generated configuration files...\"\n\n          # WireGuard configs\n          if [[ \"${{ matrix.vpn_type }}\" == \"wireguard\" || \"${{ matrix.vpn_type }}\" == \"both\" ]]; then\n            for user in alice bob; do\n              if [ ! -f \"configs/localhost/wireguard/${user}.conf\" ]; then\n                echo \"✗ Missing WireGuard config for ${user}\"\n                exit 1\n              fi\n              if [ ! -f \"configs/localhost/wireguard/${user}.png\" ]; then\n                echo \"✗ Missing WireGuard QR code for ${user}\"\n                exit 1\n              fi\n            done\n            echo \"✓ All WireGuard configs generated\"\n          fi\n\n          # IPsec configs (p12 in manual/, mobileconfig in apple/)\n          if [[ \"${{ matrix.vpn_type }}\" == \"ipsec\" || \"${{ matrix.vpn_type }}\" == \"both\" ]]; then\n            for user in alice bob; do\n              if [ ! -f \"configs/localhost/ipsec/manual/${user}.p12\" ]; then\n                echo \"✗ Missing IPsec certificate for ${user}\"\n                exit 1\n              fi\n              if [ ! -f \"configs/localhost/ipsec/apple/${user}.mobileconfig\" ]; then\n                echo \"✗ Missing IPsec mobile config for ${user}\"\n                exit 1\n              fi\n            done\n            echo \"✓ All IPsec configs generated\"\n          fi\n\n      - name: Test VPN connectivity\n        run: |\n          echo \"Testing basic VPN connectivity...\"\n\n          # Test WireGuard\n          if [[ \"${{ matrix.vpn_type }}\" == \"wireguard\" || \"${{ matrix.vpn_type }}\" == \"both\" ]]; then\n            # Get server's WireGuard public key\n            SERVER_PUBKEY=$(sudo wg show wg0 public-key)\n            echo \"Server public key: $SERVER_PUBKEY\"\n\n            # Check if interface has peers\n            PEER_COUNT=$(sudo wg show wg0 peers | wc -l)\n            echo \"✓ WireGuard has $PEER_COUNT peer(s) configured\"\n          fi\n\n          # Test StrongSwan\n          if [[ \"${{ matrix.vpn_type }}\" == \"ipsec\" || \"${{ matrix.vpn_type }}\" == \"both\" ]]; then\n            # Check IPsec policies\n            sudo ipsec statusall | grep -E \"INSTALLED|ESTABLISHED\" || echo \"No active IPsec connections (expected)\"\n          fi\n\n      - name: Run E2E VPN connectivity tests\n        env:\n          VPN_TYPE: ${{ matrix.vpn_type }}\n        run: |\n          chmod +x tests/e2e/test-vpn-connectivity.sh\n          sudo tests/e2e/test-vpn-connectivity.sh \"${VPN_TYPE}\"\n\n      - name: Collect E2E debug info on failure\n        if: failure()\n        run: |\n          echo \"=== E2E Test Debug Information ===\"\n          echo \"=== Network Namespaces ===\"\n          ip netns list || true\n          echo \"=== WireGuard Config (alice) ===\"\n          cat configs/localhost/wireguard/alice.conf 2>/dev/null || echo \"Not found\"\n          echo \"=== IPsec Certificates ===\"\n          ls -la configs/localhost/ipsec/.pki/certs/ 2>/dev/null || echo \"Not found\"\n          echo \"=== iptables NAT ===\"\n          sudo iptables -t nat -L -n -v || true\n\n      - name: Upload configs as artifacts\n        if: always()\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f  # v7.0.0\n        with:\n          name: vpn-configs-${{ matrix.vpn_type }}-${{ github.run_id }}\n          path: configs/\n          retention-days: 7\n\n      - name: Upload logs on failure\n        if: failure()\n        run: |\n          echo \"=== Network Interfaces ===\"\n          ip addr || true\n          echo \"=== Listening Ports ===\"\n          sudo ss -tulnp || true\n          echo \"=== WireGuard Status ===\"\n          sudo wg show || true\n          echo \"=== IPsec Status ===\"\n          sudo ipsec statusall || true\n          echo \"=== DNS Services ===\"\n          sudo systemctl status dnscrypt-proxy dnscrypt-proxy.socket dnsmasq --no-pager || true\n          echo \"=== WireGuard Log ===\"\n          sudo journalctl -u wg-quick@wg0 -n 50 --no-pager || true\n          echo \"=== StrongSwan Log ===\"\n          sudo journalctl -u strongswan -n 50 --no-pager || true\n          echo \"=== dnscrypt-proxy Log ===\"\n          sudo journalctl -u dnscrypt-proxy -n 50 --no-pager || true\n          echo \"=== System Log (last 100 lines) ===\"\n          sudo journalctl -n 100 --no-pager || true\n\n  docker-build-test:\n    name: Docker Image Build Test\n    runs-on: ubuntu-22.04\n    timeout-minutes: 10\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Build Algo Docker image\n        run: |\n          docker build -t algo:ci-test .\n\n      - name: Test Docker image\n        run: |\n          # Test that the image can run and show help\n          docker run --rm --entrypoint /bin/sh algo:ci-test -c \"cd /algo && ./algo --help\" || true\n\n          # Test that required binaries exist in the virtual environment\n          docker run --rm --entrypoint /bin/sh algo:ci-test -c \"cd /algo && uv run which ansible\"\n          docker run --rm --entrypoint /bin/sh algo:ci-test -c \"which python3\"\n          docker run --rm --entrypoint /bin/sh algo:ci-test -c \"which rsync\"\n\n      - name: Test Docker config validation\n        run: |\n          # Create a minimal valid config\n          mkdir -p test-data\n          cat > test-data/config.cfg << 'EOF'\n          users:\n            - test-user\n          cloud_providers:\n            ec2:\n              size: t3.micro\n              region: us-east-1\n          wireguard_enabled: true\n          ipsec_enabled: false\n          dns_encryption: true\n          algo_provider: ec2\n          EOF\n\n          # Test that config is readable\n          docker run --rm --entrypoint cat -v \"$(pwd)/test-data:/data\" algo:ci-test /data/config.cfg\n\n          echo \"✓ Docker image built and basic tests passed\"\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "---\nname: Lint\n\n'on':\n  push:\n    branches: [main, master]\n  pull_request:\n\npermissions:\n  contents: read\n\njobs:\n  ansible-lint:\n    name: Ansible linting\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Setup Algo environment\n        uses: ./.github/actions/setup-algo\n        with:\n          install-ansible-collections: 'true'\n\n      - name: Run ansible-lint\n        run: |\n          uv run --with ansible-lint ansible-lint .\n\n      - name: Run playbook dry-run check (catch runtime issues)\n        run: |\n          # Test main playbook logic without making changes\n          # This catches filter warnings, collection issues, and runtime errors\n          uv run ansible-playbook main.yml --check --connection=local \\\n            -e \"server_ip=test\" \\\n            -e \"server_name=ci-test\" \\\n            -e \"IP_subject_alt_name=192.168.1.1\" \\\n            || echo \"Dry-run check completed with issues - review output above\"\n\n  yaml-lint:\n    name: YAML linting\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Run yamllint\n        run: uv run --with yamllint yamllint -c .yamllint .\n\n  jinja2-lint:\n    name: Jinja2 template linting\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Run j2lint\n        run: |\n          # Lint Jinja2 templates for syntax and style issues\n          # Ignored rules (incompatible with Ansible config-file templates):\n          #   S3: indentation (dictated by output format, not Jinja style)\n          #   S5: tabs (some config formats require them)\n          #   S6: whitespace-control delimiters ({%- -%} are standard Ansible)\n          #   S7: single-statement-per-line (inline Jinja in config output)\n          #   V1: lowercase variables (existing names like IP_subject_alt_name)\n          uv run --with j2lint j2lint roles/ --ignore S3 S5 S6 S7 V1\n\n  python-lint:\n    name: Python linting\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Setup Algo environment\n        uses: ./.github/actions/setup-algo\n\n      - name: Run ruff check\n        run: |\n          # Fast Python linter\n          uv run --with ruff ruff check .\n\n      - name: Run ruff format check\n        run: |\n          # Verify consistent Python formatting\n          uv run --with ruff ruff format --check .\n\n  python-types:\n    name: Python type checking\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Setup Algo environment\n        uses: ./.github/actions/setup-algo\n\n      - name: Run ty check\n        run: |\n          # Type checking with ty\n          uv run --with ty ty check\n\n  shellcheck:\n    name: Shell script linting\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Setup Algo environment\n        uses: ./.github/actions/setup-algo\n        with:\n          install-shellcheck: 'true'\n\n      - name: Run shellcheck\n        run: |\n          # Check all shell scripts, not just algo and install.sh\n          find . -type f -name \"*.sh\" -not -path \"./.git/*\" -exec shellcheck {} \\;\n\n  powershell-lint:\n    name: PowerShell script linting\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Install PowerShell\n        run: |\n          # Install PowerShell Core\n          wget -q https://github.com/PowerShell/PowerShell/releases/download/v7.4.0/powershell_7.4.0-1.deb_amd64.deb\n          sudo dpkg -i powershell_7.4.0-1.deb_amd64.deb\n          sudo apt-get install -f\n\n      - name: Install PSScriptAnalyzer\n        run: |\n          pwsh -Command \"Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser\"\n\n      - name: Run PowerShell syntax check\n        run: |\n          # Check syntax by parsing the script\n          pwsh -NoProfile -NonInteractive -Command \"\n            try {\n              \\$null = [System.Management.Automation.PSParser]::Tokenize((Get-Content -Path './algo.ps1' -Raw), [ref]\\$null)\n              Write-Host '✓ PowerShell syntax check passed'\n            } catch {\n              Write-Error 'PowerShell syntax error: ' + \\$_.Exception.Message\n              exit 1\n            }\n          \"\n\n      - name: Run PSScriptAnalyzer\n        run: |\n          pwsh -Command \"\n            \\$results = Invoke-ScriptAnalyzer -Path './algo.ps1' -Severity Warning,Error\n            if (\\$results.Count -gt 0) {\n              \\$results | Format-Table -AutoSize\n              exit 1\n            } else {\n              Write-Host '✓ PSScriptAnalyzer check passed'\n            }\n          \"\n\n  actionlint:\n    name: GitHub Actions linting\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Install actionlint\n        run: |\n          bash <(curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)\n          sudo mv actionlint /usr/local/bin/\n\n      - name: Run actionlint\n        run: |\n          actionlint .github/workflows/*.yml\n\n  zizmor:\n    name: GitHub Actions security audit\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Install zizmor\n        run: |\n          pip install zizmor\n\n      - name: Run zizmor\n        run: |\n          zizmor .github/workflows/\n"
  },
  {
    "path": ".github/workflows/main.yml",
    "content": "---\nname: Main\n\n'on':\n  push:\n    branches:\n      - master\n      - main\n  workflow_dispatch:\n\npermissions:\n  contents: read\n\njobs:\n  syntax-check:\n    name: Ansible syntax check\n    runs-on: ubuntu-22.04\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Check Ansible playbook syntax\n        run: uv run ansible-playbook main.yml --syntax-check\n\n  basic-tests:\n    name: Basic sanity tests\n    runs-on: ubuntu-22.04\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Install system dependencies\n        run: sudo apt-get update && sudo apt-get install -y shellcheck\n\n      - name: Run basic sanity tests\n        run: uv run pytest tests/unit/ -v\n\n  docker-build:\n    name: Docker build test\n    runs-on: ubuntu-22.04\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Build Docker image\n        run: docker build -t local/algo:test .\n\n      - name: Test Docker image starts\n        run: |\n          # Just verify the image can start and show help\n          docker run --rm local/algo:test /algo/algo --help\n\n      - name: Run Docker deployment tests\n        run: uv run pytest tests/unit/test_docker_localhost_deployment.py -v\n\n  config-generation:\n    name: Configuration generation test\n    runs-on: ubuntu-22.04\n    timeout-minutes: 10\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Test configuration generation (local mode)\n        run: |\n          # Run our simplified config test\n          chmod +x tests/test-local-config.sh\n          ./tests/test-local-config.sh\n\n  ansible-dry-run:\n    name: Ansible dry-run validation\n    runs-on: ubuntu-22.04\n    timeout-minutes: 10\n    permissions:\n      contents: read\n    strategy:\n      matrix:\n        provider: [local, ec2, digitalocean, gce]\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Create test configuration for ${{ matrix.provider }}\n        run: |\n          # Create provider-specific test config\n          cat > test-${{ matrix.provider }}.cfg << 'EOF'\n          users:\n            - testuser\n          cloud_providers:\n            ${{ matrix.provider }}:\n              server: test-server\n              size: t3.micro\n              image: ubuntu-22.04\n              region: us-east-1\n          wireguard_enabled: true\n          ipsec_enabled: false\n          dns_adblocking: false\n          ssh_tunneling: false\n          store_pki: true\n          algo_provider: ${{ matrix.provider }}\n          algo_server_name: test-algo-vpn\n          server: test-server\n          endpoint: 10.0.0.1\n          ansible_ssh_user: ubuntu\n          ansible_ssh_port: 22\n          algo_ssh_port: 4160\n          algo_ondemand_cellular: false\n          algo_ondemand_wifi: false\n          EOF\n\n      - name: Run Ansible check mode for ${{ matrix.provider }}\n        run: |\n          # Run ansible in check mode to validate playbooks work\n          uv run ansible-playbook main.yml \\\n            -i \"localhost,\" \\\n            -c local \\\n            -e @test-${{ matrix.provider }}.cfg \\\n            -e \"provider=${{ matrix.provider }}\" \\\n            --check \\\n            --diff \\\n            -vv \\\n            --skip-tags \"facts,tests,local,update-alternatives,cloud_api\" || true\n\n          # The || true is because check mode will fail on some tasks\n          # but we're looking for syntax/undefined variable errors\n"
  },
  {
    "path": ".github/workflows/security.yml",
    "content": "---\nname: Security\n\n'on':\n  push:\n    branches: [main, master]\n  pull_request:\n\npermissions:\n  contents: read\n\njobs:\n  semgrep:\n    name: Semgrep SAST\n    runs-on: ubuntu-22.04\n    container:\n      image: semgrep/semgrep@sha256:d3d1be3a3770514d16a6a57b9761575d7536d70f45a5220274f4ec7d55c442b9  # v1.151.0\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Run semgrep\n        run: >\n          semgrep --config auto\n          --exclude-rule dockerfile.security.last-user-is-root.last-user-is-root\n          --error --quiet .\n\n  pip-audit:\n    name: Python dependency audit\n    runs-on: ubuntu-22.04\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - name: Setup Algo environment\n        uses: ./.github/actions/setup-algo\n\n      - name: Run pip-audit\n        run: uv run --with pip-audit pip-audit\n"
  },
  {
    "path": ".github/workflows/smart-tests.yml",
    "content": "---\nname: Smart Test Selection\n\n'on':\n  pull_request:\n    types: [opened, synchronize, reopened]\n\npermissions:\n  contents: read\n  pull-requests: read\n\njobs:\n  changed-files:\n    name: Detect Changed Files\n    runs-on: ubuntu-latest\n    outputs:\n      # Define what tests to run based on changes\n      run_syntax_check: ${{ steps.filter.outputs.ansible }}\n      run_basic_tests: ${{ steps.filter.outputs.python }}\n      run_docker_tests: ${{ steps.filter.outputs.docker }}\n      run_config_tests: ${{ steps.filter.outputs.configs }}\n      run_template_tests: ${{ steps.filter.outputs.templates }}\n      run_lint: ${{ steps.filter.outputs.lint }}\n      run_integration: ${{ steps.filter.outputs.integration }}\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n\n      - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36  # v3.0.2\n        id: filter\n        with:\n          filters: |\n            ansible:\n              - '**/*.yml'\n              - '**/*.yaml'\n              - 'main.yml'\n              - 'playbooks/**'\n              - 'roles/**'\n              - 'library/**'\n            python:\n              - '**/*.py'\n              - 'pyproject.toml'\n              - 'uv.lock'\n              - 'tests/**'\n            docker:\n              - 'Dockerfile*'\n              - '.dockerignore'\n              - 'docker-compose*.yml'\n            configs:\n              - 'config.cfg*'\n              - 'roles/**/templates/**'\n              - 'roles/**/defaults/**'\n            templates:\n              - '**/*.j2'\n              - 'roles/**/templates/**'\n            lint:\n              - '**/*.py'\n              - '**/*.yml'\n              - '**/*.yaml'\n              - '**/*.sh'\n              - '**/*.j2'\n              - '.ansible-lint'\n              - '.yamllint'\n              - 'pyproject.toml'\n            integration:\n              - 'main.yml'\n              - 'roles/**'\n              - 'library/**'\n              - 'playbooks/**'\n\n  syntax-check:\n    name: Ansible Syntax Check\n    needs: changed-files\n    if: needs.changed-files.outputs.run_syntax_check == 'true'\n    runs-on: ubuntu-22.04\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Check Ansible playbook syntax\n        run: uv run ansible-playbook main.yml --syntax-check\n\n  basic-tests:\n    name: Basic Sanity Tests\n    needs: changed-files\n    if: needs.changed-files.outputs.run_basic_tests == 'true' || needs.changed-files.outputs.run_template_tests == 'true'\n    runs-on: ubuntu-22.04\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Install system dependencies\n        run: sudo apt-get update && sudo apt-get install -y shellcheck\n\n      - name: Run relevant tests\n        env:\n          RUN_BASIC_TESTS: ${{ needs.changed-files.outputs.run_basic_tests }}\n          RUN_TEMPLATE_TESTS: ${{ needs.changed-files.outputs.run_template_tests }}\n        run: |\n          # Always run basic sanity\n          uv run pytest tests/unit/test_basic_sanity.py -v\n\n          # Run other tests based on what changed\n          if [[ \"${RUN_BASIC_TESTS}\" == \"true\" ]]; then\n            uv run pytest \\\n              tests/unit/test_config_validation.py \\\n              tests/unit/test_user_management.py \\\n              tests/unit/test_openssl_compatibility.py \\\n              tests/unit/test_cloud_provider_configs.py \\\n              tests/unit/test_generated_configs.py \\\n              -v\n          fi\n\n          if [[ \"${RUN_TEMPLATE_TESTS}\" == \"true\" ]]; then\n            uv run pytest tests/unit/test_template_rendering.py -v\n          fi\n\n  docker-tests:\n    name: Docker Build Test\n    needs: changed-files\n    if: needs.changed-files.outputs.run_docker_tests == 'true'\n    runs-on: ubuntu-22.04\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Build Docker image\n        run: docker build -t local/algo:test .\n\n      - name: Test Docker image starts\n        run: |\n          docker run --rm local/algo:test /algo/algo --help\n\n      - name: Run Docker deployment tests\n        run: uv run pytest tests/unit/test_docker_localhost_deployment.py -v\n\n  config-tests:\n    name: Configuration Tests\n    needs: changed-files\n    if: needs.changed-files.outputs.run_config_tests == 'true'\n    runs-on: ubuntu-22.04\n    timeout-minutes: 10\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Test configuration generation\n        run: |\n          chmod +x tests/test-local-config.sh\n          ./tests/test-local-config.sh\n\n      - name: Run ansible dry-run tests\n        run: |\n          # Quick dry-run for local provider only\n          cat > test-local.cfg << 'EOF'\n          users:\n            - testuser\n          cloud_providers:\n            local:\n              server: test-server\n          wireguard_enabled: true\n          ipsec_enabled: false\n          dns_adblocking: false\n          ssh_tunneling: false\n          algo_provider: local\n          algo_server_name: test-algo-vpn\n          server: test-server\n          endpoint: 10.0.0.1\n          EOF\n\n          uv run ansible-playbook main.yml \\\n            -i \"localhost,\" \\\n            -c local \\\n            -e @test-local.cfg \\\n            -e \"provider=local\" \\\n            --check \\\n            --diff \\\n            -vv \\\n            --skip-tags \"facts,tests,local,update-alternatives,cloud_api\" || true\n\n  lint:\n    name: Linting\n    needs: changed-files\n    if: needs.changed-files.outputs.run_lint == 'true'\n    runs-on: ubuntu-22.04\n    permissions:\n      contents: read\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: false\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n\n      - name: Setup uv environment\n        uses: ./.github/actions/setup-uv\n\n      - name: Install ansible dependencies\n        run: uv run ansible-galaxy collection install community.crypto\n\n      - name: Run relevant linters\n        env:\n          RUN_LINT: ${{ needs.changed-files.outputs.run_lint }}\n          BASE_SHA: ${{ github.event.pull_request.base.sha }}\n          HEAD_SHA: ${{ github.sha }}\n        run: |\n          # Run linters if lint-related files changed\n          if [[ \"${RUN_LINT}\" == \"true\" ]]; then\n            echo \"Running linters...\"\n\n            # Run Python linter\n            uv run --with ruff ruff check .\n\n            # Run YAML linter\n            uv run --with yamllint yamllint -c .yamllint .\n\n            # Run Ansible linter\n            uv run --with ansible-lint ansible-lint\n\n            # Check Jinja2 templates\n            if git diff --name-only \"${BASE_SHA}\" \"${HEAD_SHA}\" | grep -q '\\.j2$'; then\n              uv run --with j2lint j2lint roles/ --ignore S3 S5 S6 S7 V1\n            fi\n\n            # Check shell scripts if any changed\n            if git diff --name-only \"${BASE_SHA}\" \"${HEAD_SHA}\" | grep -q '\\.sh$'; then\n              find . -name \"*.sh\" -type f -not -path \"./.git/*\" -exec shellcheck {} +\n            fi\n          fi\n\n  all-tests-required:\n    name: All Required Tests\n    needs: [syntax-check, basic-tests, docker-tests, config-tests, lint]\n    if: always()\n    runs-on: ubuntu-latest\n    steps:\n      - name: Check test results\n        env:\n          SYNTAX_CHECK_RESULT: ${{ needs.syntax-check.result }}\n          BASIC_TESTS_RESULT: ${{ needs.basic-tests.result }}\n          DOCKER_TESTS_RESULT: ${{ needs.docker-tests.result }}\n          CONFIG_TESTS_RESULT: ${{ needs.config-tests.result }}\n          LINT_RESULT: ${{ needs.lint.result }}\n        run: |\n          # This job ensures all required tests pass\n          # It will fail if any dependent job failed\n          if [[ \"${SYNTAX_CHECK_RESULT}\" == \"failure\" ]] || \\\n             [[ \"${BASIC_TESTS_RESULT}\" == \"failure\" ]] || \\\n             [[ \"${DOCKER_TESTS_RESULT}\" == \"failure\" ]] || \\\n             [[ \"${CONFIG_TESTS_RESULT}\" == \"failure\" ]] || \\\n             [[ \"${LINT_RESULT}\" == \"failure\" ]]; then\n            echo \"One or more required tests failed\"\n            exit 1\n          fi\n          echo \"All required tests passed!\"\n\n  trigger-integration:\n    name: Trigger Integration Tests\n    needs: changed-files\n    if: |\n      needs.changed-files.outputs.run_integration == 'true' &&\n      github.event.pull_request.draft == false\n    runs-on: ubuntu-latest\n    steps:\n      - name: Trigger integration tests\n        run: |\n          echo \"Integration tests should be triggered for this PR\"\n          echo \"Changed files indicate potential breaking changes\"\n          echo \"Run workflow manually: .github/workflows/integration-tests.yml\"\n"
  },
  {
    "path": ".github/workflows/test-effectiveness.yml",
    "content": "---\nname: Test Effectiveness Tracking\n\n'on':\n  schedule:\n    - cron: '0 0 * * 0'  # Weekly on Sunday\n  workflow_dispatch:  # Allow manual runs\n\npermissions:\n  contents: write\n  issues: write\n  pull-requests: read\n  actions: read\n\njobs:\n  track-effectiveness:\n    name: Analyze Test Effectiveness\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98  # v5.0.1\n        with:\n          persist-credentials: true\n\n      - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405  # v6.2.0\n        with:\n          python-version: '3.11'\n\n      - name: Analyze test effectiveness\n        env:\n          GH_TOKEN: ${{ github.token }}\n        run: |\n          python scripts/track-test-effectiveness.py\n\n      - name: Upload metrics\n        uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f  # v7.0.0\n        with:\n          name: test-effectiveness-metrics\n          path: .metrics/\n\n      - name: Create issue if tests are ineffective\n        env:\n          GH_TOKEN: ${{ github.token }}\n        run: |\n          # Check if we need to create an issue\n          if grep -q \"⚠️\" .metrics/test-effectiveness-report.md; then\n            # Check if issue already exists\n            existing=$(gh issue list --label \"test-effectiveness\" --state open --json number --jq '.[0].number')\n\n            if [ -z \"$existing\" ]; then\n              gh issue create \\\n                --title \"Test Effectiveness Review Needed\" \\\n                --body-file .metrics/test-effectiveness-report.md \\\n                --label \"test-effectiveness,maintenance\"\n            else\n              # Update existing issue\n              gh issue comment \"$existing\" --body-file .metrics/test-effectiveness-report.md\n            fi\n          fi\n\n      - name: Commit metrics if changed\n        run: |\n          git config --local user.email \"github-actions[bot]@users.noreply.github.com\"\n          git config --local user.name \"github-actions[bot]\"\n\n          if [[ -n $(git status -s .metrics/) ]]; then\n            git add .metrics/\n            git commit -m \"chore: Update test effectiveness metrics [skip ci]\"\n            git push\n          fi\n"
  },
  {
    "path": ".gitignore",
    "content": "*.retry\n.idea/\nconfigs/*\ninventory_users\n*.kate-swp\n.env/\n.venv/\n.DS_Store\n.vagrant\n.ansible/\n__pycache__/\n*.pyc\nalgo.egg-info/\n"
  },
  {
    "path": ".pre-commit-config.yaml",
    "content": "# See https://prek.j178.dev for more information\n---\n# Apply to all files without committing:\n#   prek run --all-files\n# Update this file:\n#   prek auto-update\n\nrepos:\n  # Use prek built-in hooks (faster, Rust-native)\n  - repo: builtin\n    hooks:\n      - id: check-yaml\n        args: [--allow-multiple-documents]\n        exclude: '(files/cloud-init/base\\.yml|roles/cloud-.*/files/stack\\.yaml)'\n      - id: end-of-file-fixer\n      - id: trailing-whitespace\n      - id: check-added-large-files\n        args: ['--maxkb=500']\n      - id: check-merge-conflict\n      - id: mixed-line-ending\n        args: [--fix=lf]\n\n  # Python linting with ruff (fast, replaces many tools)\n  - repo: https://github.com/astral-sh/ruff-pre-commit\n    rev: v0.14.14\n    hooks:\n      - id: ruff\n        args: [--fix, --exit-non-zero-on-fix]\n      - id: ruff-format\n\n  # YAML linting\n  - repo: https://github.com/adrienverge/yamllint\n    rev: v1.38.0\n    hooks:\n      - id: yamllint\n        args: [-c=.yamllint]\n        exclude: '.git/.*'\n\n  # Shell script linting\n  - repo: https://github.com/shellcheck-py/shellcheck-py\n    rev: v0.11.0.1\n    hooks:\n      - id: shellcheck\n        exclude: '.git/.*'\n\n  # Local hooks that use the project's installed tools\n  - repo: local\n    hooks:\n      - id: ty-check\n        name: Python type check\n        entry: bash -c 'uv run --with ty ty check'\n        language: system\n        types: [python]\n        pass_filenames: false\n\n      - id: j2lint\n        name: Jinja2 template lint\n        entry: bash -c 'uv run j2lint roles/ --ignore S3 S5 S6 S7 V1'\n        language: system\n        files: '\\.j2$'\n        pass_filenames: false\n\n      - id: ansible-lint\n        name: Ansible-lint\n        entry: bash -c 'uv run ansible-lint --force-color || echo \"Ansible-lint had issues - check output\"'\n        language: system\n        types: [yaml]\n        files: \\.(yml|yaml)$\n        exclude: '^(.git/|.github/|requirements\\.yml)'\n        pass_filenames: false\n\n      - id: ansible-syntax\n        name: Ansible syntax check\n        entry: bash -c 'uv run ansible-playbook main.yml --syntax-check'\n        language: system\n        files: 'main\\.yml|server\\.yml|users\\.yml'\n        pass_filenames: false\n\n      - id: semgrep\n        name: Semgrep security scan\n        entry: >\n          bash -c '\n          command -v semgrep >/dev/null &&\n          semgrep --config auto\n          --exclude-rule dockerfile.security.last-user-is-root.last-user-is-root\n          --error --quiet --skip-unknown-extensions .\n          || echo \"semgrep not installed - skipping\"'\n        language: system\n        pass_filenames: false\n\n      - id: actionlint\n        name: GitHub Actions lint\n        entry: bash -c 'command -v actionlint >/dev/null && actionlint .github/workflows/ || echo \"actionlint not installed - skipping\"'\n        language: system\n        files: '^\\.github/workflows/.*\\.yml$'\n        pass_filenames: false\n\n      - id: zizmor\n        name: GitHub Actions security audit\n        entry: bash -c 'command -v zizmor >/dev/null && zizmor .github/workflows/ || echo \"zizmor not installed - skipping\"'\n        language: system\n        files: '^\\.github/workflows/.*\\.yml$'\n        pass_filenames: false\n\n# Configuration for prek\n\n# Files to exclude globally\nexclude: |\n  (?x)^(\n    .env/.*|\n    .venv/.*|\n    .git/.*|\n    __pycache__/.*|\n    .*\\.egg-info/.*\n  )$\n"
  },
  {
    "path": ".yamllint",
    "content": "---\nextends: default\n\n# Cloud-init files must be excluded from normal YAML rules\n# The #cloud-config header cannot have a space and cannot have --- document start\nignore: |\n  files/cloud-init/\n  .env/\n  .venv/\n  .ansible/\n  configs/\n  tests/integration/test-configs/\n\nrules:\n  line-length:\n    max: 160\n    level: warning\n  comments:\n    min-spaces-from-content: 1\n  comments-indentation: false\n  octal-values:\n    forbid-implicit-octal: true\n    forbid-explicit-octal: true\n  braces:\n    max-spaces-inside: 1\n  truthy:\n    allowed-values: ['true', 'false', 'yes', 'no']\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md - LLM Guidance for Algo VPN\n\nThis document provides essential context and guidance for LLMs working on the Algo VPN codebase.\n\n## Project Overview\n\nAlgo is an Ansible-based tool that sets up a personal VPN in the cloud. It's designed to be:\n- **Security-focused**: Creates hardened VPN servers with minimal attack surface\n- **Easy to use**: Automated deployment with sensible defaults\n- **Multi-platform**: Supports various cloud providers and operating systems\n- **Privacy-preserving**: No logging, minimal data retention\n\n### Core Technologies\n- **VPN Protocols**: WireGuard (preferred) and IPsec/IKEv2\n- **Configuration Management**: Ansible (v12+)\n- **Languages**: Python, YAML, Shell, Jinja2 templates\n- **Supported Providers**: AWS, Azure, DigitalOcean, GCP, Vultr, Hetzner, local deployment\n\n### Philosophy\n- Stability over features\n- Security over convenience\n- Clarity over cleverness\n- Test everything\n- Stay in scope - solve exactly what the issue asks, nothing more\n- Test assumptions - run the code before committing\n- Resist new dependencies - each one is attack surface and maintenance\n\n## Architecture and Structure\n\n```\nalgo/\n├── main.yml                 # Primary playbook\n├── users.yml               # User management playbook\n├── server.yml              # Server-specific tasks\n├── config.cfg              # Main configuration file\n├── pyproject.toml          # Python project configuration and dependencies\n├── uv.lock                 # Exact dependency versions lockfile\n├── requirements.yml        # Ansible collections\n├── roles/                  # Ansible roles\n│   ├── common/            # Base system configuration, firewall, hardening\n│   ├── wireguard/         # WireGuard VPN setup\n│   ├── strongswan/        # IPsec/IKEv2 setup\n│   ├── dns/               # DNS configuration (dnscrypt-proxy)\n│   └── cloud-*/           # Cloud provider specific roles\n├── library/               # Custom Ansible modules\n└── tests/unit/            # Python unit tests\n```\n\n## Development Workflow\n\n### Quality Gates (MANDATORY)\n\n**All PRs must pass these checks locally before submission.** CI will reject failures:\n\n```bash\n# Run the full lint suite (same as CI)\nansible-lint . && yamllint . && ruff check . && shellcheck scripts/*.sh && semgrep --config auto --exclude-rule dockerfile.security.last-user-is-root.last-user-is-root --error --quiet .\nansible-playbook main.yml --syntax-check\nansible-playbook users.yml --syntax-check\npytest tests/unit/ -q\n```\n\nCommon lint issues to fix before submitting:\n- YAML files missing `---` document start markers\n- GitHub workflows with unquoted `on:` (must be `'on':`)\n- Using `ignore_errors: true` instead of `failed_when: false`\n- Jinja2 spacing errors (`{{foo}}` should be `{{ foo }}`)\n- Missing `mode:` on file/directory tasks\n\n### Zero-Tolerance Warning Policy\n\n**No warnings are tolerated in CI.** Every linter finding must be either fixed or explicitly allowlisted in the tool's config file (`.ansible-lint`, `pyproject.toml`, etc.).\n\nWhy this matters for Algo:\n- **Security tool** - VPN misconfigurations silently break privacy guarantees. A \"cosmetic\" warning today hides a real bug tomorrow.\n- **Ansible complexity** - YAML+Jinja2 linting catches real runtime failures (wrong key order breaks `when` evaluation, spacing errors cause template failures). Warnings in Ansible are not style nits.\n- **CI signal integrity** - If 30 warnings scroll by on every run, the 31st one (a real regression) goes unnoticed. Zero warnings means every new finding gets human attention.\n\nResolution order of preference:\n1. **Fix it** - Preferred. Most findings have straightforward fixes.\n2. **Allowlist in config** - If the rule is wrong for this project, add to `skip_list` with a comment explaining why.\n3. **Inline suppress** - Last resort. Use `# noqa: rule-name` with a comment justifying the exception.\n\nNever use `warn_list` in `.ansible-lint` — it exists as a migration tool, not a permanent home. Rules either pass or are explicitly skipped.\n\n### Design Requirements\n\nWhen adding or modifying features, verify these before requesting review:\n\n1. **Validate inputs early** - Check for empty lists, missing configs, permission mismatches before expensive operations\n2. **Explicit file modes** - Always specify `mode:` on file/directory tasks (never rely on umask)\n3. **Fail vs warn** - Permission/security issues should fail; optional features can warn\n4. **Actionable errors** - Include fix commands in error messages: `\"Run: sudo chown -R $USER configs/\"`\n5. **Follow existing patterns** - Search codebase first: `rg \"when:.*localhost\" --type yaml`\n\n### Linting Tools\n\n| Tool | Target | Key Rules |\n|------|--------|-----------|\n| `ansible-lint` | YAML tasks | Use `failed_when` not `ignore_errors`, add `mode:` to files |\n| `yamllint` | All YAML | Document start `---`, quote `'on':` in workflows |\n| `ruff` | Python | Line length 120, target Python 3.11 |\n| `shellcheck` | Shell scripts | Quote variables, use `set -euo pipefail` |\n| `semgrep` | All code | SAST scanner, `--config auto`, suppress with `# nosemgrep: rule-id` |\n\n### Git Workflow\n\n1. Create feature branches from `master`\n2. Run all linters before pushing\n3. Make atomic commits with clear messages\n4. Update PR description with test results\n\n### Self-Review Checklist\n\nBefore creating a PR, review your own diff:\n\n- [ ] Did I run all linters locally?\n- [ ] Did I search for similar patterns in the codebase?\n- [ ] Did I add explicit `mode:` to file/directory tasks?\n- [ ] Did I validate inputs before expensive operations?\n- [ ] Did I update tests if I changed file paths or behavior?\n- [ ] Would a reviewer ask \"what happens if X is empty/missing?\"\n\n## Ansible Pitfalls\n\n### with_items vs loop\n\n`with_items` auto-flattens lists; `loop` does not. **Never mechanically convert:**\n\n```yaml\n# WRONG - treats list as single item, creates file named \"['alice', 'bob']\"\nloop:\n  - \"{{ users }}\"\n\n# CORRECT - iterates over list contents\nloop: \"{{ users }}\"\n\n# CORRECT - combining lists (with_items did this automatically)\nloop: \"{{ users + [server_name] }}\"\n```\n\n**Always test loop conversions** - verify the task creates expected files.\n\n### Path Variables\n\nNever include trailing slashes - causes double-slash bugs:\n\n```yaml\n# WRONG - creates paths like /etc/ipsec.d//private\nipsec_path: \"configs/{{ server }}/ipsec/\"\n\n# CORRECT\nipsec_path: \"configs/{{ server }}/ipsec\"\n```\n\n### ignore_errors vs failed_when\n\n```yaml\n# WRONG - ansible-lint failure\n- name: Clear history\n  command: some_command\n  ignore_errors: true\n\n# CORRECT - explicit about expected failures\n- name: Clear history\n  command: some_command\n  failed_when: false\n```\n\n### changed_when on Read-Only Tasks\n\nHandlers and check commands that don't modify state need `changed_when: false`:\n\n```yaml\n- name: Check service status\n  command: systemctl status foo\n  changed_when: false\n```\n\n### Jinja2 Native Mode (Ansible 12+)\n\nAnsible 12 enables `jinja2_native` by default, changing how values are evaluated:\n\n**Boolean conditionals require actual booleans:**\n```yaml\n# WRONG - string \"true\" is not boolean\nipv6_support: \"{% if ipv6 %}true{% else %}false{% endif %}\"\n\n# CORRECT - return actual boolean\nipv6_support: \"{{ ipv6 is defined }}\"\n```\n\n**No nested templates in lookup():**\n```yaml\n# WRONG - deprecated double-templating\nkey: \"{{ lookup('file', '{{ SSH_keys.public }}') }}\"\n\n# CORRECT - pass variable directly\nkey: \"{{ lookup('file', SSH_keys.public) }}\"\n```\n\n**JSON files need explicit parsing:**\n```yaml\n# WRONG - returns string in native mode\ncreds: \"{{ lookup('file', 'credentials.json') }}\"\n\n# CORRECT - parse JSON explicitly\ncreds: \"{{ lookup('file', 'credentials.json') | from_json }}\"\n```\n\n**default() doesn't trigger on empty strings:**\n```yaml\n# WRONG - empty string '' is not undefined\nkey: \"{{ lookup('env', 'AWS_KEY') | default('fallback') }}\"\n\n# CORRECT - add true to handle falsy values\nkey: \"{{ lookup('env', 'AWS_KEY') | default('fallback', true) }}\"\n```\n\n**Complex Jinja loops break in set_fact:**\n```yaml\n# WRONG - list comprehension fails in native mode\nservers: \"[{% for s in configs %}{{ s.name }},{% endfor %}]\"\n\n# CORRECT - use Ansible loop\nservers: \"{{ servers | default([]) + [item.name] }}\"\nloop: \"{{ configs }}\"\n```\n\n**Use tests (not filters) for boolean checks:**\n```yaml\n# WRONG - filters return transformed data, not booleans\nthat: my_ip | ansible.utils.ipv4\n\n# CORRECT - tests return native booleans\nthat: my_ip is ansible.utils.ipv4_address\n```\n\n## DNS Architecture\n\nAlgo uses a randomly generated IP in 172.16.0.0/12 on the loopback interface (`local_service_ip`) for DNS. This provides consistency across WireGuard and IPsec but requires understanding systemd socket activation.\n\n### Why This Design\n\n- Consistent DNS IP across both VPN protocols\n- Survives interface changes and restarts\n- Works identically across all cloud providers\n- Trade-off: Requires `route_localnet=1` sysctl\n\n### systemd Socket Activation\n\nUbuntu's dnscrypt-proxy uses socket activation which **completely ignores** the `listen_addresses` config setting. You must configure the socket, not the service:\n\n```ini\n# /etc/systemd/system/dnscrypt-proxy.socket.d/10-algo-override.conf\n[Socket]\nListenStream=              # Clear defaults first\nListenDatagram=\nListenStream=172.x.x.x:53  # Then set VPN IP\nListenDatagram=172.x.x.x:53\n```\n\nCommon mistakes:\n- Trying to disable/mask the socket (breaks service dependency)\n- Only setting ListenStream (need ListenDatagram for UDP)\n- Forgetting to restart socket after config changes\n\n### Debugging DNS\n\nMany \"routing\" issues are actually DNS issues. Start here:\n\n```bash\nss -lnup | grep :53                      # Should show local_service_ip:53\nsystemctl status dnscrypt-proxy.socket   # Check for config warnings\nsysctl net.ipv4.conf.all.route_localnet  # Must be 1\ndig @172.x.x.x google.com                # Test resolution\n```\n\nFor comprehensive diagnostics, see [docs/troubleshooting.md](docs/troubleshooting.md#diagnostic-commands).\n\n## Common Issues\n\n### iptables Backend (nft vs legacy)\n\nUbuntu 22.04+ defaults to iptables-nft which reorders rules unpredictably. Algo forces iptables-legacy for consistent behavior. Switching backends can break DNS routing that previously worked.\n\n### Multi-homed Systems (DigitalOcean, etc.)\n\nServers with both public and private IPs on the same interface need explicit output interface for NAT:\n\n```yaml\n-o {{ ansible_default_ipv4['interface'] }}\n```\n\nDon't overengineer with SNAT - MASQUERADE with interface specification works fine.\n\n### OpenSSL Version Compatibility\n\nOpenSSL 3.x dropped support for legacy algorithms. Add `-legacy` flag conditionally:\n\n```yaml\n{{ (openssl_version is version('3', '>=')) | ternary('-legacy', '') }}\n```\n\n### IPv6 Endpoint Formatting\n\nWireGuard configs must bracket IPv6 addresses:\n\n```jinja2\n{% if ':' in IP %}[{{ IP }}]:{{ port }}{% else %}{{ IP }}:{{ port }}{% endif %}\n```\n\n### Jinja2 Templates\n\nMany templates use Ansible-specific filters. Test with `tests/unit/test_template_rendering.py` and mock Ansible filters when testing.\n\n## Time Wasters to Avoid\n\nLessons learned - don't spend time on these unless absolutely necessary:\n\n1. **Converting MASQUERADE to SNAT** - MASQUERADE works fine for Algo's use case\n2. **Fighting systemd socket activation** - Configure it properly instead of disabling\n3. **Debugging NAT before checking DNS** - Most \"routing\" issues are DNS issues\n4. **Complex IPsec policy matching** - Keep NAT rules simple\n5. **Testing on existing servers** - Always test on fresh deployments\n6. **Interface-specific route_localnet** - WireGuard interface doesn't exist until service starts\n7. **DNAT for loopback addresses** - Packets to local IPs don't traverse PREROUTING\n\n## What to Avoid\n\n- **Speculative features** - Don't add \"might be useful\" functionality. Open an issue instead.\n- **New dependencies without justification** - Vanilla Ansible/Python can do most things.\n- **Bundling unrelated fixes** - One PR, one purpose. Separate issues get separate PRs.\n- **Assuming behavior** - If converting `with_items` to `loop`, test that it still works. If adding a firewall rule, verify packets flow.\n- **Configuration options** - Don't add flags unless users actively need them. Each option doubles testing surface.\n- **Undocumented workarounds** - When working around broken upstream modules, file an issue and add a comment linking to it. Future maintainers need to know why workarounds exist.\n\n## Writing Effective Tests\n\nWhen writing tests, **verify your test actually detects the failure case** (mutation testing approach):\n\n1. Write the test for the bug you're preventing\n2. Temporarily introduce the bug to verify the test fails\n3. Fix the bug and verify the test passes\n4. Document what specific issue the test prevents\n\n```python\ndef test_regression_openssl_inline_comments():\n    \"\"\"Tests that we detect inline comments in Jinja2 expressions.\"\"\"\n    # This pattern SHOULD fail (has inline comments)\n    problematic = \"{{ ['DNS:' + id,  # comment ] }}\"\n    assert not validate(problematic), \"Should detect inline comments\"\n\n    # This pattern SHOULD pass (no inline comments)\n    fixed = \"{{ ['DNS:' + id] }}\"\n    assert validate(fixed), \"Should pass without comments\"\n```\n\n## Quick Reference\n\n### Local Development Setup\n\n```bash\nuv sync\nuv run ansible-galaxy install -r requirements.yml\nansible-playbook main.yml -e \"provider=local\"\n```\n\n### Common Commands\n\n```bash\n# Add/update users\nansible-playbook users.yml -e \"server=SERVER_NAME\"\n\n# Update dependencies\nuv lock && pytest tests/unit/ -q\n\n# Debug deployment\nansible-playbook main.yml -vvv\n```\n\n### Key Directories\n\n- `configs/` - Generated client configurations\n- `roles/*/tasks/` - Main task files\n- `roles/*/templates/` - Jinja2 templates\n- `library/` - Custom Ansible modules (add to `mock_modules` in `.ansible-lint`)\n\n## Non-Interactive Deployment\n\nAll `pause:` prompts in `input.yml` and provider roles skip when their\nvariable is pre-defined via `-e` or environment variables. This enables\nfully headless deployment for CI, agents, and scripted workflows.\nSee [docs/deploy-from-ansible.md](docs/deploy-from-ansible.md) for\nfull human-facing documentation.\n\n### Core variables\n\nThese bypass the main prompts in `input.yml`:\n\n| Variable | Type | Default | Purpose |\n|----------|------|---------|---------|\n| `provider` | string | *(prompt)* | Provider alias (e.g., `digitalocean`, `ec2`, `local`) |\n| `server_name` | string | `algo` | VPN server name |\n| `ondemand_cellular` | bool | `false` | iOS/macOS Connect On Demand for cellular |\n| `ondemand_wifi` | bool | `false` | iOS/macOS Connect On Demand for Wi-Fi |\n| `ondemand_wifi_exclude` | string | *(none)* | Comma-separated trusted Wi-Fi networks |\n| `store_pki` | bool | `false` | Retain PKI keys (needed to add users later) |\n| `dns_adblocking` | bool | `false` | Enable DNS ad blocking |\n| `ssh_tunneling` | bool | `false` | Per-user SSH tunnel accounts |\n\n### Provider credentials\n\n| Provider | `-e` variables | Env var fallbacks |\n|----------|---------------|-------------------|\n| `digitalocean` | `do_token`, `region` | `DO_API_TOKEN` |\n| `ec2` | `aws_access_key`, `aws_secret_key`, `region` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` (also reads `~/.aws/credentials`) |\n| `lightsail` | `aws_access_key`, `aws_secret_key`, `region` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` |\n| `azure` | `azure_secret`, `azure_tenant`, `azure_client_id`, `azure_subscription_id`, `region` | `AZURE_SECRET`, `AZURE_TENANT`, `AZURE_CLIENT_ID`, `AZURE_SUBSCRIPTION_ID` |\n| `gce` | `gce_credentials_file`, `region` | `GCE_CREDENTIALS_FILE_PATH` |\n| `hetzner` | `hcloud_token`, `region` | `HCLOUD_TOKEN` |\n| `vultr` | `vultr_config`, `region` | `VULTR_API_CONFIG` |\n| `scaleway` | `scaleway_token`, `scaleway_org_id`, `region` | `SCW_TOKEN`, `SCW_DEFAULT_ORGANIZATION_ID` |\n| `linode` | `linode_token`, `region` | `LINODE_API_TOKEN` |\n| `cloudstack` | `cs_key`, `cs_secret`, `cs_url`, `region` | `CLOUDSTACK_KEY`, `CLOUDSTACK_SECRET`, `CLOUDSTACK_ENDPOINT` |\n| `openstack` | `region` | `OS_AUTH_URL` (source your `openrc.sh`) |\n| `local` | `server`, `endpoint`, `local_install_confirmed` | *(none)* |\n\n### Minimal examples\n\n```bash\n# DigitalOcean — fully headless\nansible-playbook main.yml -e \\\n  \"provider=digitalocean\n   server_name=algo\n   region=nyc3\n   do_token=YOUR_TOKEN\n   ondemand_cellular=false\n   ondemand_wifi=false\n   dns_adblocking=false\n   ssh_tunneling=false\n   store_pki=false\"\n\n# Local — for CI/testing\nansible-playbook main.yml -e \\\n  \"provider=local\n   server=localhost\n   endpoint=10.0.0.1\n   local_install_confirmed=true\n   ondemand_cellular=false\n   ondemand_wifi=false\n   dns_adblocking=false\n   ssh_tunneling=false\"\n```\n\n### Updating users non-interactively\n\n```bash\nansible-playbook users.yml -e \"server=YOUR_SERVER ca_password=YOUR_CA_PASS\"\n```\n\nThe `server` variable bypasses the server selection prompt.\n`ca_password` is only required when IPsec is enabled.\n\n## Security Considerations\n\n- **Never expose secrets** - No passwords/keys in commits\n- **CVE Response** - Update immediately when security issues found\n- **Least Privilege** - Minimal permissions, dropped capabilities\n- **Secure Defaults** - Strong crypto (secp384r1), no logging, strict firewall\n\n## Platform Support\n\n- **Primary OS**: Ubuntu 22.04/24.04 LTS\n- **Secondary**: Debian 11/12\n- **Architectures**: x86_64 and ARM64\n- **Testing tip**: DigitalOcean droplets have both public and private IPs on eth0, making them good test cases for multi-IP NAT scenarios\n"
  },
  {
    "path": "CODEOWNERS",
    "content": "* @jackivanov\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "### Filing New Issues\n\n* We welcome bug reports! Before filing, a quick check of the [FAQ](docs/faq.md) or [troubleshooting](docs/troubleshooting.md) docs might have your answer\n* Algo automatically installs dependencies with uv - no manual setup required\n* We support modern clients: macOS 12+, iOS 15+, Windows 11+, Ubuntu 22.04+, etc.\n* Supported cloud providers: DigitalOcean, AWS, Azure, GCP, Vultr, Hetzner, Linode, OpenStack, CloudStack\n* If you need to file a new issue, fill out any relevant fields in the Issue Template\n\n### Pull Requests\n\n* Run the full linter suite: `./scripts/lint.sh`\n* Test your changes on multiple platforms when possible\n* Use conventional commit messages that clearly describe your changes\n* Pin dependency versions rather than using ranges (e.g., `==1.2.3` not `>=1.2.0`)\n\n### Development Setup\n\n* Clone the repository: `git clone https://github.com/trailofbits/algo.git`\n* Run Algo: `./algo` (dependencies installed automatically via uv)\n* Install git hooks: `prek install` (optional, for contributors)\n* For local testing, consider using Docker or a cloud provider test instance\n\nThanks!\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax=docker/dockerfile:1\nFROM python:3.12-alpine\n\nARG VERSION=\"git\"\n# Removed rust/cargo (not needed with uv), simplified package list\nARG PACKAGES=\"bash openssh-client openssl rsync tini\"\n\nLABEL name=\"algo\" \\\n      version=\"${VERSION}\" \\\n      description=\"Set up a personal IPsec VPN in the cloud\" \\\n      maintainer=\"Trail of Bits <https://github.com/trailofbits/algo>\" \\\n      org.opencontainers.image.source=\"https://github.com/trailofbits/algo\" \\\n      org.opencontainers.image.description=\"Algo VPN - Set up a personal IPsec VPN in the cloud\" \\\n      org.opencontainers.image.licenses=\"AGPL-3.0\"\n\n# Install system packages in a single layer\nRUN apk --no-cache add ${PACKAGES} && \\\n    adduser -D -H -u 19857 algo && \\\n    mkdir -p /algo /algo/configs\n\nWORKDIR /algo\n\n# Copy uv binary from official image (using latest tag for automatic updates)\nCOPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv\n\n# Copy dependency files and install in single layer for better optimization\nCOPY pyproject.toml uv.lock ./\nRUN uv sync --locked --no-dev\n\n# Copy application code\nCOPY . .\n\n# Install Ansible Galaxy collections for cloud provider modules\nRUN uv run ansible-galaxy collection install -r requirements.yml\n\n# Set executable permissions and prepare runtime\n# Note: /algo must remain root-owned for --cap-drop=all compatibility\n# (root without CAP_DAC_OVERRIDE cannot write to files owned by others)\nRUN chmod 0755 /algo/algo-docker.sh && \\\n    mkdir -p /data && \\\n    chown algo:algo /data\n\n# Multi-arch support metadata\nARG TARGETPLATFORM\nARG BUILDPLATFORM\nRUN printf \"Built on: %s\\nTarget: %s\\n\" \"${BUILDPLATFORM}\" \"${TARGETPLATFORM}\" > /algo/build-info\n\n# Note: Running as root for bind mount compatibility with algo-docker.sh\n# The script handles /data volume permissions and needs root access\n# This is a Docker limitation with bind-mounted volumes\nUSER root\n\n# Health check to ensure container is functional\nHEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \\\n  CMD /bin/uv --version || exit 1\n\nVOLUME [\"/data\"]\nCMD [ \"/algo/algo-docker.sh\" ]\nENTRYPOINT [ \"/sbin/tini\", \"--\" ]\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<https://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "PULL_REQUEST_TEMPLATE.md",
    "content": "<!--- Provide a general summary of your changes in the Title above -->\n\n## Description\n<!--- Describe your changes in detail -->\n\n## Motivation and Context\n<!--- Why is this change required? What problem does it solve? -->\n<!--- If it fixes an open issue, please link to the issue here. -->\n\n## How Has This Been Tested?\n<!--- Please describe in detail how you tested your changes. -->\n<!--- Include details of your testing environment, tests ran to see how -->\n<!--- your change affects other areas of the code, etc. -->\n\n## Types of changes\n<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->\n- Bug fix (non-breaking change which fixes an issue)\n- New feature (non-breaking change which adds functionality)\n- Breaking change (fix or feature that would cause existing functionality to not work as expected)\n\n## Checklist:\n<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->\n<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->\n- [ ] I have read the **CONTRIBUTING** document.\n- [ ] My code passes all linters (`./scripts/lint.sh`)\n- [ ] My code follows the code style of this project.\n- [ ] My change requires a change to the documentation.\n- [ ] I have updated the documentation accordingly.\n- [ ] I have added tests to cover my changes.\n- [ ] All new and existing tests passed.\n- [ ] Dependencies use exact versions (e.g., `==1.2.3` not `>=1.2.0`).\n"
  },
  {
    "path": "README.md",
    "content": "# Algo VPN\n\n[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/fold_left.svg?style=social&label=Follow%20%40AlgoVPN)](https://x.com/AlgoVPN)\n\nAlgo VPN is a set of Ansible scripts that simplify the setup of a personal WireGuard and IPsec VPN. It uses the most secure defaults available and works with common cloud providers.\n\nSee our [release announcement](https://blog.trailofbits.com/2016/12/12/meet-algo-the-vpn-that-works/) for more information.\n\n## Features\n\n* Supports only IKEv2 with strong crypto (AES-GCM, SHA2, and P-256) for iOS, MacOS, and Linux\n* Supports [WireGuard](https://www.wireguard.com/) for all of the above, in addition to Android and Windows 11\n* Generates .conf files and QR codes for iOS, macOS, Android, and Windows WireGuard clients\n* Generates Apple profiles to auto-configure iOS and macOS devices for IPsec - no client software required\n* Includes helper scripts to add, remove, and manage users\n* Blocks ads with a local DNS resolver (optional)\n* Sets up limited SSH users for tunneling traffic (optional)\n* Privacy-focused with minimal logging, automatic log rotation, and configurable privacy enhancements\n* Based on Ubuntu 22.04 LTS with automatic security updates\n* Installs to DigitalOcean, Amazon Lightsail, Amazon EC2, Vultr, Microsoft Azure, Google Compute Engine, Scaleway, OpenStack, CloudStack, Hetzner Cloud, Linode, or [your own Ubuntu server (for advanced users)](docs/deploy-to-ubuntu.md)\n\n## Anti-features\n\n* Does not support legacy cipher suites or protocols like L2TP, IKEv1, or RSA\n* Does not install Tor, OpenVPN, or other risky servers\n* Does not depend on the security of [TLS](https://tools.ietf.org/html/rfc7457)\n* Does not claim to provide anonymity or censorship avoidance\n* Does not claim to protect you from the [FSB](https://en.wikipedia.org/wiki/Federal_Security_Service), [MSS](https://en.wikipedia.org/wiki/Ministry_of_State_Security_(China)), [DGSE](https://en.wikipedia.org/wiki/Directorate-General_for_External_Security), or [FSM](https://en.wikipedia.org/wiki/Flying_Spaghetti_Monster)\n\n## Deploy the Algo Server\n\nThe easiest way to get an Algo server running is to run it on your local system or from [Google Cloud Shell](docs/deploy-from-cloudshell.md) and let it set up a _new_ virtual machine in the cloud for you.\n\n1. **Setup an account on a cloud hosting provider.** Algo supports [DigitalOcean](https://m.do.co/c/4d7f4ff9cfe4) (most user friendly), [Amazon Lightsail](https://aws.amazon.com/lightsail/), [Amazon EC2](https://aws.amazon.com/), [Vultr](https://www.vultr.com/), [Microsoft Azure](https://azure.microsoft.com/), [Google Compute Engine](https://cloud.google.com/compute/), [Scaleway](https://www.scaleway.com/), [DreamCompute](https://www.dreamhost.com/cloud/computing/), [Linode](https://www.linode.com), other OpenStack-based cloud hosting, CloudStack-based cloud hosting, or [Hetzner Cloud](https://www.hetzner.com/).\n\n2. **Get a copy of Algo.** The Algo scripts will be run from your local system. There are two ways to get a copy:\n\n    - Download the [ZIP file](https://github.com/trailofbits/algo/archive/master.zip). Unzip the file to create a directory named `algo-master` containing the Algo scripts.\n\n    - Use `git clone` to create a directory named `algo` containing the Algo scripts:\n        ```bash\n        git clone https://github.com/trailofbits/algo.git\n        ```\n\n3. **Set your configuration options.** Open `config.cfg` in your favorite text editor. Specify the users you want to create in the `users` list. Create a unique user for each device you plan to connect to your VPN. You should also review the other options before deployment, as changing your mind about them later [may require you to deploy a brand new server](https://github.com/trailofbits/algo/blob/master/docs/faq.md#i-deployed-an-algo-server-can-you-update-it-with-new-features).\n\n4. **Start the deployment.** Return to your terminal. In the Algo directory, run the appropriate script for your platform:\n\n    **macOS/Linux:**\n    ```bash\n    ./algo\n    ```\n\n    **Windows:**\n    ```powershell\n    .\\algo.ps1\n    ```\n\n    The first time you run the script, it will automatically install the required Python environment (Python 3.11+). On subsequent runs, it starts immediately and works on all platforms (macOS, Linux, Windows via WSL). The Windows PowerShell script automatically uses WSL when needed, since Ansible requires a Unix-like environment. There are several optional features available, none of which are required for a fully functional VPN server. These optional features are described in the [deployment documentation](docs/deploy-from-ansible.md).\n\nThat's it! You can now set up clients to connect to your VPN. Proceed to [Configure the VPN Clients](#configure-the-vpn-clients) below.\n\n```\n    \"#                          Congratulations!                            #\"\n    \"#                     Your Algo server is running.                     #\"\n    \"#    Config files and certificates are in the ./configs/ directory.    #\"\n    \"#              Go to https://whoer.net/ after connecting               #\"\n    \"#        and ensure that all your traffic passes through the VPN.      #\"\n    \"#                     Local DNS resolver 172.16.0.1                    #\"\n    \"#        The p12 and SSH keys password for new users is XXXXXXXX       #\"\n    \"#        The CA key password is XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX       #\"\n    \"#      Shell access: ssh -F configs/<server_ip>/ssh_config <hostname>  #\"\n```\n\n## Configure the VPN Clients\n\nCertificates and configuration files that users will need are placed in the `configs` directory. Make sure to secure these files since many contain private keys. All files are saved under a subdirectory named with the IP address of your new Algo VPN server.\n\n**Important for IPsec users**: If you want to add or delete users later, you must select `yes` at the `Do you want to retain the keys (PKI)?` prompt during the server deployment. This preserves the certificate authority needed for user management.\n\n### Apple\n\nWireGuard is used to provide VPN services on Apple devices. Algo generates a WireGuard configuration file, `wireguard/<username>.conf`, and a QR code, `wireguard/<username>.png`, for each user defined in `config.cfg`.\n\nOn iOS, install the [WireGuard](https://itunes.apple.com/us/app/wireguard/id1441195209?mt=8) app from the iOS App Store. Then, use the WireGuard app to scan the QR code or AirDrop the configuration file to the device.\n\nOn macOS, install the [WireGuard](https://itunes.apple.com/us/app/wireguard/id1451685025?mt=12) app from the Mac App Store. WireGuard will appear in the menu bar once you run the app. Click on the WireGuard icon, choose **Import tunnel(s) from file...**, then select the appropriate WireGuard configuration file.\n\nOn either iOS or macOS, you can enable \"Connect on Demand\" and/or exclude certain trusted Wi-Fi networks (such as your home or work) by editing the tunnel configuration in the WireGuard app. (Algo can't do this automatically for you.)\n\nIf you prefer to use the built-in IPsec VPN on Apple devices, or need \"Connect on Demand\" or excluded Wi-Fi networks automatically configured, see the [Apple IPsec client setup guide](docs/client-apple-ipsec.md) for detailed configuration instructions.\n\n### Android\n\nWireGuard is used to provide VPN services on Android. Install the [WireGuard VPN Client](https://play.google.com/store/apps/details?id=com.wireguard.android). Import the corresponding `wireguard/<name>.conf` file to your device, then set up a new connection with it. See the [Android setup guide](docs/client-android.md) for detailed installation and configuration instructions.\n\n### Windows\n\nWireGuard is used to provide VPN services on Windows. Algo generates a WireGuard configuration file, `wireguard/<username>.conf`, for each user defined in `config.cfg`.\n\nInstall the [WireGuard VPN Client](https://www.wireguard.com/install/#windows-7-8-81-10-2012-2016-2019). Import the generated `wireguard/<username>.conf` file to your device, then set up a new connection with it. See the [Windows setup instructions](docs/client-windows.md) for more detailed walkthrough and troubleshooting.\n\n### Linux\n\nLinux clients can use either WireGuard or IPsec:\n\nWireGuard: WireGuard works great with Linux clients. See the [Linux WireGuard setup guide](docs/client-linux-wireguard.md) for step-by-step instructions on configuring WireGuard on Ubuntu and other distributions.\n\nIPsec: For strongSwan IPsec clients (including OpenWrt, Ubuntu Server, and other distributions), see the [Linux IPsec setup guide](docs/client-linux-ipsec.md) for detailed configuration instructions.\n\n### OpenWrt\n\nFor OpenWrt routers using WireGuard, see the [OpenWrt WireGuard setup guide](docs/client-openwrt-router-wireguard.md) for router-specific configuration instructions.\n\n### Other Devices\n\nFor devices not covered above or manual configuration, you'll need specific certificate and configuration files. The files you need depend on your device platform and VPN protocol (WireGuard or IPsec).\n\n* ipsec/manual/cacert.pem: CA Certificate\n* ipsec/manual/<user>.p12: User Certificate and Private Key (in PKCS#12 format)\n* ipsec/manual/<user>.conf: strongSwan client configuration\n* ipsec/manual/<user>.secrets: strongSwan client configuration\n* ipsec/apple/<user>.mobileconfig: Apple Profile\n* wireguard/<user>.conf: WireGuard configuration profile\n* wireguard/<user>.png: WireGuard configuration QR code\n\n## Setup an SSH Tunnel\n\nIf you turned on the optional SSH tunneling role, local user accounts will be created for each user in `config.cfg`, and SSH authorized_key files for them will be in the `configs` directory (user.pem). SSH user accounts do not have shell access, cannot authenticate with a password, and only have limited tunneling options (e.g., `ssh -N` is required). This ensures that SSH users have the least access required to set up a tunnel and can perform no other actions on the Algo server.\n\nUse the example command below to start an SSH tunnel by replacing `<user>` and `<ip>` with your own. Once the tunnel is set up, you can configure a browser or other application to use 127.0.0.1:1080 as a SOCKS proxy to route traffic through the Algo server:\n\n```bash\nssh -D 127.0.0.1:1080 -f -q -C -N <user>@algo -i configs/<ip>/ssh-tunnel/<user>.pem -F configs/<ip>/ssh_config\n```\n\n## SSH into Algo Server\n\nYour Algo server is configured for key-only SSH access for administrative purposes. Open the Terminal app, `cd` into the `algo-master` directory where you originally downloaded Algo, and then use the command listed on the success message:\n\n```\nssh -F configs/<ip>/ssh_config <hostname>\n```\n\nwhere `<ip>` is the IP address of your Algo server. If you find yourself regularly logging into the server, it will be useful to load your Algo SSH key automatically. Add the following snippet to the bottom of `~/.bash_profile` to add it to your shell environment permanently:\n\n```\nssh-add ~/.ssh/algo > /dev/null 2>&1\n```\n\nAlternatively, you can choose to include the generated configuration for any Algo servers created into your SSH config. Edit the file `~/.ssh/config` to include this directive at the top:\n\n```\nInclude <algodirectory>/configs/*/ssh_config\n```\n\nwhere `<algodirectory>` is the directory where you cloned Algo.\n\n## Adding or Removing Users\n\nAlgo makes it easy to add or remove users from your VPN server after initial deployment.\n\nFor IPsec users: You must have selected `yes` at the `Do you want to retain the keys (PKI)?` prompt during the initial server deployment. This preserves the certificate authority needed for user management. You should also save the p12 and CA key passwords shown during deployment, as they're only displayed once.\n\nTo add or remove users, first edit the `users` list in your `config.cfg` file. Add new usernames or remove existing ones as needed. Then navigate to the algo directory in your terminal and run:\n\n**macOS/Linux:**\n```bash\n./algo update-users\n```\n\n**Windows:**\n```powershell\n.\\algo.ps1 update-users\n```\n\nAfter the process completes, new configuration files will be generated in the `configs` directory for any new users. The Algo VPN server will be updated to contain only the users listed in the `config.cfg` file. Removed users will no longer be able to connect, and new users will have fresh certificates and configuration files ready for use.\n\n## Privacy and Logging\n\nAlgo takes a pragmatic approach to privacy. By default, we minimize logging while maintaining enough information for security and troubleshooting.\n\nWhat IS logged by default:\n* System security events (failed SSH attempts, firewall blocks, system updates)\n* Kernel messages and boot diagnostics (with reduced verbosity)\n* WireGuard client state (visible via `sudo wg` - shows last endpoint and handshake time)\n* Basic service status (service starts/stops/errors)\n* All logs automatically rotate and delete after 7 days\n\nPrivacy is controlled by two main settings in `config.cfg`:\n* `strongswan_log_level: -1` - Controls StrongSwan connection logging (-1 = disabled, 2 = debug)\n* `privacy_enhancements_enabled: true` - Master switch for log rotation, history clearing, log filtering, and cleanup\n\nTo enable full debugging when troubleshooting, set both `strongswan_log_level: 2` and `privacy_enhancements_enabled: false`. This will capture detailed connection logs and disable all privacy features. Remember to revert these changes after debugging.\n\nAfter deployment, verify your privacy settings:\n```bash\nssh -F configs/<server_ip>/ssh_config <hostname>\nsudo /usr/local/bin/privacy-monitor.sh\n```\n\nPerfect privacy is impossible with any VPN solution. Your cloud provider sees and logs network traffic metadata regardless of your server configuration. And of course, your ISP knows you're connecting to a VPN server, even if they can't see what you're doing through it.\n\nFor the highest level of privacy, treat your Algo servers as disposable. Spin up a new instance when you need it, use it for your specific purpose, then destroy it completely. The ephemeral nature of cloud infrastructure can be a privacy feature if you use it intentionally.\n\n## Additional Documentation\n* [FAQ](docs/faq.md)\n* [Troubleshooting](docs/troubleshooting.md)\n* How Algo uses [Firewalls](docs/firewalls.md)\n\n### Setup Instructions for Specific Cloud Providers\n* Configure [Amazon EC2](docs/cloud-amazon-ec2.md)\n* Configure [Azure](docs/cloud-azure.md)\n* Configure [DigitalOcean](docs/cloud-do.md)\n* Configure [Google Cloud Platform](docs/cloud-gce.md)\n* Configure [Vultr](docs/cloud-vultr.md)\n* Configure [CloudStack](docs/cloud-cloudstack.md)\n* Configure [Hetzner Cloud](docs/cloud-hetzner.md)\n\n### Install and Deploy from Common Platforms\n* Deploy from [macOS](docs/deploy-from-macos.md)\n* Deploy from [Windows](docs/deploy-from-windows.md)\n* Deploy from [Google Cloud Shell](docs/deploy-from-cloudshell.md)\n* Deploy from a [Docker container](docs/deploy-from-docker.md)\n\n### Setup VPN Clients to Connect to the Server\n* Setup [Windows](docs/client-windows.md) clients\n* Setup [Android](docs/client-android.md) clients\n* Setup [Linux](docs/client-linux.md) clients with Ansible\n* Setup Ubuntu clients to use [WireGuard](docs/client-linux-wireguard.md)\n* Setup Linux clients to use [IPsec](docs/client-linux-ipsec.md)\n* Setup Apple devices to use [IPsec](docs/client-apple-ipsec.md)\n* Setup Macs running macOS 10.13 or older to use [WireGuard](docs/client-macos-wireguard.md)\n\n### Advanced Deployment\n* Deploy to your own [Ubuntu](docs/deploy-to-ubuntu.md) server, and road warrior setup\n* Deploy from [Ansible](docs/deploy-from-ansible.md) non-interactively\n* Deploy onto a [cloud server at time of creation with shell script or cloud-init](docs/deploy-from-script-or-cloud-init-to-localhost.md)\n* Deploy to an [unsupported cloud provider](docs/deploy-to-unsupported-cloud.md)\n\nIf you've read all the documentation and have further questions, [create a new discussion](https://github.com/trailofbits/algo/discussions).\n\n## Endorsements\n\n> I've been ranting about the sorry state of VPN svcs for so long, probably about\n> time to give a proper talk on the subject. TL;DR: use Algo.\n\n-- [Kenn White](https://twitter.com/kennwhite/status/814166603587788800)\n\n> Before picking a VPN provider/app, make sure you do some research\n> https://research.csiro.au/ng/wp-content/uploads/sites/106/2016/08/paper-1.pdf ... – or consider Algo\n\n-- [The Register](https://twitter.com/TheRegister/status/825076303657177088)\n\n> Algo is really easy and secure.\n\n-- [the grugq](https://twitter.com/thegrugq/status/786249040228786176)\n\n> I played around with Algo VPN, a set of scripts that let you set up a VPN in the cloud in very little time, even if you don’t know much about development. I’ve got to say that I was quite impressed with Trail of Bits’ approach.\n\n-- [Romain Dillet](https://twitter.com/romaindillet/status/851037243728965632) for [TechCrunch](https://techcrunch.com/2017/04/09/how-i-made-my-own-vpn-server-in-15-minutes/)\n\n> If you’re uncomfortable shelling out the cash to an anonymous, random VPN provider, this is the best solution.\n\n-- [Thorin Klosowski](https://twitter.com/kingthor) for [Lifehacker](http://lifehacker.com/how-to-set-up-your-own-completely-free-vpn-in-the-cloud-1794302432)\n\n## Contributing\n\nSee our [Development Guide](docs/DEVELOPMENT.md) for information on:\n* Setting up your development environment\n* Using prek hooks for code quality\n* Running tests and linters\n* Contributing code via pull requests\n\n## Support Algo VPN\n[![PayPal](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=CYZZD39GXUJ3E)\n[![Patreon](https://img.shields.io/badge/back_on-patreon-red.svg)](https://www.patreon.com/algovpn)\n\nAll donations support continued development. Thanks!\n\n* We accept donations via [PayPal](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=CYZZD39GXUJ3E) and [Patreon](https://www.patreon.com/algovpn).\n* Use our [referral code](https://m.do.co/c/4d7f4ff9cfe4) when you sign up to Digital Ocean for a $10 credit.\n* We also accept and appreciate contributions of new code and bugfixes via Github Pull Requests.\n\nAlgo is licensed and distributed under the AGPLv3. If you want to distribute a closed-source modification or service based on Algo, then please consider <a href=\"mailto:opensource@trailofbits.com\">purchasing an exception</a> . As with the methods above, this will help support continued development.\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Reporting Security Issues\n\nThe Algo team and community take security bugs in Algo seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.\n\nTo report a security issue, please use the GitHub Security Advisory [\"Report a Vulnerability\"](https://github.com/trailofbits/algo/security/) tab.\n\nThe Algo team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.\n\nReport security bugs in third-party modules to the person or team maintaining the module.\n"
  },
  {
    "path": "algo",
    "content": "#!/usr/bin/env bash\n\nset -e\n\n# Track which installation method succeeded\nUV_INSTALL_METHOD=\"\"\n\n# Function to install uv via package managers (most secure)\ninstall_uv_via_package_manager() {\n    echo \"Attempting to install uv via system package manager...\"\n\n    if command -v brew &> /dev/null; then\n        echo \"Using Homebrew...\"\n        brew install uv && UV_INSTALL_METHOD=\"Homebrew\" && return 0\n    elif command -v apt &> /dev/null && apt list uv 2>/dev/null | grep -q uv; then\n        echo \"Using apt...\"\n        sudo apt update && sudo apt install -y uv && UV_INSTALL_METHOD=\"apt\" && return 0\n    elif command -v dnf &> /dev/null; then\n        echo \"Using dnf...\"\n        sudo dnf install -y uv 2>/dev/null && UV_INSTALL_METHOD=\"dnf\" && return 0\n    elif command -v pacman &> /dev/null; then\n        echo \"Using pacman...\"\n        sudo pacman -S --noconfirm uv 2>/dev/null && UV_INSTALL_METHOD=\"pacman\" && return 0\n    elif command -v zypper &> /dev/null; then\n        echo \"Using zypper...\"\n        sudo zypper install -y uv 2>/dev/null && UV_INSTALL_METHOD=\"zypper\" && return 0\n    elif command -v winget &> /dev/null; then\n        echo \"Using winget...\"\n        winget install --id=astral-sh.uv -e && UV_INSTALL_METHOD=\"winget\" && return 0\n    elif command -v scoop &> /dev/null; then\n        echo \"Using scoop...\"\n        scoop install uv && UV_INSTALL_METHOD=\"scoop\" && return 0\n    fi\n\n    return 1\n}\n\n# Function to handle Ubuntu-specific installation alternatives\ninstall_uv_ubuntu_alternatives() {\n    # Check if we're on Ubuntu\n    if ! command -v lsb_release &> /dev/null || [[ \"$(lsb_release -si)\" != \"Ubuntu\" ]]; then\n        return 1  # Not Ubuntu, skip these options\n    fi\n\n    echo \"\"\n    echo \"Ubuntu detected. Additional trusted installation options available:\"\n    echo \"\"\n    echo \"1. pipx (official PyPI, installs ~9 packages)\"\n    echo \"   Command: sudo apt install pipx && pipx install uv\"\n    echo \"\"\n    echo \"2. snap (community-maintained by Canonical employee)\"\n    echo \"   Command: sudo snap install astral-uv --classic\"\n    echo \"   Source: https://github.com/lengau/uv-snap\"\n    echo \"\"\n    echo \"3. Continue to official installer script download\"\n    echo \"\"\n\n    while true; do\n        read -r -p \"Choose installation method (1/2/3): \" choice\n        case $choice in\n            1)\n                echo \"Installing uv via pipx...\"\n                if sudo apt update && sudo apt install -y pipx; then\n                    if pipx install uv; then\n                        # Add pipx bin directory to PATH\n                        export PATH=\"$HOME/.local/bin:$PATH\"\n                        UV_INSTALL_METHOD=\"pipx\"\n                        return 0\n                    fi\n                fi\n                echo \"pipx installation failed, trying next option...\"\n                ;;\n            2)\n                echo \"Installing uv via snap...\"\n                if sudo snap install astral-uv --classic; then\n                    # Snap binaries should be automatically in PATH via /snap/bin\n                    UV_INSTALL_METHOD=\"snap\"\n                    return 0\n                fi\n                echo \"snap installation failed, trying next option...\"\n                ;;\n            3)\n                return 1  # Continue to official installer download\n                ;;\n            *)\n                echo \"Invalid option. Please choose 1, 2, or 3.\"\n                ;;\n        esac\n    done\n}\n\n# Function to install uv via download (with user consent)\ninstall_uv_via_download() {\n    echo \"\"\n    echo \"⚠️  SECURITY NOTICE ⚠️\"\n    echo \"uv is not available via system package managers on this system.\"\n    echo \"To continue, we need to download and execute an installation script from:\"\n    echo \"  https://astral.sh/uv/install.sh (Linux/macOS)\"\n    echo \"  https://astral.sh/uv/install.ps1 (Windows)\"\n    echo \"\"\n    echo \"For maximum security, you can install uv manually instead:\"\n    echo \"  1. Visit: https://docs.astral.sh/uv/getting-started/installation/\"\n    echo \"  2. Download the binary for your platform from GitHub releases\"\n    echo \"  3. Verify checksums and install manually\"\n    echo \"  4. Then run: ./algo\"\n    echo \"\"\n\n    read -p \"Continue with script download? (y/N): \" -n 1 -r\n    echo\n    if [[ ! $REPLY =~ ^[Yy]$ ]]; then\n        echo \"Installation cancelled. Please install uv manually and retry.\"\n        exit 1\n    fi\n\n    echo \"Downloading uv installation script...\"\n    if [[ \"$OSTYPE\" == \"msys\" || \"$OSTYPE\" == \"cygwin\" || \"$OSTYPE\" == \"linux-gnu\" && -n \"${WSL_DISTRO_NAME:-}\" ]] || uname -s | grep -q \"MINGW\\|MSYS\"; then\n        # Windows (Git Bash/WSL/MINGW) - use versioned installer\n        powershell -ExecutionPolicy ByPass -c \"irm https://github.com/astral-sh/uv/releases/download/0.8.5/uv-installer.ps1 | iex\"\n        UV_INSTALL_METHOD=\"official installer (Windows)\"\n    else\n        # macOS/Linux - use the versioned script for consistency\n        curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.8.5/uv-installer.sh | sh\n        UV_INSTALL_METHOD=\"official installer\"\n    fi\n}\n\n# Check if uv is installed, if not, install it securely\nif ! command -v uv &> /dev/null; then\n    echo \"uv (Python package manager) not found. Installing...\"\n\n    # Try package managers first (most secure)\n    if ! install_uv_via_package_manager; then\n        # Try Ubuntu-specific alternatives if available\n        if ! install_uv_ubuntu_alternatives; then\n            # Fall back to download with user consent\n            install_uv_via_download\n        fi\n    fi\n\n    # Reload PATH to find uv (includes pipx, cargo, and snap paths)\n    # Note: This PATH change only affects the current shell session.\n    # Users may need to restart their terminal for subsequent runs.\n    export PATH=\"$HOME/.local/bin:$HOME/.cargo/bin:/snap/bin:$PATH\"\n\n    # Verify installation worked\n    if ! command -v uv &> /dev/null; then\n        echo \"Error: uv installation failed. Please restart your terminal and try again.\"\n        echo \"Or install manually from: https://docs.astral.sh/uv/getting-started/installation/\"\n        exit 1\n    fi\n\n    echo \"✓ uv installed successfully via ${UV_INSTALL_METHOD}!\"\nfi\n\n# Install Ansible Galaxy collections if requirements.yml exists\n# This is needed for cloud providers that use collection modules (Linode, DigitalOcean, Azure, etc.)\nif [ -f \"requirements.yml\" ]; then\n    uv run ansible-galaxy collection install -r requirements.yml > /dev/null 2>&1 || true\nfi\n\n# Run the appropriate playbook\ncase \"$1\" in\n  help|-h|--help)\n    echo \"Usage: ./algo [COMMAND] [ANSIBLE_OPTIONS]\"\n    echo \"\"\n    echo \"Set up a personal VPN in the cloud.\"\n    echo \"\"\n    echo \"Commands:\"\n    echo \"  (default)        Deploy a new VPN server\"\n    echo \"  update-users     Add or remove users on an existing server\"\n    echo \"  destroy          Destroy a deployed server and clean up configs\"\n    echo \"  list-servers     List deployed servers (JSON output)\"\n    echo \"\"\n    echo \"Configuration:\"\n    echo \"  Edit config.cfg to set users, DNS, and VPN options before deploying.\"\n    echo \"\"\n    echo \"Non-interactive deployment:\"\n    echo \"  ./algo -e 'provider=digitalocean server_name=algo region=nyc3 do_token=TOKEN'\"\n    echo \"\"\n    echo \"Common Ansible options (passed through):\"\n    echo \"  -e KEY=VALUE     Set variable (bypass interactive prompts)\"\n    echo \"  -v, -vvv         Increase output verbosity\"\n    echo \"  --skip-tags TAG  Skip specific components\"\n    echo \"  -t, --tags TAG   Run only specific components\"\n    echo \"\"\n    echo \"Docs: https://trailofbits.github.io/algo/\"\n    exit 0\n    ;;\n  update-users)\n    uv run ansible-playbook users.yml \"${@:2}\" -t update-users ;;\n  destroy)\n    if [ -z \"${2:-}\" ] || [[ \"$2\" == -* ]]; then\n      echo \"Usage: ./algo destroy <server-ip> [ANSIBLE_OPTIONS]\"\n      echo \"\"\n      echo \"Destroy a deployed Algo VPN server and remove local configs.\"\n      echo \"\"\n      echo \"Arguments:\"\n      echo \"  server-ip        IP address of the server to destroy\"\n      echo \"\"\n      echo \"Examples:\"\n      echo \"  ./algo destroy 188.166.66.185\"\n      echo \"  ./algo destroy 52.1.2.3 -e \\\"region=us-east-1\\\"\"\n      echo \"  ./algo destroy 188.166.66.185 -e \\\"confirm_destroy=true\\\"\"\n      exit 1\n    fi\n    uv run ansible-playbook destroy.yml -e \"server_ip=$2\" \"${@:3}\" ;;\n  list-servers)\n    uv run python3 scripts/list_servers.py \"${@:2}\" ;;\n  *)\n    uv run ansible-playbook main.yml \"${@}\" ;;\nesac\n"
  },
  {
    "path": "algo-docker.sh",
    "content": "#!/usr/bin/env bash\n\nset -eEo pipefail\n\nALGO_DIR=\"/algo\"\nDATA_DIR=\"/data\"\n\numask 0077\n\nusage() {\n    retcode=\"${1:-0}\"\n    echo \"To run algo from Docker:\"\n    echo \"\"\n    echo \"docker run --cap-drop=all -it -v <path to configurations>:${DATA_DIR} ghcr.io/trailofbits/algo:latest\"\n    echo \"\"\n    exit \"${retcode}\"\n}\n\nif [ ! -f \"${DATA_DIR}\"/config.cfg ] ; then\n  echo \"Looks like you're not bind-mounting your config.cfg into this container.\"\n  echo \"algo needs a configuration file to run.\"\n  echo \"\"\n  usage -1\nfi\n\nif [ ! -e /dev/console ] ; then\n  echo \"Looks like you're trying to run this container without a TTY.\"\n  echo \"If you don't pass -t, you can't interact with the algo script.\"\n  echo \"\"\n  usage -1\nfi\n\n# To work around problems with bind-mounting Windows volumes, we need to\n# copy files out of ${DATA_DIR}, ensure appropriate line endings and permissions,\n# then copy the algo-generated files into ${DATA_DIR}.\n\ntr -d '\\r' < \"${DATA_DIR}\"/config.cfg > \"${ALGO_DIR}\"/config.cfg\ntest -d \"${DATA_DIR}\"/configs && rsync -qLktr --delete \"${DATA_DIR}\"/configs \"${ALGO_DIR}\"/\n\n\"${ALGO_DIR}\"/algo \"${ALGO_ARGS[@]}\"\nretcode=${?}\n\nrsync -qLktr --delete \"${ALGO_DIR}\"/configs \"${DATA_DIR}\"/\nexit \"${retcode}\"\n"
  },
  {
    "path": "algo-showenv.sh",
    "content": "#!/usr/bin/env bash\n#\n# Print information about Algo's invocation environment to aid in debugging.\n# This is normally called from Ansible right before a deployment gets underway.\n\n# Skip printing this header if we're just testing with no arguments.\nif [[ $# -gt 0 ]]; then\n    echo \"\"\n    echo \"--> Please include the following block of text when reporting issues:\"\n    echo \"\"\nfi\n\nif [[ ! -f ./algo ]]; then\n    echo \"This should be run from the top level Algo directory\"\nfi\n\n# Determine the operating system.\ncase \"$(uname -s)\" in\n    Linux)\n        OS=\"Linux ($(uname -r) $(uname -v))\"\n        if [[ -f /etc/os-release ]]; then\n            # shellcheck disable=SC1091\n            # I hope this isn't dangerous.\n            . /etc/os-release\n            if [[ ${PRETTY_NAME} ]]; then\n                OS=\"${PRETTY_NAME}\"\n            elif [[ ${NAME} ]]; then\n                OS=\"${NAME} ${VERSION}\"\n            fi\n        fi\n        STAT=\"stat -c %y\"\n        ;;\n    Darwin)\n        OS=\"$(sw_vers -productName) $(sw_vers -productVersion)\"\n        STAT=\"stat -f %Sm\"\n        ;;\n    *)\n        OS=\"Unknown\"\n        ;;\nesac\n\n# Determine if virtualization is being used with Linux.\nVIRTUALIZED=\"\"\nif [[ -x $(command -v systemd-detect-virt) ]]; then\n    DETECT_VIRT=\"$(systemd-detect-virt)\"\n    if [[ ${DETECT_VIRT} != \"none\" ]]; then\n        VIRTUALIZED=\" (Virtualized: ${DETECT_VIRT})\"\n    fi\nelif [[ -f /.dockerenv ]]; then\n    VIRTUALIZED=\" (Virtualized: docker)\"\nfi\n\necho \"Algo running on: ${OS}${VIRTUALIZED}\"\n\n# Determine the currentness of the Algo software.\nif [[ -d .git && -x $(command -v git) ]]; then\n    ORIGIN=\"$(git remote get-url origin)\"\n    COMMIT=\"$(git log --max-count=1 --oneline --no-decorate --no-color)\"\n    if [[ ${ORIGIN} == \"https://github.com/trailofbits/algo.git\" ]]; then\n        SOURCE=\"clone\"\n    else\n        SOURCE=\"fork\"\n    fi\n    echo \"Created from git ${SOURCE}. Last commit: ${COMMIT}\"\nelif [[ -f LICENSE && ${STAT} ]]; then\n    CREATED=\"$(${STAT} LICENSE)\"\n    echo \"ZIP file created: ${CREATED}\"\nfi\n\n# The Python version might be useful to know.\nif [[ -x $(command -v uv) ]]; then\n    echo \"uv Python environment:\"\n    uv run python --version 2>&1\n    uv --version 2>&1\nelif [[ -f ./algo ]]; then\n    echo \"uv not found: try running './algo' to install dependencies\"\nfi\n\n# Just print out all command line arguments, which are expected\n# to be Ansible variables.\nif [[ $# -gt 0 ]]; then\n    echo \"Runtime variables:\"\n    for VALUE in \"$@\"; do\n        echo \"    ${VALUE}\"\n    done\nfi\n\nexit 0\n"
  },
  {
    "path": "algo.ps1",
    "content": "# PowerShell script for Windows users to run Algo VPN\nparam(\n    [Parameter(ValueFromRemainingArguments)]\n    [string[]]$Arguments\n)\n\n# Check if we're actually running inside WSL (not just if WSL is available)\nfunction Test-RunningInWSL {\n    # These environment variables are only set when running inside WSL\n    return $env:WSL_DISTRO_NAME -or $env:WSLENV\n}\n\n# Function to run Algo in WSL\nfunction Invoke-AlgoInWSL {\n    param($Arguments)\n\n    Write-Host \"NOTICE: Ansible requires a Unix-like environment and cannot run natively on Windows.\"\n    Write-Host \"Attempting to run Algo via Windows Subsystem for Linux (WSL)...\"\n    Write-Host \"\"\n\n    if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) {\n        Write-Host \"ERROR: WSL (Windows Subsystem for Linux) is not installed.\" -ForegroundColor Red\n        Write-Host \"\"\n        Write-Host \"Algo requires WSL to run Ansible on Windows. To install WSL:\" -ForegroundColor Yellow\n        Write-Host \"\"\n        Write-Host \"  Step 1: Open PowerShell as Administrator and run:\"\n        Write-Host \"          wsl --install -d Ubuntu-22.04\" -ForegroundColor Cyan\n        Write-Host \"          (Note: 22.04 LTS recommended for WSL stability)\" -ForegroundColor Gray\n        Write-Host \"\"\n        Write-Host \"  Step 2: Restart your computer when prompted\"\n        Write-Host \"\"\n        Write-Host \"  Step 3: After restart, open Ubuntu from the Start menu\"\n        Write-Host \"          and complete the initial setup (create username/password)\"\n        Write-Host \"\"\n        Write-Host \"  Step 4: Run this script again: .\\algo.ps1\"\n        Write-Host \"\"\n        Write-Host \"For detailed instructions, see:\" -ForegroundColor Yellow\n        Write-Host \"https://github.com/trailofbits/algo/blob/master/docs/deploy-from-windows.md\"\n        exit 1\n    }\n\n    # Check if any WSL distributions are installed and running\n    Write-Host \"Checking for WSL Linux distributions...\"\n    $wslList = wsl -l -v 2>$null\n    if ($LASTEXITCODE -ne 0) {\n        Write-Host \"ERROR: WSL is installed but no Linux distributions are available.\" -ForegroundColor Red\n        Write-Host \"\"\n        Write-Host \"You need to install Ubuntu. Run this command as Administrator:\" -ForegroundColor Yellow\n        Write-Host \"  wsl --install -d Ubuntu-22.04\" -ForegroundColor Cyan\n        Write-Host \"          (Note: 22.04 LTS recommended for WSL stability)\" -ForegroundColor Gray\n        Write-Host \"\"\n        Write-Host \"Then restart your computer and try again.\"\n        exit 1\n    }\n\n    Write-Host \"Successfully found WSL. Launching Algo...\" -ForegroundColor Green\n    Write-Host \"\"\n\n    # Get current directory name for WSL path mapping\n    $currentDir = Split-Path -Leaf (Get-Location)\n\n    try {\n        if ($Arguments.Count -gt 0 -and $Arguments[0] -eq \"update-users\") {\n            $remainingArgs = $Arguments[1..($Arguments.Count-1)] -join \" \"\n            wsl bash -c \"cd /mnt/c/$currentDir 2>/dev/null || (echo 'Error: Cannot access directory in WSL. Make sure you are running from a Windows drive (C:, D:, etc.)' && exit 1) && ./algo update-users $remainingArgs\"\n        } else {\n            $allArgs = $Arguments -join \" \"\n            wsl bash -c \"cd /mnt/c/$currentDir 2>/dev/null || (echo 'Error: Cannot access directory in WSL. Make sure you are running from a Windows drive (C:, D:, etc.)' && exit 1) && ./algo $allArgs\"\n        }\n\n        if ($LASTEXITCODE -ne 0) {\n            Write-Host \"\"\n            Write-Host \"Algo finished with exit code: $LASTEXITCODE\" -ForegroundColor Yellow\n            if ($LASTEXITCODE -eq 1) {\n                Write-Host \"This may indicate a configuration issue or user cancellation.\"\n            }\n        }\n    } catch {\n        Write-Host \"\"\n        Write-Host \"ERROR: Failed to run Algo in WSL.\" -ForegroundColor Red\n        Write-Host \"Error details: $($_.Exception.Message)\" -ForegroundColor Red\n        Write-Host \"\"\n        Write-Host \"Troubleshooting:\" -ForegroundColor Yellow\n        Write-Host \"1. Make sure you're running from a Windows drive (C:, D:, etc.)\"\n        Write-Host \"2. Try opening Ubuntu directly and running: cd /mnt/c/$currentDir && ./algo\"\n        Write-Host \"3. See: https://github.com/trailofbits/algo/blob/master/docs/deploy-from-windows.md\"\n        exit 1\n    }\n}\n\n# Main execution\ntry {\n    # Check if we're actually running inside WSL\n    if (Test-RunningInWSL) {\n        Write-Host \"Detected WSL environment. Running Algo using standard Unix approach...\"\n\n        # Verify bash is available (should be in WSL)\n        if (-not (Get-Command bash -ErrorAction SilentlyContinue)) {\n            Write-Host \"ERROR: Running in WSL but bash is not available.\" -ForegroundColor Red\n            Write-Host \"Your WSL installation may be incomplete. Try running:\" -ForegroundColor Yellow\n            Write-Host \"  wsl --shutdown\" -ForegroundColor Cyan\n            Write-Host \"  wsl\" -ForegroundColor Cyan\n            exit 1\n        }\n\n        # Run the standard Unix algo script\n        & bash -c \"./algo $($Arguments -join ' ')\"\n        exit $LASTEXITCODE\n    }\n\n    # We're on native Windows - need to use WSL\n    Invoke-AlgoInWSL $Arguments\n\n} catch {\n    Write-Host \"\"\n    Write-Host \"UNEXPECTED ERROR:\" -ForegroundColor Red\n    Write-Host $_.Exception.Message -ForegroundColor Red\n    Write-Host \"\"\n    Write-Host \"If you continue to have issues:\" -ForegroundColor Yellow\n    Write-Host \"1. Ensure WSL is properly installed and Ubuntu is set up\"\n    Write-Host \"2. See troubleshooting guide: https://github.com/trailofbits/algo/blob/master/docs/deploy-from-windows.md\"\n    Write-Host \"3. Or use WSL directly: open Ubuntu and run './algo'\"\n    exit 1\n}\n"
  },
  {
    "path": "ansible.cfg",
    "content": "[defaults]\ninventory = inventory\npipelining = True\nretry_files_enabled = False\nhost_key_checking = False\ntimeout = 60\nstdout_callback = default\ndisplay_skipped_hosts = no\nforce_valid_group_names = ignore\nremote_tmp = /tmp/.ansible/tmp\n\n[paramiko_connection]\nrecord_host_keys = False\n\n[ssh_connection]\nssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null -o ConnectTimeout=6 -o ConnectionAttempts=30 -o IdentitiesOnly=yes\nscp_if_ssh = True\nretries = 30\n"
  },
  {
    "path": "cloud.yml",
    "content": "---\n- name: Provision the server\n  hosts: localhost\n  tags: always\n  become: false\n  vars_files:\n    - config.cfg\n\n  tasks:\n    - block:\n        - name: Local pre-tasks\n          import_tasks: playbooks/cloud-pre.yml\n\n        - name: Include a provisioning role\n          include_role:\n            name: \"{{ 'local' if algo_provider == 'local' else 'cloud-' + algo_provider }}\"\n\n        - name: Local post-tasks\n          import_tasks: playbooks/cloud-post.yml\n      rescue:\n        - include_tasks: playbooks/rescue.yml\n"
  },
  {
    "path": "config.cfg",
    "content": "---\n\n# ============================================\n# TROUBLESHOOTING DEPLOYMENT ISSUES\n# ============================================\n# If your deployment fails with hidden/censored output, temporarily set\n# algo_no_log to 'false' below. This will show detailed error messages\n# including API responses.\n# IMPORTANT: Set back to 'true' before sharing logs or screenshots!\n# ============================================\nalgo_no_log: true  # Set to 'false' for debugging (shows sensitive data in output)\n\n# This is the list of users to generate.\n# Every device must have a unique user.\n# You can add up to 65,534 new users over the lifetime of an AlgoVPN.\n# User names with leading 0's or containing only numbers should be escaped in double quotes, e.g. \"000dan\" or \"123\".\n# Email addresses are not allowed.\nusers:\n  - phone\n  - laptop\n  - desktop\n\n### Review these options BEFORE you run Algo, as they are very difficult/impossible to change after the server is deployed.\n\n# SSH port for cloud deployments (doesn't apply to existing Ubuntu servers)\nssh_port: 4160\n\n# VPN protocols to deploy\nipsec_enabled: true\nwireguard_enabled: true\nwireguard_port: 51820  # Change if blocked by your network (avoid 53/UDP)\n\n# Use different IP for outbound traffic (DigitalOcean only)\nalternative_ingress_ip: false\n\n# Reduce MTU if connections hang (0 = auto-detect)\n# See: docs/troubleshooting.md#various-websites-appear-to-be-offline-through-the-vpn\nreduce_mtu: 0\n\n# Ad blocking lists (modify /usr/local/sbin/adblock.sh after deployment to add more)\nadblock_lists:\n  - \"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts\"\n\n# DNS encryption (required if using ad blocking)\ndns_encryption: true\n\n# Client isolation (set false for \"road warrior\" setup where clients can reach each other)\nBetweenClients_DROP: true\nblock_smb: true          # Block SMB/CIFS traffic\nblock_netbios: true      # Block NETBIOS traffic\n\n# Automatic reboot for security updates (time in server's timezone, default UTC)\nunattended_reboot:\n  enabled: false\n  time: 06:00\n\n### Privacy Settings ###\n# StrongSwan connection logging (-1 = disabled, 2 = debug)\nstrongswan_log_level: -1\n\n# Master switch for privacy enhancements (log rotation, history clearing, etc.)\n# Set to false for debugging. For advanced privacy options, see roles/privacy/defaults/main.yml\nprivacy_enhancements_enabled: true\n\n### Advanced users only below this line ###\n\n# DNSCrypt providers (see https://github.com/DNSCrypt/dnscrypt-resolvers/blob/master/v2/public-resolvers.md)\ndnscrypt_servers:\n  ipv4:\n    - cloudflare\n#   - google\n#   - YourCustomServer  # For NextDNS etc., add stamp below\n  ipv6:\n    - cloudflare-ipv6\n\ncustom_server_stamps:\n# YourCustomServer: 'sdns://...'\n\n# DNS servers when encryption is disabled\ndns_servers:\n  ipv4:\n    - 1.1.1.1\n    - 1.0.0.1\n  ipv6:\n    - 2606:4700:4700::1111\n    - 2606:4700:4700::1001\n\n# Store PKI in RAM disk when not retaining (MacOS/Linux only)\npki_in_tmpfs: true\n\n# Regenerate ALL user credentials on update-users (not just new users)\n# When false: existing WireGuard keys and IPsec certs are preserved, new users added\n# When true: all credentials deleted and regenerated - ALL CLIENTS MUST RECONFIGURE\n# Use true after: suspected key compromise, removing untrusted users, or security audit\nkeys_clean_all: false\n\n### VPN Network Configuration ###\nstrongswan_network: 10.48.0.0/16\nstrongswan_network_ipv6: '2001:db8:4160::/48'\n\nwireguard_network_ipv4: 10.49.0.0/16\nwireguard_network_ipv6: 2001:db8:a160::/48\n\n# Keep NAT connections alive (0 = disabled)\nwireguard_PersistentKeepalive: 0\n\n### Experimental Performance Options ###\n# These are experimental and may cause issues. Enable at your own risk.\n# performance_skip_optional_reboots: false  # Skip non-kernel reboots\n# performance_parallel_crypto: false        # Parallel key generation\n# performance_parallel_packages: false      # Batch package installation\n# performance_preinstall_packages: false    # Pre-install via cloud-init\n# performance_parallel_services: false      # Configure VPN services in parallel\n\n# Randomly generated IP address for the local dns resolver\nlocal_service_ip: \"{{ '172.16.0.1' | ansible.utils.ipmath(1048573 | random(seed=algo_server_name + ansible_fqdn)) }}\"\nlocal_service_ipv6: \"{{ 'fd00::1' | ansible.utils.ipmath(1048573 | random(seed=algo_server_name + ansible_fqdn)) }}\"\n\n\ncongrats:\n  common: |\n    \"#                          Congratulations!                            #\"\n    \"#                     Your Algo server is running.                     #\"\n    \"#    Config files and certificates are in the ./configs/ directory.    #\"\n    \"#              Go to https://whoer.net/ after connecting               #\"\n    \"#        and ensure that all your traffic passes through the VPN.      #\"\n    \"#                     Local DNS resolver {{ local_service_ip }}{{ ', ' + local_service_ipv6 if ipv6_support else '' }}                   #\"\n  p12_pass: |\n    \"#        The p12 and SSH keys password for new users is {{ p12_export_password }}       #\"\n  ca_key_pass: |\n    \"#        The CA key password is {{ CA_password|default(omit) }}       #\"\n  ssh_access: |\n    \"#      Shell access: ssh -F configs/{{ ansible_ssh_host|default(omit) }}/ssh_config {{ algo_server_name }}        #\"\n\nSSH_keys:\n  comment: algo@ssh\n  private: configs/algo.pem\n  private_tmp: /tmp/algo-ssh.pem\n  public: configs/algo.pem.pub\n\ncloud_providers:\n  azure:\n    size: Standard_B1S\n    osDisk:\n      # The storage account type to use for the OS disk. Possible values:\n      # 'Standard_LRS', 'Premium_LRS', 'StandardSSD_LRS', 'UltraSSD_LRS',\n      # 'Premium_ZRS', 'StandardSSD_ZRS', 'PremiumV2_LRS'.\n      type: Standard_LRS\n    image:\n      publisher: Canonical\n      offer: 0001-com-ubuntu-minimal-jammy-daily\n      sku: minimal-22_04-daily-lts\n      version: latest\n  digitalocean:\n    # See docs for extended droplet options, pricing, and availability.\n    # Possible values: 's-1vcpu-512mb-10gb', 's-1vcpu-1gb', ...\n    size: s-1vcpu-1gb\n    image: \"ubuntu-22-04-x64\"\n  ec2:\n    # Change the encrypted flag to \"false\" to disable AWS volume encryption.\n    encrypted: true\n    # Set use_existing_eip to \"true\" if you want to use a pre-allocated Elastic IP\n    # Additional prompt will be raised to determine which IP to use\n    use_existing_eip: false\n    size: t3.micro\n    image:\n      name: \"ubuntu-jammy-22.04\"\n      arch: x86_64\n      owner: \"099720109477\"\n    # Change instance_market_type from \"on-demand\" to \"spot\" to launch a spot\n    # instance. See deploy-from-ansible.md for spot's additional IAM permission\n    instance_market_type: on-demand\n  gce:\n    size: e2-micro\n    image: ubuntu-2204-lts\n    external_static_ip: false\n  lightsail:\n    size: nano_2_0\n    image: ubuntu_22_04\n  scaleway:\n    size: DEV1-S\n    image: Ubuntu 22.04 Jammy Jellyfish\n    arch: x86_64\n  hetzner:\n    server_type: cpx22\n    image: ubuntu-22.04\n  openstack:\n    flavor_ram: \">=512\"\n    image: Ubuntu-22.04\n  cloudstack:\n    size: Micro\n    image: Linux Ubuntu 22.04 LTS 64-bit\n    disk: 10\n  vultr:\n    os: Ubuntu 22.04 LTS x64\n    size: vc2-1c-1gb\n  linode:\n    type: g6-nanode-1\n    image: linode/ubuntu22.04\n  local:\n\nfail_hint:\n  - Sorry, but something went wrong!\n  - Check troubleshooting for common fixes, or file an issue if you found a bug.\n  - https://trailofbits.github.io/algo/troubleshooting.html\n  - https://github.com/trailofbits/algo/issues/new\n\nbooleans_map:\n  Y: true\n  y: true\n"
  },
  {
    "path": "deploy_client.yml",
    "content": "---\n- name: Configure the client\n  hosts: localhost\n  become: false\n  vars_files:\n    - config.cfg\n\n  tasks:\n    - name: Add the droplet to an inventory group\n      add_host:\n        name: \"{{ client_ip }}\"\n        groups: client-host\n        ansible_ssh_user: \"{{ 'root' if client_ip == 'localhost' else ssh_user }}\"\n        vpn_user: \"{{ vpn_user }}\"\n        IP_subject_alt_name: \"{{ server_ip }}\"\n        ansible_python_interpreter: \"{% if client_ip == 'localhost' %}{{ ansible_playbook_python }}{% else %}/usr/bin/python3{% endif %}\"\n\n- name: Configure the client and install required software\n  hosts: client-host\n  gather_facts: false\n  become: true\n  vars_files:\n    - config.cfg\n    - roles/strongswan/defaults/main.yml\n  roles:\n    - role: client\n"
  },
  {
    "path": "destroy.yml",
    "content": "---\n- name: Destroy an Algo VPN server\n  hosts: localhost\n  gather_facts: false\n  become: false\n  vars_files:\n    - config.cfg\n\n  tasks:\n    - block:\n        - name: Validate server_ip is provided\n          assert:\n            that: server_ip is defined and server_ip | length > 0\n            fail_msg: |\n              server_ip is required. Usage:\n                ./algo destroy <server-ip>\n                ansible-playbook destroy.yml -e \"server_ip=YOUR_SERVER_IP\"\n\n        - name: Check that server config exists\n          stat:\n            path: \"configs/{{ server_ip }}/.config.yml\"\n          register: _server_config\n\n        - name: Fail if server config not found\n          fail:\n            msg: |\n              No config found at configs/{{ server_ip }}/.config.yml\n\n              This server may not have been deployed by Algo, or\n              its configs were already removed.\n\n              Known servers:\n                ls configs/*/\n          when: not _server_config.stat.exists\n\n        - name: Load server configuration\n          include_vars:\n            file: \"configs/{{ server_ip }}/.config.yml\"\n            name: _server_cfg\n\n        - name: Set provider and server name from config\n          set_fact:\n            algo_provider: \"{{ _server_cfg.algo_provider }}\"\n            algo_server_name: \"{{ _server_cfg.algo_server_name }}\"\n\n        - name: Validate required config values\n          assert:\n            that:\n              - algo_provider is defined and algo_provider | length > 0\n              - algo_server_name is defined and algo_server_name | length > 0\n            fail_msg: |\n              Server config is missing algo_provider or algo_server_name.\n              Check configs/{{ server_ip }}/.config.yml\n\n        - name: Install cloud provider dependencies\n          shell: \"uv pip install '.[{{ _provider_extras[algo_provider] | default(algo_provider) }}]'\"\n          vars:\n            _provider_extras:\n              ec2: aws\n              lightsail: aws\n              azure: azure\n              gce: gcp\n              hetzner: hetzner\n              linode: linode\n              openstack: openstack\n              cloudstack: cloudstack\n          when: algo_provider != \"local\"\n          changed_when: false\n\n        - name: Set region from stored config\n          set_fact:\n            region: \"{{ _server_cfg.algo_region }}\"\n          when:\n            - region is not defined\n            - _server_cfg.algo_region is defined\n            - _server_cfg.algo_region | length > 0\n\n        - name: Validate region for providers that require it\n          fail:\n            msg: |\n              Region is required to destroy {{ algo_provider }} servers.\n              Pass it with: -e \"region=YOUR_REGION\"\n\n              Example:\n                ./algo destroy {{ server_ip }} -e \"region=us-east-1\"\n          when:\n            - algo_provider in ['ec2', 'lightsail', 'gce', 'scaleway', 'vultr']\n            - region is not defined\n\n        - name: Set dummy region for providers that do not need it\n          set_fact:\n            region: \"unused\"\n          when:\n            - region is not defined\n            - algo_provider not in ['ec2', 'lightsail', 'gce', 'scaleway', 'vultr']\n\n        - name: Gather provider credentials\n          include_tasks: \"roles/cloud-{{ algo_provider }}/tasks/prompts.yml\"\n          when: algo_provider != \"local\"\n\n        - name: Display destroy plan\n          debug:\n            msg:\n              - \"Server IP:  {{ server_ip }}\"\n              - \"Server name: {{ algo_server_name }}\"\n              - \"Provider:    {{ algo_provider }}\"\n\n        - name: Confirm destruction\n          pause:\n            prompt: |\n              This will permanently destroy the server and remove local configs.\n              Type 'yes' to confirm\n          register: _confirm_destroy\n          when: confirm_destroy is not defined or not confirm_destroy | bool\n\n        - name: Abort if not confirmed\n          fail:\n            msg: \"Destroy aborted by user.\"\n          when:\n            - confirm_destroy is not defined or not confirm_destroy | bool\n            - _confirm_destroy.user_input | default('') | lower != 'yes'\n\n        - name: Destroy cloud resources\n          include_tasks: \"roles/cloud-{{ algo_provider }}/tasks/destroy.yml\"\n          when: algo_provider != \"local\"\n\n        - name: Remove local config directory\n          file:\n            path: \"configs/{{ server_ip }}\"\n            state: absent\n\n        - name: Remove localhost symlink\n          file:\n            path: configs/localhost\n            state: absent\n          when: server_ip == \"localhost\"\n\n        - name: Destroy complete\n          debug:\n            msg:\n              - \"Server {{ algo_server_name }} ({{ server_ip }}) destroyed.\"\n              - \"Local configs removed from configs/{{ server_ip }}/\"\n      rescue:\n        - include_tasks: playbooks/rescue.yml\n"
  },
  {
    "path": "docs/aws-credentials.md",
    "content": "# AWS Credential Configuration\n\nAlgo supports multiple methods for providing AWS credentials, following standard AWS practices:\n\n## Methods (in order of precedence)\n\n1. **Command-line variables** (highest priority)\n   ```bash\n   ./algo -e \"aws_access_key=YOUR_KEY aws_secret_key=YOUR_SECRET\"\n   ```\n\n2. **Environment variables**\n   ```bash\n   export AWS_ACCESS_KEY_ID=YOUR_KEY\n   export AWS_SECRET_ACCESS_KEY=YOUR_SECRET\n   export AWS_SESSION_TOKEN=YOUR_TOKEN  # Optional, for temporary credentials\n   ./algo\n   ```\n\n3. **AWS credentials file** (lowest priority)\n   - Default location: `~/.aws/credentials`\n   - Custom location: Set `AWS_SHARED_CREDENTIALS_FILE` environment variable\n   - Profile selection: Set `AWS_PROFILE` environment variable (defaults to \"default\")\n\n## Using AWS Credentials File\n\nAfter running `aws configure` or manually creating `~/.aws/credentials`:\n\n```ini\n[default]\naws_access_key_id = YOUR_KEY_ID\naws_secret_access_key = YOUR_SECRET_KEY\n\n[work]\naws_access_key_id = WORK_KEY_ID\naws_secret_access_key = WORK_SECRET_KEY\naws_session_token = TEMPORARY_TOKEN  # Optional\n```\n\nTo use a specific profile:\n```bash\nAWS_PROFILE=work ./algo\n```\n\n## Security Considerations\n\n- Credentials files should have restricted permissions (600)\n- Consider using AWS IAM roles or temporary credentials when possible\n- Tools like [aws-vault](https://github.com/99designs/aws-vault) can provide additional security by storing credentials encrypted\n\n## Troubleshooting\n\nIf Algo isn't finding your credentials:\n\n1. Check file permissions: `ls -la ~/.aws/credentials`\n2. Verify the profile name matches: `AWS_PROFILE=your-profile`\n3. Test with AWS CLI: `aws sts get-caller-identity`\n\nIf credentials are found but authentication fails:\n- Ensure your IAM user has the required permissions (see [EC2 deployment guide](deploy-from-ansible.md))\n- Check if you need session tokens for temporary credentials\n"
  },
  {
    "path": "docs/client-android.md",
    "content": "# Android client setup\n\n## Installation via profiles\n\n1. [Install the WireGuard VPN Client](https://play.google.com/store/apps/details?id=com.wireguard.android).\n2. Open QR code `configs/<ip_address>/wireguard/<username>.png` and scan it in the WireGuard app\n"
  },
  {
    "path": "docs/client-apple-ipsec.md",
    "content": "# Using the built-in IPSEC VPN on Apple Devices\n\n## Configure IPsec\n\nFind the corresponding `mobileconfig` (Apple Profile) for each user and send it to them over AirDrop or other secure means. Apple Configuration Profiles are all-in-one configuration files for iOS and macOS devices. On macOS, double-clicking a profile to install it will fully configure the VPN. On iOS, users are prompted to install the profile as soon as the AirDrop is accepted.\n\n## Enable the VPN\n\nOn iOS, connect to the VPN by opening **Settings** and clicking the toggle next to \"VPN\" near the top of the list. If using WireGuard, you can also enable the VPN from the WireGuard app. On macOS, connect to the VPN by opening **System Settings** -> **Network** (or **VPN** on macOS Sequoia 15.0+), finding the Algo VPN in the left column, and clicking \"Connect.\" Check \"Show VPN status in menu bar\" to easily connect and disconnect from the menu bar.\n\n## Managing \"Connect On Demand\"\n\nIf you enable \"Connect On Demand\", the VPN will connect automatically whenever it is able. Most Apple users will want to enable \"Connect On Demand\", but if you do then simply disabling the VPN will not cause it to stay disabled; it will just \"Connect On Demand\" again. To disable the VPN you'll need to disable \"Connect On Demand\".\n\nOn iOS, you can turn off \"Connect On Demand\" in **Settings** by clicking the (i) next to the entry for your Algo VPN and toggling off \"Connect On Demand.\" On macOS, you can turn off \"Connect On Demand\" by opening **System Settings** -> **Network** (or **VPN** on macOS Sequoia 15.0+), finding the Algo VPN in the left column, unchecking the box for \"Connect on demand\", and clicking Apply.\n"
  },
  {
    "path": "docs/client-linux-ipsec.md",
    "content": "# Linux strongSwan IPsec Clients (e.g., OpenWRT, Ubuntu Server, etc.)\n\nInstall strongSwan, then copy the included ipsec_user.conf, ipsec_user.secrets, user.crt (user certificate), and user.key (private key) files to your client device. These will require customization based on your exact use case. These files were originally generated with a point-to-point OpenWRT-based VPN in mind.\n\n## Ubuntu Server example\n\n1. `sudo apt install strongswan libstrongswan-standard-plugins`: install strongSwan\n2. `/etc/ipsec.d/certs`: copy `<name>.crt` from `algo-master/configs/<server_ip>/ipsec/.pki/certs/<name>.crt`\n3. `/etc/ipsec.d/private`: copy `<name>.key` from `algo-master/configs/<server_ip>/ipsec/.pki/private/<name>.key`\n4. `/etc/ipsec.d/cacerts`: copy `cacert.pem` from `algo-master/configs/<server_ip>/ipsec/manual/cacert.pem`\n5. `/etc/ipsec.secrets`: add your `user.key` to the list, e.g. `<server_ip> : ECDSA <name>.key`\n6. `/etc/ipsec.conf`: add the connection from `ipsec_user.conf` and ensure `leftcert` matches the `<name>.crt` filename\n7. `sudo ipsec restart`: pick up config changes\n8. `sudo ipsec up <conn-name>`: start the ipsec tunnel\n9. `sudo ipsec down <conn-name>`: shutdown the ipsec tunnel\n\nOne common use case is to let your server access your local LAN without going through the VPN. Set up a passthrough connection by adding the following to `/etc/ipsec.conf`:\n\n    conn lan-passthrough\n    leftsubnet=192.168.1.1/24 # Replace with your LAN subnet\n    rightsubnet=192.168.1.1/24 # Replace with your LAN subnet\n    authby=never # No authentication necessary\n    type=pass # passthrough\n    auto=route # no need to ipsec up lan-passthrough\n\nTo configure the connection to come up at boot time replace `auto=add` with `auto=start`.\n\n## Notes on SELinux\n\nIf you use a system with SELinux enabled, you might need to set appropriate file contexts:\n\n````\nsemanage fcontext -a -t ipsec_key_file_t \"$(pwd)(/.*)?\"\nrestorecon -R -v $(pwd)\n````\n\nSee [this comment](https://github.com/trailofbits/algo/issues/263#issuecomment-328053950).\n"
  },
  {
    "path": "docs/client-linux-wireguard.md",
    "content": "# Using Ubuntu as a Client with WireGuard\n\n## Install WireGuard\n\nTo connect to your AlgoVPN using [WireGuard](https://www.wireguard.com) from Ubuntu, make sure your system is up-to-date then install WireGuard:\n\n```shell\n# Update your system:\nsudo apt update && sudo apt upgrade\n\n# If the file /var/run/reboot-required exists then reboot:\n[ -e /var/run/reboot-required ] && sudo reboot\n\n# Install WireGuard:\nsudo apt install wireguard\n# Note: openresolv is no longer needed on Ubuntu 22.04 LTS+\n```\n\nFor installation on other Linux distributions, see the [Installation](https://www.wireguard.com/install/) page on the WireGuard site.\n\n## Locate the Config File\n\nThe Algo-generated config files for WireGuard are named `configs/<ip_address>/wireguard/<username>.conf` on the system where you ran `./algo`. One file was generated for each of the users you added to `config.cfg`. Each WireGuard client you connect to your AlgoVPN must use a different config file. Choose one of these files and copy it to your Linux client.\n\n## Configure WireGuard\n\nFinally, install the config file on your client as `/etc/wireguard/wg0.conf` and start WireGuard:\n\n```shell\n# Install the config file to the WireGuard configuration directory on your\n# Linux client:\nsudo install -o root -g root -m 600 <username>.conf /etc/wireguard/wg0.conf\n\n# Start the WireGuard VPN:\nsudo systemctl start wg-quick@wg0\n\n# Check that it started properly:\nsudo systemctl status wg-quick@wg0\n\n# Verify the connection to the AlgoVPN:\nsudo wg\n\n# See that your client is using the IP address of your AlgoVPN:\ncurl ipv4.icanhazip.com\n\n# Optionally configure the connection to come up at boot time:\nsudo systemctl enable wg-quick@wg0\n```\n\nIf your Linux distribution does not use `systemd` you can bring up WireGuard with `sudo wg-quick up wg0`.\n\n## Using a DNS Search Domain\n\nAs of the `v1.0.20200510` release of `wireguard-tools` WireGuard supports setting a DNS search domain. In your `wg0.conf` file a non-numeric entry on the `DNS` line will be used as a search domain. For example, this:\n```\nDNS =  172.27.153.31, fd00::b:991f, mydomain.com\n```\nwill cause your `/etc/resolv.conf` to contain:\n```\nsearch mydomain.com\nnameserver 172.27.153.31\nnameserver fd00::b:991f\n```\n"
  },
  {
    "path": "docs/client-linux.md",
    "content": "# Linux client setup\n\n## Provision client config\n\nAfter you deploy a server, you can use an included Ansible script to provision Linux clients too! Debian, Ubuntu, CentOS, and Fedora are supported. The playbook is `deploy_client.yml`.\n\n### Required variables\n\n* `client_ip` - The IP address of your client machine (You can use `localhost` in order to deploy locally)\n* `vpn_user` - The username. (Ensure that you have valid certificates and keys in the `configs/SERVER_ip/pki/` directory)\n* `ssh_user` - The username that we need to use in order to connect to the client machine via SSH (ignore if you are deploying locally)\n* `server_ip` - The vpn server ip address\n\n### Example\n\n```shell\nansible-playbook deploy_client.yml -e 'client_ip=client.com vpn_user=jack server_ip=vpn-server.com ssh_user=root'\n```\n\n### Additional options\n\nIf the user requires sudo password use the following argument: `--ask-become-pass`.\n\n## OS Specific instructions\n\nSome Linux clients may require more specific and details instructions to configure a connection to the deployed Algo VPN, these are documented here.\n\n### Fedora Workstation\n\n#### (Gnome) Network Manager install\n\nFirst, install the required plugins.\n\n````\ndnf install NetworkManager-strongswan NetworkManager-strongswan-gnome\n````\n\n#### (Gnome) Network Manager configuration\n\nIn this example we'll assume the IP of our Algo VPN server is `1.2.3.4` and the user we created is `user-name`.\n\n* Go to *Settings* > *Network*\n* Add a new Network (`+` bottom left of the window)\n* Select *IPsec/IKEv2 (strongswan)*\n* Fill out the options:\n  * Name: your choice, e.g.: *ikev2-1.2.3.4*\n  * Gateway:\n    * Address: IP of the Algo VPN server, e.g: `1.2.3.4`\n    * Certificate: `cacert.pem` found at `/path/to/algo/configs/1.2.3.4/ipsec/.pki/cacert.pem`\n  * Client:\n    * Authentication: *Certificate/Private key*\n    * Certificate: `user-name.crt` found at `/path/to/algo/configs/1.2.3.4/ipsec/.pki/certs/user-name.crt`\n    * Private key: `user-name.key` found at `/path/to/algo/configs/1.2.3.4/ipsec/.pki/private/user-name.key`\n  * Options:\n    * Check *Request an inner IP address*, connection will fail without this option\n    * Optionally check *Enforce UDP encapsulation*\n    * Optionally check *Use IP compression*\n    * For the later 2 options, hover to option in the settings to see a description\n  * Cipher proposal:\n    * Check *Enable custom proposals*\n    * IKE: `aes256gcm16-prfsha512-ecp384`\n    * ESP: `aes256gcm16-ecp384`\n* Apply and turn the connection on, you should now be connected\n"
  },
  {
    "path": "docs/client-macos-wireguard.md",
    "content": "# MacOS WireGuard Client Setup\n\nThe WireGuard macOS app is unavailable for older operating systems. Please update your operating system if you can. If you are on a macOS High Sierra (10.13) or earlier, then you can still use WireGuard via their userspace drivers via the process detailed below.\n\n## Install WireGuard\n\nInstall the wireguard-go userspace driver:\n\n```\nbrew install wireguard-tools\n```\n\n## Locate the Config File\n\nAlgo generates a WireGuard configuration file, `wireguard/<username>.conf`, and a QR code, `wireguard/<username>.png`, for each user defined in `config.cfg`. Find the configuration file and copy it to your device if you don't already have it.\n\nNote that each client you use to connect to Algo VPN must have a unique WireGuard config.\n\n## Configure WireGuard\n\nYou'll need to copy the appropriate WireGuard configuration file into a location where the userspace driver can find it. After it is in the right place, start the VPN, and verify connectivity.\n\n```\n# Copy the config file to the WireGuard configuration directory on your macOS device\nmkdir /usr/local/etc/wireguard/\ncp <username>.conf /usr/local/etc/wireguard/wg0.conf\n\n# Start the WireGuard VPN\nsudo wg-quick up wg0\n\n# Verify the connection to the Algo VPN\nwg\n\n# See that your client is using the IP address of your Algo VPN:\ncurl ipv4.icanhazip.com\n```\n"
  },
  {
    "path": "docs/client-openwrt-router-wireguard.md",
    "content": "# OpenWrt Router as WireGuard Client\n\nThis guide explains how to configure an OpenWrt router as a WireGuard VPN client, allowing all devices connected to your network to route traffic through your Algo VPN automatically. This setup is ideal for devices that don't support VPN natively (smart TVs, IoT devices, game consoles) or when you want seamless VPN access for all network clients.\n\n## Use Cases\n\n- Connect devices without native VPN support (smart TVs, gaming consoles, IoT devices)\n- Automatically route all connected devices through the VPN\n- Create a secure connection when traveling with multiple devices\n- Configure VPN once at the router level instead of per-device\n\n## Prerequisites\n\nYou'll need an OpenWrt-compatible router with sufficient RAM (minimum 64MB recommended) and OpenWrt 23.05 or later installed. Your Algo VPN server must be deployed and running, and you'll need the WireGuard configuration file from your Algo deployment.\n\nEnsure your router's LAN subnet doesn't conflict with upstream networks. The default OpenWrt IP is `192.168.1.1` - change to `192.168.2.1` if conflicts exist.\n\nThis configuration has been verified on TP-Link TL-WR1043ND and TP-Link Archer C20i AC750 with OpenWrt 23.05+. For compatibility with other devices, check the [OpenWrt Table of Hardware](https://openwrt.org/toh/start).\n\n## Install Required Packages\n\n### Web Interface Method\n\n1. Access your router's web interface (typically `http://192.168.1.1`)\n2. Login with your credentials (default: username `root`, no password)\n3. Navigate to System → Software\n4. Click \"Update lists\" to refresh the package database\n5. Search for and install these packages:\n   - `wireguard-tools`\n   - `kmod-wireguard`\n   - `luci-app-wireguard`\n   - `wireguard`\n   - `kmod-crypto-sha256`\n   - `kmod-crypto-sha1`\n   - `kmod-crypto-md5`\n6. Restart the router after installation completes\n\n### SSH Method\n\n1. SSH into your router: `ssh root@192.168.1.1`\n2. Update the package list:\n   ```bash\n   opkg update\n   ```\n3. Install required packages:\n   ```bash\n   opkg install wireguard-tools kmod-wireguard luci-app-wireguard wireguard kmod-crypto-sha256 kmod-crypto-sha1 kmod-crypto-md5\n   ```\n4. Reboot the router:\n   ```bash\n   reboot\n   ```\n\n## Locate Your WireGuard Configuration\n\nBefore proceeding, locate your WireGuard configuration file from your Algo deployment. This file is typically located at:\n```\nconfigs/<server_ip>/wireguard/<username>.conf\n```\n\nYour configuration file should look similar to:\n```ini\n[Interface]\nPrivateKey = <your_private_key>\nAddress = 10.49.0.2/16\nDNS = 172.16.0.1\n\n[Peer]\nPublicKey = <server_public_key>\nPresharedKey = <preshared_key>\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = <server_ip>:51820\nPersistentKeepalive = 25\n```\n\n## Configure WireGuard Interface\n\n1. In the OpenWrt web interface, navigate to Network → Interfaces\n2. Click \"Add new interface...\"\n3. Set the name to `AlgoVPN` (or your preferred name) and select \"WireGuard VPN\" as the protocol\n4. Click \"Create interface\"\n\nIn the General Settings tab:\n- Check \"Bring up on boot\"\n- Enter your private key from the Algo config file\n- Add your IP address from the Algo config file (e.g., `10.49.0.2/16`)\n\nSwitch to the Peers tab and click \"Add peer\":\n- Description: `Algo Server`\n- Public Key: Copy from the `[Peer]` section of your config\n- Preshared Key: Copy from the `[Peer]` section of your config\n- Allowed IPs: `0.0.0.0/0, ::/0` (routes all traffic through VPN)\n- Route Allowed IPs: Check this box\n- Endpoint Host: Extract the IP address from the `Endpoint` line\n- Endpoint Port: Extract the port from the `Endpoint` line (typically `51820`)\n- Persistent Keep Alive: `25`\n\nClick \"Save & Apply\".\n\n## Configure Firewall Rules\n\n1. Navigate to Network → Firewall\n2. Click \"Add\" to create a new zone\n3. Configure the firewall zone:\n   - Name: `vpn`\n   - Input: `Reject`\n   - Output: `Accept`\n   - Forward: `Reject`\n   - Masquerading: Check this box\n   - MSS clamping: Check this box\n   - Covered networks: Select your WireGuard interface (`AlgoVPN`)\n\n4. In the Inter-Zone Forwarding section:\n   - Allow forward from source zones: Select `lan`\n   - Allow forward to destination zones: Leave unspecified\n\n5. Click \"Save & Apply\"\n6. Reboot your router to ensure all changes take effect\n\n## Verification and Testing\n\nNavigate to Network → Interfaces and verify your WireGuard interface shows as \"Connected\" with a green status. Check that it has received the correct IP address.\n\nFrom a device connected to your router, visit https://whatismyipaddress.com/. Your public IP should match your Algo VPN server's IP address. Test DNS resolution to ensure it's working through the VPN.\n\nFor command line verification, SSH into your router and check:\n```bash\n# Check interface status\nwg show\n\n# Check routing table\nip route\n\n# Test connectivity\nping 8.8.8.8\n```\n\n## Configuration File Reference\n\nYour OpenWrt network configuration (`/etc/config/network`) should include sections similar to:\n\n```uci\nconfig interface 'AlgoVPN'\n    option proto 'wireguard'\n    list addresses '10.49.0.2/16'\n    option private_key '<your_private_key>'\n\nconfig wireguard_AlgoVPN\n    option public_key '<server_public_key>'\n    option preshared_key '<preshared_key>'\n    option route_allowed_ips '1'\n    list allowed_ips '0.0.0.0/0'\n    list allowed_ips '::/0'\n    option endpoint_host '<server_ip>'\n    option endpoint_port '51820'\n    option persistent_keepalive '25'\n```\n\n## Troubleshooting\n\nIf the interface won't connect, verify all keys are correctly copied with no extra spaces or line breaks. Check that your Algo server is running and accessible, and confirm the endpoint IP and port are correct.\n\nIf you have no internet access after connecting, verify firewall rules allow forwarding from LAN to VPN zone. Check that masquerading is enabled on the VPN zone and ensure MSS clamping is enabled.\n\nIf some websites don't work, try disabling MSS clamping temporarily to test. Verify DNS is working by testing `nslookup google.com` and check that IPv6 is properly configured if used.\n\nFor DNS resolution issues, configure custom DNS servers in Network → DHCP and DNS. Consider using your Algo server's DNS (typically `172.16.0.1`).\n\nCheck system logs for WireGuard-related errors:\n```bash\n# View system logs\nlogread | grep -i wireguard\n\n# Check kernel messages\ndmesg | grep -i wireguard\n```\n\n## Advanced Configuration\n\nFor split tunneling (routing only specific traffic through the VPN), change \"Allowed IPs\" in the peer configuration to specific subnets and add custom routing rules for desired traffic.\n\nIf your Algo server supports IPv6, add the IPv6 address to your interface configuration and include `::/0` in \"Allowed IPs\" for the peer.\n\nFor optimal privacy, configure your router to use your Algo server's DNS by navigating to Network → DHCP and DNS and adding your Algo DNS server IP (typically `172.16.0.1`) to the DNS forwardings.\n\n## Security Notes\n\nStore your private keys securely and never share them. Keep OpenWrt and packages updated for security patches. Regularly check VPN connectivity to ensure ongoing protection, and save your configuration before making changes.\n\nThis configuration routes ALL traffic from your router through the VPN. If you need selective routing or have specific requirements, consider consulting the [OpenWrt WireGuard documentation](https://openwrt.org/docs/guide-user/services/vpn/wireguard/start) for advanced configurations.\n"
  },
  {
    "path": "docs/client-windows.md",
    "content": "# Windows Client Setup\n\nThis guide will help you set up your Windows device to connect to your Algo VPN server.\n\n## Supported Versions\n\n- Windows 10 (all editions)\n- Windows 11 (all editions)\n- Windows Server 2016 and later\n\n## WireGuard Setup (Recommended)\n\nWireGuard is the recommended VPN protocol for Windows clients due to its simplicity and performance.\n\n### Installation\n\n1. Download and install the official [WireGuard client for Windows](https://www.wireguard.com/install/)\n2. Locate your configuration file: `configs/<server-ip>/wireguard/<username>.conf`\n3. In the WireGuard application, click \"Import tunnel(s) from file\"\n4. Select your `.conf` file and import it\n5. Click \"Activate\" to connect to your VPN\n\n### Alternative Import Methods\n\n- **QR Code**: If you have access to the QR code (`wireguard/<username>.png`), you can scan it using a mobile device first, then export the configuration\n- **Manual Entry**: You can create a new empty tunnel and paste the contents of your `.conf` file\n\n## IPsec/IKEv2 Setup (Legacy)\n\nWhile Algo supports IPsec/IKEv2, it requires PowerShell scripts for Windows setup. WireGuard is strongly recommended instead.\n\nIf you must use IPsec:\n1. Locate the PowerShell setup script in your configs directory\n2. Run PowerShell as Administrator\n3. Execute the setup script\n4. The VPN connection will appear in Settings → Network & Internet → VPN\n\n## Troubleshooting\n\n### \"The parameter is incorrect\" Error\n\nThis is a common error that occurs when trying to connect. See the [troubleshooting guide](troubleshooting.md#windows-the-parameter-is-incorrect-error-when-connecting) for the solution.\n\n### Connection Issues\n\n1. **Check Windows Firewall**: Ensure Windows Firewall isn't blocking the VPN connection\n2. **Verify Server Address**: Make sure the server IP/domain in your configuration is correct\n3. **Check Date/Time**: Ensure your system date and time are correct\n4. **Disable Other VPNs**: Disconnect from any other VPN services before connecting\n\n### WireGuard Specific Issues\n\n- **DNS Not Working**: Check if \"Block untunneled traffic (kill-switch)\" is enabled in tunnel settings\n- **Slow Performance**: Try reducing the MTU in the tunnel configuration (default is 1420)\n- **Can't Import Config**: Ensure the configuration file has a `.conf` extension\n\n### Performance Optimization\n\n1. **Use WireGuard**: It's significantly faster than IPsec on Windows\n2. **Close Unnecessary Apps**: Some antivirus or firewall software can slow down VPN connections\n3. **Check Network Adapter**: Update your network adapter drivers to the latest version\n\n## Advanced Configuration\n\n### Split Tunneling\n\nTo exclude certain traffic from the VPN:\n1. Edit your WireGuard configuration file\n2. Modify the `AllowedIPs` line to exclude specific networks\n3. For example, to exclude local network: Remove `0.0.0.0/0` and add specific routes\n\n### Automatic Connection\n\nTo connect automatically:\n1. Open WireGuard\n2. Select your tunnel\n3. Edit → Uncheck \"On-demand activation\"\n4. Windows will maintain the connection automatically\n\n### Multiple Servers\n\nYou can import multiple `.conf` files for different Algo servers. Give each a descriptive name to distinguish them.\n\n## Security Notes\n\n- Keep your configuration files secure - they contain your private keys\n- Don't share your configuration with others\n- Each user should have their own unique configuration\n- Regularly update your WireGuard client for security patches\n\n## Need More Help?\n\n- Check the main [troubleshooting guide](troubleshooting.md)\n- Review [WireGuard documentation](https://www.wireguard.com/quickstart/)\n- [Create a discussion](https://github.com/trailofbits/algo/discussions) for help\n"
  },
  {
    "path": "docs/cloud-alternative-ingress-ip.md",
    "content": "# Alternative Ingress IP\n\nThis feature allows you to configure the Algo server to send outbound traffic through a different external IP address than the one you are establishing the VPN connection with.\n\n![cloud-alternative-ingress-ip](/docs/images/cloud-alternative-ingress-ip.png)\n\nAdditional info might be found in [this issue](https://github.com/trailofbits/algo/issues/1047)\n\n\n\n\n#### Caveats\n\n##### Extra charges\n\n- DigitalOcean: Floating IPs are free when assigned to a Droplet, but after manually deleting a Droplet, you need to also delete the Floating IP or you'll get charged for it.\n\n##### IPv6\n\nSome cloud providers provision a VM with an `/128` address block size. This is the only IPv6 address provided and for outbound and incoming traffic.\n\nIf the provided address block size is bigger, e.g., `/64`, Algo takes a separate address than the one is assigned to the server to send outbound IPv6 traffic.\n"
  },
  {
    "path": "docs/cloud-amazon-ec2.md",
    "content": "# Amazon EC2 Cloud Setup\n\nThis guide walks you through setting up Algo VPN on Amazon EC2, including account creation, permissions configuration, and deployment process.\n\n## AWS Account Creation\n\nCreating an Amazon AWS account requires providing a phone number that can receive automated calls with PIN verification. The phone verification system occasionally fails, but you can request a new PIN and try again until it succeeds.\n\n## Choose Your EC2 Plan\n\n### AWS Free Tier\n\nThe most cost-effective option for new AWS customers is the [AWS Free Tier](https://aws.amazon.com/free/), which provides:\n\n- 750 hours of Amazon EC2 Linux t2.micro or t3.micro instance usage per month\n- 100 GB of outbound data transfer per month\n- 30 GB of cloud storage\n\nThe Free Tier is available for 12 months from account creation. Some regions like Middle East (Bahrain), EU (Stockholm), and Israel (il-central-1) don't offer t2.micro instances, but t3.micro is available as an alternative.\n\nNote that your Algo instance will continue working if you exceed bandwidth limits - you'll just start accruing standard charges on your AWS account.\n\n### Cost-Effective Alternatives\n\nIf you're not eligible for the Free Tier or prefer more predictable costs, consider AWS Graviton instances. To use Graviton instances, modify your `config.cfg` file:\n\n```yaml\nec2:\n  size: t4g.nano\n  arch: arm64\n```\n\nThe t4g.nano instance is currently the least expensive option without promotional requirements. AWS is also running a promotion offering free t4g.small instances until December 31, 2025 - see the [AWS documentation](https://aws.amazon.com/ec2/faqs/#t4g-instances) for details.\n\nFor additional EC2 configuration options, see the [deploy from ansible guide](https://github.com/trailofbits/algo/blob/master/docs/deploy-from-ansible.md#amazon-ec2).\n\n## Set Up IAM Permissions\n\n### Create IAM Policy\n\n1. In the AWS console, navigate to Services → IAM → Policies\n2. Click \"Create Policy\"\n3. Switch to the JSON tab\n4. Replace the default content with the [minimum required AWS policy for Algo deployment](https://github.com/trailofbits/algo/blob/master/docs/deploy-from-ansible.md#minimum-required-iam-permissions-for-deployment)\n5. Name the policy `AlgoVPN_Provisioning`\n\n![Creating a new permissions policy in the AWS console.](/docs/images/aws-ec2-new-policy.png)\n\n### Create IAM User\n\n1. Navigate to Services → IAM → Users\n2. Enable multi-factor authentication (MFA) on your root account using Google Authenticator or a hardware token\n3. Click \"Add User\" and create a username (e.g., `algovpn`)\n4. Select \"Programmatic access\"\n5. Click \"Next: Permissions\"\n\n![The new user screen in the AWS console.](/docs/images/aws-ec2-new-user.png)\n\n6. Choose \"Attach existing policies directly\"\n7. Search for \"Algo\" and select the `AlgoVPN_Provisioning` policy you created\n8. Click \"Next: Tags\" (optional), then \"Next: Review\"\n\n![Attaching a policy to an IAM user in the AWS console.](/docs/images/aws-ec2-attach-policy.png)\n\n9. Review your settings and click \"Create user\"\n10. Download the CSV file containing your access credentials - you'll need these for Algo deployment\n\n![Downloading the credentials for an AWS IAM user.](/docs/images/aws-ec2-new-user-csv.png)\n\nKeep the CSV file secure as it contains sensitive credentials that grant access to your AWS account.\n\n## Deploy with Algo\n\nOnce you've installed Algo and its dependencies, you can deploy your VPN server to EC2.\n\n### Provider Selection\n\nRun `./algo` and select Amazon EC2 when prompted:\n\n```\n$ ./algo\n\n  What provider would you like to use?\n    1. DigitalOcean\n    2. Amazon Lightsail\n    3. Amazon EC2\n    4. Microsoft Azure\n    5. Google Compute Engine\n    6. Hetzner Cloud\n    7. Vultr\n    8. Scaleway\n    9. OpenStack (DreamCompute optimised)\n    10. CloudStack\n    11. Linode\n    12. Install to existing Ubuntu server (for more advanced users)\n\nEnter the number of your desired provider\n: 3\n```\n\n### AWS Credentials\n\nAlgo will automatically detect AWS credentials in this order:\n\n1. Command-line variables\n2. Environment variables (`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`)\n3. AWS credentials file (`~/.aws/credentials`)\n\nIf no credentials are found, you'll be prompted to enter them manually:\n\n```\nEnter your aws_access_key (http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html)\nNote: Make sure to use an IAM user with an acceptable policy attached (see https://github.com/trailofbits/algo/blob/master/docs/deploy-from-ansible.md).\n[pasted values will not be displayed]\n[AKIA...]:\n\nEnter your aws_secret_key (http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html)\n[pasted values will not be displayed]\n[ABCD...]:\n```\n\nFor detailed credential configuration options, see the [AWS Credentials guide](aws-credentials.md).\n\n### Server Configuration\n\nYou'll be prompted to name your server (default is \"algo\"):\n\n```\nName the vpn server:\n[algo]: algovpn\n```\n\nNext, select your preferred AWS region:\n\n```\nWhat region should the server be located in?\n(https://docs.aws.amazon.com/general/latest/gr/rande.html#ec2_region)\n    1. ap-northeast-1\n    2. ap-northeast-2\n    3. ap-south-1\n    4. ap-southeast-1\n    5. ap-southeast-2\n    6. ca-central-1\n    7. eu-central-1\n    8. eu-north-1\n    9. eu-west-1\n    10. eu-west-2\n    11. eu-west-3\n    12. sa-east-1\n    13. us-east-1\n    14. us-east-2\n    15. us-west-1\n    16. us-west-2\n\nEnter the number of your desired region\n[13]\n:\n```\n\nChoose a region close to your location for optimal performance, keeping in mind that some regions may have different pricing or instance availability.\n\nAfter region selection, Algo will continue with the standard setup questions for user configuration and VPN options.\n\n## Resource Cleanup\n\nIf you deploy Algo to EC2 multiple times, unused resources (instances, VPCs, subnets) may accumulate and potentially cause future deployment issues.\n\nThe cleanest way to remove an Algo deployment is through CloudFormation:\n\n1. Go to the AWS console and navigate to CloudFormation\n2. Find the stack associated with your Algo server\n3. Delete the entire stack\n\nWarning: Deleting a CloudFormation stack will permanently delete your EC2 instance and all associated resources unless you've enabled termination protection. Make sure you're deleting the correct stack and have backed up any important data.\n\nThis approach ensures all related AWS resources are properly cleaned up, preventing resource conflicts in future deployments.\n"
  },
  {
    "path": "docs/cloud-azure.md",
    "content": "# Azure cloud setup\n\nThe easiest way to get started with the Azure CLI is by running it in an Azure Cloud Shell environment through your browser.\n\nHere you can find some information from [the official doc](https://docs.microsoft.com/en-us/cli/azure/get-started-with-azure-cli?view=azure-cli-latest). We put the essential commands together for simplest usage.\n\n## Install azure-cli\n\n- macOS ([link](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli-macos?view=azure-cli-latest)):\n  ```bash\n  $ brew update && brew install azure-cli\n  ```\n\n- Linux (deb-based) ([link](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli-apt?view=azure-cli-latest)):\n  ```bash\n  $ sudo apt-get update && sudo apt-get install \\\n      apt-transport-https \\\n      lsb-release \\\n      software-properties-common \\\n      dirmngr -y\n  $ AZ_REPO=$(lsb_release -cs)\n  $ echo \"deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ $AZ_REPO main\" | \\\n      sudo tee /etc/apt/sources.list.d/azure-cli.list\n  $ sudo apt-key --keyring /etc/apt/trusted.gpg.d/Microsoft.gpg adv \\\n      --keyserver packages.microsoft.com \\\n      --recv-keys BC528686B50D79E339D3721CEB3E94ADBE1229CF\n  $ sudo apt-get update\n  $ sudo apt-get install azure-cli\n  ```\n\n- Linux (rpm-based) ([link](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli-yum?view=azure-cli-latest)):\n  ```bash\n  $ sudo rpm --import https://packages.microsoft.com/keys/microsoft.asc\n  $ sudo sh -c 'echo -e \"[azure-cli]\\nname=Azure CLI\\nbaseurl=https://packages.microsoft.com/yumrepos/azure-cli\\nenabled=1\\ngpgcheck=1\\ngpgkey=https://packages.microsoft.com/keys/microsoft.asc\" > /etc/yum.repos.d/azure-cli.repo'\n  $ sudo yum install azure-cli\n  ```\n\n- Windows ([link](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli-windows?view=azure-cli-latest)):\n  For Windows the Azure CLI is installed via an MSI, which gives you access to the CLI through the Windows Command Prompt (CMD) or PowerShell. When installing for Windows Subsystem for Linux (WSL), packages are available for your Linux distribution. [Download the MSI installer](https://aka.ms/installazurecliwindows)\n\nIf your OS is missing or to get more information, see [the official doc](https://docs.microsoft.com/en-us/cli/azure/install-azure-cli?view=azure-cli-latest)\n\n\n## Sign in\n\n1. Run the `login` command:\n```bash\naz login\n```\n\n  If the CLI can open your default browser, it will do so and load a sign-in page.\n\n  Otherwise, you need to open a browser page and follow the instructions on the command line to enter an authorization code after navigating to https://aka.ms/devicelogin in your browser.\n\n2. Sign in with your account credentials in the browser.\n\nThere are ways to sign in non-interactively, which are covered in detail in [Sign in with Azure CLI](https://docs.microsoft.com/en-us/cli/azure/authenticate-azure-cli?view=azure-cli-latest).\n\n\n**Now you are able to deploy an AlgoVPN instance without hassle**\n"
  },
  {
    "path": "docs/cloud-cloudstack.md",
    "content": "### Configuration file\n\n> **⚠️ Important Note:** Exoscale is no longer supported as they deprecated their CloudStack API on May 1, 2024. Please use alternative providers like Hetzner, DigitalOcean, Vultr, or Scaleway.\n\nAlgo scripts will ask you for the API details. You need to fetch the API credentials and the endpoint from your CloudStack provider's control panel.\n\nFor CloudStack providers, you'll need to set:\n\n```bash\nexport CLOUDSTACK_KEY=\"<your api key>\"\nexport CLOUDSTACK_SECRET=\"<your secret>\"\nexport CLOUDSTACK_ENDPOINT=\"<your provider's API endpoint>\"\n```\n\nMake sure your provider supports the CloudStack API. Contact your provider for the correct API endpoint URL.\n"
  },
  {
    "path": "docs/cloud-do.md",
    "content": "# DigitalOcean cloud setup\n\n## API Token creation\n\nFirst, login into your DigitalOcean account.\n\nSelect **API** from the titlebar. This will take you to the \"Applications & API\" page.\n\n![The Applications & API page](/docs/images/do-api.png)\n\nOn the **Tokens/Keys** tab, select **Generate New Token**. A dialog will pop up. In that dialog, give your new token a name, and make sure **Write** is checked off. Click the **Generate Token** button when you are ready.\n\n![The new token dialog, showing a form requesting a name and confirmation on the scope for the new token.](/docs/images/do-new-token.png)\n\nYou will be returned to the **Tokens/Keys** tab, and your new key will be shown under the **Personal Access Tokens** header.\n\n![The new token in the listing.](/docs/images/do-view-token.png)\n\nCopy or note down the hash that shows below the name you entered, as this will be necessary for the steps below. This value will disappear if you leave this page, and you'll need to regenerate it if you forget it.\n\n## Select a Droplet (optional)\n\nThe default option is the `s-1vcpu-1gb` because it is available in all regions. However, you may want to switch to a cheaper droplet such as `s-1vcpu-512mb-10gb` even though it is not available in all regions. This can be edited in the [Configuration File](config.cfg) under `cloud_providers > digitalocean > size`. See this brief comparison between the two droplets below:\n\n| Droplet Type | Monthly Cost | Bandwidth | Availability |\n|:--|:-:|:-:|:--|\n| `s-1vcpu-512mb-10gb` | $4/month | 0.5 TB | Limited |\n| `s-1vcpu-1gb`        | $6/month | 1.0 TB | All regions |\n| ... | ... | ... | ... |\n\n*Note: Exceeding bandwidth limits costs $0.01/GiB at time of writing ([docs](https://docs.digitalocean.com/products/billing/bandwidth/#droplets)). See the live list of droplets [here](https://slugs.do-api.dev/).*\n\n## Using DigitalOcean with Algo (interactive)\n\nThese steps are for those who run Algo using Docker or using the `./algo` command.\n\nChoose DigitalOcean as your provider:\n\n```\nWhat provider would you like to use?\n    1. DigitalOcean\n    2. Amazon Lightsail\n    3. Amazon EC2\n    4. Vultr\n    5. Microsoft Azure\n    6. Google Compute Engine\n    7. Scaleway\n    8. OpenStack (DreamCompute optimised)\n    9. Install to existing Ubuntu server (Advanced)\n\nEnter the number of your desired provider\n:\n1\n```\n\nEnter a name for your server. Leave this as the default if you are not certain how this will affect your setup:\n\n```\nName the vpn server:\n[algo]:\n```\n\nAfter several prompts related to Algo features you will be asked for the API Token value. Paste the API Token value you copied when following the steps in [API Token creation](#api-token-creation) (you won't see any output as the key is not echoed by Algo):\n\n```\nEnter your API token. The token must have read and write permissions (https://cloud.digitalocean.com/settings/api/tokens):\n (output is hidden):\n```\n\nFinally, you will be asked the region in which you wish to setup your new Algo server. This list is dynamic and can change based on availability of resources. Enter the number next to name of the region:\n\n```\nWhat region should the server be located in?\n    1. ams3     Amsterdam 3\n    2. blr1     Bangalore 1\n    3. fra1     Frankfurt 1\n    4. lon1     London 1\n    5. nyc1     New York 1\n    6. nyc3     New York 3\n    7. sfo2     San Francisco 2\n    8. sgp1     Singapore 1\n    9. tor1     Toronto 1\n\nEnter the number of your desired region\n[6]\n:\n9\n```\n\n## Using DigitalOcean with Algo (scripted)\n\nIf you are using Ansible directly to run Algo you will need to pass the API Token as `do_token`. For example:\n\n```shell\nansible-playbook main.yml -e \"provider=digitalocean\n                                server_name=algo\n                                ondemand_cellular=true\n                                ondemand_wifi=true\n                                dns_adblocking=false\n                                ssh_tunneling=false\n                                store_pki=true\n                                region=nyc3\n                                do_token=token\"\n```\n\nFor more, see [Scripted Deployment](deploy-from-ansible.md).\n\n## Using the DigitalOcean firewall with Algo\n\nMany cloud providers include the option to configure an external firewall between the Internet and your cloud server. For some providers this is mandatory and Algo will configure it for you, but for DigitalOcean the external firewall is optional. See [AlgoVPN and Firewalls](/docs/firewalls.md) for more information.\n\nTo configure the DigitalOcean firewall, go to **Networking**, **Firewalls**, and choose **Create Firewall**.\n\nConfigure your **Inbound Rules** as follows:\n\n![Inbound Rules](/docs/images/do-firewall.png)\n\nLeave the **Outbound Rules** at their defaults.\n\nUnder **Apply to Droplets** enter the tag `Environment:Algo` to apply this firewall to all current and future Algo VPNs you create.\n"
  },
  {
    "path": "docs/cloud-gce.md",
    "content": "# Google Cloud Platform setup\n\n* Follow the [`gcloud` installation instructions](https://cloud.google.com/sdk/)\n\n* Log into your account using `gcloud init`\n\n### Creating a project\n\nThe recommendation on GCP is to group resources into **Projects**, so we will create a new project for our VPN server and use a service account restricted to it.\n\n```bash\n## Create the project to group the resources\n### You might need to change it to have a global unique project id\nPROJECT_ID=${USER}-algo-vpn\nBILLING_ID=\"$(gcloud beta billing accounts list --format=\"value(ACCOUNT_ID)\")\"\n\ngcloud projects create ${PROJECT_ID} --name algo-vpn --set-as-default\ngcloud beta billing projects link ${PROJECT_ID} --billing-account ${BILLING_ID}\n\n## Create an account that have access to the VPN\ngcloud iam service-accounts create algo-vpn --display-name \"Algo VPN\"\ngcloud iam service-accounts keys create configs/gce.json \\\n  --iam-account algo-vpn@${PROJECT_ID}.iam.gserviceaccount.com\ngcloud projects add-iam-policy-binding ${PROJECT_ID} \\\n  --member serviceAccount:algo-vpn@${PROJECT_ID}.iam.gserviceaccount.com \\\n  --role roles/compute.admin\ngcloud projects add-iam-policy-binding ${PROJECT_ID} \\\n  --member serviceAccount:algo-vpn@${PROJECT_ID}.iam.gserviceaccount.com \\\n  --role roles/iam.serviceAccountUser\n\n## Enable the services\ngcloud services enable compute.googleapis.com\n\n./algo -e \"provider=gce\" -e \"gce_credentials_file=$(pwd)/configs/gce.json\"\n\n```\n\n**Attention:** take care of the `configs/gce.json` file, which contains the credentials to manage your Google Cloud account, including create and delete servers on this project.\n\n\nThere are more advanced arguments available for deployment [using ansible](deploy-from-ansible.md).\n"
  },
  {
    "path": "docs/cloud-hetzner.md",
    "content": "## API Token\n\nSign in into the [Hetzner Cloud Console](https://console.hetzner.cloud/) choose a project, go to `Security` → `API Tokens`, and `Generate API Token` with `Read & Write` access. Make sure to copy the token because it won’t be shown to you again. A token is bound to a project. To interact with the API of another project you have to create a new token inside the project.\n"
  },
  {
    "path": "docs/cloud-linode.md",
    "content": "## API Token\n\nSign in to the Linode Manager and go to the\n[tokens management page](https://cloud.linode.com/profile/tokens).\n\nClick `Add a Personal Access Token`. Label your new token and select *at least* the\n`Linodes` read/write permission and `StackScripts` read/write permission.\nPress `Submit` and make sure to copy the displayed token\nas it won't be shown again.\n"
  },
  {
    "path": "docs/cloud-scaleway.md",
    "content": "### Configuration file\n\nAlgo requires an API key from your Scaleway account to create a server.\nThe API key is generated by going to your Scaleway credentials at [https://console.scaleway.com/project/credentials](https://console.scaleway.com/project/credentials), and then selecting \"Generate new API key\" on the right side of the box labeled \"API Keys\".\nYou'll be ask for to specify a purpose for your API key before it is created. You will then be presented and \"Access key\" and a \"Secret key\".\n\nEnter the \"Secret key\" when Algo prompts you for the `auth token`. You won't need the \"Access key\".\nThis information will be pass as the `algo_scaleway_token` variable when asked for in the Algo prompt.\n\nYour organization ID is also on this page: https://console.scaleway.com/account/credentials\n"
  },
  {
    "path": "docs/cloud-vultr.md",
    "content": "### Configuration file\n\nAlgo requires an API key from your Vultr account in order to create a server. The API key is generated by going to your Vultr settings at https://my.vultr.com/settings/#settingsapi, and then selecting \"generate new API key\" on the right side of the box labeled \"API Key\".\n\nAlgo can read the API key in several different ways. Algo will first look for the file containing the API key in the environment variable $VULTR_API_CONFIG if present. You can set this with the command: `export VULTR_API_CONFIG=/path/to/vultr.ini`. Probably the simplest way to give Algo the API key is to create a file titled `.vultr.ini` in your home directory by typing `nano ~/.vultr.ini`, then entering the following text:\n\n```\n[default]\nkey = <your api key>\n```\nwhere you've cut-and-pasted the API key from above into the `<your api key>` field (no brackets).\n\nWhen Algo asks `Enter the local path to your configuration INI file\n(https://trailofbits.github.io/algo/cloud-vultr.html):` if you hit enter without typing anything, Algo will look for the file in `~/.vultr.ini` by default.\n"
  },
  {
    "path": "docs/deploy-from-ansible.md",
    "content": "# Deployment from Ansible\n\nBefore you begin, make sure you have installed all the dependencies necessary for your operating system as described in the [README](../README.md).\n\nYou can deploy Algo non-interactively by running the Ansible playbooks directly with `ansible-playbook`.\n\n`ansible-playbook` accepts variables via the `-e` or `--extra-vars` option. You can pass variables as space separated key=value pairs. Algo requires certain variables that are listed below. You can also use the `--skip-tags` option to skip certain parts of the install, such as `iptables` (overwrite iptables rules), `ipsec` (install strongSwan), `wireguard` (install Wireguard). We don't recommend using the `-t` option as it will only include the tagged portions of the deployment, and skip certain necessary roles (such as `common`).\n\nHere is a full example for DigitalOcean:\n\n```shell\nansible-playbook main.yml -e \"provider=digitalocean\n                                server_name=algo\n                                ondemand_cellular=false\n                                ondemand_wifi=false\n                                dns_adblocking=true\n                                ssh_tunneling=true\n                                store_pki=true\n                                region=ams3\n                                do_token=token\"\n```\n\nSee below for more information about variables and roles.\n\n### Variables\n\n- `provider` - (Required) The provider to use. See possible values below\n- `server_name` - (Required) Server name. Default: algo\n- `ondemand_cellular` (Optional) Enables VPN On Demand when connected to cellular networks for iOS/macOS clients using IPsec. Default: false\n- `ondemand_wifi` - (Optional. See `ondemand_wifi_exclude`) Enables VPN On Demand when connected to WiFi networks for iOS/macOS clients using IPsec. Default: false\n- `ondemand_wifi_exclude` (Required if `ondemand_wifi` set) - WiFi networks to exclude from using the VPN. Comma-separated values\n- `dns_adblocking` - (Optional) Enables dnscrypt-proxy adblocking. Default: false\n- `ssh_tunneling` - (Optional) Enable SSH tunneling for each user. Default: false\n- `store_pki` - (Optional) Whether or not keep the CA key (required to add users in the future, but less secure). Default: false\n\nIf any of the above variables are unspecified, ansible will ask the user to input them.\n\n### Ansible roles\n\nCloud roles can be activated by specifying an extra variable `provider`.\n\nCloud roles:\n\n- role: cloud-digitalocean, [provider: digitalocean](#digital-ocean)\n- role: cloud-ec2,          [provider: ec2](#amazon-ec2)\n- role: cloud-gce,          [provider: gce](#google-compute-engine)\n- role: cloud-vultr,        [provider: vultr](#vultr)\n- role: cloud-azure,        [provider: azure](#azure)\n- role: cloud-lightsail,    [provider: lightsail](#lightsail)\n- role: cloud-scaleway,     [provider: scaleway](#scaleway)\n- role: cloud-openstack,    [provider: openstack](#openstack)\n- role: cloud-cloudstack,   [provider: cloudstack](#cloudstack)\n- role: cloud-hetzner,      [provider: hetzner](#hetzner)\n- role: cloud-linode,       [provider: linode](#linode)\n\nServer roles:\n\n- role: strongswan\n  - Installs [strongSwan](https://www.strongswan.org/)\n  - Enables AppArmor, limits CPU and memory access, and drops user privileges\n  - Builds a Certificate Authority (CA) with [easy-rsa-ipsec](https://github.com/ValdikSS/easy-rsa-ipsec) and creates one client certificate per user\n  - Bundles the appropriate certificates into Apple mobileconfig profiles for each user\n- role: dns_adblocking\n  - Installs DNS encryption through [dnscrypt-proxy](https://github.com/jedisct1/dnscrypt-proxy) with blacklists to be updated daily from `adblock_lists` in `config.cfg` - note this will occur even if `dns_encryption` in `config.cfg` is set to `false`\n  - Constrains dnscrypt-proxy with AppArmor and cgroups CPU and memory limitations\n- role: ssh_tunneling\n  - Adds a restricted `algo` group with no shell access and limited SSH forwarding options\n  - Creates one limited, local account and an SSH public key for each user\n- role: wireguard\n  - Install a [Wireguard](https://www.wireguard.com/) server, with a startup script, and automatic checks for upgrades\n  - Creates wireguard.conf files for Linux clients as well as QR codes for Apple/Android clients\n\nNote: The `strongswan` role generates Apple profiles with On-Demand Wifi and Cellular if you pass the following variables:\n\n- ondemand_wifi: true\n- ondemand_wifi_exclude: HomeNet,OfficeWifi\n- ondemand_cellular: true\n\n### Local Installation\n\n- role: local, provider: local\n\nThis role is intended to be run for local installation onto an Ubuntu server, or onto an unsupported cloud provider's Ubuntu instance. Required variables:\n\n- server - IP address of your server (or \"localhost\" if deploying to the local machine)\n- endpoint - public IP address of the server you're installing on\n- ssh_user - name of the SSH user you will use to install on the machine (passwordless login required). If `server=localhost`, this isn't required.\n- ca_password - Password for the private CA key\n\nNote that by default, the iptables rules on your existing server will be overwritten. If you don't want to overwrite the iptables rules, you can use the `--skip-tags iptables` flag.\n\n### Digital Ocean\n\nRequired variables:\n\n- do_token\n- region\n\nPossible options can be gathered calling to <https://api.digitalocean.com/v2/regions>\n\n### Amazon EC2\n\nRequired variables:\n\n- aws_access_key: `AKIA...`\n- aws_secret_key\n- region: e.g. `us-east-1`\n\nPossible options can be gathered via cli `aws ec2 describe-regions`\n\nAdditional variables:\n\n- [encrypted](https://aws.amazon.com/blogs/aws/new-encrypted-ebs-boot-volumes/) - Encrypted EBS boot volume. Boolean (Default: true)\n- [size](https://aws.amazon.com/ec2/instance-types/) - EC2 instance type. String (Default: t3.micro)\n- [image](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/describe-images.html) - AMI `describe-images` search parameters to find the OS for the hosted image. Each OS and architecture has a unique AMI-ID. The OS owner, for example, [Ubuntu](https://cloud-images.ubuntu.com/locator/ec2/), updates these images often. If parameters below result in multiple results, the most recent AMI-ID is chosen\n\n   ```\n   # Example of equivalent cli command\n   aws ec2 describe-images --owners \"099720109477\" --filters \"Name=architecture,Values=arm64\" \"Name=name,Values=ubuntu/images/hvm-ssd/ubuntu-jammy-22.04*\"\n   ```\n\n  - [owners] - The operating system owner id. Default is [Canonical](https://help.ubuntu.com/community/EC2StartersGuide#Official_Ubuntu_Cloud_Guest_Amazon_Machine_Images_.28AMIs.29) (Default: 099720109477)\n  - [arch] - The architecture (Default: x86_64, Optional: arm64)\n  - [name] - The wildcard string to filter available ami names. Algo appends this name with the string \"-\\*64-server-\\*\", and prepends with \"ubuntu/images/hvm-ssd/\" (Default: Ubuntu latest LTS)\n- [instance_market_type](https://aws.amazon.com/ec2/pricing/) - Two pricing models are supported: on-demand and spot. String (Default: on-demand)\n  - If using spot instance types, one additional IAM permission along with the below minimum is required for deployment:\n\n    ```\n      \"ec2:CreateLaunchTemplate\"\n    ```\n\n#### Minimum required IAM permissions for deployment\n\n```\n{\n    \"Version\": \"2012-10-17\",\n    \"Statement\": [\n        {\n            \"Sid\": \"PreDeployment\",\n            \"Effect\": \"Allow\",\n            \"Action\": [\n                \"ec2:DescribeImages\",\n                \"ec2:DescribeKeyPairs\",\n                \"ec2:DescribeRegions\",\n                \"ec2:ImportKeyPair\",\n                \"ec2:CopyImage\"\n            ],\n            \"Resource\": [\n                \"*\"\n            ]\n        },\n        {\n            \"Sid\": \"DeployCloudFormationStack\",\n            \"Effect\": \"Allow\",\n            \"Action\": [\n                \"cloudformation:CreateStack\",\n                \"cloudformation:UpdateStack\",\n                \"cloudformation:DescribeStacks\",\n                \"cloudformation:DescribeStackEvents\",\n                \"cloudformation:ListStackResources\"\n            ],\n            \"Resource\": [\n                \"*\"\n            ]\n        },\n        {\n            \"Sid\": \"CloudFormationEC2Access\",\n            \"Effect\": \"Allow\",\n            \"Action\": [\n                \"ec2:DescribeRegions\",\n                \"ec2:CreateInternetGateway\",\n                \"ec2:DescribeVpcs\",\n                \"ec2:CreateVpc\",\n                \"ec2:DescribeInternetGateways\",\n                \"ec2:ModifyVpcAttribute\",\n                \"ec2:CreateTags\",\n                \"ec2:CreateSubnet\",\n                \"ec2:AssociateVpcCidrBlock\",\n                \"ec2:AssociateSubnetCidrBlock\",\n                \"ec2:AssociateRouteTable\",\n                \"ec2:AssociateAddress\",\n                \"ec2:CreateRouteTable\",\n                \"ec2:AttachInternetGateway\",\n                \"ec2:DescribeRouteTables\",\n                \"ec2:DescribeSubnets\",\n                \"ec2:ModifySubnetAttribute\",\n                \"ec2:CreateRoute\",\n                \"ec2:CreateSecurityGroup\",\n                \"ec2:DescribeSecurityGroups\",\n                \"ec2:AuthorizeSecurityGroupIngress\",\n                \"ec2:RunInstances\",\n                \"ec2:DescribeInstances\",\n                \"ec2:AllocateAddress\",\n                \"ec2:DescribeAddresses\"\n            ],\n            \"Resource\": [\n                \"*\"\n            ]\n        }\n    ]\n}\n```\n\n### Google Compute Engine\n\nRequired variables:\n\n- gce_credentials_file: e.g. /configs/gce.json if you use the [GCE docs](https://trailofbits.github.io/algo/cloud-gce.html) - can also be defined in environment as GCE_CREDENTIALS_FILE_PATH\n- [region](https://cloud.google.com/compute/docs/regions-zones/): e.g. `useast-1`\n\n### Vultr\n\nRequired variables:\n\n- [vultr_config](https://trailofbits.github.io/algo/cloud-vultr.html): /path/to/.vultr.ini\n- [region](https://api.vultr.com/v1/regions/list): e.g. `Chicago`, `'New Jersey'`\n\n### Azure\n\nRequired variables:\n\n- azure_secret\n- azure_tenant\n- azure_client_id\n- azure_subscription_id\n- [region](https://azure.microsoft.com/en-us/global-infrastructure/regions/)\n\n### Lightsail\n\nRequired variables:\n\n- aws_access_key: `AKIA...`\n- aws_secret_key\n- region: e.g. `us-east-1`\n\nPossible options can be gathered via cli `aws lightsail get-regions`\n\n#### Minimum required IAM permissions for deployment\n\n```\n{\n    \"Version\": \"2012-10-17\",\n    \"Statement\": [\n        {\n            \"Sid\": \"LightsailDeployment\",\n            \"Effect\": \"Allow\",\n            \"Action\": [\n                \"lightsail:GetRegions\",\n                \"lightsail:GetInstance\",\n                \"lightsail:CreateInstances\",\n                \"lightsail:DisableAddOn\",\n                \"lightsail:PutInstancePublicPorts\",\n                \"lightsail:StartInstance\",\n                \"lightsail:TagResource\",\n                \"lightsail:GetStaticIp\",\n                \"lightsail:AllocateStaticIp\",\n                \"lightsail:AttachStaticIp\"\n            ],\n            \"Resource\": [\n                \"*\"\n            ]\n        },\n        {\n            \"Sid\": \"DeployCloudFormationStack\",\n            \"Effect\": \"Allow\",\n            \"Action\": [\n                \"cloudformation:CreateStack\",\n                \"cloudformation:UpdateStack\",\n                \"cloudformation:DescribeStacks\",\n                \"cloudformation:DescribeStackEvents\",\n                \"cloudformation:ListStackResources\"\n            ],\n            \"Resource\": [\n                \"*\"\n            ]\n        }\n    ]\n}\n```\n\n### Scaleway\n\nRequired variables:\n\n- [scaleway_token](https://www.scaleway.com/docs/generate-an-api-token/)\n- region: e.g. `ams1`, `par1`\n\n### OpenStack\n\nYou need to source the rc file prior to run Algo. Download it from the OpenStack dashboard->Compute->API Access and source it in the shell (eg: source /tmp/dhc-openrc.sh)\n\n### CloudStack\n\n> **Note:** Exoscale is no longer supported as they deprecated their CloudStack API on May 1, 2024.\n\nRequired variables:\n\n- [cs_config](https://trailofbits.github.io/algo/cloud-cloudstack.html): /path/to/.cloudstack.ini\n- cs_region: your CloudStack region\n- cs_zones: your CloudStack zone\n\nThe first two can also be defined in your environment, using the variables `CLOUDSTACK_CONFIG` and `CLOUDSTACK_REGION`.\n\n### Hetzner\n\nRequired variables:\n\n- hcloud_token: Your [API token](https://trailofbits.github.io/algo/cloud-hetzner.html#api-token) - can also be defined in the environment as HCLOUD_TOKEN\n- region: e.g. `nbg1`\n\n### Linode\n\nRequired variables:\n\n- linode_token: Your [API token](https://trailofbits.github.io/algo/cloud-linode.html#api-token) - can also be defined in the environment as LINODE_TOKEN\n- region: e.g. `us-east`\n\n### Update users\n\nPlaybook:\n\n```\nusers.yml\n```\n\nRequired variables:\n\n- server - IP or hostname to access the server via SSH\n- ca_password - Password to access the CA key\n\nTags required:\n\n- update-users\n"
  },
  {
    "path": "docs/deploy-from-cloudshell.md",
    "content": "# Deploy from Google Cloud Shell\n\nIf you want to try Algo but don't wish to install anything on your own system, you can use the **free** [Google Cloud Shell](https://cloud.google.com/shell/) to deploy a VPN to any supported cloud provider. Note that you cannot choose `Install to existing Ubuntu server` to turn Google Cloud Shell into your VPN server.\n\n1. See the [Cloud Shell documentation](https://cloud.google.com/shell/docs/) to start an instance of Cloud Shell in your browser.\n\n2. Get Algo and run it:\n    ```bash\n    git clone https://github.com/trailofbits/algo.git\n    cd algo\n    ./algo\n    ```\n\n    The first time you run `./algo`, it will automatically install all required dependencies. Google Cloud Shell already has most tools available, making this even faster than on your local system.\n\n3. Once Algo has completed, retrieve a copy of the configuration files that were created to your local system. While still in the Algo directory, run:\n    ```\n    zip -r configs configs\n    dl configs.zip\n    ```\n\n4. Unzip `configs.zip` on your local system and use the files to configure your VPN clients.\n"
  },
  {
    "path": "docs/deploy-from-docker.md",
    "content": "# Docker Support\n\nWhile it is not possible to run your Algo server from within a Docker container, it is possible to use Docker to provision your Algo server.\n\n## Limitations\n\n1. This has not yet been tested with user namespacing enabled.\n2. If you're running this on Windows, take care when editing files under `configs/` to ensure that line endings are set appropriately for Unix systems.\n\n## Deploying an Algo Server with Docker\n\n1. Install [Docker](https://www.docker.com/community-edition#/download) -- setup and configuration is not covered here\n2. Create a local directory to hold your VPN configs (e.g. `C:\\Users\\trailofbits\\Documents\\VPNs\\`)\n3. Create a local copy of [config.cfg](https://github.com/trailofbits/algo/blob/master/config.cfg), with required modifications (e.g. `C:\\Users\\trailofbits\\Documents\\VPNs\\config.cfg`)\n4. Run the Docker container, mounting your configurations appropriately (assuming the container is named `trailofbits/algo` with a tag `latest`):\n\n- From Windows:\n\n   ```powershell\n   C:\\Users\\trailofbits> docker run --cap-drop=all -it \\\n     -v C:\\Users\\trailofbits\\Documents\\VPNs:/data \\\n     ghcr.io/trailofbits/algo:latest\n   ```\n\n- From Linux:\n\n  ```bash\n  $ docker run --cap-drop=all -it \\\n    -v /home/trailofbits/Documents/VPNs:/data \\\n    ghcr.io/trailofbits/algo:latest\n  ```\n\n5. When it exits, you'll be left with a fully populated `configs` directory, containing all appropriate configuration data for your clients, and for future server management\n\n### Providing Additional Files\n\nIf you need to provide additional files -- like authorization files for Google Cloud Project -- you can simply specify an additional `-v` parameter, and provide the appropriate path when prompted by `algo`.\n\nFor example, you can specify `-v C:\\Users\\trailofbits\\Documents\\VPNs\\gce_auth.json:/algo/gce_auth.json`, making the local path to your credentials JSON file `/algo/gce_auth.json`.\n\n### Scripted deployment\n\nAnsible variables (see [Deployment from Ansible](deploy-from-ansible.md)) can be passed via `ALGO_ARGS` environment variable.\n_The leading `-e` (or `--extra-vars`) is required_, e.g.\n\n```bash\n$ ALGO_ARGS=\"-e\n    provider=digitalocean\n    server_name=algo\n    ondemand_cellular=false\n    ondemand_wifi=false\n    dns_adblocking=true\n    ssh_tunneling=true\n    store_pki=true\n    region=ams3\n    do_token=token\"\n\n$ docker run --cap-drop=all -it \\\n    -e \"ALGO_ARGS=$ALGO_ARGS\" \\\n    -v /home/trailofbits/Documents/VPNs:/data \\\n    ghcr.io/trailofbits/algo:latest\n```\n\n## Managing an Algo Server with Docker\n\nEven though the container itself is transient, because you've persisted the configuration data, you can use the same Docker image to manage your Algo server. This is done by setting the environment variable `ALGO_ARGS`.\n\nIf you want to use Algo to update the users on an existing server, specify `-e \"ALGO_ARGS=update-users\"` in your `docker run` command:\n\n```powershell\n$ docker run --cap-drop=all -it \\\n  -e \"ALGO_ARGS=update-users\" \\\n  -v C:\\Users\\trailofbits\\Documents\\VPNs:/data \\\n  ghcr.io/trailofbits/algo:latest\n```\n\n## GNU Makefile for Docker\n\nYou can also build and deploy with a Makefile. This simplifies some of the command strings and opens the door for further user configuration.\n\nThe `Makefile` consists of three targets: `docker-build`, `docker-deploy`, and `docker-prune`.\n`docker-all` will run thru all of them.\n\n## Building Your Own Docker Image\n\nYou can use the Dockerfile provided in this repository as-is, or modify it to suit your needs. Further instructions on building an image can be found in the [Docker engine](https://docs.docker.com/engine/) documents.\n\n## Security Considerations\n\nUsing Docker is largely no different from running Algo yourself, with a couple of notable exceptions: we run as root within the container, and you're retrieving your content from Docker Hub.\n\nTo work around the limitations of bind mounts in docker, we have to run as root within the container. To mitigate concerns around doing this, we pass the `--cap-drop=all` parameter to `docker run`, which effectively removes all privileges from the root account, reducing it to a generic user account that happens to have a userid of 0. Further steps can be taken by applying `seccomp` profiles to the container; this is being considered as a future improvement.\n\nDocker themselves provide a concept of [Content Trust](https://docs.docker.com/engine/security/trust/content_trust/) for image management, which helps to ensure that the image you download is, in fact, the image that was uploaded. Content trust is still under development, and while we may be using it, its implementation, limitations, and constraints are documented with Docker.\n\n## Future Improvements\n\n1. Even though we're taking care to drop all capabilities to minimize the impact of running as root, we can probably include not only a `seccomp` profile, but also AppArmor and/or SELinux profiles as well.\n2. The Docker image doesn't natively support [advanced](deploy-from-ansible.md) Algo deployments, which is useful for scripting. This can be done by launching an interactive shell and running the commands yourself.\n3. The way configuration is passed into and out of the container is a bit kludgy. Hopefully, future improvements in Docker volumes will make this a bit easier to handle.\n\n## Advanced Usage\n\nIf you want to poke around the Docker container yourself, you can do so by changing your `entrypoint`. Pass `--entrypoint=/bin/ash` as a parameter to `docker run`, and you'll be dropped into a full Linux shell in the container.\n"
  },
  {
    "path": "docs/deploy-from-macos.md",
    "content": "# Deploy from macOS\n\nYou can install the Algo scripts on a macOS system and use them to deploy your AlgoVPN to a cloud provider.\n\n## Installation\n\nAlgo handles all Python setup automatically. Simply:\n\n1. Get Algo: `git clone https://github.com/trailofbits/algo.git && cd algo`\n2. Run Algo: `./algo`\n\nThe first time you run `./algo`, it will automatically install the required Python environment (Python 3.11+) using [uv](https://docs.astral.sh/uv/), a fast Python package manager. This works on all macOS versions without any manual Python installation.\n\n## What happens automatically\n\nWhen you run `./algo` for the first time:\n- uv is installed automatically using curl\n- Python 3.11+ is installed and managed by uv\n- All required dependencies (Ansible, etc.) are installed\n- Your VPN deployment begins\n\nNo manual Python installation, virtual environments, or dependency management required!\n"
  },
  {
    "path": "docs/deploy-from-script-or-cloud-init-to-localhost.md",
    "content": "# Deploy from script or cloud-init\n\nYou can use `install.sh` to prepare the environment and deploy AlgoVPN on the local Ubuntu server in one shot using cloud-init, or run the script directly on the server after it's been created.\nThe script doesn't configure any parameters in your cloud, so you're on your own to configure related [firewall rules](/docs/firewalls.md), a floating IP address and other resources you may need. The output of the install script (including the p12 and CA passwords) can be found at `/var/log/algo.log`, and user config files will be installed into the `/opt/algo/configs/localhost` directory. If you need to update users later, `cd /opt/algo`, change the user list in `config.cfg`, install additional dependencies as in step 4 of the [main README](https://github.com/trailofbits/algo/blob/master/README.md), and run `./algo update-users` from that directory.\n\n## Cloud init deployment\n\nYou can copy-paste the snippet below to the user data (cloud-init or startup script) field when creating a new server.\n\nFor now this has only been successfully tested on [DigitalOcean](https://www.digitalocean.com/docs/droplets/resources/metadata/), Amazon [EC2](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html) and [Lightsail](https://lightsail.aws.amazon.com/ls/docs/en/articles/lightsail-how-to-configure-server-additional-data-shell-script), [Google Cloud](https://cloud.google.com/compute/docs/startupscript), [Azure](https://docs.microsoft.com/en-us/azure/virtual-machines/linux/using-cloud-init) and [Vultr](https://my.vultr.com/startup/), although Vultr doesn't [officially support cloud-init](https://www.vultr.com/docs/getting-started-with-cloud-init).\n\n```\n#!/bin/bash\ncurl -s https://raw.githubusercontent.com/trailofbits/algo/master/install.sh | sudo -E bash -x\n```\nThe command will prepare the environment and install AlgoVPN with the default parameters below. If you want to modify the behavior, you may define additional variables.\n\n## Variables\n\n- `METHOD`: which method of the deployment to use. Possible values are local and cloud. Default: cloud. The cloud method is intended to use in cloud-init deployments only. If you are not using cloud-init to deploy the server, you have to use the local method.\n\n- `ONDEMAND_CELLULAR`: \"Connect On Demand\" when connected to cellular networks. Boolean. Default: false.\n\n- `ONDEMAND_WIFI`: \"Connect On Demand\" when connected to Wi-Fi. Default: false.\n\n- `ONDEMAND_WIFI_EXCLUDE`: List the names of any trusted Wi-Fi networks where macOS/iOS IPsec clients should not use \"Connect On Demand\". Comma-separated list.\n\n- `STORE_PKI`: To retain the PKI. (required to add users in the future, but less secure). Default: false.\n\n- `DNS_ADBLOCKING`: To install an ad blocking DNS resolver. Default: false.\n\n- `SSH_TUNNELING`: Enable SSH tunneling for each user. Default: false.\n\n- `ENDPOINT`: The public IP address or domain name of your server: (IMPORTANT! This is used to verify the certificate). It will be gathered automatically for DigitalOcean, AWS, GCE, Azure or Vultr if the `METHOD` is cloud. Otherwise, you need to define this variable according to your public IP address.\n\n- `USERS`: list of VPN users. Comma-separated list. Default: user1.\n\n- `REPO_SLUG`: Owner and repository that used to get the installation scripts from. Default: trailofbits/algo.\n\n- `REPO_BRANCH`: Branch for `REPO_SLUG`. Default: master.\n\n- `EXTRA_VARS`: Additional extra variables.\n\n- `ANSIBLE_EXTRA_ARGS`: Any available ansible parameters. ie: `--skip-tags apparmor`.\n\n## Examples\n\n##### How to customise a cloud-init deployment by variables\n\n```\n#!/bin/bash\nexport ONDEMAND_CELLULAR=true\nexport SSH_TUNNELING=true\ncurl -s https://raw.githubusercontent.com/trailofbits/algo/master/install.sh | sudo -E bash -x\n```\n\n##### How to deploy locally without using cloud-init\n\n```\nexport METHOD=local\nexport ONDEMAND_CELLULAR=true\nexport ENDPOINT=[your server's IP here]\ncurl -s https://raw.githubusercontent.com/trailofbits/algo/master/install.sh | sudo -E bash -x\n```\n\n##### How to deploy a server using arguments\n\nThe arguments order as per [variables](#variables) above\n\n```\ncurl -s https://raw.githubusercontent.com/trailofbits/algo/master/install.sh | sudo -E bash -x -s local true false _null true true true true myvpnserver.com phone,laptop,desktop\n```\n"
  },
  {
    "path": "docs/deploy-from-windows.md",
    "content": "# Deploy from Windows\n\nYou have three options to run Algo on Windows:\n\n1. **PowerShell Script** (Recommended) - Automated WSL wrapper for easy use\n2. **Windows Subsystem for Linux (WSL)** - Direct Linux environment access\n3. **Git Bash/MSYS2** - Unix-like shell environment (limited compatibility)\n\n## Option 1: PowerShell Script (Recommended)\n\nThe PowerShell script provides the easiest Windows experience by automatically using WSL when needed:\n\n```powershell\ngit clone https://github.com/trailofbits/algo\ncd algo\n.\\algo.ps1\n```\n\n**How it works:**\n- Detects if you're already in WSL and uses the standard Unix approach\n- On native Windows, automatically runs Algo via WSL (since Ansible requires Unix)\n- Provides clear guidance if WSL isn't installed\n\n**Requirements:**\n- Windows Subsystem for Linux (WSL) with Ubuntu 22.04\n- If WSL isn't installed, the script will guide you through installation\n\n## Option 2: Windows Subsystem for Linux (WSL)\n\nFor users who prefer a full Linux environment or need advanced features:\n\n### Prerequisites\n* 64-bit Windows 10/11 (Anniversary update or later)\n\n### Setup WSL\n1. Install WSL from PowerShell (as Administrator):\n```powershell\nwsl --install -d Ubuntu-22.04\n```\n\n2. After restart, open Ubuntu and create your user account\n\n### Install Algo in WSL\n```bash\ncd ~\ngit clone https://github.com/trailofbits/algo\ncd algo\n./algo\n```\n\n**Important**: Don't install Algo in `/mnt/c` directory due to file permission issues.\n\n### WSL Configuration (if needed)\n\nYou may encounter permission issues if you clone Algo to a Windows drive (like `/mnt/c/`). Symptoms include:\n\n- **Git errors**: \"fatal: could not set 'core.filemode' to 'false'\"\n- **Ansible errors**: \"ERROR! Skipping, '/mnt/c/.../ansible.cfg' as it is not safe to use as a configuration file\"\n- **SSH key errors**: \"WARNING: UNPROTECTED PRIVATE KEY FILE!\" or \"Permissions 0777 for key are too open\"\n\nIf you see these errors, configure WSL:\n\n1. Edit `/etc/wsl.conf` to allow metadata:\n```ini\n[automount]\noptions = \"metadata\"\n```\n\n2. Restart WSL completely:\n```powershell\nwsl --shutdown\n```\n\n3. Fix directory permissions for Ansible:\n```bash\nchmod 744 .\n```\n\n**Why this happens**: Windows filesystems mounted in WSL (`/mnt/c/`) don't support Unix file permissions by default. Git can't set executable bits, and Ansible refuses to load configs from \"world-writable\" directories for security.\n\nAfter deployment, copy configs to Windows:\n```bash\ncp -r configs /mnt/c/Users/$USER/\n```\n\n## Option 3: Git Bash/MSYS2\n\nIf you have Git for Windows installed, you can use the included Git Bash terminal:\n\n```bash\ngit clone https://github.com/trailofbits/algo\ncd algo\n./algo\n```\n\n**Pros**:\n- Uses the standard Unix `./algo` script\n- No WSL setup required\n- Familiar Unix-like environment\n\n**Cons**:\n- **Limited compatibility**: Ansible may not work properly due to Windows/Unix differences\n- **Not officially supported**: May encounter unpredictable issues\n- Less robust than WSL or PowerShell options\n- Requires Git for Windows installation\n\n**Note**: This approach is not recommended due to Ansible's Unix requirements. Use WSL-based options instead.\n"
  },
  {
    "path": "docs/deploy-to-ubuntu.md",
    "content": "# Local Installation\n\n**IMPORTANT**: Algo is designed to create a dedicated VPN server. There is no uninstallation option. Installing Algo on an existing server may break existing services, especially since firewall rules will be overwritten. See [AlgoVPN and Firewalls](/docs/firewalls.md) for details.\n\n## Requirements\n\nAlgo currently supports **Ubuntu 22.04 LTS only**. Your target server must be running an unmodified installation of Ubuntu 22.04.\n\n## Installation\n\nYou can install Algo on an existing Ubuntu server instead of creating a new cloud instance. This is called a **local** installation. If you're new to Algo or Linux, cloud deployment is easier.\n\n1. Follow the normal Algo installation instructions\n2. When prompted, choose: `Install to existing Ubuntu latest LTS server (for advanced users)`\n3. The target can be:\n   - The same system where you installed Algo (requires `sudo ./algo`)\n   - A remote Ubuntu server accessible via SSH without password prompts (use `ssh-agent`)\n\nFor local installation on the same machine, you must run:\n```bash\nsudo ./algo\n```\n\n## Confirmation Prompt\n\nLocal installation displays a warning and requires you to type `yes` to proceed. This ensures you understand that Algo will modify firewall rules and system settings, and that there is no uninstall option.\n\nFor automated deployments or CI/CD pipelines, skip the confirmation with:\n```bash\nansible-playbook main.yml -e \"provider=local local_install_confirmed=true server=localhost endpoint=YOUR_IP\"\n```\n\nOnly use `local_install_confirmed=true` when you have already taken a backup and understand the risks.\n\n## Road Warrior Setup\n\nA \"road warrior\" setup lets you securely access your home network and its resources when traveling. This involves installing Algo on a server within your home LAN.\n\n**Network Configuration:**\n- Forward the necessary ports from your router to the Algo server (see [firewall documentation](/docs/firewalls.md#external-firewall))\n\n**Algo Configuration** (edit `config.cfg` before deployment):\n- Set `BetweenClients_DROP` to `false` (allows VPN clients to reach your LAN)\n- Consider setting `block_smb` and `block_netbios` to `false` (enables SMB/NetBIOS traffic)\n- For local DNS resolution (e.g., Pi-hole), set `dns_encryption` to `false` and update `dns_servers` to your local DNS server IP\n"
  },
  {
    "path": "docs/deploy-to-unsupported-cloud.md",
    "content": "# Deploying to Unsupported Cloud Providers\n\nAlgo officially supports the [cloud providers listed in the README](https://github.com/trailofbits/algo/blob/master/README.md#deploy-the-algo-server). If you want to deploy Algo on another cloud provider, that provider must meet specific technical requirements for compatibility.\n\n## Technical Requirements\n\nYour cloud provider must support:\n\n1. **Ubuntu 22.04 LTS** - Algo exclusively supports Ubuntu 22.04 LTS as the base operating system\n2. **Required kernel modules** - Specific modules needed for strongSwan IPsec and WireGuard VPN functionality\n3. **Network capabilities** - Full networking stack access, not containerized environments\n\n## Compatibility Testing\n\nBefore attempting to deploy Algo on an unsupported provider, test compatibility using strongSwan's kernel module checker:\n\n1. Deploy a basic Ubuntu 22.04 LTS instance on your target provider\n2. Run the [kernel module compatibility script](https://wiki.strongswan.org/projects/strongswan/wiki/KernelModules) from strongSwan\n3. Verify all required modules are available and loadable\n\nThe script will identify any missing kernel modules that would prevent Algo from functioning properly.\n\n## Adding Official Support\n\nFor Algo to officially support a new cloud provider, the provider must have:\n\n- An available Ansible [cloud module](https://docs.ansible.com/ansible/list_of_cloud_modules.html)\n- Reliable API for programmatic instance management\n- Consistent Ubuntu 22.04 LTS image availability\n\nIf no Ansible module exists for your provider:\n\n1. Check Ansible's [open issues](https://github.com/ansible/ansible/issues) and [pull requests](https://github.com/ansible/ansible/pulls) for existing development efforts\n2. Consider developing the module yourself using the [Ansible module developer documentation](https://docs.ansible.com/ansible/dev_guide/developing_modules.html)\n3. Reference your provider's API documentation for implementation details\n\n## Unsupported Environments\n\n### Container-Based Hosting\n\nProviders using **OpenVZ**, **Docker containers**, or other **containerized environments** cannot run Algo because:\n\n- Container environments don't provide access to kernel modules\n- VPN functionality requires low-level network interface access\n- IPsec and WireGuard need direct kernel interaction\n\nFor more details, see strongSwan's [Cloud Platforms documentation](https://wiki.strongswan.org/projects/strongswan/wiki/Cloudplatforms).\n\n### Userland IPsec (libipsec)\n\nSome providers attempt to work around kernel limitations using strongSwan's [kernel-libipsec](https://wiki.strongswan.org/projects/strongswan/wiki/Kernel-libipsec) plugin, which implements IPsec entirely in userspace.\n\n**Algo does not support libipsec** for these reasons:\n\n- **Performance issues** - Buffers each packet in memory, causing performance degradation\n- **Resource consumption** - Can cause out-of-memory conditions on resource-constrained systems\n- **Stability concerns** - May crash the charon daemon or lock up the host system\n- **Security implications** - Less thoroughly audited than kernel implementations\n- **Added complexity** - Introduces additional code paths that increase attack surface\n\nWe strongly recommend choosing a provider that supports native kernel modules rather than attempting workarounds.\n\n## Alternative Deployment Options\n\nIf your preferred provider doesn't support Algo's requirements:\n\n1. **Use a supported provider** - Deploy on AWS, DigitalOcean, Azure, GCP, or another [officially supported provider](https://github.com/trailofbits/algo/blob/master/README.md#deploy-the-algo-server)\n2. **Deploy locally** - Use the [Ubuntu server deployment option](deploy-to-ubuntu.md) on your own hardware\n3. **Hybrid approach** - Deploy the VPN server on a supported provider while using your preferred provider for other services\n\n## Contributing Support\n\nIf you successfully deploy Algo on an unsupported provider and want to contribute official support:\n\n1. Ensure the provider meets all technical requirements\n2. Verify consistent deployment success across multiple regions\n3. Create an Ansible module or verify existing module compatibility\n4. Document the deployment process and any provider-specific considerations\n5. Submit a pull request with your implementation\n\nCommunity contributions to expand provider support are welcome, provided they meet Algo's security and reliability standards.\n"
  },
  {
    "path": "docs/faq.md",
    "content": "# FAQ\n\n* [Has Algo been audited?](#has-algo-been-audited)\n* [What's the current status of WireGuard?](#whats-the-current-status-of-wireguard)\n* [Why aren't you using Tor?](#why-arent-you-using-tor)\n* [Why aren't you using Racoon, LibreSwan, or OpenSwan?](#why-arent-you-using-racoon-libreswan-or-openswan)\n* [Why aren't you using a memory-safe or verified IKE daemon?](#why-arent-you-using-a-memory-safe-or-verified-ike-daemon)\n* [Why aren't you using OpenVPN?](#why-arent-you-using-openvpn)\n* [Why aren't you using Alpine Linux or OpenBSD?](#why-arent-you-using-alpine-linux-or-openbsd)\n* [Why does Algo support only a single cipher suite?](#why-does-algo-support-only-a-single-cipher-suite)\n* [Why doesn't Algo support censorship circumvention?](#why-doesnt-algo-support-censorship-circumvention)\n* [I deployed an Algo server. Can you update it with new features?](#i-deployed-an-algo-server-can-you-update-it-with-new-features)\n* [Can I migrate my existing clients to a new Algo server?](#can-i-migrate-my-existing-clients-to-a-new-algo-server)\n* [Where did the name \"Algo\" come from?](#where-did-the-name-algo-come-from)\n* [Can DNS filtering be disabled?](#can-dns-filtering-be-disabled)\n* [Does Algo support zero logging?](#does-algo-support-zero-logging)\n* [Wasn't IPSEC backdoored by the US government?](#wasnt-ipsec-backdoored-by-the-us-government)\n* [What inbound ports are used?](#what-inbound-ports-are-used)\n* [How do I monitor user activity?](#how-do-i-monitor-user-activity)\n* [How do I reach another connected client?](#how-do-i-reach-another-connected-client)\n\n## Has Algo been audited?\n\nNo. This project is under active development. We're happy to [accept and fix issues](https://github.com/trailofbits/algo/issues) as they are identified. Use Algo at your own risk. If you find a security issue of any severity, please [contact us on Slack](https://slack.empirehacking.nyc).\n\n## What's the current status of WireGuard?\n\n[WireGuard reached \"stable\" 1.0.0 release](https://lists.zx2c4.com/pipermail/wireguard/2020-March/005206.html) in Spring 2020. It has undergone [substantial](https://www.wireguard.com/formal-verification/) security review.\n\n## Why aren't you using Tor?\n\nThe goal of this project is not to provide anonymity, but to ensure confidentiality of network traffic. Tor introduces new risks that are unsuitable for Algo's intended users. Namely, with Algo, users are in control over the gateway routing their traffic. With Tor, users are at the mercy of [actively](https://www.securityweek2016.tu-darmstadt.de/fileadmin/user_upload/Group_securityweek2016/pets2016/10_honions-sanatinia.pdf) [malicious](https://web.archive.org/web/20150705184539/https://chloe.re/2015/06/20/a-month-with-badonions/) [exit](https://community.fireeye.com/people/archit.mehta/blog/2014/11/18/onionduke-apt-malware-distributed-via-malicious-tor-exit-node) [nodes](https://www.wired.com/2010/06/wikileaks-documents/).\n\n## Why aren't you using Racoon, LibreSwan, or OpenSwan?\n\nRacoon does not support IKEv2. Racoon2 supports IKEv2 but is not actively maintained. When we looked, the documentation for strongSwan was better than the corresponding documentation for LibreSwan or OpenSwan. strongSwan also has the benefit of a from-scratch rewrite to support IKEv2. I consider such rewrites a positive step when supporting a major new protocol version.\n\n## Why aren't you using a memory-safe or verified IKE daemon?\n\nI would, but I don't know of any [suitable ones](https://github.com/trailofbits/algo/issues/68). If you're in the position to fund the development of such a project, [contact us](mailto:info@trailofbits.com). We would be interested in leading such an effort. At the very least, I plan to make modifications to strongSwan and the environment it's deployed in that prevent or significantly complicate exploitation of any latent issues.\n\n## Why aren't you using OpenVPN?\n\nOpenVPN does not have out-of-the-box client support on any major desktop or mobile operating system. This introduces user experience issues and requires the user to [update](https://www.exploit-db.com/exploits/34037/) and [maintain](https://www.exploit-db.com/exploits/20485/) the software themselves. OpenVPN depends on the security of [TLS](https://tools.ietf.org/html/rfc7457), both the [protocol](https://arstechnica.com/security/2016/08/new-attack-can-pluck-secrets-from-1-of-https-traffic-affects-top-sites/) and its [implementations](https://arstechnica.com/security/2014/04/confirmed-nasty-heartbleed-bug-exposes-openvpn-private-keys-too/), and we simply trust the server less due to [past](https://sweet32.info/) [security](https://github.com/ValdikSS/openvpn-fix-dns-leak-plugin/blob/master/README.md) [incidents](https://www.exploit-db.com/exploits/34879/).\n\n## Why aren't you using Alpine Linux or OpenBSD?\n\nAlpine Linux is not supported out-of-the-box by any major cloud provider. While we considered BSD variants in the past, Algo now focuses exclusively on Ubuntu LTS for consistency, security, and maintainability.\n\n## Why does Algo support only a single cipher suite?\n\nAlgo deliberately supports only one modern cipher suite (AES256-GCM with SHA2 and P-256) rather than offering a menu of cryptographic options. This design decision enhances security by:\n\n1. **Eliminating downgrade attacks** - With no weaker ciphers available, attackers cannot force connections to use vulnerable algorithms\n2. **Reducing complexity** - A single, well-tested configuration minimizes the chance of implementation errors\n3. **Ensuring modern clients only** - This approach naturally filters out outdated systems that might have unpatched vulnerabilities\n4. **Simplifying audits** - Security researchers can focus on validating one strong configuration rather than multiple combinations\n\nThe chosen cipher suite (AES256-GCM-SHA512 with ECP384) represents current cryptographic best practices and is supported by all modern operating systems (macOS 10.11+, iOS 10+, Windows 10+, and current Linux distributions). If your device doesn't support this cipher suite, it's likely outdated and shouldn't be trusted for secure communications anyway.\n\n## Why doesn't Algo support censorship circumvention?\n\nAlgo is designed for privacy and security, not censorship avoidance. This distinction is important for several reasons:\n\n1. **Different threat models** - Censorship circumvention requires techniques like traffic obfuscation and protocol mimicry that add complexity and potential vulnerabilities. Algo focuses on protecting your data from eavesdroppers and ensuring confidential communications.\n\n2. **Legal considerations** - Operating VPNs to bypass censorship is illegal in several countries. We don't want to encourage users to break local laws or put themselves at legal risk.\n\n3. **Security focus** - Adding censorship circumvention tools would expand our codebase significantly, making it harder to audit and maintain the high security standards Algo promises. Each additional component is a potential attack vector.\n\n4. **Separation of concerns** - Tools like Tor, Shadowsocks, and V2Ray are specifically designed for censorship circumvention with teams dedicated to that cat-and-mouse game. Algo maintains its effectiveness by staying focused on its core mission: providing a secure, private VPN that \"just works.\"\n\nIf you need to bypass censorship, consider purpose-built tools designed for that specific threat model. Algo will give you privacy from your ISP and security on untrusted networks, but it won't hide the fact that you're using a VPN or help you access blocked content in restrictive countries.\n\n## I deployed an Algo server. Can you update it with new features?\n\nNo. By design, the Algo development team has no access to any Algo server that our users have deployed. We cannot modify the configuration, update the software, or sniff the traffic that goes through your personal Algo VPN server. This prevents scenarios where we are legally compelled or hacked to push down backdoored updates that surveil our users.\n\nAs a result, once your Algo server has been deployed, it is yours to maintain. It will use unattended-upgrades by default to apply security and feature updates to Ubuntu, as well as to the core VPN software of strongSwan, dnscrypt-proxy and WireGuard. However, if you want to take advantage of new features available in the current release of Algo, then you have two options. You can use the [SSH administrative interface](/README.md#ssh-into-algo-server) to make the changes you want on your own or you can shut down the server and deploy a new one (recommended).\n\nAs an extension of this rationale, most configuration options (other than users) available in `config.cfg` can only be set at the time of initial deployment.\n\n## Can I migrate my existing clients to a new Algo server?\n\nTechnically yes, but it's rarely worth the effort. WireGuard clients would need their server endpoint and public key updated. IPsec clients additionally require securely copying the CA certificate and potentially regenerating client certificates. Since your existing server auto-updates its VPN software via unattended-upgrades, there's usually no benefit to migrating—you already have the latest security patches for strongSwan, WireGuard, and dnscrypt-proxy.\n\nIf you need features from a newer Algo release, deploy a fresh server and redistribute new client configs. This is simpler and more secure than attempting key migration.\n\n## Where did the name \"Algo\" come from?\n\nAlgo is short for \"Al Gore\", the **V**ice **P**resident of **N**etworks everywhere for [inventing the Internet](https://www.youtube.com/watch?v=BnFJ8cHAlco).\n\n## Can DNS filtering be disabled?\n\nYou can temporarily disable DNS filtering for all IPsec clients at once with the following workaround: SSH to your Algo server (using the 'shell access' command printed upon a successful deployment), edit `/etc/ipsec.conf`, and change `rightdns=<random_ip>` to `rightdns=8.8.8.8`. Then run `sudo systemctl restart strongswan`. DNS filtering for WireGuard clients has to be disabled on each client device separately by modifying the settings in the app, or by directly modifying the `DNS` setting on the `clientname.conf` file. If all else fails, we recommend deploying a new Algo server without the adblocking feature enabled.\n\n## Does Algo support zero logging?\n\nYes, Algo includes privacy enhancements that minimize logging by default. StrongSwan connection logging is disabled, DNSCrypt syslog is turned off, and logs are automatically rotated after 7 days. However, some system-level logging remains for security and troubleshooting purposes. For detailed privacy configuration and limitations, see the [Privacy and Logging](#privacy-and-logging) section in the README.\n\n## Wasn't IPSEC backdoored by the US government?\n\nNo.\n\n[Per security researcher Thomas Ptacek](https://news.ycombinator.com/item?id=2014197):\n\n> In 2001, Angelos Keromytis --- then a grad student at Penn, now a Columbia professor --- added support for hardware-accelerated IPSEC NICs. When you have an IPSEC NIC, the channel between the NIC and the IPSEC stack keeps state to tell the stack not to bother doing the things the NIC already did, among them validating the IPSEC ESP authenticator. Angelos' code had a bug; it appears to have done the software check only when the hardware had already done it, and skipped it otherwise.\n>\n> The bug happened during a change that simultaneously refactored and added a feature to OpenBSD's ESP code; a comparison that should have been == was instead !=; the \"if\" statement with the bug was originally and correctly !=, but should have been flipped based on how the code was refactored.\n>\n> HD Moore may as we speak be going through the pain of reconstituting a nearly decade-old version of OpenBSD to verify the bug, but stipulate that it was there, and here's what you get: IPSEC ESP packet authentication was disabled if you didn't have hardware IPSEC. There is probably an elaborate man-in-the-middle scenario in which this could get you traffic inspection, but it's nowhere nearly as straightforward as leaking key bits.\n>\n> To entertain the conspiracy theory, you're still suggesting that the FBI not only introduced this bug, but also developed the technology required to MITM ESP sessions, bouncing them through some secret FBI-developed middlebox.\n>\n> One year later, Jason Wright from NETSEC (the company at the heart of the [I think silly] allegations about OpenBSD IPSEC backdoors) fixed the bug.\n>\n> It's interesting that the bug was fixed without an advisory (oh to be a fly on the wall on ICB that day; Theo had a, um, a, \"way\" with his dev team). On the other hand, we don't know what releases of OpenBSD actually had the bug right now.\n>\n> It seems vanishingly unlikely that there could have been anything deliberate about this series of changes. You are unlikely to find anyone who will impugn Angelos. Meanwhile, the diffs tell exactly the opposite of the story that Greg Perry told.\n\n## What inbound ports are used?\n\nYou should only need 4160/TCP, 500/UDP, 4500/UDP, and 51820/UDP opened on any firewall that sits between your clients and your Algo server. See [AlgoVPN and Firewalls](/docs/firewalls.md) for more information.\n\n## How do I monitor user activity?\n\nYour Algo server will track IPsec client logins by default in `/var/log/syslog`. This will give you client names, date/time of connection and reconnection, and what IP addresses they're connecting from. This can be disabled entirely by setting `strongswan_log_level` to `-1` in `config.cfg`. WireGuard doesn't save any logs, but entering `sudo wg` on the server will give you the last endpoint and contact time of each client. Disabling this is [paradoxically difficult](https://git.zx2c4.com/blind-operator-mode/about/). There isn't any out-of-the-box way to monitor actual user _activity_ (e.g. websites browsed, etc.)\n\n## How do I reach another connected client?\n\nBy default, your Algo server doesn't allow connections between connected clients. This can be changed at the time of deployment by enabling the `BetweenClients_DROP` flag in `config.cfg`. See the [\"Road Warrior\" instructions](/docs/deploy-to-ubuntu.md#road-warrior-setup) for more details.\n"
  },
  {
    "path": "docs/firewalls.md",
    "content": "# AlgoVPN and Firewalls\n\nYour AlgoVPN requires properly configured firewalls. The key points to know are:\n\n* If you deploy to a **cloud** provider all firewall configuration will done automatically.\n\n* If you perform a **local** installation on an existing server you are responsible for configuring any external firewalls. You must also take care not to interfere with the server firewall configuration of the AlgoVPN.\n\n## The Two Types of Firewall\n\n![Firewall Illustration](/docs/images/firewalls.png)\n\n### Server Firewall\n\nDuring installation Algo configures the Linux [Netfilter](https://en.wikipedia.org/wiki/Netfilter) firewall on the server. The rules added are required for AlgoVPN to work properly. The package `netfilter-persistent` is used to load the IPv4 and IPv6 rules files that Algo generates and stores in `/etc/iptables`. The rules for IPv6 are only generated if the server appears to be properly configured for IPv6. The use of conflicting firewall packages on the server such as `ufw` will likely break AlgoVPN.\n\n### External Firewall\n\nMost cloud service providers offer a firewall that sits between the Internet and your AlgoVPN. With some providers (such as EC2, Lightsail, and GCE) this firewall is required and is configured by Algo during a **cloud** deployment. If the firewall is not required by the provider then Algo does not configure it.\n\nExternal firewalls are not configured when performing a **local** installation, even when using a server from a cloud service provider.\n\nAny external firewall must be configured to pass the following incoming ports over IPv4 :\n\nPort | Protocol | Description | Related variables in `config.cfg`\n---- | -------- | ----------- | ---------------------------------\n4160  | TCP | Secure Shell (SSH) | `ssh_port` (**cloud** only; for **local** port remains 22)\n500   | UDP | IPsec IKEv2 | `ipsec_enabled`\n4500  | UDP | IPsec NAT-T | `ipsec_enabled`\n51820 | UDP | WireGuard | `wireguard_enabled`, `wireguard_port`\n\nIf you have chosen to disable either IPsec or WireGuard in `config.cfg` before running `./algo` then the corresponding ports don't need to pass through the firewall. SSH is used when performing a **cloud** deployment and when subsequently modifying the list of VPN users by running `./algo update-users`.\n\nEven when not required by the cloud service provider, you still might wish to use an external firewall to limit SSH access to your AlgoVPN to connections from certain IP addresses, or perhaps to block SSH access altogether if you don't need it. Every service provider firewall is different so refer to the provider's documentation for more information.\n"
  },
  {
    "path": "docs/index.md",
    "content": "# Algo VPN documentation\n\n* Deployment instructions\n  - Deploy from [RedHat/CentOS 6.x](deploy-from-redhat-centos6.md)\n  - Deploy from [Windows](deploy-from-windows.md)\n  - Deploy from a [Docker container](deploy-from-docker.md)\n  - Deploy from [Ansible](deploy-from-ansible.md) non-interactively\n  - Deploy onto a [cloud server at time of creation with shell script or cloud-init](deploy-from-script-or-cloud-init-to-localhost.md)\n  - Deploy from [macOS](deploy-from-macos.md)\n  - Deploy from [Google Cloud Shell](deploy-from-cloudshell.md)\n* Client setup\n  - Setup [Android](client-android.md) clients\n  - Setup [Generic/Linux](client-linux.md) clients with Ansible\n  - Setup Ubuntu clients to use [WireGuard](client-linux-wireguard.md)\n  - Setup Linux clients to use [IPsec](client-linux-ipsec.md)\n  - Setup Apple devices to use [IPsec](client-apple-ipsec.md)\n  - Setup Macs running macOS 10.13 or older to use [WireGuard](client-macos-wireguard.md)\n* Cloud provider setup\n  - Configure [Amazon EC2](cloud-amazon-ec2.md)\n  - Configure [Azure](cloud-azure.md)\n  - Configure [DigitalOcean](cloud-do.md)\n  - Configure [Google Cloud Platform](cloud-gce.md)\n  - Configure [Vultr](cloud-vultr.md)\n  - Configure [CloudStack](cloud-cloudstack.md)\n  - Configure [Hetzner Cloud](cloud-hetzner.md)\n* Advanced Deployment\n  - Deploy to your own [Ubuntu](deploy-to-ubuntu.md) server, and road warrior setup\n  - Deploy to an [unsupported cloud provider](deploy-to-unsupported-cloud.md)\n* [FAQ](faq.md)\n* [Firewalls](firewalls.md)\n* [Troubleshooting](troubleshooting.md)\n"
  },
  {
    "path": "docs/troubleshooting.md",
    "content": "# Troubleshooting\n\nFirst of all, check [this](https://github.com/trailofbits/algo#features) and ensure that you are deploying to Ubuntu 22.04 LTS, the only supported server platform.\n\n  * [Installation Problems](#installation-problems)\n     * General Setup\n        * [Python version is not supported](#python-version-is-not-supported)\n        * [Error: \"ansible-playbook: command not found\"](#error-ansible-playbook-command-not-found)\n        * [Fatal: \"Failed to validate the SSL certificate for ...\"](#fatal-failed-to-validate-the-SSL-certificate)\n        * [Bad owner or permissions on .ssh](#bad-owner-or-permissions-on-ssh)\n     * Cloud Providers\n        * [The region you want is not available](#the-region-you-want-is-not-available)\n        * [AWS: SSH permission denied with an ECDSA key](#aws-ssh-permission-denied-with-an-ecdsa-key)\n        * [AWS: \"Deploy the template\" fails with CREATE_FAILED](#aws-deploy-the-template-fails-with-create_failed)\n        * [AWS: not authorized to perform: cloudformation:UpdateStack](#aws-not-authorized-to-perform-cloudformationupdatestack)\n        * [Azure: No such file or directory .azure/azureProfile.json](#azure-no-such-file-or-directory-homeusernameazureazureprofilejson)\n        * [Azure: Deployment Permissions Error](#azure-deployment-permissions-error)\n        * [Linode: Stackscript error](#linode-error-unable-to-query-the-linode-api-saw-400-the-requested-distribution-is-not-supported-by-this-stackscript-)\n     * Windows\n        * [Windows: The value of parameter linuxConfiguration.ssh.publicKeys.keyData is invalid](#windows-the-value-of-parameter-linuxconfigurationsshpublickeyskeydata-is-invalid)\n        * [Windows: \"The parameter is incorrect\" error when connecting](#windows-the-parameter-is-incorrect-error-when-connecting)\n     * Local Deployment\n        * [Error: Failed to create symlinks for deploying to localhost](#error-failed-to-create-symlinks-for-deploying-to-localhost)\n        * [Wireguard: Unable to find 'configs/...' in expected paths](#wireguard-unable-to-find-configs-in-expected-paths)\n     * Network\n        * [Timeout when waiting for search string OpenSSH](#old-networking-firewall-in-place)\n  * [Connection Problems](#connection-problems)\n     * [I'm blocked or get CAPTCHAs when I access certain websites](#im-blocked-or-get-captchas-when-i-access-certain-websites)\n     * [I want to change the list of trusted Wifi networks on my Apple device](#i-want-to-change-the-list-of-trusted-wifi-networks-on-my-apple-device)\n     * [Error: \"The VPN Service payload could not be installed.\"](#error-the-vpn-service-payload-could-not-be-installed)\n     * [Little Snitch is broken when connected to the VPN](#little-snitch-is-broken-when-connected-to-the-vpn)\n     * [I can't get my router to connect to the Algo server](#i-cant-get-my-router-to-connect-to-the-algo-server)\n     * [Various websites appear to be offline through the VPN](#various-websites-appear-to-be-offline-through-the-vpn)\n     * [Clients appear stuck in a reconnection loop](#clients-appear-stuck-in-a-reconnection-loop)\n     * [Wireguard: clients can connect on Wifi but not LTE](#wireguard-clients-can-connect-on-wifi-but-not-lte)\n     * [IPsec: Difficulty connecting through router](#ipsec-difficulty-connecting-through-router)\n  * [Diagnostic Commands](#diagnostic-commands)\n     * [Enable Verbose Logging](#enable-verbose-logging)\n     * [Server-Side Diagnostics](#server-side-diagnostics)\n     * [Client-Side Diagnostics](#client-side-diagnostics)\n  * [I have a problem not covered here](#i-have-a-problem-not-covered-here)\n\n## Installation Problems\n\nLook here if you have a problem running the installer to set up a new Algo server.\n\n### Python version is not supported\n\nThe minimum Python version required to run Algo is 3.11. Most modern operation systems should have it by default, but if the OS you are using doesn't meet the requirements, you have to upgrade. See the official documentation for your OS, or manual download it from https://www.python.org/downloads/. Otherwise, you may [deploy from docker](deploy-from-docker.md)\n\n### Error: \"ansible-playbook: command not found\"\n\nYou tried to install Algo and you see an error that reads \"ansible-playbook: command not found.\"\n\nThis indicates that Ansible is not installed or not available in your PATH. Algo automatically installs all dependencies (including Ansible) using uv when you run `./algo` for the first time. If you're seeing this error, try running `./algo` again - it should automatically install the required Python environment and dependencies. If the issue persists, ensure you're running `./algo` from the Algo project directory.\n\n### Fatal: \"Failed to validate the SSL certificate\"\n\nYou received a message like this:\n```\nfatal: [localhost]: FAILED! => {\"changed\": false, \"msg\": \"Failed to validate the SSL certificate for api.digitalocean.com:443. Make sure your managed systems have a valid CA certificate installed. You can use validate_certs=False if you do not need to confirm the servers identity but this is unsafe and not recommended. Paths checked for this platform: /etc/ssl/certs, /etc/ansible, /usr/local/etc/openssl. The exception msg was: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1076).\", \"status\": -1, \"url\": \"https://api.digitalocean.com/v2/regions\"}\n```\n\nYour local system does not have a CA certificate that can validate the cloud provider's API. This typically occurs with custom Python installations. Try reinstalling Python using Homebrew (`brew install python3`) or ensure your system has proper CA certificates installed.\n\n### Bad owner or permissions on .ssh\n\nYou tried to run Algo and it quickly exits with an error about a bad owner or permissions:\n\n```\nfatal: [104.236.2.94]: UNREACHABLE! => {\"changed\": false, \"msg\": \"Failed to connect to the host via ssh: Bad owner or permissions on /home/user/.ssh/config\\r\\n\", \"unreachable\": true}\n```\n\nYou need to reset the permissions on your `.ssh` directory. Run `chmod 700 /home/user/.ssh` and then `chmod 600 /home/user/.ssh/config`. You may need to repeat this for other files mentioned in the error message.\n\n### The region you want is not available\n\nAlgo downloads the regions from the supported cloud providers (other than Microsoft Azure) listed in the first menu using APIs. If the region you want isn't available, the cloud provider has probably taken it offline for some reason. You should investigate further with your cloud provider.\n\nIf there's a specific region you want to install to in Microsoft Azure that isn't available, you should [file an issue](https://github.com/trailofbits/algo/issues/new), give us information about what region is missing, and we'll add it.\n\n### AWS: SSH permission denied with an ECDSA key\n\nYou tried to deploy Algo to AWS and you received an error like this one:\n\n```\nTASK [Copy the algo ssh key to the local ssh directory] ************************\nok: [localhost -> localhost]\n\nPLAY [Configure the server and install required software] **********************\n\nTASK [Check the system] ********************************************************\nfatal: [X.X.X.X]: UNREACHABLE! => {\"changed\": false, \"msg\": \"Failed to connect to the host via ssh: Warning: Permanently added 'X.X.X.X' (ECDSA) to the list of known hosts.\\r\\nPermission denied (publickey).\\r\\n\", \"unreachable\": true}\n```\n\nYou previously deployed Algo to a hosting provider other than AWS, and Algo created an ECDSA keypair at that time. You are now deploying to AWS which [does not support ECDSA keys](https://aws.amazon.com/certificate-manager/faqs/) via their API. As a result, the deploy has failed.\n\nIn order to fix this issue, delete the `algo.pem` and `algo.pem.pub` keys from your `configs` directory and run the deploy again. If AWS is selected, Algo will now generate new RSA ssh keys which are compatible with the AWS API.\n\n### AWS: \"Deploy the template fails\" with CREATE_FAILED\n\nYou tried to deploy Algo to AWS and you received an error like this one:\n\n```\nTASK [cloud-ec2 : Make a cloudformation template] ******************************\nchanged: [localhost]\n\nTASK [cloud-ec2 : Deploy the template] *****************************************\nfatal: [localhost]: FAILED! => {\"changed\": true, \"events\": [\"StackEvent AWS::CloudFormation::Stack algopvpn1 ROLLBACK_COMPLETE\", \"StackEvent AWS::EC2::VPC VPC DELETE_COMPLETE\", \"StackEvent AWS::EC2::InternetGateway InternetGateway DELETE_COMPLETE\", \"StackEvent AWS::CloudFormation::Stack algopvpn1 ROLLBACK_IN_PROGRESS\", \"StackEvent AWS::EC2::VPC VPC CREATE_FAILED\", \"StackEvent AWS::EC2::VPC VPC CREATE_IN_PROGRESS\", \"StackEvent AWS::EC2::InternetGateway InternetGateway CREATE_FAILED\", \"StackEvent AWS::EC2::InternetGateway InternetGateway CREATE_IN_PROGRESS\", \"StackEvent AWS::CloudFormation::Stack algopvpn1 CREATE_IN_PROGRESS\"], \"failed\": true, \"output\": \"Problem with CREATE. Rollback complete\", \"stack_outputs\": {}, \"stack_resources\": [{\"last_updated_time\": null, \"logical_resource_id\": \"InternetGateway\", \"physical_resource_id\": null, \"resource_type\": \"AWS::EC2::InternetGateway\", \"status\": \"DELETE_COMPLETE\", \"status_reason\": null}, {\"last_updated_time\": null, \"logical_resource_id\": \"VPC\", \"physical_resource_id\": null, \"resource_type\": \"AWS::EC2::VPC\", \"status\": \"DELETE_COMPLETE\", \"status_reason\": null}]}\n```\n\nAlgo builds a [Cloudformation](https://aws.amazon.com/cloudformation/) template to deploy to AWS. You can find the entire contents of the Cloudformation template in `configs/algo.yml`. In order to troubleshoot this issue, login to the AWS console, go to the Cloudformation service, find the failed deployment, click the events tab, and find the corresponding \"CREATE_FAILED\" events. Note that all AWS resources created by Algo are tagged with `Environment => Algo` for easy identification.\n\nIn many cases, failed deployments are the result of [service limits](http://docs.aws.amazon.com/general/latest/gr/aws_service_limits.html) being reached, such as \"CREATE_FAILED\tAWS::EC2::VPC\tVPC\tThe maximum number of VPCs has been reached.\" In these cases, you must either [delete the VPCs from previous deployments](https://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/working-with-vpcs.html#VPC_Deleting), or [contact AWS support](https://console.aws.amazon.com/support/home?region=us-east-1#/case/create?issueType=service-limit-increase&limitType=service-code-direct-connect) to increase the limits on your account.\n\n### AWS: not authorized to perform: cloudformation:UpdateStack\n\nYou tried to deploy Algo to AWS and you received an error like this one:\n\n```\nTASK [cloud-ec2 : Deploy the template] *****************************************\nfatal: [localhost]: FAILED! => {\"changed\": false, \"failed\": true, \"msg\": \"User: arn:aws:iam::082851645362:user/algo is not authorized to perform: cloudformation:UpdateStack on resource: arn:aws:cloudformation:us-east-1:082851645362:stack/algo/*\"}\n```\n\nThis error indicates you already have Algo deployed to Cloudformation. Need to [delete it](cloud-amazon-ec2.md#cleanup) first, then re-deploy.\n\n### Azure: No such file or directory: '/home/username/.azure/azureProfile.json'\n\n ```\n TASK [cloud-azure : Create AlgoVPN Server] *****************************************************************************************************************************************************************\nAn exception occurred during task execution. To see the full traceback, use -vvv.\nThe error was: FileNotFoundError: [Errno 2] No such file or directory: '/home/ubuntu/.azure/azureProfile.json'\nfatal: [localhost]: FAILED! => {\"changed\": false, \"module_stderr\": \"Traceback (most recent call last):\nFile \\\"/usr/local/lib/python3.11/dist-packages/azure/cli/core/_session.py\\\", line 39, in load\nwith codecs_open(self.filename, 'r', encoding=self._encoding) as f:\nFile \\\"/usr/lib/python3.11/codecs.py\\\", line 897, in open\\n    file = builtins.open(filename, mode, buffering)\nFileNotFoundError: [Errno 2] No such file or directory: '/home/ubuntu/.azure/azureProfile.json'\n\", \"module_stdout\": \"\", \"msg\": \"MODULE FAILURE\nSee stdout/stderr for the exact error\", \"rc\": 1}\n```\n\nIt happens when your machine is not authenticated in the azure cloud, follow this [guide](https://trailofbits.github.io/algo/cloud-azure.html) to configure your environment\n\n### Azure: Deployment Permissions Error\n\nThe AAD Application Registration (aka, the 'Service Principal', where you got the ClientId) needs permission to create the resources for the subscription. Otherwise, you will get the following error when you run the Ansible deploy script:\n\n```\nfatal: [localhost]: FAILED! => {\"changed\": false, \"msg\": \"Resource group create_or_update failed with status code: 403 and message: The client 'xxxxx' with object id 'THE_OBJECT_ID' does not have authorization to perform action 'Microsoft.Resources/subscriptions/resourcegroups/write' over scope '/subscriptions/THE_SUBSCRIPTION_ID/resourcegroups/algo' or the scope is invalid. If access was recently granted, please refresh your credentials.\"}\n```\n\nThe solution for this is to open the Azure CLI and run the following command to grant contributor role to the Service Principal:\n\n```\naz role assignment create --assignee-object-id THE_OBJECT_ID --scope subscriptions/THE_SUBSCRIPTION_ID --role contributor\n```\n\nAfter this is applied, the Service Principal has permissions to create the resources and you can re-run `ansible-playbook main.yml` to complete the deployment.\n\n### Linode Error: \"Unable to query the Linode API. Saw: 400: The requested distribution is not supported by this stackscript.; \"\n\nStackScript is a custom deployment script that defines a set of configurations for a Linode instance (e.g. which distribution, specs, etc.). if you used algo with default values in the past deployments, a stackscript that would've been created is 're-used' in the deployment process (in fact, go see 'create Linodes' and under 'StackScripts' tab). Thus, there's a little chance that your deployment process will generate this 'unsupported stackscript' error due to a pre-existing StackScript that doesn't support a particular configuration setting or value due to an 'old' stackscript. The quickest solution is just to change the name of your deployment from the default value of 'algo' (or any other name that you've used before, again see the dashboard) and re-run the deployment.\n\n### Windows: The value of parameter linuxConfiguration.ssh.publicKeys.keyData is invalid\n\nYou tried to deploy Algo from Windows and you received an error like this one:\n\n```\nTASK [cloud-azure : Create an instance].\nfatal: [localhost]: FAILED! => {\"changed\": false,\n\"msg\": \"Error creating or updating virtual machine AlgoVPN - Azure Error:\nInvalidParameter\\n\nMessage: The value of parameter linuxConfiguration.ssh.publicKeys.keyData is invalid.\\n\nTarget: linuxConfiguration.ssh.publicKeys.keyData\"}\n```\n\nThis is related to [the chmod issue](https://github.com/Microsoft/WSL/issues/81) inside /mnt directory which is NTFS. The fix is to place Algo outside of /mnt directory.\n\n### Windows: \"The parameter is incorrect\" error when connecting\n\nWhen trying to connect to your Algo VPN on Windows 10/11, you may receive an error stating \"The parameter is incorrect\". This is a common issue that can usually be resolved by resetting your Windows networking stack.\n\n#### Solution\n\n1. **Clear the networking caches**\n\n   Open Command Prompt as Administrator (right-click on Command Prompt and select \"Run as Administrator\") and run these commands:\n   ```cmd\n   netsh int ip reset\n   netsh int ipv6 reset\n   netsh winsock reset\n   ```\n\n   Then restart your computer.\n\n2. **Reset Device Manager network adapters** (if step 1 doesn't work)\n\n   - Open Device Manager\n   - Find \"Network Adapters\"\n   - Uninstall all WAN Miniport drivers (IKEv2, IP, IPv6, etc.)\n   - Click Action → Scan for hardware changes\n   - The adapters you just uninstalled should reinstall automatically\n\n   Try connecting to the VPN again.\n\n#### What causes this issue?\n\nThis error typically occurs when:\n- Windows networking stack becomes corrupted\n- After Windows updates that affect network drivers\n- When switching between different VPN configurations\n- After network-related software installations/uninstallations\n\nNote: This issue has been reported by many users and the above solution has proven effective in most cases.\n\n### Error: Failed to create symlinks for deploying to localhost\n\nYou tried to run Algo and you received an error like this one:\n\n```\nTASK [Create a symlink if deploying to localhost] ********************************************************************\nfatal: [localhost]: FAILED! => {\"changed\": false, \"gid\": 1000, \"group\": \"ubuntu\", \"mode\": \"0775\", \"msg\": \"the directory configs/localhost is not empty, refusing to convert it\", \"owner\": \"ubuntu\", \"path\": \"configs/localhost\", \"size\": 4096, \"state\": \"directory\", \"uid\": 1000}\nincluded: /home/ubuntu/algo-master/playbooks/rescue.yml for localhost\n\nTASK [debug] *********************************************************************************************************\nok: [localhost] => {\n    \"fail_hint\": [\n        \"Sorry, but something went wrong!\",\n        \"Please check the troubleshooting guide.\",\n        \"https://trailofbits.github.io/algo/troubleshooting.html\"\n    ]\n}\n\nTASK [Fail the installation] *****************************************************************************************\n```\nThis error is usually encountered when using the local install option and `localhost` is provided in answer to this question, which is expecting an IP address or domain name of your server:\n```\nEnter the public IP address or domain name of your server: (IMPORTANT! This is used to verify the certificate)\n[localhost]\n:\n```\n\nYou should remove the files in /etc/wireguard/ and configs/ as follows:\n```ssh\nsudo rm -rf /etc/wireguard/*\nrm -rf configs/*\n```\n\nAnd then immediately re-run `./algo` and provide a domain name or IP address in response to the question referenced above.\n\n### Wireguard: Unable to find 'configs/...' in expected paths\n\nYou tried to run Algo and you received an error like this one:\n\n```\nTASK [wireguard : Generate public keys] ********************************************************************************\n[WARNING]: Unable to find 'configs/xxx.xxx.xxx.xxx/wireguard//private/dan' in expected paths.\n\nfatal: [localhost]: FAILED! => {\"msg\": \"An unhandled exception occurred while running the lookup plugin 'file'. Error was a <class 'ansible.errors.AnsibleError'>, original message: could not locate file in lookup: configs/xxx.xxx.xxx.xxx/wireguard//private/dan\"}\n```\nThis error is usually hit when using the local install option on an unsupported server. Algo requires Ubuntu 22.04 LTS. You should upgrade your server to Ubuntu 22.04 LTS. If this doesn't work, try removing files in /etc/wireguard/ and the configs directories as follows:\n\n```ssh\nsudo rm -rf /etc/wireguard/*\nrm -rf configs/*\n```\nThen immediately re-run `./algo`.\n\n### Old Networking Firewall In Place\n\nYou may see the following output when attemptint to run ./algo from your localhost:\n\n```\nTASK [Wait until SSH becomes ready...] **********************************************************************************************************************\nfatal: [localhost]: FAILED! => {\"changed\": false, \"elapsed\": 321, \"msg\": \"Timeout when waiting for search string OpenSSH in xxx.xxx.xxx.xxx:4160\"}\nincluded: /home/<username>/algo/algo/playbooks/rescue.yml for localhost\n\nTASK [debug] ************************************************************************************************************************************************\nok: [localhost] => {\n    \"fail_hint\": [\n        \"Sorry, but something went wrong!\",\n        \"Please check the troubleshooting guide.\",\n        \"https://trailofbits.github.io/algo/troubleshooting.html\"\n    ]\n}\n```\n\nIf you see this error then one possible explanation is that you have a previous firewall configured in your cloud hosting provider which needs to be either updated or ideally removed. Removing this can often fix this issue.\n\n## Connection Problems\n\nLook here if you deployed an Algo server but now have a problem connecting to it with a client.\n\n### I'm blocked or get CAPTCHAs when I access certain websites\n\nThis is normal.\n\nWhen you deploy a Algo to a new cloud server, the address you are given may have been used before. In some cases, a malicious individual may have attacked others with that address and had it added to \"IP reputation\" feeds or simply a blacklist. In order to regain the trust for that address, you may be asked to enter CAPTCHAs to prove that you are a human, and not a Denial of Service (DoS) bot trying to attack others. This happens most frequently with Google. You can try entering the CAPTCHAs or you can try redeploying your Algo server to a new IP to resolve this issue.\n\nIn some cases, a website will block any visitors accessing their site through a cloud hosting provider due to previous, frequent DoS attacks originating from them. In these cases, there is not much you can do except deploy Algo to your own server or another IP that the website has not outright blocked.\n\n### I want to change the list of trusted Wifi networks on my Apple device\n\nThis setting is enforced on your client device via the Apple profile you put on it. You can edit the profile with new settings, then load it on your device to change the settings. You can use the [Apple Configurator](https://itunes.apple.com/us/app/apple-configurator-2/id1037126344?mt=12) to edit and resave the profile. Advanced users can edit the file directly in a text editor. Use the [Configuration Profile Reference](https://developer.apple.com/library/content/featuredarticles/iPhoneConfigurationProfileRef/Introduction/Introduction.html) for information about the file format and other available options. If you're not comfortable editing the profile, you can also simply redeploy a new Algo server with different settings to receive a new auto-generated profile.\n\n### Error: \"The VPN Service payload could not be installed.\"\n\nYou tried to install the Apple profile on one of your devices and you received an error stating `The \"VPN Service\" payload could not be installed. The VPN service could not be created.` Client support for Algo VPN is limited to modern operating systems, e.g. macOS 10.11+, iOS 9+. Please upgrade your operating system and try again.\n\n### Little Snitch is broken when connected to the VPN\n\nLittle Snitch is not compatible with IPSEC VPNs due to a known bug in macOS and there is no solution. The Little Snitch \"filter\" does not get incoming packets from IPSEC VPNs and, therefore, cannot evaluate any rules over them. Their developers have filed a bug report with Apple but there has been no response. There is nothing they or Algo can do to resolve this problem on their own. You can read more about this problem in [issue #134](https://github.com/trailofbits/algo/issues/134).\n\n### I can't get my router to connect to the Algo server\n\nIn order to connect to the Algo VPN server, your router must support IKEv2, ECC certificate-based authentication, and the cipher suite we use. See the ipsec.conf files we generate in the `config` folder for more information. Note that we do not officially support routers as clients for Algo VPN at this time, though patches and documentation for them are welcome (for example, see open issues for [Ubiquiti](https://github.com/trailofbits/algo/issues/307) and [pfSense](https://github.com/trailofbits/algo/issues/292)).\n\n### Various websites appear to be offline through the VPN\n\nThis issue appears occasionally due to issues with [MTU](https://en.wikipedia.org/wiki/Maximum_transmission_unit) size. Different networks may require the MTU to be within a specific range to correctly pass traffic. We made an effort to set the MTU to the most conservative, most compatible size by default but problems may still occur.\n\nIf either your Internet service provider or your chosen cloud service provider use an MTU smaller than the normal value of 1500 you can use the `reduce_mtu` option in the file `config.cfg` to correspondingly reduce the size of the VPN tunnels created by Algo. Algo will attempt to automatically set `reduce_mtu` based on the MTU found on the server at the time of deployment, but it cannot detect if the MTU is smaller on the client side of the connection.\n\nIf you change `reduce_mtu` you'll need to deploy a new Algo VPN.\n\nTo determine the value for `reduce_mtu` you should examine the MTU on your Algo VPN server's primary network interface (see below). You might algo want to run tests using `ping`, both on a local client *when not connected to the VPN* and also on your Algo VPN server (see below). Then take the smallest MTU you find (local or server side), subtract it from 1500, and use that for `reduce_mtu`. An exception to this is if you find the smallest MTU is your local MTU at 1492, typical for PPPoE connections, then no MTU reduction should be necessary.\n\n#### Check the MTU on the Algo VPN server\n\nTo check the MTU on your server, SSH in to it, run the command `ifconfig`, and look for the MTU of the main network interface. For example:\n```\nens4: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1460\n```\nThe MTU shown here is 1460 instead of 1500. Therefore set `reduce_mtu: 40` in `config.cfg`. Algo should do this automatically.\n\n#### Determine the MTU using `ping`\n\nWhen using `ping` you increase the payload size with the \"Don't Fragment\" option set until it fails. The largest payload size that works, plus the `ping` overhead of 28, is the MTU of the connection.\n\n##### Example: Test on your Algo VPN server (Ubuntu)\n```\n$ ping -4 -s 1432 -c 1 -M do github.com\nPING github.com (192.30.253.112) 1432(1460) bytes of data.\n1440 bytes from lb-192-30-253-112-iad.github.com (192.30.253.112): icmp_seq=1 ttl=53 time=13.1 ms\n\n--- github.com ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 13.135/13.135/13.135/0.000 ms\n\n$ ping -4 -s 1433 -c 1 -M do github.com\nPING github.com (192.30.253.113) 1433(1461) bytes of data.\nping: local error: Message too long, mtu=1460\n\n--- github.com ping statistics ---\n1 packets transmitted, 0 received, +1 errors, 100% packet loss, time 0ms\n```\nIn this example the largest payload size that works is 1432. The `ping` overhead is 28 so the MTU is 1432 + 28 = 1460, which is 40 lower than the normal MTU of 1500. Therefore set `reduce_mtu: 40` in `config.cfg`.\n\n##### Example: Test on a macOS client *not connected to your Algo VPN*\n```\n$ ping -c 1 -D -s 1464 github.com\nPING github.com (192.30.253.113): 1464 data bytes\n1472 bytes from 192.30.253.113: icmp_seq=0 ttl=50 time=169.606 ms\n\n--- github.com ping statistics ---\n1 packets transmitted, 1 packets received, 0.0% packet loss\nround-trip min/avg/max/stddev = 169.606/169.606/169.606/0.000 ms\n\n$ ping -c 1 -D -s 1465 github.com\nPING github.com (192.30.253.113): 1465 data bytes\n\n--- github.com ping statistics ---\n1 packets transmitted, 0 packets received, 100.0% packet loss\n```\nIn this example the largest payload size that works is 1464. The `ping` overhead is 28 so the MTU is 1464 + 28 = 1492, which is typical for a PPPoE Internet connection and does not require an MTU adjustment. Therefore use the default of `reduce_mtu: 0` in `config.cfg`.\n\n#### Change the client MTU without redeploying the Algo VPN\n\nIf you don't wish to deploy a new Algo VPN (which is required to incorporate a change to `reduce_mtu`) you can change the client side MTU of WireGuard clients and Linux IPsec clients without needing to make changes to your Algo VPN.\n\nFor WireGuard on Linux, or macOS (when installed with `brew`), you can specify the MTU yourself in the client configuration file (typically `wg0.conf`). Refer to the documentation (see `man wg-quick`).\n\nFor WireGuard on iOS and Android you can change the MTU in the app.\n\nFor IPsec on Linux you can change the MTU of your network interface to match the required MTU. For example:\n```\nsudo ifconfig eth0 mtu 1440\n```\nTo make the change take effect after a reboot, on Ubuntu 22.04 LTS edit the relevant file in the `/etc/netplan` directory (see `man netplan`).\n\n#### Note for WireGuard iOS users\n\nAs of WireGuard for iOS 0.0.20190107 the default MTU is 1280, a conservative value intended to allow mobile devices to continue to work as they switch between different networks which might have smaller than normal MTUs. In order to use this default MTU review the configuration in the WireGuard app and remove any value for MTU that might have been added automatically by Algo.\n\n### Clients appear stuck in a reconnection loop\n\nIf you're using 'Connect on Demand' on iOS and your client device appears stuck in a reconnection loop after switching from WiFi to LTE or vice versa, you may want to try disabling DoS protection in strongSwan.\n\nThe configuration value can be found in `/etc/strongswan.d/charon.conf`. After making the change you must reload or restart ipsec.\n\nExample command:\n```\nsed -i -e 's/#*.dos_protection = yes/dos_protection = no/' /etc/strongswan.d/charon.conf && ipsec restart\n```\n\n### WireGuard: Clients can connect on Wifi but not LTE\n\nCertain cloud providers (like AWS Lightsail) don't assign an IPv6 address to your server, but certain cellular carriers (e.g. T-Mobile in the United States, [EE](https://community.ee.co.uk/t5/4G-and-mobile-data/IPv4-VPN-Connectivity/td-p/757881) in the United Kingdom) operate an IPv6-only network. This somehow leads to the Wireguard app not being able to make a connection when transitioning to cell service. Go to the Wireguard app on the device when you're having problems with cell connectivity and select \"Export log file\" or similar option. If you see a long string of error messages like \"`Failed to send data packet write udp6 [::]:49727->[2607:7700:0:2a:0:1:354:40ae]:51820: sendto: no route to host` then you might be having this problem.\n\nManually disconnecting and then reconnecting should restore your connection. To solve this, you need to either \"force IPv4 connection\" if available on your phone, or install an IPv4 APN, which might be available from your carrier tech support. T-mobile's is available [for iOS here under \"iOS IPv4/IPv6 fix\"](https://www.reddit.com/r/tmobile/wiki/index), and [here is a walkthrough for Android phones](https://www.myopenrouter.com/article/vpn-connections-not-working-t-mobile-heres-how-fix).\n\n### IPsec: Difficulty connecting through router\n\nSome routers treat IPsec connections specially because older versions of IPsec did not work properly through [NAT](https://en.wikipedia.org/wiki/Network_address_translation). If you're having problems connecting to your AlgoVPN through a specific router using IPsec you might need to change some settings on the router.\n\n#### Change the \"VPN Passthrough\" settings\n\nIf your router has a setting called something like \"VPN Passthrough\" or \"IPsec Passthrough\" try changing the setting to a different value.\n\n#### Change the default pfSense NAT rules\n\nIf your router runs [pfSense](https://www.pfsense.org) and a single IPsec client can connect but you have issues when using multiple clients, you'll need to change the **Outbound NAT** mode to **Manual Outbound NAT** and disable the rule that specifies **Static Port** for IKE (UDP port 500). See [Outbound NAT](https://docs.netgate.com/pfsense/en/latest/book/nat/outbound-nat.html#outbound-nat) in the [pfSense Book](https://docs.netgate.com/pfsense/en/latest/book).\n\n## Diagnostic Commands\n\nIf you want to investigate issues yourself, here are useful commands to run on your Algo server.\n\n### Enable Verbose Logging\n\nBy default, Algo minimizes logging for privacy. To enable detailed logging for debugging:\n\n**During deployment** - Edit `config.cfg` before running `./algo`:\n```yaml\nalgo_no_log: false              # Show detailed Ansible output (includes sensitive data!)\nstrongswan_log_level: 2         # IPsec debug logging (default: -1 disabled)\nprivacy_enhancements_enabled: false  # Disable log rotation/clearing\n```\n\n**Important:** Reset these to defaults before sharing logs or screenshots, as they may contain sensitive information.\n\n### Server-Side Diagnostics\n\n**Check service status:**\n```bash\n# WireGuard\nsystemctl status wg-quick@wg0\nwg show                          # Show WireGuard interface and peers\n\n# IPsec/StrongSwan\nsystemctl status strongswan\nipsec statusall                  # Show all IKE_SA and CHILD_SA\nipsec leases                     # Show assigned virtual IPs\n\n# DNS\nsystemctl status dnscrypt-proxy.socket dnscrypt-proxy.service\nss -lnup | grep :53              # Check what's listening on DNS port\n```\n\n**View logs:**\n```bash\n# WireGuard (kernel module, limited logging)\ndmesg | grep wireguard\n\n# IPsec/StrongSwan\njournalctl -u strongswan -f      # Follow strongswan logs\njournalctl -t charon -f          # Follow IKE daemon logs\n\n# DNS\njournalctl -u dnscrypt-proxy -f\n\n# General system\njournalctl -f                    # Follow all system logs\n```\n\n**Check network and firewall:**\n```bash\n# Verify VPN interfaces exist\nip addr show wg0                 # WireGuard interface\nip addr show                     # All interfaces\n\n# Check firewall rules\niptables -L -v -n                # IPv4 filter rules with counters\niptables -t nat -L -v -n         # IPv4 NAT rules\nip6tables -L -v -n               # IPv6 filter rules\n\n# Test DNS resolution\ndig @172.x.x.x google.com        # Replace with your local_service_ip\n```\n\n**Find your local DNS IP:**\n```bash\ngrep local_service_ip /etc/dnsmasq.d/algo.conf 2>/dev/null || \\\n  grep listen_addresses /etc/dnscrypt-proxy/dnscrypt-proxy.toml\n```\n\n### Client-Side Diagnostics\n\n**macOS:**\n```bash\n# View VPN-related logs (last hour)\nlog show --predicate 'subsystem == \"com.apple.networkextension\"' --info --last 1h\n\n# Or use Console.app and search for: nesessionmanager\n```\n\n**Linux (WireGuard):**\n```bash\nsudo wg show\njournalctl -t NetworkManager -f  # If using NetworkManager\n```\n\n**Windows:**\n```powershell\n# View VPN event logs\nGet-WinEvent -LogName \"Microsoft-Windows-VPN-Client/Operational\" -MaxEvents 50\n```\n\n## I have a problem not covered here\n\nIf you have an issue that you cannot solve with the guidance here, please [file an issue](https://github.com/trailofbits/algo/issues/new). We welcome bug reports and want to hear about problems you encounter.\n"
  },
  {
    "path": "files/cloud-init/README.md",
    "content": "# Cloud-Init Files - Critical Format Requirements\n\n## ⚠️ CRITICAL WARNING ⚠️\n\nThe files in this directory have **STRICT FORMAT REQUIREMENTS** that must not be changed by linters or automated formatting tools.\n\n## Cloud-Config Header Format\n\nThe first line of `base.yml` **MUST** be exactly:\n```\n#cloud-config\n```\n\n### ❌ DO NOT CHANGE TO:\n- `# cloud-config` (space after #) - **BREAKS CLOUD-INIT PARSING**\n- Add YAML document start `---` - **NOT ALLOWED IN CLOUD-INIT**\n\n### Why This Matters\n\nCloud-init's YAML parser expects the exact string `#cloud-config` as the first line. Any deviation causes:\n\n1. **Complete parsing failure** - All directives are skipped\n2. **SSH configuration not applied** - Servers remain on port 22 instead of 4160\n3. **Deployment timeouts** - Ansible cannot connect to configure the VPN\n4. **DigitalOcean specific impact** - Other providers may be more tolerant\n\n## Historical Context\n\n- **Working**: All versions before PR #14775 (August 2025)\n- **Broken**: PR #14775 \"Apply ansible-lint improvements\" added space by mistake\n- **Fixed**: PR #14801 restored correct format + added protections\n\nSee GitHub issue #14800 for full technical details.\n\n## Linter Configuration\n\nThese files are **excluded** from:\n- `yamllint` (`.yamllint` config)\n- `ansible-lint` (`.ansible-lint` config)\n\nThis prevents automated tools from \"fixing\" the format and breaking deployments.\n\n## Template Variables\n\nThe cloud-init files use Jinja2 templating:\n- `{{ ssh_port }}` - Configured SSH port (typically 4160)\n- `{{ lookup('file', '{{ SSH_keys.public }}') }}` - SSH public key\n\n## Editing Guidelines\n\n1. **Never** run automated formatters on these files\n2. **Test immediately** after any changes with real deployments\n3. **Check yamllint warnings** are expected (missing space in comment, missing ---)\n4. **Verify first line** remains exactly `#cloud-config`\n\n## References\n\n- [Cloud-init documentation](https://cloudinit.readthedocs.io/)\n- [Cloud-config examples](https://cloudinit.readthedocs.io/en/latest/reference/examples.html)\n- [GitHub Issue #14800](https://github.com/trailofbits/algo/issues/14800)\n"
  },
  {
    "path": "files/cloud-init/base.sh",
    "content": "#!/bin/sh\nset -eux\n\n# shellcheck disable=SC2230\nwhich sudo || until \\\n  apt-get update -y && \\\n  apt-get install sudo -yf --install-suggests; do\n  sleep 3\ndone\n\ngetent passwd algo || useradd -m -d /home/algo -s /bin/bash -G adm -p '!' algo\n\n(umask 337 && echo \"algo ALL=(ALL) NOPASSWD:ALL\" >/etc/sudoers.d/10-algo-user)\n\ncat <<EOF >/etc/ssh/sshd_config\n{{ lookup('template', 'files/cloud-init/sshd_config') }}\nEOF\n\ntest -d /home/algo/.ssh || sudo -u algo mkdir -m 0700 /home/algo/.ssh\necho \"{{ lookup('file', SSH_keys.public) }}\" | (sudo -u algo tee /home/algo/.ssh/authorized_keys && chmod 0600 /home/algo/.ssh/authorized_keys)\n\nufw --force reset\n\n# shellcheck disable=SC2015\ndpkg -l sshguard && until apt-get remove -y --purge sshguard; do\n  sleep 3\ndone || true\n\nsystemctl restart sshd.service\n"
  },
  {
    "path": "files/cloud-init/base.yml",
    "content": "#cloud-config\n# CRITICAL: The above line MUST be exactly \"#cloud-config\" (no space after #)\n# This is required by cloud-init's YAML parser. Adding a space breaks parsing\n# and causes all cloud-init directives to be skipped, resulting in SSH timeouts.\n# See: https://github.com/trailofbits/algo/issues/14800\noutput: {all: '| tee -a /var/log/cloud-init-output.log'}\n\npackage_update: true\npackage_upgrade: true\n\npackages:\n  - sudo\n{% if performance_preinstall_packages | default(false) %}\n  # Universal tools always needed by Algo (performance optimization)\n  - git\n  - screen\n  - apparmor-utils\n  - uuid-runtime\n  - coreutils\n  - iptables-persistent\n  - cgroup-tools\n{% endif %}\n\nusers:\n  - default\n  - name: algo\n    homedir: /home/algo\n    sudo: ALL=(ALL) NOPASSWD:ALL\n    groups: adm,netdev\n    shell: /bin/bash\n    lock_passwd: true\n    ssh_authorized_keys:\n      - \"{{ lookup('file', SSH_keys.public) | string }}\"\n\nwrite_files:\n  - path: /etc/ssh/sshd_config\n    content: |\n{{ lookup('template', 'files/cloud-init/sshd_config') | string | indent(width=6, first=True) }}\n\nruncmd:\n  - set -x\n  - ufw --force reset\n  - sudo apt-get remove -y --purge sshguard || true\n  - systemctl restart sshd.service\n"
  },
  {
    "path": "files/cloud-init/sshd_config",
    "content": "Port {{ ssh_port }}\nAllowGroups algo\nPermitRootLogin no\nPasswordAuthentication no\nChallengeResponseAuthentication no\nUsePAM yes\nX11Forwarding yes\nPrintMotd no\nAcceptEnv LANG LC_*\nSubsystem\tsftp\t/usr/lib/openssh/sftp-server\n"
  },
  {
    "path": "input.yml",
    "content": "---\n- name: Ask user for the input\n  hosts: localhost\n  tags: always\n  vars:\n    defaults:\n      server_name: algo\n      ondemand_cellular: false\n      ondemand_wifi: false\n      dns_adblocking: false\n      ssh_tunneling: false\n      store_pki: false\n    providers_map:\n      - { name: DigitalOcean, alias: digitalocean }\n      - { name: Amazon Lightsail, alias: lightsail }\n      - { name: Amazon EC2, alias: ec2 }\n      - { name: Microsoft Azure, alias: azure }\n      - { name: Google Compute Engine, alias: gce }\n      - { name: Hetzner Cloud, alias: hetzner }\n      - { name: Vultr, alias: vultr }\n      - { name: Scaleway, alias: scaleway }\n      - { name: OpenStack (DreamCompute optimised), alias: openstack }\n      - { name: CloudStack, alias: cloudstack }\n      - { name: Linode, alias: linode }\n      - { name: Install to existing Ubuntu latest LTS server (for more advanced users), alias: local }\n  vars_files:\n    - config.cfg\n\n  tasks:\n    - block:\n        - name: Cloud prompt\n          pause:\n            prompt: |\n              What provider would you like to use?\n                {% for p in providers_map %}\n                {{ loop.index }}. {{ p['name'] }}\n                {% endfor %}\n\n              Enter the number of your desired provider\n          register: _algo_provider\n          when: provider is undefined\n\n        - name: Set facts based on the input\n          set_fact:\n            algo_provider: \"{{ provider | default(providers_map[_algo_provider.user_input | default(omit) | int - 1]['alias']) }}\"\n\n        - name: VPN server name prompt\n          pause:\n            prompt: |\n              Name the vpn server\n              [algo]\n          register: _algo_server_name\n          when:\n            - server_name is undefined\n            - algo_provider != \"local\"\n\n        - name: Cellular On Demand prompt\n          pause:\n            prompt: |\n              Do you want macOS/iOS clients to enable \"Connect On Demand\" when connected to cellular networks?\n              [y/N]\n          register: _ondemand_cellular\n          when: ondemand_cellular is undefined\n\n        - name: Wi-Fi On Demand prompt\n          pause:\n            prompt: |\n              Do you want macOS/iOS clients to enable \"Connect On Demand\" when connected to Wi-Fi?\n              [y/N]\n          register: _ondemand_wifi\n          when: ondemand_wifi is undefined\n\n        - name: Trusted Wi-Fi networks prompt\n          pause:\n            prompt: |\n              List the names of any trusted Wi-Fi networks where macOS/iOS clients should not use \"Connect On Demand\"\n              (e.g., your home network. Comma-separated value, e.g., HomeNet,OfficeWifi,AlgoWiFi)\n          register: _ondemand_wifi_exclude\n          when:\n            - ondemand_wifi_exclude is undefined\n            - (ondemand_wifi|default(false)|bool) or (booleans_map[_ondemand_wifi.user_input|default(omit)]|default(false))\n\n        - name: Retain the PKI prompt\n          pause:\n            prompt: |\n              Do you want to retain the keys (PKI)? (required to add users in the future, but less secure)\n              [y/N]\n          register: _store_pki\n          when:\n            - store_pki is undefined\n            - ipsec_enabled\n\n        - name: DNS adblocking prompt\n          pause:\n            prompt: |\n              Do you want to enable DNS ad blocking on this VPN server?\n              [y/N]\n          register: _dns_adblocking\n          when: dns_adblocking is undefined\n\n        - name: SSH tunneling prompt\n          pause:\n            prompt: |\n              Do you want each user to have their own account for SSH tunneling?\n              [y/N]\n          register: _ssh_tunneling\n          when: ssh_tunneling is undefined\n\n        - name: Set facts based on the input\n          set_fact:\n            algo_server_name: >-\n              {%- if server_name is defined -%}{% set _server = server_name %}{%-\n              elif _algo_server_name.user_input is defined and _algo_server_name.user_input | length > 0 -%}{%-\n              set _server = _algo_server_name.user_input -%}{%-\n              else -%}{% set _server = defaults['server_name'] %}{%-\n              endif -%}\n              {{ _server | regex_replace('(?!\\.)(\\W|_)', '-') }}\n            algo_ondemand_cellular: >-\n              {%- if ondemand_cellular is defined -%}{{ ondemand_cellular | bool }}{%-\n              elif _ondemand_cellular.user_input is defined -%}{{ booleans_map[_ondemand_cellular.user_input] | default(defaults['ondemand_cellular']) }}{%-\n              else -%}{{ false }}{%-\n              endif -%}\n            algo_ondemand_wifi: >-\n              {%- if ondemand_wifi is defined -%}{{ ondemand_wifi | bool }}{%-\n              elif _ondemand_wifi.user_input is defined -%}{{ booleans_map[_ondemand_wifi.user_input] | default(defaults['ondemand_wifi']) }}{%-\n              else -%}{{ false }}{%-\n              endif -%}\n            algo_ondemand_wifi_exclude: >-\n              {%- if ondemand_wifi_exclude is defined -%}{{ ondemand_wifi_exclude | b64encode }}{%-\n              elif _ondemand_wifi_exclude.user_input is defined and _ondemand_wifi_exclude.user_input | length > 0 -%}\n              {{ _ondemand_wifi_exclude.user_input | b64encode }}{%-\n              else -%}{{ '_null' | b64encode }}{%-\n              endif -%}\n            algo_dns_adblocking: >-\n              {%- if dns_adblocking is defined -%}{{ dns_adblocking | bool }}{%-\n              elif _dns_adblocking.user_input is defined -%}{{ booleans_map[_dns_adblocking.user_input] | default(defaults['dns_adblocking']) }}{%-\n              else -%}{{ false }}{%-\n              endif -%}\n            algo_ssh_tunneling: >-\n              {%- if ssh_tunneling is defined -%}{{ ssh_tunneling | bool }}{%-\n              elif _ssh_tunneling.user_input is defined -%}{{ booleans_map[_ssh_tunneling.user_input] | default(defaults['ssh_tunneling']) }}{%-\n              else -%}{{ false }}{%-\n              endif -%}\n            algo_store_pki: >-\n              {%- if ipsec_enabled -%}\n              {%- if store_pki is defined -%}{{ store_pki | bool }}{%-\n              elif _store_pki.user_input is defined -%}{{ booleans_map[_store_pki.user_input] | default(defaults['store_pki']) }}{%-\n              else -%}{{ false }}{%-\n              endif -%}\n              {%- endif -%}\n      rescue:\n        - include_tasks: playbooks/rescue.yml\n"
  },
  {
    "path": "install.sh",
    "content": "#!/usr/bin/env sh\n\nset -ex\n\nMETHOD=\"${1:-${METHOD:-cloud}}\"\nONDEMAND_CELLULAR=\"${2:-${ONDEMAND_CELLULAR:-false}}\"\nONDEMAND_WIFI=\"${3:-${ONDEMAND_WIFI:-false}}\"\nONDEMAND_WIFI_EXCLUDE=\"${4:-${ONDEMAND_WIFI_EXCLUDE:-_null}}\"\nSTORE_PKI=\"${5:-${STORE_PKI:-false}}\"\nDNS_ADBLOCKING=\"${6:-${DNS_ADBLOCKING:-false}}\"\nSSH_TUNNELING=\"${7:-${SSH_TUNNELING:-false}}\"\nENDPOINT=\"${8:-${ENDPOINT:-localhost}}\"\nUSERS=\"${9:-${USERS:-user1}}\"\nREPO_SLUG=\"${10:-${REPO_SLUG:-trailofbits/algo}}\"\nREPO_BRANCH=\"${11:-${REPO_BRANCH:-master}}\"\nEXTRA_VARS=\"${12:-${EXTRA_VARS:-placeholder=null}}\"\nANSIBLE_EXTRA_ARGS=\"${13:-${ANSIBLE_EXTRA_ARGS}}\"\n\ncd /opt/\n\ninstallRequirements() {\n  export DEBIAN_FRONTEND=noninteractive\n  apt-get update\n  apt-get install \\\n    curl \\\n    jq -y\n\n  # Install uv\n  curl -LsSf https://astral.sh/uv/install.sh | sh\n  export PATH=\"$HOME/.local/bin:$HOME/.cargo/bin:$PATH\"\n}\n\ngetAlgo() {\n  [ ! -d \"algo\" ] && git clone \"https://github.com/${REPO_SLUG}\" -b \"${REPO_BRANCH}\" algo\n  cd algo\n\n  # uv handles all dependency installation automatically\n  uv sync\n}\n\npublicIpFromInterface() {\n  echo \"Couldn't find a valid ipv4 address, using the first IP found on the interfaces as the endpoint.\"\n  DEFAULT_INTERFACE=\"$(ip -4 route list match default | grep -Eo \"dev .*\" | awk '{print $2}')\"\n  ENDPOINT=$(ip -4 addr sh dev \"$DEFAULT_INTERFACE\" | grep -w inet | head -n1 | awk '{print $2}' | grep -oE '\\b([0-9]{1,3}\\.){3}[0-9]{1,3}\\b')\n  export ENDPOINT=\"${ENDPOINT}\"\n  echo \"Using ${ENDPOINT} as the endpoint\"\n}\n\ntryGetMetadata() {\n  # Helper function to fetch metadata with retry\n  url=\"$1\"\n  headers=\"$2\"\n  response=\"\"\n\n  # Try up to 2 times\n  for attempt in 1 2; do\n    if [ -n \"$headers\" ]; then\n      response=\"$(curl -s --connect-timeout 5 --max-time \"${METADATA_TIMEOUT}\" -H \"$headers\" \"$url\" || true)\"\n    else\n      response=\"$(curl -s --connect-timeout 5 --max-time \"${METADATA_TIMEOUT}\" \"$url\" || true)\"\n    fi\n\n    # If we got a response, return it\n    if [ -n \"$response\" ]; then\n      echo \"$response\"\n      return 0\n    fi\n\n    # Wait before retry (only on first attempt)\n    [ $attempt -eq 1 ] && sleep 2\n  done\n\n  # Return empty string if all attempts failed\n  echo \"\"\n  return 1\n}\n\npublicIpFromMetadata() {\n  # Set default timeout from environment or use 20 seconds\n  METADATA_TIMEOUT=\"${METADATA_TIMEOUT:-20}\"\n\n  if tryGetMetadata \"http://169.254.169.254/metadata/v1/vendor-data\" \"\" | grep DigitalOcean >/dev/null; then\n    ENDPOINT=\"$(tryGetMetadata \"http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address\" \"\")\"\n  elif test \"$(tryGetMetadata \"http://169.254.169.254/latest/meta-data/services/domain\" \"\")\" = \"amazonaws.com\"; then\n    ENDPOINT=\"$(tryGetMetadata \"http://169.254.169.254/latest/meta-data/public-ipv4\" \"\")\"\n  elif host -t A -W 10 metadata.google.internal 127.0.0.53 >/dev/null; then\n    ENDPOINT=\"$(tryGetMetadata \"http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip\" \"Metadata-Flavor: Google\")\"\n  elif test \"$(tryGetMetadata \"http://169.254.169.254/metadata/instance/compute/publisher/?api-version=2017-04-02&format=text\" \"Metadata:true\")\" = \"Canonical\"; then\n    ENDPOINT=\"$(tryGetMetadata \"http://169.254.169.254/metadata/instance/network/interface/0/ipv4/ipAddress/0/publicIpAddress?api-version=2017-04-02&format=text\" \"Metadata:true\")\"\n  fi\n\n  if echo \"${ENDPOINT}\" | grep -oE \"\\b([0-9]{1,3}\\.){3}[0-9]{1,3}\\b\"; then\n    export ENDPOINT=\"${ENDPOINT}\"\n    echo \"Using ${ENDPOINT} as the endpoint\"\n  else\n    publicIpFromInterface\n  fi\n}\n\ndeployAlgo() {\n  getAlgo\n\n  cd /opt/algo\n\n  export HOME=/root\n  export ANSIBLE_LOCAL_TEMP=/root/.ansible/tmp\n  export ANSIBLE_REMOTE_TEMP=/root/.ansible/tmp\n\n  # shellcheck disable=SC2086\n  uv run ansible-playbook main.yml \\\n    -e provider=local \\\n    -e \"ondemand_cellular=${ONDEMAND_CELLULAR}\" \\\n    -e \"ondemand_wifi=${ONDEMAND_WIFI}\" \\\n    -e \"ondemand_wifi_exclude=${ONDEMAND_WIFI_EXCLUDE}\" \\\n    -e \"store_pki=${STORE_PKI}\" \\\n    -e \"dns_adblocking=${DNS_ADBLOCKING}\" \\\n    -e \"ssh_tunneling=${SSH_TUNNELING}\" \\\n    -e \"endpoint=$ENDPOINT\" \\\n    -e \"users=$(echo \"$USERS\" | jq -Rc 'split(\",\")')\" \\\n    -e server=localhost \\\n    -e ssh_user=root \\\n    -e \"${EXTRA_VARS}\" \\\n    --skip-tags debug ${ANSIBLE_EXTRA_ARGS} |\n      tee /var/log/algo.log\n}\n\nif test \"$METHOD\" = \"cloud\"; then\n  publicIpFromMetadata\nfi\n\ninstallRequirements\n\ndeployAlgo\n"
  },
  {
    "path": "inventory",
    "content": "[local]\nlocalhost ansible_connection=local ansible_python_interpreter=python3\n"
  },
  {
    "path": "library/gcp_compute_location_info.py",
    "content": "#!/usr/bin/python\n\n\nimport json\n\nfrom ansible.module_utils.gcp_utils import GcpModule, GcpSession, navigate_hash\n\n################################################################################\n# Documentation\n################################################################################\n\nANSIBLE_METADATA = {\"metadata_version\": \"1.1\", \"status\": [\"preview\"], \"supported_by\": \"community\"}\n\n################################################################################\n# Main\n################################################################################\n\n\ndef main():\n    module = GcpModule(\n        argument_spec={\"filters\": {\"type\": \"list\", \"elements\": \"str\"}, \"scope\": {\"required\": True, \"type\": \"str\"}}\n    )\n\n    if module._name == \"gcp_compute_image_facts\":\n        module.deprecate(\n            \"The 'gcp_compute_image_facts' module has been renamed to 'gcp_compute_regions_info'\", version=\"2.13\"\n        )\n\n    if not module.params[\"scopes\"]:\n        module.params[\"scopes\"] = [\"https://www.googleapis.com/auth/compute\"]\n\n    items = fetch_list(module, collection(module), query_options(module.params[\"filters\"]))\n    if items.get(\"items\"):\n        items = items.get(\"items\")\n    else:\n        items = []\n    return_value = {\"resources\": items}\n    module.exit_json(**return_value)\n\n\ndef collection(module):\n    return \"https://www.googleapis.com/compute/v1/projects/{project}/{scope}\".format(**module.params)\n\n\ndef fetch_list(module, link, query):\n    auth = GcpSession(module, \"compute\")\n    response = auth.get(link, params={\"filter\": query})\n    return return_if_object(module, response)\n\n\ndef query_options(filters):\n    if not filters:\n        return \"\"\n\n    if len(filters) == 1:\n        return filters[0]\n    else:\n        queries = []\n        for f in filters:\n            # For multiple queries, all queries should have ()\n            if f[0] != \"(\" and f[-1] != \")\":\n                queries.append(\"({})\".format(\"\".join(f)))\n            else:\n                queries.append(f)\n\n        return \" \".join(queries)\n\n\ndef return_if_object(module, response):\n    # If not found, return nothing.\n    if response.status_code == 404:\n        return None\n\n    # If no content, return nothing.\n    if response.status_code == 204:\n        return None\n\n    try:\n        module.raise_for_status(response)\n        result = response.json()\n    except getattr(json.decoder, \"JSONDecodeError\", ValueError) as inst:\n        module.fail_json(msg=f\"Invalid JSON response with error: {inst}\")\n\n    if navigate_hash(result, [\"error\", \"errors\"]):\n        module.fail_json(msg=navigate_hash(result, [\"error\", \"errors\"]))\n\n    return result\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "library/lightsail_region_facts.py",
    "content": "#!/usr/bin/python\n# Copyright: Ansible Project\n# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)\n\n\nANSIBLE_METADATA = {\"metadata_version\": \"1.1\", \"status\": [\"preview\"], \"supported_by\": \"community\"}\n\nDOCUMENTATION = \"\"\"\n---\nmodule: lightsail_region_facts\nshort_description: Gather facts about AWS Lightsail regions.\ndescription:\n     - Gather facts about AWS Lightsail regions.\nversion_added: \"2.5.3\"\nauthor: \"Jack Ivanov (@jackivanov)\"\noptions:\nrequirements:\n  - \"python >= 2.6\"\n  - boto3\n\nextends_documentation_fragment:\n  - aws\n  - ec2\n\"\"\"\n\n\nEXAMPLES = \"\"\"\n# Gather facts about all regions\n- lightsail_region_facts:\n\"\"\"\n\nRETURN = \"\"\"\nregions:\n    returned: on success\n    description: >\n        Each element consists of a dict with all the information related\n        to that region.\n    type: list\n    sample: \"[{\n                \"availabilityZones\": [],\n                \"continentCode\": \"NA\",\n                \"description\": \"This region is recommended to serve users in the eastern United States\",\n                \"displayName\": \"Virginia\",\n                \"name\": \"us-east-1\"\n            }]\"\n\"\"\"\n\nimport traceback\n\ntry:\n    import botocore\n\n    HAS_BOTOCORE = True\nexcept ImportError:\n    HAS_BOTOCORE = False\n\ntry:\n    import boto3\nexcept ImportError:\n    # will be caught by imported HAS_BOTO3\n    pass\n\nfrom ansible.module_utils.basic import AnsibleModule\nfrom ansible.module_utils.ec2 import (\n    HAS_BOTO3,\n    boto3_conn,\n    ec2_argument_spec,\n    get_aws_connection_info,\n)\n\n\ndef main():\n    argument_spec = ec2_argument_spec()\n    module = AnsibleModule(argument_spec=argument_spec)\n\n    if not HAS_BOTO3:\n        module.fail_json(msg='Python module \"boto3\" is missing, please install it')\n\n    if not HAS_BOTOCORE:\n        module.fail_json(msg='Python module \"botocore\" is missing, please install it')\n\n    try:\n        region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module)\n\n        client = None\n        try:\n            client = boto3_conn(\n                module, conn_type=\"client\", resource=\"lightsail\", region=region, endpoint=ec2_url, **aws_connect_kwargs\n            )\n        except (botocore.exceptions.ClientError, botocore.exceptions.ValidationError) as e:\n            module.fail_json(\n                msg=\"Failed while connecting to the lightsail service: %s\" % e, exception=traceback.format_exc()\n            )\n\n        response = client.get_regions(includeAvailabilityZones=False)\n        module.exit_json(changed=False, data=response)\n    except (botocore.exceptions.ClientError, Exception) as e:\n        module.fail_json(msg=str(e), exception=traceback.format_exc())\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "library/scaleway_compute.py",
    "content": "#!/usr/bin/python\n#\n# Scaleway Compute management module\n#\n# Copyright (C) 2018 Online SAS.\n# https://www.scaleway.com\n#\n# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)\n\n\nANSIBLE_METADATA = {\"metadata_version\": \"1.1\", \"status\": [\"preview\"], \"supported_by\": \"community\"}\n\nDOCUMENTATION = \"\"\"\n---\nmodule: scaleway_compute\nshort_description: Scaleway compute management module\nversion_added: \"2.6\"\nauthor: Remy Leone (@sieben)\ndescription:\n    - \"This module manages compute instances on Scaleway.\"\nextends_documentation_fragment: scaleway\n\noptions:\n\n  public_ip:\n    description:\n    - Manage public IP on a Scaleway server\n    - Could be Scaleway IP address UUID\n    - C(dynamic) Means that IP is destroyed at the same time the host is destroyed\n    - C(absent) Means no public IP at all\n    version_added: '2.8'\n    default: absent\n\n  enable_ipv6:\n    description:\n      - Enable public IPv6 connectivity on the instance\n    default: false\n    type: bool\n\n  boot_type:\n    description:\n      - Boot method\n    default: bootscript\n    choices:\n      - bootscript\n      - local\n\n  image:\n    description:\n      - Image identifier used to start the instance with\n    required: true\n\n  name:\n    description:\n      - Name of the instance\n\n  organization:\n    description:\n      - Organization identifier\n    required: true\n\n  state:\n    description:\n     - Indicate desired state of the instance.\n    default: present\n    choices:\n      - present\n      - absent\n      - running\n      - restarted\n      - stopped\n\n  tags:\n    description:\n    - List of tags to apply to the instance (5 max)\n    required: false\n    default: []\n\n  region:\n    description:\n    - Scaleway compute zone\n    required: true\n    choices:\n      - ams1\n      - EMEA-NL-EVS\n      - par1\n      - EMEA-FR-PAR1\n\n  commercial_type:\n    description:\n    - Commercial name of the compute node\n    required: true\n\n  wait:\n    description:\n    - Wait for the instance to reach its desired state before returning.\n    type: bool\n    default: 'no'\n\n  wait_timeout:\n    description:\n    - Time to wait for the server to reach the expected state\n    required: false\n    default: 300\n\n  wait_sleep_time:\n    description:\n    - Time to wait before every attempt to check the state of the server\n    required: false\n    default: 3\n\n  security_group:\n    description:\n    - Security group unique identifier\n    - If no value provided, the default security group or current security group will be used\n    required: false\n    version_added: \"2.8\"\n\"\"\"\n\nEXAMPLES = \"\"\"\n- name: Create a server\n  scaleway_compute:\n    name: foobar\n    state: present\n    image: 89ee4018-f8c3-4dc4-a6b5-bca14f985ebe\n    organization: 951df375-e094-4d26-97c1-ba548eeb9c42\n    region: ams1\n    commercial_type: VC1S\n    tags:\n      - test\n      - www\n\n- name: Create a server attached to a security group\n  scaleway_compute:\n    name: foobar\n    state: present\n    image: 89ee4018-f8c3-4dc4-a6b5-bca14f985ebe\n    organization: 951df375-e094-4d26-97c1-ba548eeb9c42\n    region: ams1\n    commercial_type: VC1S\n    security_group: 4a31b633-118e-4900-bd52-facf1085fc8d\n    tags:\n      - test\n      - www\n\n- name: Destroy it right after\n  scaleway_compute:\n    name: foobar\n    state: absent\n    image: 89ee4018-f8c3-4dc4-a6b5-bca14f985ebe\n    organization: 951df375-e094-4d26-97c1-ba548eeb9c42\n    region: ams1\n    commercial_type: VC1S\n\"\"\"\n\nRETURN = \"\"\"\n\"\"\"\n\nimport datetime\nimport time\n\nfrom ansible.module_utils.basic import AnsibleModule\nfrom ansible.module_utils.scaleway import SCALEWAY_LOCATION, Scaleway, scaleway_argument_spec\n\nSCALEWAY_SERVER_STATES = (\"stopped\", \"stopping\", \"starting\", \"running\", \"locked\")\n\nSCALEWAY_TRANSITIONS_STATES = (\"stopping\", \"starting\", \"pending\")\n\n\ndef check_image_id(compute_api, image_id):\n    response = compute_api.get(path=\"images\")\n\n    if response.ok and response.json:\n        image_ids = [image[\"id\"] for image in response.json[\"images\"]]\n        if image_id not in image_ids:\n            compute_api.module.fail_json(\n                msg=\"Error in getting image %s on %s\" % (image_id, compute_api.module.params.get(\"api_url\"))\n            )\n    else:\n        compute_api.module.fail_json(msg=\"Error in getting images from: %s\" % compute_api.module.params.get(\"api_url\"))\n\n\ndef fetch_state(compute_api, server):\n    compute_api.module.debug(\"fetch_state of server: %s\" % server[\"id\"])\n    response = compute_api.get(path=\"servers/%s\" % server[\"id\"])\n\n    if response.status_code == 404:\n        return \"absent\"\n\n    if not response.ok:\n        msg = \"Error during state fetching: (%s) %s\" % (response.status_code, response.json)\n        compute_api.module.fail_json(msg=msg)\n\n    try:\n        compute_api.module.debug(\"Server %s in state: %s\" % (server[\"id\"], response.json[\"server\"][\"state\"]))\n        return response.json[\"server\"][\"state\"]\n    except KeyError:\n        compute_api.module.fail_json(msg=\"Could not fetch state in %s\" % response.json)\n\n\ndef wait_to_complete_state_transition(compute_api, server):\n    wait = compute_api.module.params[\"wait\"]\n    if not wait:\n        return\n    wait_timeout = compute_api.module.params[\"wait_timeout\"]\n    wait_sleep_time = compute_api.module.params[\"wait_sleep_time\"]\n\n    start = datetime.datetime.utcnow()\n    end = start + datetime.timedelta(seconds=wait_timeout)\n    while datetime.datetime.utcnow() < end:\n        compute_api.module.debug(\"We are going to wait for the server to finish its transition\")\n        if fetch_state(compute_api, server) not in SCALEWAY_TRANSITIONS_STATES:\n            compute_api.module.debug(\"It seems that the server is not in transition anymore.\")\n            compute_api.module.debug(\"Server in state: %s\" % fetch_state(compute_api, server))\n            break\n        time.sleep(wait_sleep_time)\n    else:\n        compute_api.module.fail_json(msg=\"Server takes too long to finish its transition\")\n\n\ndef public_ip_payload(compute_api, public_ip):\n    # We don't want a public ip\n    if public_ip in (\"absent\",):\n        return {\"dynamic_ip_required\": False}\n\n    # IP is only attached to the instance and is released as soon as the instance terminates\n    if public_ip in (\"dynamic\", \"allocated\"):\n        return {\"dynamic_ip_required\": True}\n\n    # We check that the IP we want to attach exists, if so its ID is returned\n    response = compute_api.get(\"ips\")\n    if not response.ok:\n        msg = \"Error during public IP validation: (%s) %s\" % (response.status_code, response.json)\n        compute_api.module.fail_json(msg=msg)\n\n    ip_list = []\n    try:\n        ip_list = response.json[\"ips\"]\n    except KeyError:\n        compute_api.module.fail_json(msg=\"Error in getting the IP information from: %s\" % response.json)\n\n    lookup = [ip[\"id\"] for ip in ip_list]\n    if public_ip in lookup:\n        return {\"public_ip\": public_ip}\n\n\ndef create_server(compute_api, server):\n    compute_api.module.debug(\"Starting a create_server\")\n    target_server = None\n    data = {\n        \"enable_ipv6\": server[\"enable_ipv6\"],\n        \"tags\": server[\"tags\"],\n        \"commercial_type\": server[\"commercial_type\"],\n        \"image\": server[\"image\"],\n        \"dynamic_ip_required\": server[\"dynamic_ip_required\"],\n        \"name\": server[\"name\"],\n        \"organization\": server[\"organization\"],\n    }\n\n    if server[\"boot_type\"]:\n        data[\"boot_type\"] = server[\"boot_type\"]\n\n    if server[\"security_group\"]:\n        data[\"security_group\"] = server[\"security_group\"]\n\n    response = compute_api.post(path=\"servers\", data=data)\n\n    if not response.ok:\n        msg = \"Error during server creation: (%s) %s\" % (response.status_code, response.json)\n        compute_api.module.fail_json(msg=msg)\n\n    try:\n        target_server = response.json[\"server\"]\n    except KeyError:\n        compute_api.module.fail_json(msg=\"Error in getting the server information from: %s\" % response.json)\n\n    wait_to_complete_state_transition(compute_api=compute_api, server=target_server)\n\n    return target_server\n\n\ndef restart_server(compute_api, server):\n    return perform_action(compute_api=compute_api, server=server, action=\"reboot\")\n\n\ndef stop_server(compute_api, server):\n    return perform_action(compute_api=compute_api, server=server, action=\"poweroff\")\n\n\ndef start_server(compute_api, server):\n    return perform_action(compute_api=compute_api, server=server, action=\"poweron\")\n\n\ndef perform_action(compute_api, server, action):\n    response = compute_api.post(path=\"servers/%s/action\" % server[\"id\"], data={\"action\": action})\n    if not response.ok:\n        msg = \"Error during server %s: (%s) %s\" % (action, response.status_code, response.json)\n        compute_api.module.fail_json(msg=msg)\n\n    wait_to_complete_state_transition(compute_api=compute_api, server=server)\n\n    return response\n\n\ndef remove_server(compute_api, server):\n    compute_api.module.debug(\"Starting remove server strategy\")\n    response = compute_api.delete(path=\"servers/%s\" % server[\"id\"])\n    if not response.ok:\n        msg = \"Error during server deletion: (%s) %s\" % (response.status_code, response.json)\n        compute_api.module.fail_json(msg=msg)\n\n    wait_to_complete_state_transition(compute_api=compute_api, server=server)\n\n    return response\n\n\ndef present_strategy(compute_api, wished_server):\n    compute_api.module.debug(\"Starting present strategy\")\n    changed = False\n    query_results = find(compute_api=compute_api, wished_server=wished_server, per_page=1)\n\n    if not query_results:\n        changed = True\n        if compute_api.module.check_mode:\n            return changed, {\"status\": \"A server would be created.\"}\n\n        target_server = create_server(compute_api=compute_api, server=wished_server)\n    else:\n        target_server = query_results[0]\n\n    if server_attributes_should_be_changed(\n        compute_api=compute_api, target_server=target_server, wished_server=wished_server\n    ):\n        changed = True\n\n        if compute_api.module.check_mode:\n            return changed, {\"status\": \"Server %s attributes would be changed.\" % target_server[\"id\"]}\n\n        target_server = server_change_attributes(\n            compute_api=compute_api, target_server=target_server, wished_server=wished_server\n        )\n\n    return changed, target_server\n\n\ndef absent_strategy(compute_api, wished_server):\n    compute_api.module.debug(\"Starting absent strategy\")\n    changed = False\n    target_server = None\n    query_results = find(compute_api=compute_api, wished_server=wished_server, per_page=1)\n\n    if not query_results:\n        return changed, {\"status\": \"Server already absent.\"}\n    else:\n        target_server = query_results[0]\n\n    changed = True\n\n    if compute_api.module.check_mode:\n        return changed, {\"status\": \"Server %s would be made absent.\" % target_server[\"id\"]}\n\n    # A server MUST be stopped to be deleted.\n    while fetch_state(compute_api=compute_api, server=target_server) != \"stopped\":\n        wait_to_complete_state_transition(compute_api=compute_api, server=target_server)\n        response = stop_server(compute_api=compute_api, server=target_server)\n\n        if not response.ok:\n            err_msg = f\"Error while stopping a server before removing it [{response.status_code}: {response.json}]\"\n            compute_api.module.fail_json(msg=err_msg)\n\n        wait_to_complete_state_transition(compute_api=compute_api, server=target_server)\n\n    response = remove_server(compute_api=compute_api, server=target_server)\n\n    if not response.ok:\n        err_msg = f\"Error while removing server [{response.status_code}: {response.json}]\"\n        compute_api.module.fail_json(msg=err_msg)\n\n    return changed, {\"status\": \"Server %s deleted\" % target_server[\"id\"]}\n\n\ndef running_strategy(compute_api, wished_server):\n    compute_api.module.debug(\"Starting running strategy\")\n    changed = False\n    query_results = find(compute_api=compute_api, wished_server=wished_server, per_page=1)\n\n    if not query_results:\n        changed = True\n        if compute_api.module.check_mode:\n            return changed, {\"status\": \"A server would be created before being run.\"}\n\n        target_server = create_server(compute_api=compute_api, server=wished_server)\n    else:\n        target_server = query_results[0]\n\n    if server_attributes_should_be_changed(\n        compute_api=compute_api, target_server=target_server, wished_server=wished_server\n    ):\n        changed = True\n\n        if compute_api.module.check_mode:\n            return changed, {\"status\": \"Server %s attributes would be changed before running it.\" % target_server[\"id\"]}\n\n        target_server = server_change_attributes(\n            compute_api=compute_api, target_server=target_server, wished_server=wished_server\n        )\n\n    current_state = fetch_state(compute_api=compute_api, server=target_server)\n    if current_state not in (\"running\", \"starting\"):\n        compute_api.module.debug(\"running_strategy: Server in state: %s\" % current_state)\n        changed = True\n\n        if compute_api.module.check_mode:\n            return changed, {\"status\": \"Server %s attributes would be changed.\" % target_server[\"id\"]}\n\n        response = start_server(compute_api=compute_api, server=target_server)\n        if not response.ok:\n            msg = f\"Error while running server [{response.status_code}: {response.json}]\"\n            compute_api.module.fail_json(msg=msg)\n\n    return changed, target_server\n\n\ndef stop_strategy(compute_api, wished_server):\n    compute_api.module.debug(\"Starting stop strategy\")\n    query_results = find(compute_api=compute_api, wished_server=wished_server, per_page=1)\n\n    changed = False\n\n    if not query_results:\n        if compute_api.module.check_mode:\n            return changed, {\"status\": \"A server would be created before being stopped.\"}\n\n        target_server = create_server(compute_api=compute_api, server=wished_server)\n        changed = True\n    else:\n        target_server = query_results[0]\n\n    compute_api.module.debug(\"stop_strategy: Servers are found.\")\n\n    if server_attributes_should_be_changed(\n        compute_api=compute_api, target_server=target_server, wished_server=wished_server\n    ):\n        changed = True\n\n        if compute_api.module.check_mode:\n            return changed, {\n                \"status\": \"Server %s attributes would be changed before stopping it.\" % target_server[\"id\"]\n            }\n\n        target_server = server_change_attributes(\n            compute_api=compute_api, target_server=target_server, wished_server=wished_server\n        )\n\n    wait_to_complete_state_transition(compute_api=compute_api, server=target_server)\n\n    current_state = fetch_state(compute_api=compute_api, server=target_server)\n    if current_state not in (\"stopped\",):\n        compute_api.module.debug(\"stop_strategy: Server in state: %s\" % current_state)\n\n        changed = True\n\n        if compute_api.module.check_mode:\n            return changed, {\"status\": \"Server %s would be stopped.\" % target_server[\"id\"]}\n\n        response = stop_server(compute_api=compute_api, server=target_server)\n        compute_api.module.debug(response.json)\n        compute_api.module.debug(response.ok)\n\n        if not response.ok:\n            msg = f\"Error while stopping server [{response.status_code}: {response.json}]\"\n            compute_api.module.fail_json(msg=msg)\n\n    return changed, target_server\n\n\ndef restart_strategy(compute_api, wished_server):\n    compute_api.module.debug(\"Starting restart strategy\")\n    changed = False\n    query_results = find(compute_api=compute_api, wished_server=wished_server, per_page=1)\n\n    if not query_results:\n        changed = True\n        if compute_api.module.check_mode:\n            return changed, {\"status\": \"A server would be created before being rebooted.\"}\n\n        target_server = create_server(compute_api=compute_api, server=wished_server)\n    else:\n        target_server = query_results[0]\n\n    if server_attributes_should_be_changed(\n        compute_api=compute_api, target_server=target_server, wished_server=wished_server\n    ):\n        changed = True\n\n        if compute_api.module.check_mode:\n            return changed, {\n                \"status\": \"Server %s attributes would be changed before rebooting it.\" % target_server[\"id\"]\n            }\n\n        target_server = server_change_attributes(\n            compute_api=compute_api, target_server=target_server, wished_server=wished_server\n        )\n\n    changed = True\n    if compute_api.module.check_mode:\n        return changed, {\"status\": \"Server %s would be rebooted.\" % target_server[\"id\"]}\n\n    wait_to_complete_state_transition(compute_api=compute_api, server=target_server)\n\n    if fetch_state(compute_api=compute_api, server=target_server) in (\"running\",):\n        response = restart_server(compute_api=compute_api, server=target_server)\n        wait_to_complete_state_transition(compute_api=compute_api, server=target_server)\n        if not response.ok:\n            msg = f\"Error while restarting server that was running [{response.status_code}: {response.json}].\"\n            compute_api.module.fail_json(msg=msg)\n\n    if fetch_state(compute_api=compute_api, server=target_server) in (\"stopped\",):\n        response = restart_server(compute_api=compute_api, server=target_server)\n        wait_to_complete_state_transition(compute_api=compute_api, server=target_server)\n        if not response.ok:\n            msg = f\"Error while restarting server that was stopped [{response.status_code}: {response.json}].\"\n            compute_api.module.fail_json(msg=msg)\n\n    return changed, target_server\n\n\nstate_strategy = {\n    \"present\": present_strategy,\n    \"restarted\": restart_strategy,\n    \"stopped\": stop_strategy,\n    \"running\": running_strategy,\n    \"absent\": absent_strategy,\n}\n\n\ndef find(compute_api, wished_server, per_page=1):\n    compute_api.module.debug(\"Getting inside find\")\n    # Only the name attribute is accepted in the Compute query API\n    response = compute_api.get(\"servers\", params={\"name\": wished_server[\"name\"], \"per_page\": per_page})\n\n    if not response.ok:\n        msg = \"Error during server search: (%s) %s\" % (response.status_code, response.json)\n        compute_api.module.fail_json(msg=msg)\n\n    search_results = response.json[\"servers\"]\n\n    return search_results\n\n\nPATCH_MUTABLE_SERVER_ATTRIBUTES = (\n    \"ipv6\",\n    \"tags\",\n    \"name\",\n    \"dynamic_ip_required\",\n    \"security_group\",\n)\n\n\ndef server_attributes_should_be_changed(compute_api, target_server, wished_server):\n    compute_api.module.debug(\"Checking if server attributes should be changed\")\n    compute_api.module.debug(\"Current Server: %s\" % target_server)\n    compute_api.module.debug(\"Wished Server: %s\" % wished_server)\n    debug_dict = dict(\n        (x, (target_server[x], wished_server[x]))\n        for x in PATCH_MUTABLE_SERVER_ATTRIBUTES\n        if x in target_server and x in wished_server\n    )\n    compute_api.module.debug(\"Debug dict %s\" % debug_dict)\n    try:\n        for key in PATCH_MUTABLE_SERVER_ATTRIBUTES:\n            if key in target_server and key in wished_server:\n                # When you are working with dict, only ID matter as we ask user to put only the resource ID in the playbook\n                if (\n                    isinstance(target_server[key], dict)\n                    and wished_server[key]\n                    and \"id\" in target_server[key].keys()\n                    and target_server[key][\"id\"] != wished_server[key]\n                ):\n                    return True\n                # Handling other structure compare simply the two objects content\n                elif not isinstance(target_server[key], dict) and target_server[key] != wished_server[key]:\n                    return True\n        return False\n    except AttributeError:\n        compute_api.module.fail_json(msg=\"Error while checking if attributes should be changed\")\n\n\ndef server_change_attributes(compute_api, target_server, wished_server):\n    compute_api.module.debug(\"Starting patching server attributes\")\n    patch_payload = dict()\n\n    for key in PATCH_MUTABLE_SERVER_ATTRIBUTES:\n        if key in target_server and key in wished_server:\n            # When you are working with dict, only ID matter as we ask user to put only the resource ID in the playbook\n            if isinstance(target_server[key], dict) and \"id\" in target_server[key] and wished_server[key]:\n                # Setting all key to current value except ID\n                key_dict = dict((x, target_server[key][x]) for x in target_server[key].keys() if x != \"id\")\n                # Setting ID to the user specified ID\n                key_dict[\"id\"] = wished_server[key]\n                patch_payload[key] = key_dict\n            elif not isinstance(target_server[key], dict):\n                patch_payload[key] = wished_server[key]\n\n    response = compute_api.patch(path=\"servers/%s\" % target_server[\"id\"], data=patch_payload)\n    if not response.ok:\n        msg = \"Error during server attributes patching: (%s) %s\" % (response.status_code, response.json)\n        compute_api.module.fail_json(msg=msg)\n\n    try:\n        target_server = response.json[\"server\"]\n    except KeyError:\n        compute_api.module.fail_json(msg=\"Error in getting the server information from: %s\" % response.json)\n\n    wait_to_complete_state_transition(compute_api=compute_api, server=target_server)\n\n    return target_server\n\n\ndef core(module):\n    region = module.params[\"region\"]\n    wished_server = {\n        \"state\": module.params[\"state\"],\n        \"image\": module.params[\"image\"],\n        \"name\": module.params[\"name\"],\n        \"commercial_type\": module.params[\"commercial_type\"],\n        \"enable_ipv6\": module.params[\"enable_ipv6\"],\n        \"boot_type\": module.params[\"boot_type\"],\n        \"tags\": module.params[\"tags\"],\n        \"organization\": module.params[\"organization\"],\n        \"security_group\": module.params[\"security_group\"],\n    }\n    module.params[\"api_url\"] = SCALEWAY_LOCATION[region][\"api_endpoint\"]\n\n    compute_api = Scaleway(module=module)\n\n    if wished_server[\"state\"] != \"absent\":\n        check_image_id(compute_api, wished_server[\"image\"])\n\n    # IP parameters of the wished server depends on the configuration\n    ip_payload = public_ip_payload(compute_api=compute_api, public_ip=module.params[\"public_ip\"])\n    wished_server.update(ip_payload)\n\n    changed, summary = state_strategy[wished_server[\"state\"]](compute_api=compute_api, wished_server=wished_server)\n    module.exit_json(changed=changed, msg=summary)\n\n\ndef main():\n    argument_spec = scaleway_argument_spec()\n    argument_spec.update(\n        dict(\n            image=dict(),\n            name=dict(),\n            region=dict(required=True, choices=SCALEWAY_LOCATION.keys()),\n            commercial_type=dict(),\n            enable_ipv6=dict(default=False, type=\"bool\"),\n            boot_type=dict(choices=[\"bootscript\", \"local\"]),\n            public_ip=dict(default=\"absent\"),\n            state=dict(choices=state_strategy.keys(), default=\"present\"),\n            tags=dict(type=\"list\", default=[]),\n            organization=dict(),\n            wait=dict(type=\"bool\", default=False),\n            wait_timeout=dict(type=\"int\", default=300),\n            wait_sleep_time=dict(type=\"int\", default=3),\n            security_group=dict(),\n        )\n    )\n    module = AnsibleModule(\n        argument_spec=argument_spec,\n        required_if=[\n            (\"state\", \"present\", [\"image\", \"commercial_type\", \"organization\"]),\n            (\"state\", \"running\", [\"image\", \"commercial_type\", \"organization\"]),\n            (\"state\", \"stopped\", [\"image\", \"commercial_type\", \"organization\"]),\n            (\"state\", \"restarted\", [\"image\", \"commercial_type\", \"organization\"]),\n        ],\n        supports_check_mode=True,\n    )\n\n    core(module)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "library/x25519_pubkey.py",
    "content": "#!/usr/bin/python\n\n# x25519_pubkey.py - Ansible module to derive a base64-encoded WireGuard-compatible public key\n# from a base64-encoded 32-byte X25519 private key.\n#\n# Why: community.crypto does not provide raw public key derivation for X25519 keys.\n\nimport base64\n\nfrom ansible.module_utils.basic import AnsibleModule\nfrom cryptography.hazmat.primitives import serialization\nfrom cryptography.hazmat.primitives.asymmetric import x25519\n\n\"\"\"\nAnsible module to derive base64-encoded X25519 public keys from private keys.\n\nSupports both base64-encoded strings and raw 32-byte key files.\nUsed for WireGuard key generation where community.crypto lacks raw public key derivation.\n\nParameters:\n- private_key_b64: Base64-encoded X25519 private key string\n- private_key_path: Path to file containing X25519 private key (base64 or raw 32 bytes)\n- public_key_path: Path where the derived public key should be written\n\nReturns:\n- public_key: Base64-encoded X25519 public key\n- changed: Whether the public key file was modified\n- public_key_path: Path where public key was written (if specified)\n\"\"\"\n\n\ndef run_module():\n    \"\"\"\n    Main execution function for the x25519_pubkey Ansible module.\n\n    Handles parameter validation, private key processing, public key derivation,\n    and optional file output with idempotent behavior.\n    \"\"\"\n    module_args = {\n        \"private_key_b64\": {\"type\": \"str\", \"required\": False},\n        \"private_key_path\": {\"type\": \"path\", \"required\": False},\n        \"public_key_path\": {\"type\": \"path\", \"required\": False},\n    }\n\n    result = {\n        \"changed\": False,\n        \"public_key\": \"\",\n    }\n\n    module = AnsibleModule(\n        argument_spec=module_args, required_one_of=[[\"private_key_b64\", \"private_key_path\"]], supports_check_mode=True\n    )\n\n    priv_b64 = None\n\n    if module.params[\"private_key_path\"]:\n        try:\n            with open(module.params[\"private_key_path\"], \"rb\") as f:\n                data = f.read()\n            try:\n                # First attempt: assume file contains base64 text data\n                # Strip whitespace from edges for text files (safe for base64 strings)\n                stripped_data = data.strip()\n                base64.b64decode(stripped_data, validate=True)\n                priv_b64 = stripped_data.decode()\n            except (base64.binascii.Error, ValueError):\n                # Second attempt: assume file contains raw binary data\n                # CRITICAL: Do NOT strip raw binary data - X25519 keys can contain\n                # whitespace-like bytes (0x09, 0x0A, etc.) that must be preserved\n                # Stripping would corrupt the key and cause \"got 31 bytes\" errors\n                if len(data) != 32:\n                    module.fail_json(\n                        msg=f\"Private key file must be either base64 or exactly 32 raw bytes, got {len(data)} bytes\"\n                    )\n                priv_b64 = base64.b64encode(data).decode()\n        except OSError as e:\n            module.fail_json(msg=f\"Failed to read private key file: {e}\")\n    else:\n        priv_b64 = module.params[\"private_key_b64\"]\n\n    # Validate input parameters\n    if not priv_b64:\n        module.fail_json(msg=\"No private key provided\")\n\n    try:\n        priv_raw = base64.b64decode(priv_b64, validate=True)\n    except Exception as e:\n        module.fail_json(msg=f\"Invalid base64 private key format: {e}\")\n\n    if len(priv_raw) != 32:\n        module.fail_json(msg=f\"Private key must decode to exactly 32 bytes, got {len(priv_raw)}\")\n\n    try:\n        priv_key = x25519.X25519PrivateKey.from_private_bytes(priv_raw)\n        pub_key = priv_key.public_key()\n        pub_raw = pub_key.public_bytes(encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw)\n        pub_b64 = base64.b64encode(pub_raw).decode()\n        result[\"public_key\"] = pub_b64\n\n        if module.params[\"public_key_path\"]:\n            pub_path = module.params[\"public_key_path\"]\n            existing = None\n\n            try:\n                with open(pub_path) as f:\n                    existing = f.read().strip()\n            except OSError:\n                existing = None\n\n            if existing != pub_b64:\n                try:\n                    with open(pub_path, \"w\") as f:\n                        f.write(pub_b64)\n                    result[\"changed\"] = True\n                except OSError as e:\n                    module.fail_json(msg=f\"Failed to write public key file: {e}\")\n\n            result[\"public_key_path\"] = pub_path\n\n    except Exception as e:\n        module.fail_json(msg=f\"Failed to derive public key: {e}\")\n\n    module.exit_json(**result)\n\n\ndef main():\n    \"\"\"Entry point when module is executed directly.\"\"\"\n    run_module()\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "main.yml",
    "content": "---\n- name: Algo VPN Setup\n  hosts: localhost\n  become: false\n  tasks:\n    - name: Playbook dir stat\n      stat:\n        path: \"{{ playbook_dir }}\"\n      register: _playbook_dir\n\n    - name: Ensure Ansible is not being run in a world writable directory\n      assert:\n        that: _playbook_dir.stat.mode|int <= 775\n        msg: >\n          Ansible is being run in a world writable directory ({{ playbook_dir }}), ignoring it as an ansible.cfg source.\n          For more information see https://docs.ansible.com/ansible/devel/reference_appendices/config.html#cfg-in-world-writable-dir\n\n    - name: Ensure the requirements installed\n      debug:\n        msg: \"{{ '192.168.1.1' | ansible.utils.ipaddr }}\"\n      failed_when: false\n      no_log: true\n      register: ipaddr\n\n    - name: Extract ansible version from pyproject.toml\n      set_fact:\n        ansible_requirement: \"{{ lookup('file', 'pyproject.toml') | regex_search('ansible==[0-9]+\\\\.[0-9]+\\\\.[0-9]+') }}\"\n\n    - name: Parse ansible version requirement\n      set_fact:\n        required_ansible_version:\n          op: \"{{ ansible_requirement | regex_replace('^ansible\\\\s*([~>=<]+)\\\\s*.*$', '\\\\1') }}\"\n          ver: \"{{ ansible_requirement | regex_replace('^ansible\\\\s*[~>=<]+\\\\s*(\\\\d+\\\\.\\\\d+(?:\\\\.\\\\d+)?).*$', '\\\\1') }}\"\n      when: ansible_requirement is defined\n\n    - name: Get current ansible package version\n      command: uv pip list\n      register: uv_package_list\n      changed_when: false\n\n    - name: Extract ansible version from uv package list\n      set_fact:\n        current_ansible_version: \"{{ uv_package_list.stdout | regex_search('ansible\\\\s+([0-9]+\\\\.[0-9]+\\\\.[0-9]+)', '\\\\1') | first }}\"\n\n    - name: Verify Python meets Algo VPN requirements\n      assert:\n        that: (ansible_python.version.major|string + '.' + ansible_python.version.minor|string) is version('3.11', '>=')\n        msg: >\n          Python version is not supported.\n          You must upgrade to at least Python 3.11 to use this version of Algo.\n          See for more details - https://trailofbits.github.io/algo/troubleshooting.html#python-version-is-not-supported\n\n    - name: Verify Ansible meets Algo VPN requirements\n      assert:\n        that:\n          - current_ansible_version is version(required_ansible_version.ver, required_ansible_version.op)\n          - not ipaddr.failed\n        msg: >\n          Ansible version is {{ current_ansible_version }}.\n          You must update the requirements to use this version of Algo.\n          Try to run: uv sync\n\n    - name: Check cryptography library SECP384R1 support\n      command: >\n        {{ ansible_playbook_python }} -c\n        \"from cryptography.hazmat.primitives.asymmetric.ec import SECP384R1\"\n      changed_when: false\n      failed_when: false\n      register: _crypto_check\n      when: ipsec_enabled | default(true) | bool\n\n    - name: Verify cryptography library supports IPsec requirements\n      assert:\n        that: _crypto_check.rc == 0\n        msg: >\n          The Python cryptography library is missing or does not support SECP384R1.\n          IPsec/IKEv2 requires the cryptography package with elliptic curve support.\n          Fix: Run ./algo (manages dependencies automatically) or: uv sync && uv run ansible-playbook main.yml\n      when: ipsec_enabled | default(true) | bool\n\n- name: Include prompts playbook\n  import_playbook: input.yml\n\n- name: Include cloud provisioning playbook\n  import_playbook: cloud.yml\n\n- name: Include server configuration playbook\n  import_playbook: server.yml\n"
  },
  {
    "path": "playbooks/cloud-post.yml",
    "content": "---\n- name: Set subjectAltName as a fact\n  set_fact:\n    IP_subject_alt_name: \"{{ (IP_subject_alt_name if algo_provider == 'local' else cloud_instance_ip) | lower }}\"\n\n- name: Add the server to an inventory group\n  add_host:\n    name: \"{% if cloud_instance_ip == 'localhost' %}localhost{% else %}{{ cloud_instance_ip }}{% endif %}\"\n    groups: vpn-host\n    ansible_connection: \"{% if cloud_instance_ip == 'localhost' %}local{% else %}ssh{% endif %}\"\n    ansible_ssh_user: \"{{ ansible_ssh_user | default('root') }}\"\n    ansible_ssh_port: \"{{ ansible_ssh_port | default(22) }}\"\n    ansible_python_interpreter: \"{% if cloud_instance_ip == 'localhost' %}{{ ansible_playbook_python }}{% else %}/usr/bin/python3{% endif %}\"\n    algo_provider: \"{{ algo_provider }}\"\n    algo_server_name: \"{{ algo_server_name }}\"\n    algo_ondemand_cellular: \"{{ algo_ondemand_cellular }}\"\n    algo_ondemand_wifi: \"{{ algo_ondemand_wifi }}\"\n    algo_ondemand_wifi_exclude: \"{{ algo_ondemand_wifi_exclude }}\"\n    algo_dns_adblocking: \"{{ algo_dns_adblocking }}\"\n    algo_ssh_tunneling: \"{{ algo_ssh_tunneling }}\"\n    algo_store_pki: \"{{ algo_store_pki }}\"\n    IP_subject_alt_name: \"{{ IP_subject_alt_name }}\"\n    alternative_ingress_ip: \"{{ alternative_ingress_ip | default(omit) }}\"\n    cloudinit: \"{{ cloudinit | default(false) }}\"\n\n- name: Additional variables for the server\n  add_host:\n    name: \"{% if cloud_instance_ip == 'localhost' %}localhost{% else %}{{ cloud_instance_ip }}{% endif %}\"\n    ansible_ssh_private_key_file: \"{{ SSH_keys.private_tmp }}\"\n  when: algo_provider != 'local'\n\n- name: Wait until SSH becomes ready...\n  wait_for:\n    port: \"{{ ansible_ssh_port | default(22) }}\"\n    host: \"{{ cloud_instance_ip }}\"\n    search_regex: OpenSSH\n    delay: 10\n    timeout: 320\n    state: present\n  when: cloud_instance_ip != \"localhost\"\n\n- name: Mount tmpfs\n  import_tasks: tmpfs/main.yml\n  when:\n    - pki_in_tmpfs\n    - not algo_store_pki\n    - ansible_system == \"Darwin\" or ansible_system == \"Linux\"\n\n- debug:\n    var: IP_subject_alt_name\n\n- name: Wait for target connection to become reachable/usable\n  wait_for_connection:\n    delay: 10         # Wait 10 seconds before first attempt (conservative)\n    timeout: 480      # Reduce from 600 to 480 seconds (8 minutes - safer)\n    sleep: 10         # Check every 10 seconds (less aggressive polling)\n  delegate_to: \"{{ item }}\"\n  loop: \"{{ groups['vpn-host'] }}\"\n  when: cloud_instance_ip != \"localhost\"\n"
  },
  {
    "path": "playbooks/cloud-pre.yml",
    "content": "---\n- block:\n    - name: Display the invocation environment\n      shell: >\n            ./algo-showenv.sh \\\n              'algo_provider \"{{ algo_provider }}\"' \\\n              {% if ipsec_enabled %}\n              'algo_ondemand_cellular \"{{ algo_ondemand_cellular }}\"' \\\n              'algo_ondemand_wifi \"{{ algo_ondemand_wifi }}\"' \\\n              'algo_ondemand_wifi_exclude \"{{ algo_ondemand_wifi_exclude }}\"' \\\n              {% endif %}\n              'algo_dns_adblocking \"{{ algo_dns_adblocking }}\"' \\\n              'algo_ssh_tunneling \"{{ algo_ssh_tunneling }}\"' \\\n              'wireguard_enabled \"{{ wireguard_enabled }}\"' \\\n              'dns_encryption \"{{ dns_encryption }}\"' \\\n              > /dev/tty || true\n      tags: debug\n\n    # Install cloud provider specific dependencies\n    - name: Install cloud provider dependencies\n      shell: uv pip install '.[{{ cloud_provider_extra }}]'\n      vars:\n        cloud_provider_extra: >-\n          {%- if algo_provider in ['ec2', 'lightsail'] -%}aws\n          {%- elif algo_provider == 'azure' -%}azure\n          {%- elif algo_provider == 'gce' -%}gcp\n          {%- elif algo_provider == 'hetzner' -%}hetzner\n          {%- elif algo_provider == 'linode' -%}linode\n          {%- elif algo_provider == 'openstack' -%}openstack\n          {%- elif algo_provider == 'cloudstack' -%}cloudstack\n          {%- else -%}{{ algo_provider }}\n          {%- endif -%}\n      when: algo_provider != \"local\"\n      changed_when: false\n\n  # Note: pyOpenSSL and segno are now included in pyproject.toml dependencies\n  # and installed automatically by uv sync\n  delegate_to: localhost\n  become: false\n\n- block:\n    - name: Generate the SSH private key\n      community.crypto.openssl_privatekey:\n        path: \"{{ SSH_keys.private }}\"\n        size: 4096\n        mode: \"0600\"\n        type: RSA\n\n    - name: Generate the SSH public key\n      community.crypto.openssl_publickey:\n        path: \"{{ SSH_keys.public }}\"\n        privatekey_path: \"{{ SSH_keys.private }}\"\n        format: OpenSSH\n\n    - name: Copy the private SSH key to /tmp\n      copy:\n        src: \"{{ SSH_keys.private }}\"\n        dest: \"{{ SSH_keys.private_tmp }}\"\n        force: true\n        mode: \"0600\"\n      delegate_to: localhost\n      become: false\n  when: algo_provider != \"local\"\n"
  },
  {
    "path": "playbooks/rescue.yml",
    "content": "---\n- debug:\n    var: fail_hint\n\n- name: Fail the installation\n  fail:\n"
  },
  {
    "path": "playbooks/tmpfs/linux.yml",
    "content": "---\n- name: Linux | set OS specific facts\n  set_fact:\n    tmpfs_volume_name: AlgoVPN-{{ IP_subject_alt_name }}\n    tmpfs_volume_path: /dev/shm\n"
  },
  {
    "path": "playbooks/tmpfs/macos.yml",
    "content": "---\n- name: MacOS | set OS specific facts\n  set_fact:\n    tmpfs_volume_name: AlgoVPN-{{ IP_subject_alt_name }}\n    tmpfs_volume_path: /Volumes\n\n- name: MacOS | mount a ram disk\n  shell: >\n    /usr/sbin/diskutil info \"/{{ tmpfs_volume_path }}/{{ tmpfs_volume_name }}/\" ||\n    /usr/sbin/diskutil erasevolume HFS+ \"{{ tmpfs_volume_name }}\" $(hdiutil attach -nomount ram://64000)\n  args:\n    creates: /{{ tmpfs_volume_path }}/{{ tmpfs_volume_name }}\n"
  },
  {
    "path": "playbooks/tmpfs/main.yml",
    "content": "---\n- name: Include tasks for MacOS\n  import_tasks: macos.yml\n  when: ansible_system == \"Darwin\"\n\n- name: Include tasks for Linux\n  import_tasks: linux.yml\n  when: ansible_system == \"Linux\"\n\n- name: Set config paths as facts\n  set_fact:\n    ipsec_pki_path: /{{ tmpfs_volume_path }}/{{ tmpfs_volume_name }}/IPsec/\n\n- name: Update config paths\n  add_host:\n    name: \"{{ 'localhost' if cloud_instance_ip == 'localhost' else cloud_instance_ip }}\"\n    ipsec_pki_path: \"{{ ipsec_pki_path }}\"\n"
  },
  {
    "path": "playbooks/tmpfs/umount.yml",
    "content": "---\n- name: Linux | Delete the PKI directory\n  file:\n    path: /{{ facts.tmpfs_volume_path }}/{{ facts.tmpfs_volume_name }}/\n    state: absent\n  when: facts.ansible_system == \"Linux\"\n\n- block:\n    - name: MacOS | check fs the ramdisk exists\n      command: /usr/sbin/diskutil info \"{{ facts.tmpfs_volume_name }}\"\n      failed_when: false\n      changed_when: false\n      register: diskutil_info\n\n    - name: MacOS | unmount and eject the ram disk\n      shell: >\n        /usr/sbin/diskutil umount force \"/{{ facts.tmpfs_volume_path }}/{{ facts.tmpfs_volume_name }}/\" &&\n        /usr/sbin/diskutil eject \"{{ facts.tmpfs_volume_name }}\"\n      changed_when: false\n      when: diskutil_info.rc == 0\n      register: result\n      until: result.rc == 0\n      retries: 5\n      delay: 3\n  when:\n    - facts.ansible_system == \"Darwin\"\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[build-system]\nrequires = [\"setuptools>=68.0.0\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[project]\nname = \"algo\"\ndescription = \"Set up a personal IPSEC VPN in the cloud\"\nversion = \"2.0.0-beta\"\nrequires-python = \">=3.11\"\ndependencies = [\n    \"ansible==12.3.0\",\n    \"cryptography>=42.0.0\",\n    \"jinja2>=3.1.6\",\n    \"netaddr==1.3.0\",\n    \"pyyaml>=6.0.2\",\n    \"segno>=1.6.0\",\n]\n\n[tool.setuptools]\n# Explicitly disable package discovery since Algo is not a Python package\npy-modules = []\n\n[project.optional-dependencies]\n# Cloud provider dependencies (installed automatically based on provider selection)\naws = [\n    \"boto3>=1.34.0\",\n]\nazure = [\n    \"azure-identity>=1.15.0\",\n    \"azure-mgmt-compute>=30.0.0\",\n    \"azure-mgmt-network>=25.0.0\",\n    \"azure-mgmt-resource>=23.0.0\",\n    \"msrestazure>=0.6.4\",\n]\ngcp = [\n    \"google-auth>=2.28.0\",\n    \"requests>=2.31.0\",\n]\nhetzner = [\n    \"hcloud>=1.33.0\",\n]\nlinode = [\n    \"linode-api4>=5.15.0\",\n]\nopenstack = [\n    \"openstacksdk>=2.1.0\",\n]\ncloudstack = [\n    \"cs>=3.0.0\",\n]\n\n[tool.ruff]\n# Ruff configuration\ntarget-version = \"py311\"\nline-length = 120\n\n[tool.ruff.lint]\nselect = [\n    \"E\",    # pycodestyle errors\n    \"W\",    # pycodestyle warnings\n    \"F\",    # pyflakes\n    \"I\",    # isort\n    \"B\",    # flake8-bugbear\n    \"C4\",   # flake8-comprehensions\n    \"UP\",   # pyupgrade\n    \"S\",    # flake8-bandit (security)\n    \"SIM\",  # flake8-simplify\n    \"RUF\",  # Ruff-specific rules\n    \"ERA\",  # commented-out code detection\n    \"PTH\",  # pathlib recommendations\n]\nignore = [\n    \"E501\",   # line too long (handled by formatter)\n    \"B011\",   # assert False is acceptable in test code\n    \"S101\",   # assert is acceptable in test code\n    \"S110\",   # try-except-pass - used intentionally for optional checks\n    \"S112\",   # try-except-continue - used intentionally for skipping files\n    \"S603\",   # subprocess calls - needed for Ansible modules\n    \"S607\",   # partial path - needed for Ansible modules\n    \"S701\",   # jinja2 autoescape - templates are for config files, not HTML\n    \"S602\",   # shell=True in subprocess - needed for test mocks\n    \"SIM102\", # nested if - sometimes clearer than combined conditions\n    \"SIM108\", # ternary - sometimes if/else is more readable\n    \"ERA001\", # commented code - some comments explain regex patterns\n    \"RUF005\", # iterable unpacking - concatenation is clearer in some cases\n    \"PTH100\", # pathlib - existing code uses os.path\n    \"PTH108\", # pathlib - existing code uses os.unlink\n    \"PTH110\", # pathlib - existing code uses os.path.exists\n    \"PTH118\", # pathlib - existing code uses os.path.join\n    \"PTH119\", # pathlib - existing code uses os.path.basename\n    \"PTH120\", # pathlib - existing code uses os.path.dirname\n    \"PTH123\", # pathlib - existing code uses open()\n    \"PTH201\", # pathlib - existing code uses Path(\".\")\n    \"PTH207\", # pathlib - existing code uses glob\n]\n\n[tool.ruff.lint.per-file-ignores]\n\"library/*\" = [\"ALL\"]  # Exclude Ansible library modules (external code)\n\"tests/*\" = [\"S101\"]   # Allow assert in tests\n\n[tool.ty.environment]\n# Type checking configuration\npython-version = \"3.11\"\n\n[tool.ty.src]\n# Exclude Ansible library modules and tests (test code has looser typing)\nexclude = [\"library/**\", \"tests/**\"]\n\n[tool.ty.rules]\n# Ignore import warnings - ty doesn't see the venv when run via uv --with\n# These are checked by Python's import system at runtime\nunresolved-import = \"ignore\"\nunknown-argument = \"warn\"\n\n[tool.uv]\n# Centralized uv version management\ndev-dependencies = [\n    \"pytest>=8.0.0\",\n    \"pytest-xdist>=3.0.0\",  # Parallel test execution\n    \"ruff>=0.8.0\",         # Python linter and formatter\n    \"yamllint>=1.35.0\",    # YAML linter\n    \"ansible-lint>=24.0.0\", # Ansible linter\n    \"j2lint>=1.2.0\",       # Jinja2 template linter\n]\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\npython_files = [\"test_*.py\"]\npython_classes = [\"Test*\"]\npython_functions = [\"test_*\"]\naddopts = [\n    \"-v\",                   # Verbose output\n    \"--strict-markers\",     # Strict marker validation\n    \"--strict-config\",      # Strict config validation\n    \"--tb=short\",          # Short traceback format\n]\nmarkers = [\n    \"slow: marks tests as slow (deselect with '-m \\\"not slow\\\"')\",\n    \"integration: marks tests as integration tests\",\n]\n"
  },
  {
    "path": "pytest.ini",
    "content": "[pytest]\ntestpaths = tests/unit\npython_files = test_*.py\npython_classes = Test*\npython_functions = test_*\naddopts = -v --tb=short\nfilterwarnings =\n    ignore::DeprecationWarning\n    ignore::PendingDeprecationWarning\n"
  },
  {
    "path": "requirements.yml",
    "content": "---\ncollections:\n  - name: ansible.posix\n    version: \"==2.1.0\"\n  - name: ansible.utils\n    version: \">=4.0.0\"\n  - name: community.general\n    version: \"==11.1.0\"\n  - name: community.crypto\n    version: \">=3.1.1\"\n  - name: openstack.cloud\n    version: \"==2.4.1\"\n  - name: linode.cloud\n    version: \">=0.41.0\"\n  - name: community.digitalocean\n    version: \">=1.26.0\"\n  - name: azure.azcollection\n    version: \">=3.0.0\"\n"
  },
  {
    "path": "roles/client/files/libstrongswan-relax-constraints.conf",
    "content": "libstrongswan {\n  x509 {\n    enforce_critical = no\n  }\n}\n"
  },
  {
    "path": "roles/client/handlers/main.yml",
    "content": "---\n- name: restart strongswan\n  service: name={{ strongswan_service }} state=restarted\n"
  },
  {
    "path": "roles/client/tasks/main.yml",
    "content": "---\n- name: Gather Facts\n  setup:\n- name: Include system based facts and tasks\n  import_tasks: systems/main.yml\n\n- name: Install prerequisites\n  package: name=\"{{ item }}\" state=present\n  loop: \"{{ prerequisites }}\"\n  register: result\n  until: result is succeeded\n  retries: 10\n  delay: 3\n\n- name: Install strongSwan\n  package: name=strongswan state=present\n  register: result\n  until: result is succeeded\n  retries: 10\n  delay: 3\n\n- name: Setup the ipsec config\n  template:\n    src: roles/strongswan/templates/client_ipsec.conf.j2\n    dest: \"{{ configs_prefix }}/ipsec.{{ IP_subject_alt_name }}.conf\"\n    mode: \"0644\"\n  notify:\n    - restart strongswan\n\n- name: Setup the ipsec secrets\n  template:\n    src: roles/strongswan/templates/client_ipsec.secrets.j2\n    dest: \"{{ configs_prefix }}/ipsec.{{ IP_subject_alt_name }}.secrets\"\n    mode: \"0600\"\n  notify:\n    - restart strongswan\n\n- name: Include additional ipsec config\n  lineinfile:\n    dest: \"{{ item.dest }}\"\n    line: \"{{ item.line }}\"\n    create: true\n    mode: \"{{ item.mode }}\"\n  loop:\n    - dest: \"{{ configs_prefix }}/ipsec.conf\"\n      line: include ipsec.{{ IP_subject_alt_name }}.conf\n      mode: '0644'\n    - dest: \"{{ configs_prefix }}/ipsec.secrets\"\n      line: include ipsec.{{ IP_subject_alt_name }}.secrets\n      mode: '0600'\n  notify:\n    - restart strongswan\n\n- name: Configure libstrongswan to relax CA constraints\n  copy:\n    src: libstrongswan-relax-constraints.conf\n    dest: \"{{ configs_prefix }}/strongswan.d/relax-ca-constraints.conf\"\n    owner: root\n    group: root\n    mode: '0644'\n\n- name: Setup the certificates and keys\n  template:\n    src: \"{{ item.src }}\"\n    dest: \"{{ item.dest }}\"\n    mode: \"{{ item.mode }}\"\n  loop:\n    - src: configs/{{ IP_subject_alt_name }}/ipsec/.pki/certs/{{ vpn_user }}.crt\n      dest: \"{{ configs_prefix }}/ipsec.d/certs/{{ vpn_user }}.crt\"\n      mode: '0644'\n    - src: configs/{{ IP_subject_alt_name }}/ipsec/.pki/cacert.pem\n      dest: \"{{ configs_prefix }}/ipsec.d/cacerts/{{ IP_subject_alt_name }}.pem\"\n      mode: '0644'\n    - src: configs/{{ IP_subject_alt_name }}/ipsec/.pki/private/{{ vpn_user }}.key\n      dest: \"{{ configs_prefix }}/ipsec.d/private/{{ vpn_user }}.key\"\n      mode: '0600'\n  notify:\n    - restart strongswan\n"
  },
  {
    "path": "roles/client/tasks/systems/CentOS.yml",
    "content": "---\n- name: Set OS specific facts\n  set_fact:\n    prerequisites:\n      - epel-release\n    configs_prefix: /etc/strongswan\n"
  },
  {
    "path": "roles/client/tasks/systems/Debian.yml",
    "content": "---\n- name: Set OS specific facts\n  set_fact:\n    prerequisites:\n      - libstrongswan-standard-plugins\n    configs_prefix: /etc\n"
  },
  {
    "path": "roles/client/tasks/systems/Fedora.yml",
    "content": "---\n- name: Set OS specific facts\n  set_fact:\n    prerequisites:\n      - libselinux-python\n    configs_prefix: /etc/strongswan\n"
  },
  {
    "path": "roles/client/tasks/systems/Ubuntu.yml",
    "content": "---\n- name: Set OS specific facts\n  set_fact:\n    prerequisites:\n      - libstrongswan-standard-plugins\n    configs_prefix: /etc\n"
  },
  {
    "path": "roles/client/tasks/systems/main.yml",
    "content": "---\n- include_tasks: Debian.yml\n  when: ansible_distribution == 'Debian'\n\n- include_tasks: Ubuntu.yml\n  when: ansible_distribution == 'Ubuntu'\n\n- include_tasks: CentOS.yml\n  when: ansible_distribution == 'CentOS'\n\n- include_tasks: Fedora.yml\n  when: ansible_distribution == 'Fedora'\n"
  },
  {
    "path": "roles/cloud-azure/defaults/main.yml",
    "content": "---\n# az account list-locations --query 'sort_by([].{name:name,displayName:displayName,regionalDisplayName:regionalDisplayName}, &name)' -o yaml\nazure_regions:\n  - displayName: Asia\n    name: asia\n    regionalDisplayName: Asia\n  - displayName: Asia Pacific\n    name: asiapacific\n    regionalDisplayName: Asia Pacific\n  - displayName: Australia\n    name: australia\n    regionalDisplayName: Australia\n  - displayName: Australia Central\n    name: australiacentral\n    regionalDisplayName: (Asia Pacific) Australia Central\n  - displayName: Australia Central 2\n    name: australiacentral2\n    regionalDisplayName: (Asia Pacific) Australia Central 2\n  - displayName: Australia East\n    name: australiaeast\n    regionalDisplayName: (Asia Pacific) Australia East\n  - displayName: Australia Southeast\n    name: australiasoutheast\n    regionalDisplayName: (Asia Pacific) Australia Southeast\n  - displayName: Brazil\n    name: brazil\n    regionalDisplayName: Brazil\n  - displayName: Brazil South\n    name: brazilsouth\n    regionalDisplayName: (South America) Brazil South\n  - displayName: Brazil Southeast\n    name: brazilsoutheast\n    regionalDisplayName: (South America) Brazil Southeast\n  - displayName: Brazil US\n    name: brazilus\n    regionalDisplayName: (South America) Brazil US\n  - displayName: Canada\n    name: canada\n    regionalDisplayName: Canada\n  - displayName: Canada Central\n    name: canadacentral\n    regionalDisplayName: (Canada) Canada Central\n  - displayName: Canada East\n    name: canadaeast\n    regionalDisplayName: (Canada) Canada East\n  - displayName: Central India\n    name: centralindia\n    regionalDisplayName: (Asia Pacific) Central India\n  - displayName: Central US\n    name: centralus\n    regionalDisplayName: (US) Central US\n  - displayName: Central US EUAP\n    name: centraluseuap\n    regionalDisplayName: (US) Central US EUAP\n  - displayName: Central US (Stage)\n    name: centralusstage\n    regionalDisplayName: (US) Central US (Stage)\n  - displayName: East Asia\n    name: eastasia\n    regionalDisplayName: (Asia Pacific) East Asia\n  - displayName: East Asia (Stage)\n    name: eastasiastage\n    regionalDisplayName: (Asia Pacific) East Asia (Stage)\n  - displayName: East US\n    name: eastus\n    regionalDisplayName: (US) East US\n  - displayName: East US 2\n    name: eastus2\n    regionalDisplayName: (US) East US 2\n  - displayName: East US 2 EUAP\n    name: eastus2euap\n    regionalDisplayName: (US) East US 2 EUAP\n  - displayName: East US 2 (Stage)\n    name: eastus2stage\n    regionalDisplayName: (US) East US 2 (Stage)\n  - displayName: East US (Stage)\n    name: eastusstage\n    regionalDisplayName: (US) East US (Stage)\n  - displayName: East US STG\n    name: eastusstg\n    regionalDisplayName: (US) East US STG\n  - displayName: Europe\n    name: europe\n    regionalDisplayName: Europe\n  - displayName: France\n    name: france\n    regionalDisplayName: France\n  - displayName: France Central\n    name: francecentral\n    regionalDisplayName: (Europe) France Central\n  - displayName: France South\n    name: francesouth\n    regionalDisplayName: (Europe) France South\n  - displayName: Germany\n    name: germany\n    regionalDisplayName: Germany\n  - displayName: Germany North\n    name: germanynorth\n    regionalDisplayName: (Europe) Germany North\n  - displayName: Germany West Central\n    name: germanywestcentral\n    regionalDisplayName: (Europe) Germany West Central\n  - displayName: Global\n    name: global\n    regionalDisplayName: Global\n  - displayName: India\n    name: india\n    regionalDisplayName: India\n  - displayName: Israel\n    name: israel\n    regionalDisplayName: Israel\n  - displayName: Israel Central\n    name: israelcentral\n    regionalDisplayName: (Middle East) Israel Central\n  - displayName: Italy\n    name: italy\n    regionalDisplayName: Italy\n  - displayName: Italy North\n    name: italynorth\n    regionalDisplayName: (Europe) Italy North\n  - displayName: Japan\n    name: japan\n    regionalDisplayName: Japan\n  - displayName: Japan East\n    name: japaneast\n    regionalDisplayName: (Asia Pacific) Japan East\n  - displayName: Japan West\n    name: japanwest\n    regionalDisplayName: (Asia Pacific) Japan West\n  - displayName: Jio India Central\n    name: jioindiacentral\n    regionalDisplayName: (Asia Pacific) Jio India Central\n  - displayName: Jio India West\n    name: jioindiawest\n    regionalDisplayName: (Asia Pacific) Jio India West\n  - displayName: Korea\n    name: korea\n    regionalDisplayName: Korea\n  - displayName: Korea Central\n    name: koreacentral\n    regionalDisplayName: (Asia Pacific) Korea Central\n  - displayName: Korea South\n    name: koreasouth\n    regionalDisplayName: (Asia Pacific) Korea South\n  - displayName: New Zealand\n    name: newzealand\n    regionalDisplayName: New Zealand\n  - displayName: North Central US\n    name: northcentralus\n    regionalDisplayName: (US) North Central US\n  - displayName: North Central US (Stage)\n    name: northcentralusstage\n    regionalDisplayName: (US) North Central US (Stage)\n  - displayName: North Europe\n    name: northeurope\n    regionalDisplayName: (Europe) North Europe\n  - displayName: Norway\n    name: norway\n    regionalDisplayName: Norway\n  - displayName: Norway East\n    name: norwayeast\n    regionalDisplayName: (Europe) Norway East\n  - displayName: Norway West\n    name: norwaywest\n    regionalDisplayName: (Europe) Norway West\n  - displayName: Poland\n    name: poland\n    regionalDisplayName: Poland\n  - displayName: Poland Central\n    name: polandcentral\n    regionalDisplayName: (Europe) Poland Central\n  - displayName: Qatar\n    name: qatar\n    regionalDisplayName: Qatar\n  - displayName: Qatar Central\n    name: qatarcentral\n    regionalDisplayName: (Middle East) Qatar Central\n  - displayName: Singapore\n    name: singapore\n    regionalDisplayName: Singapore\n  - displayName: South Africa\n    name: southafrica\n    regionalDisplayName: South Africa\n  - displayName: South Africa North\n    name: southafricanorth\n    regionalDisplayName: (Africa) South Africa North\n  - displayName: South Africa West\n    name: southafricawest\n    regionalDisplayName: (Africa) South Africa West\n  - displayName: South Central US\n    name: southcentralus\n    regionalDisplayName: (US) South Central US\n  - displayName: South Central US (Stage)\n    name: southcentralusstage\n    regionalDisplayName: (US) South Central US (Stage)\n  - displayName: Southeast Asia\n    name: southeastasia\n    regionalDisplayName: (Asia Pacific) Southeast Asia\n  - displayName: Southeast Asia (Stage)\n    name: southeastasiastage\n    regionalDisplayName: (Asia Pacific) Southeast Asia (Stage)\n  - displayName: South India\n    name: southindia\n    regionalDisplayName: (Asia Pacific) South India\n  - displayName: Sweden\n    name: sweden\n    regionalDisplayName: Sweden\n  - displayName: Sweden Central\n    name: swedencentral\n    regionalDisplayName: (Europe) Sweden Central\n  - displayName: Switzerland\n    name: switzerland\n    regionalDisplayName: Switzerland\n  - displayName: Switzerland North\n    name: switzerlandnorth\n    regionalDisplayName: (Europe) Switzerland North\n  - displayName: Switzerland West\n    name: switzerlandwest\n    regionalDisplayName: (Europe) Switzerland West\n  - displayName: United Arab Emirates\n    name: uae\n    regionalDisplayName: United Arab Emirates\n  - displayName: UAE Central\n    name: uaecentral\n    regionalDisplayName: (Middle East) UAE Central\n  - displayName: UAE North\n    name: uaenorth\n    regionalDisplayName: (Middle East) UAE North\n  - displayName: United Kingdom\n    name: uk\n    regionalDisplayName: United Kingdom\n  - displayName: UK South\n    name: uksouth\n    regionalDisplayName: (Europe) UK South\n  - displayName: UK West\n    name: ukwest\n    regionalDisplayName: (Europe) UK West\n  - displayName: United States\n    name: unitedstates\n    regionalDisplayName: United States\n  - displayName: United States EUAP\n    name: unitedstateseuap\n    regionalDisplayName: United States EUAP\n  - displayName: West Central US\n    name: westcentralus\n    regionalDisplayName: (US) West Central US\n  - displayName: West Europe\n    name: westeurope\n    regionalDisplayName: (Europe) West Europe\n  - displayName: West India\n    name: westindia\n    regionalDisplayName: (Asia Pacific) West India\n  - displayName: West US\n    name: westus\n    regionalDisplayName: (US) West US\n  - displayName: West US 2\n    name: westus2\n    regionalDisplayName: (US) West US 2\n  - displayName: West US 2 (Stage)\n    name: westus2stage\n    regionalDisplayName: (US) West US 2 (Stage)\n  - displayName: West US 3\n    name: westus3\n    regionalDisplayName: (US) West US 3\n  - displayName: West US (Stage)\n    name: westusstage\n    regionalDisplayName: (US) West US (Stage)\n"
  },
  {
    "path": "roles/cloud-azure/files/deployment.json",
    "content": "{\n  \"$schema\": \"http://schema.management.azure.com/schemas/2014-04-01-preview/deploymentTemplate.json\",\n  \"contentVersion\": \"1.0.0.0\",\n  \"parameters\": {\n    \"sshKeyData\": {\n      \"type\": \"string\"\n    },\n    \"WireGuardPort\": {\n      \"type\": \"int\"\n    },\n    \"vmSize\": {\n      \"type\": \"string\"\n    },\n    \"imageReferencePublisher\": {\n      \"type\": \"string\"\n    },\n    \"imageReferenceOffer\": {\n      \"type\": \"string\"\n    },\n    \"imageReferenceSku\": {\n      \"type\": \"string\"\n    },\n    \"imageReferenceVersion\": {\n      \"type\": \"string\"\n    },\n    \"osDiskType\": {\n      \"type\": \"string\"\n    },\n    \"SshPort\": {\n      \"type\": \"int\"\n    },\n    \"UserData\": {\n      \"type\": \"string\"\n    }\n  },\n  \"variables\": {\n    \"vnetID\": \"[resourceId('Microsoft.Network/virtualNetworks', resourceGroup().name)]\",\n    \"subnet1Ref\": \"[concat(variables('vnetID'),'/subnets/', resourceGroup().name)]\"\n  },\n  \"resources\": [\n    {\n      \"apiVersion\": \"2015-06-15\",\n      \"type\": \"Microsoft.Network/networkSecurityGroups\",\n      \"name\": \"[resourceGroup().name]\",\n      \"location\": \"[resourceGroup().location]\",\n      \"properties\": {\n        \"securityRules\": [\n          {\n            \"name\": \"AllowSSH\",\n            \"properties\": {\n              \"description\": \"Allow SSH\",\n              \"protocol\": \"Tcp\",\n              \"sourcePortRange\": \"*\",\n              \"destinationPortRange\": \"[parameters('SshPort')]\",\n              \"sourceAddressPrefix\": \"*\",\n              \"destinationAddressPrefix\": \"*\",\n              \"access\": \"Allow\",\n              \"priority\": 100,\n              \"direction\": \"Inbound\"\n            }\n          },\n          {\n            \"name\": \"AllowIPSEC500\",\n            \"properties\": {\n              \"description\": \"Allow UDP to port 500\",\n              \"protocol\": \"Udp\",\n              \"sourcePortRange\": \"*\",\n              \"destinationPortRange\": \"500\",\n              \"sourceAddressPrefix\": \"*\",\n              \"destinationAddressPrefix\": \"*\",\n              \"access\": \"Allow\",\n              \"priority\": 110,\n              \"direction\": \"Inbound\"\n            }\n          },\n          {\n            \"name\": \"AllowIPSEC4500\",\n            \"properties\": {\n              \"description\": \"Allow UDP to port 4500\",\n              \"protocol\": \"Udp\",\n              \"sourcePortRange\": \"*\",\n              \"destinationPortRange\": \"4500\",\n              \"sourceAddressPrefix\": \"*\",\n              \"destinationAddressPrefix\": \"*\",\n              \"access\": \"Allow\",\n              \"priority\": 120,\n              \"direction\": \"Inbound\"\n            }\n          },\n          {\n            \"name\": \"AllowWireGuard\",\n            \"properties\": {\n              \"description\": \"Locks inbound down to ssh default port 22.\",\n              \"protocol\": \"Udp\",\n              \"sourcePortRange\": \"*\",\n              \"destinationPortRange\": \"[parameters('WireGuardPort')]\",\n              \"sourceAddressPrefix\": \"*\",\n              \"destinationAddressPrefix\": \"*\",\n              \"access\": \"Allow\",\n              \"priority\": 130,\n              \"direction\": \"Inbound\"\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"apiVersion\": \"2015-06-15\",\n      \"type\": \"Microsoft.Network/publicIPAddresses\",\n      \"name\": \"[resourceGroup().name]\",\n      \"location\": \"[resourceGroup().location]\",\n      \"properties\": {\n        \"publicIPAllocationMethod\": \"Static\"\n      }\n    },\n    {\n      \"apiVersion\": \"2015-06-15\",\n      \"type\": \"Microsoft.Network/virtualNetworks\",\n      \"name\": \"[resourceGroup().name]\",\n      \"location\": \"[resourceGroup().location]\",\n      \"properties\": {\n        \"addressSpace\": {\n          \"addressPrefixes\": [\n            \"10.10.0.0/16\"\n          ]\n        },\n        \"subnets\": [\n          {\n            \"name\": \"[resourceGroup().name]\",\n            \"properties\": {\n              \"addressPrefix\": \"10.10.0.0/24\"\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"apiVersion\": \"2015-06-15\",\n      \"type\": \"Microsoft.Network/networkInterfaces\",\n      \"name\": \"[resourceGroup().name]\",\n      \"location\": \"[resourceGroup().location]\",\n      \"dependsOn\": [\n        \"[concat('Microsoft.Network/networkSecurityGroups/', resourceGroup().name)]\",\n        \"[concat('Microsoft.Network/publicIPAddresses/', resourceGroup().name)]\",\n        \"[concat('Microsoft.Network/virtualNetworks/', resourceGroup().name)]\"\n      ],\n      \"properties\": {\n        \"networkSecurityGroup\": {\n          \"id\": \"[resourceId('Microsoft.Network/networkSecurityGroups', resourceGroup().name)]\"\n        },\n        \"ipConfigurations\": [\n          {\n            \"name\": \"ipconfig1\",\n            \"properties\": {\n              \"privateIPAllocationMethod\": \"Dynamic\",\n              \"publicIPAddress\": {\n                \"id\": \"[resourceId('Microsoft.Network/publicIPAddresses', resourceGroup().name)]\"\n              },\n              \"subnet\": {\n                \"id\": \"[variables('subnet1Ref')]\"\n              }\n            }\n          }\n        ]\n      }\n    },\n    {\n      \"apiVersion\": \"2016-04-30-preview\",\n      \"type\": \"Microsoft.Compute/virtualMachines\",\n      \"name\": \"[resourceGroup().name]\",\n      \"location\": \"[resourceGroup().location]\",\n      \"dependsOn\": [\n        \"[concat('Microsoft.Network/networkInterfaces/', resourceGroup().name)]\"\n      ],\n      \"properties\": {\n        \"hardwareProfile\": {\n          \"vmSize\": \"[parameters('vmSize')]\"\n        },\n        \"osProfile\": {\n          \"computerName\": \"[resourceGroup().name]\",\n          \"customData\": \"[parameters('UserData')]\",\n          \"adminUsername\": \"algo\",\n          \"linuxConfiguration\": {\n            \"disablePasswordAuthentication\": true,\n            \"ssh\": {\n              \"publicKeys\": [\n                {\n                  \"path\": \"/home/algo/.ssh/authorized_keys\",\n                  \"keyData\": \"[parameters('sshKeyData')]\"\n                }\n              ]\n            }\n          }\n        },\n        \"storageProfile\": {\n          \"imageReference\": {\n            \"publisher\": \"[parameters('imageReferencePublisher')]\",\n            \"offer\": \"[parameters('imageReferenceOffer')]\",\n            \"sku\": \"[parameters('imageReferenceSku')]\",\n            \"version\": \"[parameters('imageReferenceVersion')]\"\n          },\n          \"osDisk\": {\n            \"createOption\": \"FromImage\",\n            \"managedDisk\": {\n              \"storageAccountType\": \"[parameters('osDiskType')]\"\n            }\n          }\n        },\n        \"networkProfile\": {\n          \"networkInterfaces\": [\n            {\n              \"id\": \"[resourceId('Microsoft.Network/networkInterfaces', resourceGroup().name)]\"\n            }\n          ]\n        }\n      }\n    }\n  ],\n  \"outputs\": {\n    \"publicIPAddresses\": {\n      \"type\": \"string\",\n      \"value\": \"[reference(resourceId('Microsoft.Network/publicIPAddresses',resourceGroup().name),providers('Microsoft.Network', 'publicIPAddresses').apiVersions[0]).ipAddress]\"\n    }\n  }\n}\n"
  },
  {
    "path": "roles/cloud-azure/tasks/destroy.yml",
    "content": "---\n- name: Destroy Azure resource group\n  azure.azcollection.azure_rm_resourcegroup:\n    name: \"{{ algo_server_name }}\"\n    state: absent\n    force_delete_nonempty: true\n    secret: \"{{ secret }}\"\n    tenant: \"{{ tenant }}\"\n    client_id: \"{{ client_id }}\"\n    subscription_id: \"{{ subscription_id }}\"\n"
  },
  {
    "path": "roles/cloud-azure/tasks/main.yml",
    "content": "---\n- name: Include prompts\n  import_tasks: prompts.yml\n\n- set_fact:\n    algo_region: >-\n      {%- if region is defined -%}{{ region }}{%-\n      elif _algo_region.user_input -%}{{ azure_regions[_algo_region.user_input | int - 1]['name'] }}{%-\n      else -%}{{ azure_regions[default_region | int - 1]['name'] }}{%-\n      endif -%}\n\n- name: Create AlgoVPN Server\n  azure.azcollection.azure_rm_deployment:\n    state: present\n    deployment_name: \"{{ algo_server_name }}\"\n    template: \"{{ lookup('file', role_path + '/files/deployment.json') }}\"\n    secret: \"{{ secret }}\"\n    tenant: \"{{ tenant }}\"\n    client_id: \"{{ client_id }}\"\n    subscription_id: \"{{ subscription_id }}\"\n    resource_group_name: \"{{ algo_server_name }}\"\n    location: \"{{ algo_region }}\"\n    parameters:\n      sshKeyData:\n        value: \"{{ lookup('file', SSH_keys.public) }}\"\n      WireGuardPort:\n        value: \"{{ wireguard_port }}\"\n      vmSize:\n        value: \"{{ cloud_providers.azure.size }}\"\n      imageReferencePublisher:\n        value: \"{{ cloud_providers.azure.image.publisher }}\"\n      imageReferenceOffer:\n        value: \"{{ cloud_providers.azure.image.offer }}\"\n      imageReferenceSku:\n        value: \"{{ cloud_providers.azure.image.sku }}\"\n      imageReferenceVersion:\n        value: \"{{ cloud_providers.azure.image.version }}\"\n      osDiskType:\n        value: \"{{ cloud_providers.azure.osDisk.type }}\"\n      SshPort:\n        value: \"{{ ssh_port }}\"\n      UserData:\n        value: \"{{ lookup('template', 'files/cloud-init/base.yml') | b64encode }}\"\n  register: azure_rm_deployment\n\n- set_fact:\n    cloud_instance_ip: \"{{ azure_rm_deployment.deployment.outputs.publicIPAddresses.value }}\"\n    ansible_ssh_user: algo\n    ansible_ssh_port: \"{{ ssh_port }}\"\n    cloudinit: true\n"
  },
  {
    "path": "roles/cloud-azure/tasks/prompts.yml",
    "content": "---\n- set_fact:\n    secret: \"{{ azure_secret | default(lookup('env', 'AZURE_SECRET'), true) }}\"\n    tenant: \"{{ azure_tenant | default(lookup('env', 'AZURE_TENANT'), true) }}\"\n    client_id: \"{{ azure_client_id | default(lookup('env', 'AZURE_CLIENT_ID'), true) }}\"\n    subscription_id: \"{{ azure_subscription_id | default(lookup('env', 'AZURE_SUBSCRIPTION_ID'), true) }}\"\n  no_log: true\n\n- when: region is undefined\n  block:\n    - name: Set the default region\n      set_fact:\n        default_region: >-\n          {% for r in azure_regions %}{%- if r['name'] == \"eastus\" %}{{ loop.index }}{% endif %}{%- endfor %}\n\n    - pause:\n        prompt: |\n          What region should the server be located in?\n            {% for r in azure_regions %}\n            {{ loop.index }}. {{ r['regionalDisplayName'] }}\n            {% endfor %}\n\n          Enter the number of your desired region\n          [{{ default_region }}]\n      register: _algo_region\n"
  },
  {
    "path": "roles/cloud-cloudstack/tasks/destroy.yml",
    "content": "---\n- environment:\n    CLOUDSTACK_KEY: \"{{ algo_cs_key }}\"\n    CLOUDSTACK_SECRET: \"{{ algo_cs_token }}\"\n    CLOUDSTACK_ENDPOINT: \"{{ algo_cs_url }}\"\n  no_log: true\n  block:\n    - name: Destroy CloudStack instance\n      cs_instance:\n        name: \"{{ algo_server_name }}\"\n        state: expunged\n\n    - name: Remove security group\n      cs_securitygroup:\n        name: \"{{ algo_server_name }}-security_group\"\n        state: absent\n      failed_when: false\n"
  },
  {
    "path": "roles/cloud-cloudstack/tasks/main.yml",
    "content": "---\n- name: Include prompts\n  import_tasks: prompts.yml\n\n- environment:\n    CLOUDSTACK_KEY: \"{{ algo_cs_key }}\"\n    CLOUDSTACK_SECRET: \"{{ algo_cs_token }}\"\n    CLOUDSTACK_ENDPOINT: \"{{ algo_cs_url }}\"\n  no_log: true\n  block:\n    - set_fact:\n        algo_region: >-\n          {%- if region is defined -%}{{ region }}{%-\n          elif _algo_region.user_input is defined and _algo_region.user_input | length > 0 -%}{{ cs_zones[_algo_region.user_input | int - 1]['name'] }}{%-\n          else -%}{{ cs_zones[default_zone | int - 1]['name'] }}{%-\n          endif -%}\n\n    - name: Security group created\n      cs_securitygroup:\n        name: \"{{ algo_server_name }}-security_group\"\n        description: AlgoVPN security group\n      register: cs_security_group\n\n    - name: Security rules created\n      cs_securitygroup_rule:\n        security_group: \"{{ cs_security_group.name }}\"\n        protocol: \"{{ item.proto }}\"\n        start_port: \"{{ item.start_port }}\"\n        end_port: \"{{ item.end_port }}\"\n        cidr: \"{{ item.range }}\"\n      loop:\n        - { proto: tcp, start_port: \"{{ ssh_port }}\", end_port: \"{{ ssh_port }}\", range: 0.0.0.0/0 }\n        - { proto: udp, start_port: 4500, end_port: 4500, range: 0.0.0.0/0 }\n        - { proto: udp, start_port: 500, end_port: 500, range: 0.0.0.0/0 }\n        - { proto: udp, start_port: \"{{ wireguard_port }}\", end_port: \"{{ wireguard_port }}\", range: 0.0.0.0/0 }\n\n    - name: Set facts\n      set_fact:\n        image_id: \"{{ cloud_providers.cloudstack.image }}\"\n        size: \"{{ cloud_providers.cloudstack.size }}\"\n        disk: \"{{ cloud_providers.cloudstack.disk }}\"\n\n    - name: Server created\n      cs_instance:\n        name: \"{{ algo_server_name }}\"\n        root_disk_size: \"{{ disk }}\"\n        template: \"{{ image_id }}\"\n        security_groups: \"{{ cs_security_group.name }}\"\n        zone: \"{{ algo_region }}\"\n        service_offering: \"{{ size }}\"\n        user_data: \"{{ lookup('template', 'files/cloud-init/base.yml') }}\"\n      register: cs_server\n\n    - set_fact:\n        cloud_instance_ip: \"{{ cs_server.default_ip }}\"\n        ansible_ssh_user: algo\n        ansible_ssh_port: \"{{ ssh_port }}\"\n        cloudinit: true\n"
  },
  {
    "path": "roles/cloud-cloudstack/tasks/prompts.yml",
    "content": "---\n- block:\n    - pause:\n        prompt: |\n          Enter the API key (https://trailofbits.github.io/algo/cloud-cloudstack.html):\n        echo: false\n      register: _cs_key\n      when:\n        - cs_key is undefined\n        - lookup('env', 'CLOUDSTACK_KEY')|length <= 0\n      no_log: true\n\n    - pause:\n        prompt: |\n          Enter the API secret (https://trailofbits.github.io/algo/cloud-cloudstack.html):\n        echo: false\n      register: _cs_secret\n      when:\n        - cs_secret is undefined\n        - lookup('env', 'CLOUDSTACK_SECRET')|length <= 0\n      no_log: true\n\n    - pause:\n        prompt: |\n          Enter the API endpoint (https://trailofbits.github.io/algo/cloud-cloudstack.html)\n      register: _cs_url\n      when:\n        - cs_url is undefined\n        - lookup('env', 'CLOUDSTACK_ENDPOINT') | length <= 0\n\n    - set_fact:\n        algo_cs_key: \"{{ cs_key | default(_cs_key.user_input | default(None)) | default(lookup('env', 'CLOUDSTACK_KEY'), true) }}\"\n        algo_cs_token: \"{{ cs_secret | default(_cs_secret.user_input | default(None)) | default(lookup('env', 'CLOUDSTACK_SECRET'), true) }}\"\n        algo_cs_url: >-\n          {{ cs_url | default(_cs_url.user_input|default(None)) |\n             default(lookup('env', 'CLOUDSTACK_ENDPOINT'), true) }}\n      no_log: true\n\n    - name: Check for Exoscale API endpoint\n      fail:\n        msg: |\n          ERROR: Exoscale CloudStack API has been deprecated as of May 1, 2024.\n\n          Exoscale has migrated from CloudStack to their proprietary API v2, which is not compatible\n          with CloudStack-based tools. Algo no longer supports Exoscale deployments.\n\n          Please consider these alternative providers:\n          - Hetzner (provider: hetzner) - German provider with European coverage\n          - DigitalOcean (provider: digitalocean) - Has Amsterdam and Frankfurt regions\n          - Vultr (provider: vultr) - Multiple European locations\n          - Scaleway (provider: scaleway) - French provider\n\n          If you're using a different CloudStack provider, please provide the correct API endpoint.\n      when: \"'exoscale.com' in algo_cs_url or 'exoscale.ch' in algo_cs_url\"\n\n    - name: Get zones on cloud\n      cs_zone_info:\n      register: _cs_zones\n      environment:\n        CLOUDSTACK_KEY: \"{{ algo_cs_key }}\"\n        CLOUDSTACK_SECRET: \"{{ algo_cs_token }}\"\n        CLOUDSTACK_ENDPOINT: \"{{ algo_cs_url }}\"\n      no_log: true\n\n    - name: Extract zones from output\n      set_fact:\n        cs_zones: \"{{ _cs_zones['zones'] | sort(attribute='name') }}\"\n\n    - name: Set the default zone\n      set_fact:\n        default_zone: \"1\"  # Default to first zone in the list\n\n    - pause:\n        prompt: |\n          What zone should the server be located in?\n            {% for z in cs_zones %}\n            {{ loop.index }}. {{ z['name'] }}\n            {% endfor %}\n\n            Enter the number of your desired zone\n            [{{ default_zone }}]\n      register: _algo_region\n      when: region is undefined\n"
  },
  {
    "path": "roles/cloud-digitalocean/tasks/destroy.yml",
    "content": "---\n- name: Destroy DigitalOcean droplet\n  digital_ocean_droplet:\n    state: absent\n    name: \"{{ algo_server_name }}\"\n    oauth_token: \"{{ algo_do_token }}\"\n    unique_name: true\n"
  },
  {
    "path": "roles/cloud-digitalocean/tasks/main.yml",
    "content": "---\n- name: Include prompts\n  import_tasks: prompts.yml\n\n- name: Upload the SSH key\n  digital_ocean_sshkey:\n    oauth_token: \"{{ algo_do_token }}\"\n    name: \"{{ SSH_keys.comment }}\"\n    ssh_pub_key: \"{{ lookup('file', SSH_keys.public) }}\"\n  register: do_ssh_key\n\n- name: Creating a droplet...\n  digital_ocean_droplet:\n    state: present\n    name: \"{{ algo_server_name }}\"\n    oauth_token: \"{{ algo_do_token }}\"\n    size: \"{{ cloud_providers.digitalocean.size }}\"\n    region: \"{{ algo_do_region }}\"\n    image: \"{{ cloud_providers.digitalocean.image }}\"\n    wait_timeout: 300\n    unique_name: true\n    ipv6: true\n    ssh_keys: \"{{ do_ssh_key.data.ssh_key.id }}\"\n    user_data: \"{{ lookup('template', 'files/cloud-init/base.yml') | string }}\"\n    tags:\n      - Environment:Algo\n  register: digital_ocean_droplet\n\n# Return data is not idempotent\n- set_fact:\n    droplet: \"{{ digital_ocean_droplet.data.droplet | default(digital_ocean_droplet.data) }}\"\n\n- when: alternative_ingress_ip | bool\n  block:\n    - name: Create a Floating IP\n      community.digitalocean.digital_ocean_floating_ip:\n        state: present\n        oauth_token: \"{{ algo_do_token }}\"\n        droplet_id: \"{{ droplet.id }}\"\n      register: digital_ocean_floating_ip\n\n    - name: Set the static ip as a fact\n      set_fact:\n        cloud_alternative_ingress_ip: \"{{ digital_ocean_floating_ip.data.floating_ip.ip }}\"\n\n- set_fact:\n    cloud_instance_ip: \"{{ (droplet.networks.v4 | selectattr('type', '==', 'public')).0.ip_address }}\"\n    ansible_ssh_user: algo\n    ansible_ssh_port: \"{{ ssh_port }}\"\n    cloudinit: true\n"
  },
  {
    "path": "roles/cloud-digitalocean/tasks/prompts.yml",
    "content": "---\n- pause:\n    prompt: |\n      Enter your API token. The token must have read and write permissions (https://cloud.digitalocean.com/settings/api/tokens):\n    echo: false\n  register: _do_token\n  when:\n    - do_token is undefined\n    - lookup('env', 'DO_API_TOKEN')|length <= 0\n  no_log: true\n\n- name: Set the token as a fact\n  set_fact:\n    algo_do_token: \"{{ do_token | default(_do_token.user_input | default(None)) | default(lookup('env', 'DO_API_TOKEN'), true) }}\"\n  no_log: true\n\n- name: Get regions\n  uri:\n    url: https://api.digitalocean.com/v2/regions\n    method: GET\n    status_code: 200\n    headers:\n      Content-Type: application/json\n      Authorization: Bearer {{ algo_do_token }}\n  register: _do_regions\n  no_log: \"{{ algo_no_log | default(true) }}\"\n  failed_when: false\n\n- name: Check DigitalOcean API response\n  fail:\n    msg: |\n      {% if _do_regions.status == 401 %}\n      DigitalOcean API authentication failed (401 Unauthorized)\n\n      Your API token is invalid or expired. Please:\n      1. Go to https://cloud.digitalocean.com/settings/api/tokens\n      2. Create a new token with 'Read' and 'Write' scopes\n      3. Run the deployment again with the new token\n\n      {% elif _do_regions.status == 403 %}\n      DigitalOcean API access denied (403 Forbidden)\n\n      Your API token lacks required permissions. Please:\n      1. Go to https://cloud.digitalocean.com/settings/api/tokens\n      2. Ensure your token has both 'Read' and 'Write' scopes\n      3. Consider creating a new token with full access\n\n      {% elif _do_regions.status == 429 %}\n      DigitalOcean API rate limit exceeded (429 Too Many Requests)\n\n      You've hit the API rate limit. Please:\n      1. Wait 5-10 minutes before retrying\n      2. Check if other applications are using your token\n\n      {% elif _do_regions.status == 500 or _do_regions.status == 502 or _do_regions.status == 503 %}\n      DigitalOcean API server error ({{ _do_regions.status }})\n\n      DigitalOcean is experiencing issues. Please:\n      1. Check https://status.digitalocean.com for outages\n      2. Wait a few minutes and try again\n\n      {% elif _do_regions.status is undefined %}\n      Failed to connect to DigitalOcean API\n\n      Could not reach api.digitalocean.com. Please check:\n      1. Your internet connection\n      2. Firewall rules (port 443 must be open)\n      3. DNS resolution for api.digitalocean.com\n\n      {% else %}\n      DigitalOcean API error (HTTP {{ _do_regions.status }})\n\n      An unexpected error occurred. Please:\n      1. Verify your API token at https://cloud.digitalocean.com/settings/api/tokens\n      2. Check https://status.digitalocean.com for service issues\n      {% endif %}\n\n      For detailed error messages: Set 'algo_no_log: false' in config.cfg and run again\n  when: _do_regions.status != 200\n\n- name: Set facts about the regions\n  set_fact:\n    do_regions: \"{{ _do_regions.json.regions | selectattr('available', 'true') | sort(attribute='slug') }}\"\n\n- name: Set default region\n  set_fact:\n    default_region: >-\n      {% for r in do_regions %}{%- if r['slug'] == \"nyc3\" %}{{ loop.index }}{% endif %}{%- endfor %}\n\n- pause:\n    prompt: |\n      What region should the server be located in?\n        {% for r in do_regions %}\n        {{ loop.index }}. {{ r['slug'] }}     {{ r['name'] }}\n        {% endfor %}\n\n      Enter the number of your desired region\n      [{{ default_region }}]\n  register: _algo_region\n  when: region is undefined\n\n- name: Set additional facts\n  set_fact:\n    algo_do_region: >-\n      {%- if region is defined -%}{{ region }}{%-\n      elif _algo_region.user_input -%}{{ do_regions[_algo_region.user_input | int - 1]['slug'] }}{%-\n      else -%}{{ do_regions[default_region | int - 1]['slug'] }}{%-\n      endif -%}\n"
  },
  {
    "path": "roles/cloud-ec2/defaults/main.yml",
    "content": "---\nencrypted: \"{{ cloud_providers.ec2.encrypted }}\"\nec2_vpc_nets:\n  cidr_block: 172.16.0.0/16\n  subnet_cidr: 172.16.254.0/23\nexisting_eip: \"\"\n"
  },
  {
    "path": "roles/cloud-ec2/files/stack.yaml",
    "content": "---\nAWSTemplateFormatVersion: '2010-09-09'\nDescription: 'Algo VPN stack'\nParameters:\n  InstanceTypeParameter:\n    Type: String\n    Default: t3.micro\n  ImageIdParameter:\n    Type: AWS::EC2::Image::Id\n  WireGuardPort:\n    Type: String\n  UseThisElasticIP:\n    Type: String\n    Default: ''\n  EbsEncrypted:\n    Type: String\n  UserData:\n    Type: String\n  SshPort:\n    Type: String\n  InstanceMarketTypeParameter:\n    Description: Launch a Spot instance or standard on-demand instance\n    Type: String\n    Default: on-demand\n    AllowedValues:\n      - spot\n      - on-demand\nConditions:\n  AllocateNewEIP: !Equals [!Ref UseThisElasticIP, '']\n  AssociateExistingEIP: !Not [!Equals [!Ref UseThisElasticIP, '']]\n  InstanceIsSpot: !Equals [spot, !Ref InstanceMarketTypeParameter]\nResources:\n  VPC:\n    Type: AWS::EC2::VPC\n    Properties:\n      CidrBlock: 172.16.0.0/16\n      EnableDnsSupport: true\n      EnableDnsHostnames: true\n      InstanceTenancy: default\n      Tags:\n        - Key: Name\n          Value: !Ref AWS::StackName\n\n  VPCIPv6:\n    Type: AWS::EC2::VPCCidrBlock\n    Properties:\n      AmazonProvidedIpv6CidrBlock: true\n      VpcId: !Ref VPC\n\n  InternetGateway:\n    Type: AWS::EC2::InternetGateway\n    Properties:\n      Tags:\n        - Key: Name\n          Value: !Ref AWS::StackName\n\n  Subnet:\n    Type: AWS::EC2::Subnet\n    Properties:\n      CidrBlock: 172.16.254.0/23\n      MapPublicIpOnLaunch: false\n      VpcId: !Ref VPC\n      Tags:\n        - Key: Name\n          Value: !Ref AWS::StackName\n\n  VPCGatewayAttachment:\n    Type: AWS::EC2::VPCGatewayAttachment\n    Properties:\n      VpcId: !Ref VPC\n      InternetGatewayId: !Ref InternetGateway\n\n  RouteTable:\n    Type: AWS::EC2::RouteTable\n    Properties:\n      VpcId: !Ref VPC\n      Tags:\n        - Key: Name\n          Value: !Ref AWS::StackName\n\n  Route:\n    Type: AWS::EC2::Route\n    DependsOn:\n      - VPCGatewayAttachment\n    Properties:\n      RouteTableId: !Ref RouteTable\n      DestinationCidrBlock: 0.0.0.0/0\n      GatewayId: !Ref InternetGateway\n\n  RouteIPv6:\n    Type: AWS::EC2::Route\n    DependsOn:\n      - VPCGatewayAttachment\n    Properties:\n      RouteTableId: !Ref RouteTable\n      DestinationIpv6CidrBlock: \"::/0\"\n      GatewayId: !Ref InternetGateway\n\n  SubnetIPv6:\n    Type: AWS::EC2::SubnetCidrBlock\n    DependsOn:\n      - VPCIPv6\n    Properties:\n      Ipv6CidrBlock:\n        \"Fn::Join\":\n          - \"\"\n          - - !Select [0, !Split [\"::\", !Select [0, !GetAtt VPC.Ipv6CidrBlocks]]]\n            - \"::dead:beef/64\"\n      SubnetId: !Ref Subnet\n\n  RouteSubnet:\n    Type: \"AWS::EC2::SubnetRouteTableAssociation\"\n    Properties:\n      RouteTableId: !Ref RouteTable\n      SubnetId: !Ref Subnet\n\n  InstanceSecurityGroup:\n    Type: AWS::EC2::SecurityGroup\n    DependsOn:\n      - Subnet\n    Properties:\n      VpcId: !Ref VPC\n      GroupDescription: Enable SSH and IPsec\n      SecurityGroupIngress:\n        - IpProtocol: tcp\n          FromPort: !Ref SshPort\n          ToPort: !Ref SshPort\n          CidrIp: 0.0.0.0/0\n        - IpProtocol: udp\n          FromPort: '500'\n          ToPort: '500'\n          CidrIp: 0.0.0.0/0\n        - IpProtocol: udp\n          FromPort: '4500'\n          ToPort: '4500'\n          CidrIp: 0.0.0.0/0\n        - IpProtocol: udp\n          FromPort: !Ref WireGuardPort\n          ToPort: !Ref WireGuardPort\n          CidrIp: 0.0.0.0/0\n      Tags:\n        - Key: Name\n          Value: !Ref AWS::StackName\n\n  EC2LaunchTemplate:\n    Type: AWS::EC2::LaunchTemplate\n    Condition: InstanceIsSpot    # Only create this template if requested\n    Properties:                  # a spot instance_market_type in config.cfg\n      LaunchTemplateName: !Ref AWS::StackName\n      LaunchTemplateData:\n        InstanceMarketOptions:\n          MarketType: spot\n\n  EC2Instance:\n    Type: AWS::EC2::Instance\n    DependsOn:\n      - SubnetIPv6\n    Properties:\n      InstanceType:\n        Ref: InstanceTypeParameter\n      BlockDeviceMappings:\n        - DeviceName: /dev/sda1\n          Ebs:\n            DeleteOnTermination: true\n            VolumeSize: 8\n            Encrypted: !Ref EbsEncrypted\n      InstanceInitiatedShutdownBehavior: terminate\n      SecurityGroupIds:\n        - Ref: InstanceSecurityGroup\n      ImageId:\n        Ref: ImageIdParameter\n      SubnetId: !Ref Subnet\n      Ipv6AddressCount: 1\n      UserData: !Ref UserData\n      LaunchTemplate:\n        !If               # Only if Conditions created \"EC2LaunchTemplate\"\n        - InstanceIsSpot\n        -\n          LaunchTemplateId:\n            !Ref EC2LaunchTemplate\n          Version: 1\n        - !Ref AWS::NoValue  # Else this LaunchTemplate not set\n      Tags:\n        - Key: Name\n          Value: !Ref AWS::StackName\n\n  ElasticIP:\n    Type: AWS::EC2::EIP\n    Condition: AllocateNewEIP\n    Properties:\n      Domain: vpc\n      InstanceId: !Ref EC2Instance\n    DependsOn:\n      - VPCGatewayAttachment\n\n  ElasticIPAssociation:\n    Type: AWS::EC2::EIPAssociation\n    Condition: AssociateExistingEIP\n    Properties:\n      AllocationId: !Ref UseThisElasticIP\n      InstanceId: !Ref EC2Instance\n\n\nOutputs:\n  ElasticIP:\n    Value: !GetAtt [EC2Instance, PublicIp]\n"
  },
  {
    "path": "roles/cloud-ec2/tasks/cloudformation.yml",
    "content": "---\n# Note: Using template_body instead of deprecated 'template' parameter.\n# The 'template' parameter is deprecated and will be removed after 2026-05-01.\n- name: Deploy the template\n  cloudformation:\n    aws_access_key: \"{{ access_key }}\"\n    aws_secret_key: \"{{ secret_key }}\"\n    aws_session_token: \"{{ session_token if session_token else omit }}\"\n    stack_name: \"{{ stack_name }}\"\n    state: present\n    region: \"{{ algo_region }}\"\n    template_body: \"{{ lookup('file', 'roles/cloud-ec2/files/stack.yaml') }}\"\n    template_parameters:\n      InstanceTypeParameter: \"{{ cloud_providers.ec2.size }}\"\n      ImageIdParameter: \"{{ ami_image }}\"\n      WireGuardPort: \"{{ wireguard_port }}\"\n      UseThisElasticIP: \"{{ existing_eip }}\"\n      EbsEncrypted: \"{{ encrypted }}\"\n      UserData: \"{{ lookup('template', 'files/cloud-init/base.yml') | b64encode }}\"\n      SshPort: \"{{ ssh_port }}\"\n      InstanceMarketTypeParameter: \"{{ cloud_providers.ec2.instance_market_type }}\"\n    tags:\n      Environment: Algo\n  register: stack\n  no_log: true\n"
  },
  {
    "path": "roles/cloud-ec2/tasks/destroy.yml",
    "content": "---\n- name: Set stack name\n  set_fact:\n    stack_name: \"{{ algo_server_name | replace('.', '-') }}\"\n\n- name: Destroy CloudFormation stack\n  cloudformation:\n    aws_access_key: \"{{ access_key }}\"\n    aws_secret_key: \"{{ secret_key }}\"\n    aws_session_token: \"{{ session_token if session_token else omit }}\"\n    stack_name: \"{{ stack_name }}\"\n    state: absent\n    region: \"{{ algo_region }}\"\n  no_log: true\n"
  },
  {
    "path": "roles/cloud-ec2/tasks/main.yml",
    "content": "---\n- name: Include prompts\n  import_tasks: prompts.yml\n\n- name: Locate official AMI for region\n  ec2_ami_info:\n    aws_access_key: \"{{ access_key }}\"\n    aws_secret_key: \"{{ secret_key }}\"\n    owners: \"{{ cloud_providers.ec2.image.owner }}\"\n    region: \"{{ algo_region }}\"\n    filters:\n      architecture: \"{{ cloud_providers.ec2.image.arch }}\"\n      name: ubuntu/images/hvm-ssd/{{ cloud_providers.ec2.image.name }}-*64-server-*\n  register: ami_search\n  no_log: true\n\n- name: Set the ami id as a fact\n  set_fact:\n    ami_image: \"{{ (ami_search.images | sort(attribute='creation_date') | last)['image_id'] }}\"\n\n- name: Deploy the stack\n  import_tasks: cloudformation.yml\n\n- set_fact:\n    cloud_instance_ip: \"{{ stack.stack_outputs.ElasticIP }}\"\n    ansible_ssh_user: algo\n    ansible_ssh_port: \"{{ ssh_port }}\"\n    cloudinit: true\n"
  },
  {
    "path": "roles/cloud-ec2/tasks/prompts.yml",
    "content": "---\n# Discover AWS credentials from standard locations\n- name: Set AWS credentials file path\n  set_fact:\n    aws_credentials_path: \"{{ lookup('env', 'AWS_SHARED_CREDENTIALS_FILE') | default(lookup('env', 'HOME') + '/.aws/credentials', true) }}\"\n    aws_profile: \"{{ lookup('env', 'AWS_PROFILE') | default('default', true) }}\"\n\n# Try to read credentials from file if not already provided\n- when:\n    - aws_access_key is undefined\n    - lookup('env', 'AWS_ACCESS_KEY_ID')|length <= 0\n  block:\n    - name: Check if AWS credentials file exists\n      stat:\n        path: \"{{ aws_credentials_path }}\"\n      register: aws_creds_file\n      delegate_to: localhost\n\n    - name: Read AWS credentials from file\n      set_fact:\n        _file_access_key: \"{{ lookup('ini', 'aws_access_key_id', section=aws_profile, file=aws_credentials_path, errors='ignore') | default('', true) }}\"\n        _file_secret_key: \"{{ lookup('ini', 'aws_secret_access_key', section=aws_profile, file=aws_credentials_path, errors='ignore') | default('', true) }}\"\n        _file_session_token: \"{{ lookup('ini', 'aws_session_token', section=aws_profile, file=aws_credentials_path, errors='ignore') | default('', true) }}\"\n      when: aws_creds_file.stat.exists\n      no_log: true\n\n# Prompt for credentials if still not available\n- pause:\n    prompt: |\n      Enter your AWS Access Key ID (http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html)\n      Note: Make sure to use an IAM user with an acceptable policy attached (see https://github.com/trailofbits/algo/blob/master/docs/deploy-from-ansible.md)\n    echo: false\n  register: _aws_access_key\n  when:\n    - aws_access_key is undefined\n    - lookup('env', 'AWS_ACCESS_KEY_ID')|length <= 0\n    - _file_access_key is undefined or _file_access_key|length <= 0\n\n- pause:\n    prompt: |\n      Enter your AWS Secret Access Key (http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html)\n    echo: false\n  register: _aws_secret_key\n  when:\n    - aws_secret_key is undefined\n    - lookup('env', 'AWS_SECRET_ACCESS_KEY')|length <= 0\n    - _file_secret_key is undefined or _file_secret_key|length <= 0\n\n# Set final credentials with proper precedence\n# Note: The 'true' parameter in default() is required for Ansible 12+ compatibility.\n# Without it, empty strings from env lookups stop the default chain since they're\n# \"defined\" values, not \"undefined\". The 'true' makes default() also trigger on\n# falsy values (empty strings, None).\n- set_fact:\n    access_key: >-\n      {{ aws_access_key\n         | default(lookup('env', 'AWS_ACCESS_KEY_ID'), true)\n         | default(_file_access_key, true)\n         | default(_aws_access_key.user_input | default(None), true) }}\n    secret_key: >-\n      {{ aws_secret_key\n         | default(lookup('env', 'AWS_SECRET_ACCESS_KEY'), true)\n         | default(_file_secret_key, true)\n         | default(_aws_secret_key.user_input | default(None), true) }}\n    session_token: >-\n      {{ aws_session_token\n         | default(lookup('env', 'AWS_SESSION_TOKEN'), true)\n         | default(_file_session_token, true)\n         | default('') }}\n  no_log: true\n\n- when: region is undefined\n  block:\n    - name: Get regions\n      aws_region_info:\n        aws_access_key: \"{{ access_key }}\"\n        aws_secret_key: \"{{ secret_key }}\"\n        aws_session_token: \"{{ session_token if session_token else omit }}\"\n        region: us-east-1\n      register: _aws_regions\n      no_log: true\n\n    - name: Set facts about the regions\n      set_fact:\n        aws_regions: \"{{ _aws_regions.regions | sort(attribute='region_name') }}\"\n\n    - name: Set the default region\n      set_fact:\n        default_region: >-\n          {%- for r in aws_regions -%}\n          {%- if r['region_name'] == \"us-east-1\" %}{{ loop.index }}{% endif -%}\n          {%- endfor %}\n\n    - pause:\n        prompt: |\n          What region should the server be located in?\n          (https://docs.aws.amazon.com/general/latest/gr/rande.html#ec2_region)\n            {% for r in aws_regions %}\n            {{ loop.index }}. {{ r['region_name'] }}\n            {% endfor %}\n\n          Enter the number of your desired region\n          [{{ default_region }}]\n      register: _algo_region\n\n- name: Set algo_region and stack_name facts\n  set_fact:\n    algo_region: >-\n      {%- if region is defined -%}{{ region }}{%-\n      elif _algo_region.user_input -%}{{ aws_regions[_algo_region.user_input | int - 1]['region_name'] }}{%-\n      else -%}{{ aws_regions[default_region | int - 1]['region_name'] }}{%-\n      endif -%}\n    stack_name: \"{{ algo_server_name | replace('.', '-') }}\"\n\n- when: cloud_providers.ec2.use_existing_eip\n  block:\n    - name: Get existing available Elastic IPs\n      ec2_eip_info:\n        aws_access_key: \"{{ access_key }}\"\n        aws_secret_key: \"{{ secret_key }}\"\n        aws_session_token: \"{{ session_token if session_token else omit }}\"\n        region: \"{{ algo_region }}\"\n      register: raw_eip_addresses\n      no_log: true\n\n    - set_fact:\n        available_eip_addresses: \"{{ raw_eip_addresses.addresses | selectattr('association_id', 'undefined') | list }}\"\n\n    - pause:\n        prompt: >-\n          What Elastic IP would you like to use?\n            {% for eip in available_eip_addresses %}\n            {{ loop.index }}. {{ eip['public_ip'] }}\n            {% endfor %}\n\n          Enter the number of your desired Elastic IP\n      register: _use_existing_eip\n\n    - set_fact:\n        existing_eip: \"{{ available_eip_addresses[_use_existing_eip.user_input | int - 1]['allocation_id'] }}\"\n"
  },
  {
    "path": "roles/cloud-gce/tasks/destroy.yml",
    "content": "---\n- name: Get zones\n  gcp_compute_location_info:\n    auth_kind: serviceaccount\n    service_account_file: \"{{ credentials_file_path }}\"\n    project: \"{{ project_id }}\"\n    scope: zones\n    filters:\n      - name={{ algo_region }}-*\n      - status=UP\n  register: gcp_compute_zone_info\n\n- name: Set zone\n  set_fact:\n    algo_zone: >-\n      {{ (gcp_compute_zone_info.resources |\n          random(seed=algo_server_name + algo_region + project_id)\n         ).name }}\n\n- name: Destroy GCE instance\n  gcp_compute_instance:\n    auth_kind: serviceaccount\n    service_account_file: \"{{ credentials_file_path }}\"\n    project: \"{{ project_id }}\"\n    name: \"{{ algo_server_name }}\"\n    zone: \"{{ algo_zone }}\"\n    state: absent\n\n- name: Remove static IP\n  gcp_compute_address:\n    auth_kind: serviceaccount\n    service_account_file: \"{{ credentials_file_path }}\"\n    project: \"{{ project_id }}\"\n    name: \"{{ algo_server_name }}\"\n    region: \"{{ algo_region }}\"\n    state: absent\n  failed_when: false\n\n- name: Remove firewall rule\n  gcp_compute_firewall:\n    auth_kind: serviceaccount\n    service_account_file: \"{{ credentials_file_path }}\"\n    project: \"{{ project_id }}\"\n    name: algovpn\n    state: absent\n  failed_when: false\n\n- name: Remove network\n  gcp_compute_network:\n    auth_kind: serviceaccount\n    service_account_file: \"{{ credentials_file_path }}\"\n    project: \"{{ project_id }}\"\n    name: algovpn\n    state: absent\n  failed_when: false\n"
  },
  {
    "path": "roles/cloud-gce/tasks/main.yml",
    "content": "---\n- name: Include prompts\n  import_tasks: prompts.yml\n\n- name: Network configured\n  gcp_compute_network:\n    auth_kind: serviceaccount\n    service_account_file: \"{{ credentials_file_path }}\"\n    project: \"{{ project_id }}\"\n    name: algovpn\n    auto_create_subnetworks: true\n    routing_config:\n      routing_mode: REGIONAL\n  register: gcp_compute_network\n\n- name: Firewall configured\n  gcp_compute_firewall:\n    auth_kind: serviceaccount\n    service_account_file: \"{{ credentials_file_path }}\"\n    project: \"{{ project_id }}\"\n    name: algovpn\n    network: \"{{ gcp_compute_network }}\"\n    direction: INGRESS\n    allowed:\n      - ip_protocol: udp\n        ports:\n          - \"500\"\n          - \"4500\"\n          - \"{{ wireguard_port | string }}\"\n      - ip_protocol: tcp\n        ports:\n          - \"{{ ssh_port }}\"\n      - ip_protocol: icmp\n\n- when: cloud_providers.gce.external_static_ip\n  block:\n    - name: External IP allocated\n      gcp_compute_address:\n        auth_kind: serviceaccount\n        service_account_file: \"{{ credentials_file_path }}\"\n        project: \"{{ project_id }}\"\n        name: \"{{ algo_server_name }}\"\n        region: \"{{ algo_region }}\"\n      register: gcp_compute_address\n\n    - name: Set External IP as a fact\n      set_fact:\n        external_ip: \"{{ gcp_compute_address.address }}\"\n\n- name: Instance created\n  gcp_compute_instance:\n    auth_kind: serviceaccount\n    service_account_file: \"{{ credentials_file_path }}\"\n    project: \"{{ project_id }}\"\n    name: \"{{ algo_server_name }}\"\n    zone: \"{{ algo_zone }}\"\n    machine_type: \"{{ cloud_providers.gce.size }}\"\n    disks:\n      - auto_delete: true\n        boot: true\n        initialize_params:\n          source_image: projects/ubuntu-os-cloud/global/images/family/{{ cloud_providers.gce.image }}\n    metadata:\n      ssh-keys: algo:{{ ssh_public_key_lookup }}\n      user-data: \"{{ lookup('template', 'files/cloud-init/base.yml') }}\"\n    network_interfaces:\n      - network: \"{{ gcp_compute_network }}\"\n        access_configs:\n          - name: \"{{ algo_server_name }}\"\n            nat_ip: \"{{ gcp_compute_address | default(None) }}\"\n            type: ONE_TO_ONE_NAT\n    tags:\n      items:\n        - environment-algo\n  register: gcp_compute_instance\n\n- set_fact:\n    cloud_instance_ip: \"{{ gcp_compute_instance.networkInterfaces[0].accessConfigs[0].natIP }}\"\n    ansible_ssh_user: algo\n    ansible_ssh_port: \"{{ ssh_port }}\"\n    cloudinit: true\n"
  },
  {
    "path": "roles/cloud-gce/tasks/prompts.yml",
    "content": "---\n- pause:\n    prompt: |\n      Enter the local path to your credentials JSON file\n      (https://support.google.com/cloud/answer/6158849?hl=en&ref_topic=6262490#serviceaccounts)\n  register: _gce_credentials_file\n  when:\n    - gce_credentials_file is undefined\n    - lookup('env', 'GCE_CREDENTIALS_FILE_PATH')|length <= 0\n  no_log: true\n\n- set_fact:\n    credentials_file_path: >-\n      {{ gce_credentials_file | default(_gce_credentials_file.user_input|default(None)) |\n         default(lookup('env', 'GCE_CREDENTIALS_FILE_PATH'), true) }}\n    ssh_public_key_lookup: \"{{ lookup('file', SSH_keys.public) }}\"\n  no_log: true\n\n- set_fact:\n    credentials_file_lookup: \"{{ lookup('file', credentials_file_path) | from_json }}\"\n  no_log: true\n\n- set_fact:\n    service_account_email: \"{{ credentials_file_lookup.client_email | default(lookup('env', 'GCE_EMAIL'), true) }}\"\n    project_id: \"{{ credentials_file_lookup.project_id | default(lookup('env', 'GCE_PROJECT'), true) }}\"\n  no_log: true\n\n- when: region is undefined\n  block:\n    - name: Get regions\n      gcp_compute_location_info:\n        auth_kind: serviceaccount\n        service_account_file: \"{{ credentials_file_path }}\"\n        project: \"{{ project_id }}\"\n        scope: regions\n        filters: status=UP\n      register: gcp_compute_regions_info\n\n    - name: Set facts about the regions\n      set_fact:\n        gce_regions: \"{{ gcp_compute_regions_info.resources | sort(attribute='name') }}\"\n\n    - name: Set facts about the default region\n      set_fact:\n        default_region: >-\n          {% for r in gce_regions -%}\n            {% if r.name == \"us-east1\" %}{{ loop.index }}{% endif %}\n          {%- endfor %}\n\n    - pause:\n        prompt: |\n          What region should the server be located in?\n          (https://cloud.google.com/compute/docs/regions-zones/#locations)\n            {% for r in gce_regions %}\n            {{ loop.index }}. {{ r.name }}\n            {% endfor %}\n\n          Enter the number of your desired region\n          [{{ default_region }}]\n      register: _gce_region\n\n- name: Set region as a fact\n  set_fact:\n    algo_region: >-\n      {% if region is defined %}{{ region -}}\n      {% elif _gce_region.user_input %}{{ gce_regions[_gce_region.user_input | int - 1].name -}}\n      {% else %}{{ gce_regions[default_region | int - 1].name }}{% endif %}\n\n- name: Get zones\n  gcp_compute_location_info:\n    auth_kind: serviceaccount\n    service_account_file: \"{{ credentials_file_path }}\"\n    project: \"{{ project_id }}\"\n    scope: zones\n    filters:\n      - name={{ algo_region }}-*\n      - status=UP\n  register: gcp_compute_zone_info\n\n- name: Set random available zone as a fact\n  set_fact:\n    algo_zone: \"{{ (gcp_compute_zone_info.resources | random(seed=algo_server_name + algo_region + project_id)).name }}\"\n"
  },
  {
    "path": "roles/cloud-hetzner/tasks/destroy.yml",
    "content": "---\n- name: Destroy Hetzner server\n  hetzner.hcloud.server:\n    name: \"{{ algo_server_name }}\"\n    state: absent\n    api_token: \"{{ algo_hcloud_token }}\"\n"
  },
  {
    "path": "roles/cloud-hetzner/tasks/main.yml",
    "content": "---\n- name: Include prompts\n  import_tasks: prompts.yml\n\n- name: Create an ssh key\n  hetzner.hcloud.ssh_key:\n    name: algo-{{ 999999 | random(seed=lookup('file', SSH_keys.public)) }}\n    public_key: \"{{ lookup('file', SSH_keys.public) }}\"\n    state: present\n    api_token: \"{{ algo_hcloud_token }}\"\n  register: hcloud_ssh_key\n\n- name: Create a server...\n  hetzner.hcloud.server:\n    name: \"{{ algo_server_name }}\"\n    location: \"{{ algo_hcloud_region }}\"\n    server_type: \"{{ cloud_providers.hetzner.server_type }}\"\n    image: \"{{ cloud_providers.hetzner.image }}\"\n    state: present\n    api_token: \"{{ algo_hcloud_token }}\"\n    ssh_keys: \"{{ hcloud_ssh_key.hcloud_ssh_key.name }}\"\n    user_data: \"{{ lookup('template', 'files/cloud-init/base.yml') }}\"\n    labels:\n      Environment: algo\n  register: hcloud_server\n\n- set_fact:\n    cloud_instance_ip: \"{{ hcloud_server.hcloud_server.ipv4_address }}\"\n    ansible_ssh_user: algo\n    ansible_ssh_port: \"{{ ssh_port }}\"\n    cloudinit: true\n"
  },
  {
    "path": "roles/cloud-hetzner/tasks/prompts.yml",
    "content": "---\n- pause:\n    prompt: |\n      Enter your API token (https://trailofbits.github.io/algo/cloud-hetzner.html#api-token):\n    echo: false\n  register: _hcloud_token\n  when:\n    - hcloud_token is undefined\n    - lookup('env', 'HCLOUD_TOKEN')|length <= 0\n  no_log: true\n\n- name: Set the token as a fact\n  set_fact:\n    algo_hcloud_token: \"{{ hcloud_token | default(_hcloud_token.user_input | default(None)) | default(lookup('env', 'HCLOUD_TOKEN'), true) }}\"\n  no_log: true\n\n- name: Get regions\n  hetzner.hcloud.datacenter_info:\n    api_token: \"{{ algo_hcloud_token }}\"\n  register: _hcloud_regions\n\n- name: Set facts about the regions\n  set_fact:\n    hcloud_regions: \"{{ _hcloud_regions.hcloud_datacenter_info | sort(attribute='location') }}\"\n\n- name: Set default region\n  set_fact:\n    default_region: >-\n      {%- for r in hcloud_regions -%}\n      {%- if r['location'] == \"nbg1\" %}{{ loop.index }}{% endif -%}\n      {%- endfor %}\n\n- pause:\n    prompt: |\n      What region should the server be located in?\n        {% for r in hcloud_regions %}\n        {{ loop.index }}. {{ r['location'] }}     {{ r['description'] }}\n        {% endfor %}\n\n      Enter the number of your desired region\n      [{{ default_region }}]\n  register: _algo_region\n  when: region is undefined\n\n- name: Set additional facts\n  set_fact:\n    algo_hcloud_region: >-\n      {%- if region is defined -%}{{ region }}{%-\n      elif _algo_region.user_input -%}{{ hcloud_regions[_algo_region.user_input | int - 1]['location'] }}{%-\n      else -%}{{ hcloud_regions[default_region | int - 1]['location'] }}{%-\n      endif -%}\n"
  },
  {
    "path": "roles/cloud-lightsail/files/stack.yaml",
    "content": "---\nAWSTemplateFormatVersion: '2010-09-09'\nDescription: 'Algo VPN stack (LightSail)'\nParameters:\n  InstanceTypeParameter:\n    Type: String\n    Default: 'nano_2_0'\n  ImageIdParameter:\n    Type: String\n    Default: 'ubuntu_20_04'\n  WireGuardPort:\n    Type: String\n    Default: '51820'\n  SshPort:\n    Type: String\n    Default: '4160'\n  UserData:\n    Type: String\n    Default: 'true'\nResources:\n  Instance:\n    Type: AWS::Lightsail::Instance\n    Properties:\n      BlueprintId:\n        Ref: ImageIdParameter\n      BundleId:\n        Ref: InstanceTypeParameter\n      InstanceName: !Ref AWS::StackName\n      Networking:\n        Ports:\n          - AccessDirection: inbound\n            Cidrs: ['0.0.0.0/0']\n            Ipv6Cidrs: ['::/0']\n            CommonName: SSH\n            FromPort: !Ref SshPort\n            ToPort: !Ref SshPort\n            Protocol: tcp\n          - AccessDirection: inbound\n            Cidrs: ['0.0.0.0/0']\n            Ipv6Cidrs: ['::/0']\n            CommonName: WireGuard\n            FromPort: !Ref WireGuardPort\n            ToPort: !Ref WireGuardPort\n            Protocol: udp\n          - AccessDirection: inbound\n            Cidrs: ['0.0.0.0/0']\n            Ipv6Cidrs: ['::/0']\n            CommonName: IPSec-4500\n            FromPort: 4500\n            ToPort: 4500\n            Protocol: udp\n          - AccessDirection: inbound\n            Cidrs: ['0.0.0.0/0']\n            Ipv6Cidrs: ['::/0']\n            CommonName: IPSec-500\n            FromPort: 500\n            ToPort: 500\n            Protocol: udp\n      Tags:\n        - Key: Name\n          Value: !Ref AWS::StackName\n      UserData: !Ref UserData\n\n  StaticIP:\n    Type: AWS::Lightsail::StaticIp\n    Properties:\n      AttachedTo: !Ref Instance\n      StaticIpName: !Join [\"-\", [!Ref AWS::StackName, \"ip\"]]\n    DependsOn:\n      - Instance\n\nOutputs:\n  IpAddress:\n    Value: !GetAtt [StaticIP, IpAddress]\n"
  },
  {
    "path": "roles/cloud-lightsail/tasks/cloudformation.yml",
    "content": "---\n# Note: Using template_body instead of deprecated 'template' parameter.\n# The 'template' parameter is deprecated and will be removed after 2026-05-01.\n- name: Deploy the template\n  cloudformation:\n    aws_access_key: \"{{ access_key }}\"\n    aws_secret_key: \"{{ secret_key }}\"\n    stack_name: \"{{ stack_name }}\"\n    state: present\n    region: \"{{ algo_region }}\"\n    template_body: \"{{ lookup('file', 'roles/cloud-lightsail/files/stack.yaml') }}\"\n    template_parameters:\n      InstanceTypeParameter: \"{{ cloud_providers.lightsail.size }}\"\n      ImageIdParameter: \"{{ cloud_providers.lightsail.image }}\"\n      WireGuardPort: \"{{ wireguard_port }}\"\n      SshPort: \"{{ ssh_port }}\"\n      UserData: \"{{ lookup('template', 'files/cloud-init/base.sh') }}\"\n    tags:\n      Environment: Algo\n      Lightsail: true\n  register: stack\n  no_log: true\n"
  },
  {
    "path": "roles/cloud-lightsail/tasks/destroy.yml",
    "content": "---\n- name: Set stack name\n  set_fact:\n    stack_name: \"{{ algo_server_name | replace('.', '-') }}\"\n\n- name: Destroy CloudFormation stack\n  cloudformation:\n    aws_access_key: \"{{ access_key }}\"\n    aws_secret_key: \"{{ secret_key }}\"\n    stack_name: \"{{ stack_name }}\"\n    state: absent\n    region: \"{{ algo_region }}\"\n  no_log: true\n"
  },
  {
    "path": "roles/cloud-lightsail/tasks/main.yml",
    "content": "---\n- name: Include prompts\n  import_tasks: prompts.yml\n\n- name: Deploy the stack\n  import_tasks: cloudformation.yml\n\n- set_fact:\n    cloud_instance_ip: \"{{ stack.stack_outputs.IpAddress }}\"\n    ansible_ssh_user: algo\n    ansible_ssh_port: \"{{ ssh_port }}\"\n    cloudinit: true\n"
  },
  {
    "path": "roles/cloud-lightsail/tasks/prompts.yml",
    "content": "---\n- pause:\n    prompt: |\n      Enter your aws_access_key (http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html)\n      Note: Make sure to use an IAM user with an acceptable policy attached (see https://github.com/trailofbits/algo/blob/master/docs/deploy-from-ansible.md)\n    echo: false\n  register: _aws_access_key\n  when:\n    - aws_access_key is undefined\n    - lookup('env', 'AWS_ACCESS_KEY_ID')|length <= 0\n  no_log: true\n\n- pause:\n    prompt: |\n      Enter your aws_secret_key (http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html)\n    echo: false\n  register: _aws_secret_key\n  when:\n    - aws_secret_key is undefined\n    - lookup('env', 'AWS_SECRET_ACCESS_KEY')|length <= 0\n  no_log: true\n\n- set_fact:\n    access_key: \"{{ aws_access_key | default(_aws_access_key.user_input | default(None)) | default(lookup('env', 'AWS_ACCESS_KEY_ID'), true) }}\"\n    secret_key: \"{{ aws_secret_key | default(_aws_secret_key.user_input | default(None)) | default(lookup('env', 'AWS_SECRET_ACCESS_KEY'), true) }}\"\n  no_log: true\n\n- when: region is undefined\n  block:\n    - name: Get regions\n      lightsail_region_facts:\n        aws_access_key: \"{{ access_key }}\"\n        aws_secret_key: \"{{ secret_key }}\"\n        region: us-east-1\n      register: _lightsail_regions\n      no_log: true\n\n    - name: Set facts about the regions\n      set_fact:\n        lightsail_regions: \"{{ _lightsail_regions.data.regions | sort(attribute='name') }}\"\n\n    - name: Set the default region\n      set_fact:\n        default_region: >-\n          {% for r in lightsail_regions -%}\n            {% if r['name'] == \"us-east-1\" %}{{ loop.index }}{% endif %}\n          {%- endfor %}\n\n    - pause:\n        prompt: |\n          What region should the server be located in?\n          (https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/)\n            {% for r in lightsail_regions %}\n            {{ (loop.index | string + '.').ljust(3) }} {{ r['name'].ljust(20) }} {{ r['displayName'] }}\n            {% endfor %}\n\n          Enter the number of your desired region\n          [{{ default_region }}]\n      register: _algo_region\n\n- set_fact:\n    stack_name: \"{{ algo_server_name | replace('.', '-') }}\"\n    algo_region: >-\n      {% if region is defined %}{{ region -}}\n      {% elif _algo_region.user_input %}{{ lightsail_regions[_algo_region.user_input | int - 1]['name'] -}}\n      {% else %}{{ lightsail_regions[default_region | int - 1]['name'] }}{% endif %}\n"
  },
  {
    "path": "roles/cloud-linode/defaults/main.yml",
    "content": "---\nlinode_venv: \"{{ playbook_dir }}/configs/.venvs/linode\"\n"
  },
  {
    "path": "roles/cloud-linode/tasks/destroy.yml",
    "content": "---\n- name: Destroy Linode instance\n  linode.cloud.instance:\n    api_token: \"{{ algo_linode_token }}\"\n    label: \"{{ algo_server_name }}\"\n    state: absent\n"
  },
  {
    "path": "roles/cloud-linode/tasks/main.yml",
    "content": "---\n- name: Include prompts\n  import_tasks: prompts.yml\n\n- name: Set facts\n  set_fact:\n    stackscript: |\n      {{ lookup('template', 'files/cloud-init/base.sh') }}\n      mkdir -p /var/lib/cloud/data/ || true\n      touch /var/lib/cloud/data/result.json\n\n- name: Create a stackscript\n  linode.cloud.stackscript:\n    api_token: \"{{ algo_linode_token }}\"\n    label: \"{{ algo_server_name }}\"\n    state: present\n    description: Environment:Algo\n    images:\n      - \"{{ cloud_providers.linode.image }}\"\n    script: |\n      {{ stackscript }}\n  register: _linode_stackscript\n  no_log: true\n\n- name: Update the stackscript\n  uri:\n    url: https://api.linode.com/v4/linode/stackscripts/{{ _linode_stackscript.stackscript.id }}\n    method: PUT\n    body_format: json\n    body:\n      script: |\n        {{ stackscript }}\n    headers:\n      Content-Type: application/json\n      Authorization: Bearer {{ algo_linode_token }}\n  when: (_linode_stackscript.stackscript.script | hash('md5')) != (stackscript | hash('md5'))\n  no_log: true\n\n- name: Creating an instance...\n  linode.cloud.instance:\n    api_token: \"{{ algo_linode_token }}\"\n    label: \"{{ algo_server_name }}\"\n    state: present\n    region: \"{{ algo_linode_region }}\"\n    image: \"{{ cloud_providers.linode.image }}\"\n    type: \"{{ cloud_providers.linode.type }}\"\n    authorized_keys: \"{{ public_key }}\"\n    stackscript_id: \"{{ _linode_stackscript.stackscript.id }}\"\n  register: _linode\n  no_log: true\n\n- set_fact:\n    cloud_instance_ip: \"{{ _linode.instance.ipv4[0] }}\"\n    ansible_ssh_user: algo\n    ansible_ssh_port: \"{{ ssh_port }}\"\n    cloudinit: true\n"
  },
  {
    "path": "roles/cloud-linode/tasks/prompts.yml",
    "content": "---\n- pause:\n    prompt: |\n      Enter your ACCESS token. (https://developers.linode.com/api/v4/#access-and-authentication):\n    echo: false\n  register: _linode_token\n  when:\n    - linode_token is undefined\n    - lookup('env', 'LINODE_API_TOKEN')|length <= 0\n  no_log: true\n\n- name: Set the token as a fact\n  set_fact:\n    algo_linode_token: \"{{ linode_token | default(_linode_token.user_input | default(None)) | default(lookup('env', 'LINODE_API_TOKEN'), true) }}\"\n  no_log: true\n\n- name: Get regions\n  uri:\n    url: https://api.linode.com/v4/regions\n    method: GET\n    status_code: 200\n  register: _linode_regions\n\n- name: Set facts about the regions\n  set_fact:\n    linode_regions: \"{{ _linode_regions.json.data | sort(attribute='id') }}\"\n\n- name: Set default region\n  set_fact:\n    default_region: >-\n      {%- for r in linode_regions -%}\n      {%- if r['id'] == \"us-east\" %}{{ loop.index }}{% endif -%}\n      {%- endfor %}\n\n- pause:\n    prompt: |\n      What region should the server be located in?\n        {% for r in linode_regions %}\n        {{ loop.index }}. {{ r['id'] }}\n        {% endfor %}\n\n      Enter the number of your desired region\n      [{{ default_region }}]\n  register: _algo_region\n  when: region is undefined\n\n- name: Set additional facts\n  set_fact:\n    algo_linode_region: >-\n      {%- if region is defined -%}{{ region }}{%-\n      elif _algo_region.user_input -%}{{ linode_regions[_algo_region.user_input | int - 1]['id'] }}{%-\n      else -%}{{ linode_regions[default_region | int - 1]['id'] }}{%-\n      endif -%}\n    public_key: \"{{ lookup('file', SSH_keys.public) }}\"\n"
  },
  {
    "path": "roles/cloud-openstack/tasks/destroy.yml",
    "content": "---\n- name: Destroy OpenStack server\n  openstack.cloud.server:\n    state: absent\n    name: \"{{ algo_server_name }}\"\n\n- name: Remove security group\n  openstack.cloud.security_group:\n    state: absent\n    name: \"{{ algo_server_name }}-security_group\"\n  failed_when: false\n"
  },
  {
    "path": "roles/cloud-openstack/tasks/main.yml",
    "content": "---\n- fail:\n    msg: >-\n      OpenStack credentials are not set. Download it from the OpenStack dashboard->Compute->API Access\n      and source it in the shell (eg: source /tmp/dhc-openrc.sh)\n  when: lookup('env', 'OS_AUTH_URL')|length <= 0\n\n- name: Security group created\n  openstack.cloud.security_group:\n    state: \"{{ state | default('present') }}\"\n    name: \"{{ algo_server_name }}-security_group\"\n    description: AlgoVPN security group\n  register: os_security_group\n\n- name: Security rules created\n  openstack.cloud.security_group_rule:\n    state: \"{{ state | default('present') }}\"\n    security_group: \"{{ os_security_group.id }}\"\n    protocol: \"{{ item.proto }}\"\n    port_range_min: \"{{ item.port_min }}\"\n    port_range_max: \"{{ item.port_max }}\"\n    remote_ip_prefix: \"{{ item.range }}\"\n  loop:\n    - { proto: tcp, port_min: \"{{ ssh_port }}\", port_max: \"{{ ssh_port }}\", range: 0.0.0.0/0 }\n    - { proto: icmp, port_min: -1, port_max: -1, range: 0.0.0.0/0 }\n    - { proto: udp, port_min: 4500, port_max: 4500, range: 0.0.0.0/0 }\n    - { proto: udp, port_min: 500, port_max: 500, range: 0.0.0.0/0 }\n    - { proto: udp, port_min: \"{{ wireguard_port }}\", port_max: \"{{ wireguard_port }}\", range: 0.0.0.0/0 }\n\n- name: Gather facts about flavors\n  openstack.cloud.compute_flavor_info:\n    ram: \"{{ cloud_providers.openstack.flavor_ram }}\"\n  register: os_flavor\n\n- name: Gather facts about images\n  openstack.cloud.image_info:\n  register: os_image\n\n- name: Set image as a fact\n  set_fact:\n    image_id: \"{{ item.id }}\"\n  loop: \"{{ os_image.openstack_image }}\"\n  when:\n    - item.name == cloud_providers.openstack.image\n    - item.status == \"active\"\n\n- name: Gather facts about public networks\n  openstack.cloud.networks_info:\n  register: os_network\n\n- name: Set the network as a fact\n  set_fact:\n    public_network_id: \"{{ item.id }}\"\n  when:\n    - item['router:external']|default(omit)\n    - item['admin_state_up']|default(omit)\n    - item['status'] == 'ACTIVE'\n  loop: \"{{ os_network.openstack_networks }}\"\n\n- name: Set facts\n  set_fact:\n    flavor_id: \"{{ (os_flavor.openstack_flavors | sort(attribute='ram'))[0]['id'] }}\"\n    security_group_name: \"{{ os_security_group['secgroup']['name'] }}\"\n\n- name: Server created\n  openstack.cloud.server:\n    state: \"{{ state | default('present') }}\"\n    name: \"{{ algo_server_name }}\"\n    image: \"{{ image_id }}\"\n    flavor: \"{{ flavor_id }}\"\n    security_groups: \"{{ security_group_name }}\"\n    userdata: \"{{ lookup('template', 'files/cloud-init/base.yml') }}\"\n    nics:\n      - net-id: \"{{ public_network_id }}\"\n  register: os_server\n\n- set_fact:\n    cloud_instance_ip: \"{{ os_server['openstack']['public_v4'] }}\"\n    ansible_ssh_user: algo\n    ansible_ssh_port: \"{{ ssh_port }}\"\n    cloudinit: true\n"
  },
  {
    "path": "roles/cloud-scaleway/defaults/main.yml",
    "content": "---\nscaleway_regions:\n  - alias: par1\n  - alias: ams1\n"
  },
  {
    "path": "roles/cloud-scaleway/tasks/destroy.yml",
    "content": "---\n- environment:\n    SCW_TOKEN: \"{{ algo_scaleway_token }}\"\n  block:\n    - name: Destroy Scaleway server\n      scaleway_compute:\n        name: \"{{ algo_server_name }}\"\n        state: absent\n        region: \"{{ algo_region }}\"\n"
  },
  {
    "path": "roles/cloud-scaleway/tasks/main.yml",
    "content": "---\n- name: Include prompts\n  import_tasks: prompts.yml\n\n- environment:\n    SCW_TOKEN: \"{{ algo_scaleway_token }}\"\n  block:\n    - name: Get Ubuntu 22.04 image ID from Scaleway Marketplace API\n      uri:\n        url: \"https://api-marketplace.scaleway.com/images?arch={{ cloud_providers.scaleway.arch }}&include_eol=false\"\n        method: GET\n        return_content: true\n      register: marketplace_images\n\n    - name: Find Ubuntu 22.04 Jammy image\n      set_fact:\n        scaleway_image_id: >-\n          {{ (marketplace_images.json.images |\n              selectattr('name', 'match', '.*Ubuntu.*22\\\\.04.*Jammy.*') |\n              first).versions[0].local_images |\n              selectattr('zone', 'equalto', algo_region) |\n              map(attribute='id') | first }}\n\n    - name: Create a server\n      scaleway_compute:\n        name: \"{{ algo_server_name }}\"\n        enable_ipv6: true\n        public_ip: dynamic\n        boot_type: local\n        state: present\n        image: \"{{ scaleway_image_id }}\"\n        project: \"{{ algo_scaleway_org_id }}\"\n        region: \"{{ algo_region }}\"\n        commercial_type: \"{{ cloud_providers.scaleway.size }}\"\n        wait: true\n        tags:\n          - Environment:Algo\n          - AUTHORIZED_KEY={{ lookup('file', SSH_keys.public) | regex_replace(' ', '_') }}\n      register: scaleway_compute\n\n    - name: Patch the cloud-init\n      uri:\n        url: https://cp-{{ algo_region }}.scaleway.com/servers/{{ scaleway_compute.msg.id }}/user_data/cloud-init\n        method: PATCH\n        body: \"{{ lookup('template', 'files/cloud-init/base.yml') }}\"\n        status_code: 204\n        headers:\n          Content-Type: text/plain\n          X-Auth-Token: \"{{ algo_scaleway_token }}\"\n\n    - name: Start the server\n      scaleway_compute:\n        name: \"{{ algo_server_name }}\"\n        enable_ipv6: true\n        public_ip: dynamic\n        boot_type: local\n        state: running\n        image: \"{{ scaleway_image_id }}\"\n        project: \"{{ algo_scaleway_org_id }}\"\n        region: \"{{ algo_region }}\"\n        commercial_type: \"{{ cloud_providers.scaleway.size }}\"\n        wait: true\n        tags:\n          - Environment:Algo\n          - AUTHORIZED_KEY={{ lookup('file', SSH_keys.public) | regex_replace(' ', '_') }}\n      register: algo_instance\n      until: algo_instance.msg.public_ip\n      retries: 3\n      delay: 3\n\n- set_fact:\n    cloud_instance_ip: \"{{ algo_instance.msg.public_ip.address }}\"\n    ansible_ssh_user: algo\n    ansible_ssh_port: \"{{ ssh_port }}\"\n    cloudinit: true\n"
  },
  {
    "path": "roles/cloud-scaleway/tasks/prompts.yml",
    "content": "---\n- pause:\n    prompt: |\n      Enter your auth token (https://trailofbits.github.io/algo/cloud-scaleway.html)\n    echo: false\n  register: _scaleway_token\n  when:\n    - scaleway_token is undefined\n    - lookup('env', 'SCW_TOKEN')|length <= 0\n  no_log: true\n\n- pause:\n    prompt: |\n      What region should the server be located in?\n        {% for r in scaleway_regions %}\n        {{ loop.index }}. {{ r['alias'] }}\n        {% endfor %}\n\n      Enter the number of your desired region\n      [{{ scaleway_regions.0.alias }}]\n  register: _algo_region\n  when: region is undefined\n\n- pause:\n    prompt: |\n      Enter your Scaleway Organization ID (also serves as your default Project ID)\n      You can find this in your Scaleway console:\n      1. Go to https://console.scaleway.com/organization/settings\n      2. Copy the Organization ID from the Organization Settings page\n\n      Note: For the default project, the Project ID is the same as the Organization ID.\n      (https://trailofbits.github.io/algo/cloud-scaleway.html)\n  register: _scaleway_org_id\n  when:\n    - scaleway_org_id is undefined\n    - lookup('env', 'SCW_DEFAULT_ORGANIZATION_ID')|length <= 0\n\n- name: Set scaleway facts\n  set_fact:\n    algo_scaleway_token: \"{{ scaleway_token | default(_scaleway_token.user_input) | default(lookup('env', 'SCW_TOKEN'), true) }}\"\n    algo_region: >-\n      {% if region is defined %}{{ region }}\n      {%- elif _algo_region.user_input %}{{ scaleway_regions[_algo_region.user_input | int - 1]['alias'] }}\n      {%- else %}{{ scaleway_regions.0.alias }}{% endif %}\n    algo_scaleway_org_id: \"{{ scaleway_org_id | default(_scaleway_org_id.user_input) | default(lookup('env', 'SCW_DEFAULT_ORGANIZATION_ID'), true) }}\"\n  no_log: true\n"
  },
  {
    "path": "roles/cloud-vultr/tasks/destroy.yml",
    "content": "---\n- environment:\n    VULTR_API_KEY: \"{{ lookup('ini', 'key', section='default', file=algo_vultr_config) }}\"\n  block:\n    - name: Destroy Vultr instance\n      vultr.cloud.instance:\n        name: \"{{ algo_server_name }}\"\n        region: \"{{ algo_vultr_region }}\"\n        state: absent\n\n    - name: Remove firewall group\n      vultr.cloud.firewall_group:\n        name: \"{{ algo_server_name }}\"\n        state: absent\n      failed_when: false\n"
  },
  {
    "path": "roles/cloud-vultr/tasks/main.yml",
    "content": "---\n- name: Include prompts\n  import_tasks: prompts.yml\n\n- environment:\n    VULTR_API_KEY: \"{{ lookup('ini', 'key', section='default', file=algo_vultr_config) }}\"\n  block:\n    - name: Set cloud-init script as fact\n      set_fact:\n        algo_cloud_init_script: \"{{ lookup('template', 'files/cloud-init/base.yml') }}\"\n\n    - name: Creating a firewall group\n      vultr.cloud.firewall_group:\n        name: \"{{ algo_server_name }}\"\n\n    - name: Creating firewall rules\n      vultr.cloud.firewall_rule:\n        group: \"{{ algo_server_name }}\"\n        protocol: \"{{ item.protocol }}\"\n        port: \"{{ item.port }}\"\n        ip_type: \"{{ item.ip }}\"\n        subnet: \"{{ item.cidr.split('/')[0] }}\"\n        subnet_size: \"{{ item.cidr.split('/')[1] }}\"\n      loop:\n        - { protocol: tcp, port: \"{{ ssh_port }}\", ip: v4, cidr: 0.0.0.0/0 }\n        - { protocol: tcp, port: \"{{ ssh_port }}\", ip: v6, cidr: \"::/0\" }\n        - { protocol: udp, port: 500, ip: v4, cidr: 0.0.0.0/0 }\n        - { protocol: udp, port: 500, ip: v6, cidr: \"::/0\" }\n        - { protocol: udp, port: 4500, ip: v4, cidr: 0.0.0.0/0 }\n        - { protocol: udp, port: 4500, ip: v6, cidr: \"::/0\" }\n        - { protocol: udp, port: \"{{ wireguard_port }}\", ip: v4, cidr: 0.0.0.0/0 }\n        - { protocol: udp, port: \"{{ wireguard_port }}\", ip: v6, cidr: \"::/0\" }\n\n    - name: Upload the startup script\n      vultr.cloud.startup_script:\n        name: algo-startup\n        script: \"{{ algo_cloud_init_script }}\"\n\n    - name: Creating a server\n      vultr.cloud.instance:\n        name: \"{{ algo_server_name }}\"\n        startup_script: algo-startup\n        hostname: \"{{ algo_server_name }}\"\n        os: \"{{ cloud_providers.vultr.os }}\"\n        plan: \"{{ cloud_providers.vultr.size }}\"\n        region: \"{{ algo_vultr_region }}\"\n        firewall_group: \"{{ algo_server_name }}\"\n        state: started\n        tags:\n          - Environment:Algo\n        enable_ipv6: true\n        backups: false\n        activation_email: false\n      register: vultr_server\n\n    - set_fact:\n        cloud_instance_ip: \"{{ vultr_server.vultr_instance.main_ip }}\"\n        ansible_ssh_user: algo\n        ansible_ssh_port: \"{{ ssh_port }}\"\n        cloudinit: true\n"
  },
  {
    "path": "roles/cloud-vultr/tasks/prompts.yml",
    "content": "---\n- pause:\n    prompt: |\n      Enter the local path to your configuration INI file\n      (https://trailofbits.github.io/algo/cloud-vultr.html):\n  register: _vultr_config\n  when:\n    - vultr_config is undefined\n    - lookup('env', 'VULTR_API_CONFIG')|length <= 0\n  no_log: true\n\n- name: Set the token as a fact\n  set_fact:\n    algo_vultr_config: \"{{ vultr_config | default(_vultr_config.user_input) | default(lookup('env', 'VULTR_API_CONFIG'), true) }}\"\n  no_log: true\n\n- name: Set the Vultr API Key as a fact\n  set_fact:\n    vultr_api_key: \"{{ lookup('ansible.builtin.ini', 'key', section='default', file=algo_vultr_config) }}\"\n\n- name: Get regions\n  uri:\n    url: https://api.vultr.com/v2/regions\n    method: GET\n    status_code: 200\n    headers:\n      Authorization: \"Bearer {{ vultr_api_key }}\"\n  register: _vultr_regions\n\n- name: Format regions\n  set_fact:\n    regions: \"{{ _vultr_regions.json['regions'] }}\"\n\n- name: Set regions as a fact\n  set_fact:\n    vultr_regions: \"{{ regions | sort(attribute='country') }}\"\n\n- name: Set default region\n  set_fact:\n    default_region: 1\n\n- pause:\n    prompt: |\n      What region should the server be located in?\n      (https://www.vultr.com/locations/):\n        {% for r in vultr_regions %}\n        {{ loop.index }}.   {{ r['city'] }} ({{ r['id'] }})\n        {% endfor %}\n\n      Enter the number of your desired region\n      [{{ default_region }}]\n  register: _algo_region\n  when: region is undefined\n\n- name: Set the desired region as a fact\n  set_fact:\n    algo_vultr_region: >-\n      {%- if region is defined -%}{{ region }}{%-\n      elif _algo_region.user_input -%}{{ vultr_regions[_algo_region.user_input | int - 1]['id'] }}{%-\n      else -%}{{ vultr_regions[default_region | int - 1]['id'] }}{%-\n      endif -%}\n    algo_region: >-\n      {%- if region is defined -%}{{ region }}{%-\n      elif _algo_region.user_input -%}{{ vultr_regions[_algo_region.user_input | int - 1]['id'] }}{%-\n      else -%}{{ vultr_regions[default_region | int - 1]['id'] }}{%-\n      endif -%}\n"
  },
  {
    "path": "roles/common/defaults/main.yml",
    "content": "---\ninstall_headers: false\naip_supported_providers:\n  - digitalocean\nsnat_aipv4: false\nipv6_default: \"{{ ansible_default_ipv6.address + '/' + ansible_default_ipv6.prefix }}\"\nipv6_subnet_size: \"{{ ipv6_default | ansible.utils.ipaddr('size') }}\"\nipv6_egress_ip: >-\n  {{ (ipv6_default | ansible.utils.next_nth_usable(15\n  | random(seed=algo_server_name + ansible_fqdn)))\n  + '/124' if ipv6_subnet_size | int > 1\n  else ipv6_default }}\n"
  },
  {
    "path": "roles/common/handlers/main.yml",
    "content": "---\n- name: restart rsyslog\n  service: name=rsyslog state=restarted\n\n- name: flush routing cache\n  shell: echo 1 > /proc/sys/net/ipv4/route/flush\n  changed_when: false\n\n- name: restart systemd-networkd\n  systemd:\n    name: systemd-networkd\n    state: restarted\n    daemon_reload: true\n\n- name: restart systemd-resolved\n  systemd:\n    name: systemd-resolved\n    state: restarted\n\n- name: restart iptables\n  service: name=netfilter-persistent state=restarted\n\n- name: netplan apply\n  command: netplan apply\n  changed_when: false\n"
  },
  {
    "path": "roles/common/tasks/aip/digitalocean.yml",
    "content": "---\n- name: Get the anchor IP\n  uri:\n    url: http://169.254.169.254/metadata/v1/interfaces/public/0/anchor_ipv4/address\n    return_content: true\n  register: anchor_ipv4\n  until: anchor_ipv4 is succeeded\n  retries: 30\n  delay: 10\n\n- name: Set SNAT IP as a fact\n  set_fact:\n    snat_aipv4: \"{{ anchor_ipv4.content }}\"\n\n- name: IPv6 egress alias configured\n  template:\n    src: 99-algo-ipv6-egress.yaml.j2\n    dest: /etc/netplan/99-algo-ipv6-egress.yaml\n    mode: '0644'\n  when:\n    - ipv6_support\n    - ipv6_subnet_size|int > 1\n  notify:\n    - netplan apply\n"
  },
  {
    "path": "roles/common/tasks/aip/main.yml",
    "content": "---\n- name: Verify the provider\n  assert:\n    that: algo_provider in aip_supported_providers\n    msg: Algo does not support Alternative Ingress IP for {{ algo_provider }}\n\n- name: Include alternative ingress ip configuration\n  include_tasks:\n    file: \"{{ algo_provider if algo_provider in aip_supported_providers else 'placeholder' }}.yml\"\n  when: algo_provider in aip_supported_providers\n\n- name: Verify SNAT IPv4 found\n  assert:\n    that: snat_aipv4 | trim is ansible.utils.ipv4_address\n    msg: The SNAT IPv4 address not found. Cannot proceed with the alternative ingress ip.\n"
  },
  {
    "path": "roles/common/tasks/aip/placeholder.yml",
    "content": ""
  },
  {
    "path": "roles/common/tasks/facts.yml",
    "content": "---\n- name: Set OS platform facts\n  set_fact:\n    is_debian_based: \"{{ ansible_distribution in ['Debian', 'Ubuntu'] }}\"\n    uses_systemd_socket: \"{{ ansible_distribution in ['Debian', 'Ubuntu'] }}\"\n    is_ubuntu_22_plus: \"{{ ansible_distribution == 'Ubuntu' and ansible_distribution_version is version('22.04', '>=') }}\"\n  tags: always\n\n- name: Define facts\n  set_fact:\n    p12_export_password: \"{{ p12_password | default(lookup('password', '/dev/null length=9 chars=ascii_letters,digits,_,@')) }}\"\n  tags: update-users\n  no_log: true\n\n- name: Set facts\n  set_fact:\n    CA_password: \"{{ ca_password | default(lookup('password', '/dev/null length=16 chars=ascii_letters,digits,_,@')) }}\"\n    IP_subject_alt_name: \"{{ IP_subject_alt_name }}\"\n  no_log: true\n\n- name: Set IPv6 support as a fact\n  set_fact:\n    ipv6_support: \"{{ ansible_default_ipv6['gateway'] is defined }}\"\n  tags: always\n\n- name: Check size of MTU\n  set_fact:\n    reduce_mtu: \"{{ 1500 - ansible_default_ipv4['mtu'] | int if reduce_mtu | int == 0 and ansible_default_ipv4['mtu'] | int < 1500 else reduce_mtu | int }}\"\n  tags: always\n"
  },
  {
    "path": "roles/common/tasks/iptables.yml",
    "content": "---\n- name: Iptables configured\n  template:\n    src: \"{{ item.src }}\"\n    dest: \"{{ item.dest }}\"\n    owner: root\n    group: root\n    mode: '0640'\n  loop:\n    - { src: rules.v4.j2, dest: /etc/iptables/rules.v4 }\n  notify:\n    - restart iptables\n\n- name: Iptables configured\n  template:\n    src: \"{{ item.src }}\"\n    dest: \"{{ item.dest }}\"\n    owner: root\n    group: root\n    mode: '0640'\n  when: ipv6_support | bool\n  loop:\n    - { src: rules.v6.j2, dest: /etc/iptables/rules.v6 }\n  notify:\n    - restart iptables\n"
  },
  {
    "path": "roles/common/tasks/main.yml",
    "content": "---\n- name: Check the system\n  raw: uname -a\n  register: OS\n  changed_when: false\n  tags:\n    - update-users\n\n- fail:\n  when: cloud_test|default(false)|bool\n\n- include_tasks: ubuntu.yml\n  when: '\"Ubuntu\" in OS.stdout or \"Linux\" in OS.stdout'\n\n# Include facts separately - always runs to ensure OS detection facts are available\n- include_tasks: facts.yml\n  tags:\n    - always\n    - update-users\n\n- name: Sysctl tuning\n  sysctl: name=\"{{ item.item }}\" value=\"{{ item.value }}\"\n  when: item.item is defined and item.item != none\n  loop: \"{{ sysctl | default([]) }}\"\n  tags:\n    - always\n\n- meta: flush_handlers\n"
  },
  {
    "path": "roles/common/tasks/packages.yml",
    "content": "---\n- name: Initialize package lists\n  set_fact:\n    algo_packages: \"{{ tools | default([]) if not performance_preinstall_packages | default(false) else [] }}\"\n    algo_packages_optional: []\n  when: performance_parallel_packages | default(true)\n\n- name: Add StrongSwan packages\n  set_fact:\n    algo_packages: \"{{ algo_packages + ['strongswan'] }}\"\n  when:\n    - performance_parallel_packages | default(true)\n    - ipsec_enabled | default(false)\n\n- name: Add WireGuard packages\n  set_fact:\n    algo_packages: \"{{ algo_packages + ['wireguard'] }}\"\n  when:\n    - performance_parallel_packages | default(true)\n    - wireguard_enabled | default(true)\n\n- name: Add DNS packages\n  set_fact:\n    algo_packages: \"{{ algo_packages + ['dnscrypt-proxy'] }}\"\n  when:\n    - performance_parallel_packages | default(true)\n    # dnscrypt-proxy handles both DNS ad-blocking and DNS-over-HTTPS/TLS encryption\n    # Install if user wants either ad-blocking OR encrypted DNS (or both)\n    - algo_dns_adblocking | default(false) or dns_encryption | default(false)\n\n- name: Add kernel headers to optional packages\n  set_fact:\n    algo_packages_optional: \"{{ algo_packages_optional + ['linux-headers-generic', 'linux-headers-' + ansible_kernel] }}\"\n  when:\n    - performance_parallel_packages | default(true)\n    - install_headers | default(false)\n\n- name: Install all packages in batch (performance optimization)\n  apt:\n    name: \"{{ algo_packages | unique }}\"\n    state: present\n    update_cache: true\n    install_recommends: true\n  when:\n    - performance_parallel_packages | default(true)\n    - algo_packages | length > 0\n\n- name: Install optional packages in batch\n  apt:\n    name: \"{{ algo_packages_optional | unique }}\"\n    state: present\n  when:\n    - performance_parallel_packages | default(true)\n    - algo_packages_optional | length > 0\n\n- name: Debug - Show batched packages\n  debug:\n    msg:\n      - \"Batch installed {{ algo_packages | length }} main packages: {{ algo_packages | unique | join(', ') }}\"\n      - \"Batch installed {{ algo_packages_optional | length }} optional packages: {{ algo_packages_optional | unique | join(', ') }}\"\n  when:\n    - performance_parallel_packages | default(true)\n    - (algo_packages | length > 0 or algo_packages_optional | length > 0)\n"
  },
  {
    "path": "roles/common/tasks/ubuntu.yml",
    "content": "---\n- name: Gather facts\n  setup:\n- name: Cloud only tasks\n  when: algo_provider != \"local\"\n  block:\n    - name: Install software updates\n      apt:\n        update_cache: true\n        install_recommends: true\n        upgrade: dist\n      register: result\n      until: result is succeeded\n      retries: 30\n      delay: 10\n\n    - name: Check if reboot is required\n      shell: |\n        set -o pipefail\n        if [[ -e /var/run/reboot-required ]]; then\n          # Check if kernel was updated (most critical reboot reason)\n          if grep -q \"linux-image\\|linux-generic\\|linux-headers\" /var/log/dpkg.log.1 /var/log/dpkg.log 2>/dev/null; then\n            echo \"kernel-updated\"\n          else\n            echo \"optional\"\n          fi\n        else\n          echo \"no\"\n        fi\n      args:\n        executable: /bin/bash\n      register: reboot_required\n      changed_when: false\n\n    - name: Reboot (kernel updated or performance optimization disabled)\n      shell: sleep 2 && shutdown -r now \"Ansible updates triggered\"\n      async: 1\n      poll: 0\n      when: >\n        reboot_required is defined and (\n          reboot_required.stdout == 'kernel-updated' or\n          (reboot_required.stdout == 'optional' and not performance_skip_optional_reboots|default(false))\n        )\n      changed_when: true\n      failed_when: false\n\n    - name: Skip reboot (performance optimization enabled)\n      debug:\n        msg: \"Skipping reboot - performance optimization enabled. No kernel updates detected.\"\n      when: >\n        reboot_required is defined and\n        reboot_required.stdout == 'optional' and\n        performance_skip_optional_reboots|default(false)\n\n    - name: Wait until the server becomes ready...\n      wait_for_connection:\n        delay: 20\n        timeout: 320\n      when: >\n        reboot_required is defined and (\n          reboot_required.stdout == 'kernel-updated' or\n          (reboot_required.stdout == 'optional' and not performance_skip_optional_reboots|default(false))\n        )\n      become: false\n\n- name: Include unattended upgrades configuration\n  import_tasks: unattended-upgrades.yml\n\n- name: Disable MOTD on login and SSHD\n  replace: dest=\"{{ item.file }}\" regexp=\"{{ item.regexp }}\" replace=\"{{ item.line }}\"\n  become: true\n  loop:\n    - { regexp: ^session.*optional.*pam_motd.so.*, line: \"# MOTD DISABLED\", file: /etc/pam.d/login }\n    - { regexp: ^session.*optional.*pam_motd.so.*, line: \"# MOTD DISABLED\", file: /etc/pam.d/sshd }\n\n- name: Ensure fallback resolvers are set\n  ini_file:\n    path: /etc/systemd/resolved.conf\n    section: Resolve\n    option: FallbackDNS\n    value: \"{{ dns_servers.ipv4 | join(' ') }}\"\n    mode: '0644'\n  notify:\n    - restart systemd-resolved\n\n- name: Loopback for services configured\n  template:\n    src: 10-algo-lo100.network.j2\n    dest: /etc/systemd/network/10-algo-lo100.network\n    mode: '0644'\n  notify:\n    - restart systemd-networkd\n\n- name: systemd services enabled and started\n  systemd:\n    name: \"{{ item }}\"\n    state: started\n    enabled: true\n    daemon_reload: true\n  loop:\n    - systemd-networkd\n    - systemd-resolved\n\n- meta: flush_handlers\n\n- name: Check apparmor support\n  command: apparmor_status\n  failed_when: false\n  changed_when: false\n  register: apparmor_status\n\n- name: Set fact if apparmor enabled\n  set_fact:\n    apparmor_enabled: true\n  when: '\"profiles are in enforce mode\" in apparmor_status.stdout'\n\n- name: Gather additional facts\n  import_tasks: facts.yml\n\n- name: Set OS specific facts\n  set_fact:\n    tools:\n      - git\n      - screen\n      - apparmor-utils\n      - uuid-runtime\n      - coreutils\n      - iptables\n      - iptables-persistent\n      - cgroup-tools\n      - openssl\n      - gnupg2\n      - cron\n    # yamllint disable-line rule:line-length\n    sysctl: \"{{ [{'item': 'net.ipv4.ip_forward', 'value': 1}, {'item': 'net.ipv4.conf.all.forwarding', 'value': 1}, {'item': 'net.ipv4.conf.all.route_localnet', 'value': 1}] + ([{'item': 'net.ipv6.conf.all.forwarding', 'value': 1}] if ipv6_support | bool else []) }}\"\n\n- name: Install packages (batch optimization)\n  include_tasks: packages.yml\n  when: performance_parallel_packages | default(true)\n\n- name: Install tools (legacy method)\n  apt:\n    name: \"{{ tools | default([]) }}\"\n    state: present\n    update_cache: true\n  when:\n    - not performance_parallel_packages | default(true)\n    - not performance_preinstall_packages | default(false)\n\n- name: Install headers (legacy method)\n  apt:\n    name:\n      - linux-headers-generic\n      - linux-headers-{{ ansible_kernel }}\n    state: present\n  when:\n    - not performance_parallel_packages | default(true)\n    - install_headers | bool\n\n- name: Configure the alternative ingress ip\n  include_tasks: aip/main.yml\n  when: alternative_ingress_ip | bool\n\n- name: Ubuntu 22.04+ | Use iptables-legacy for compatibility\n  when: is_ubuntu_22_plus\n  tags: iptables\n  block:\n    - name: Install iptables packages\n      apt:\n        name:\n          - iptables\n          - iptables-persistent\n        state: present\n        update_cache: true\n\n    - name: Configure iptables-legacy as default\n      alternatives:\n        name: \"{{ item }}\"\n        path: \"/usr/sbin/{{ item }}-legacy\"\n      loop:\n        - iptables\n        - ip6tables\n\n- include_tasks: iptables.yml\n  tags: iptables\n"
  },
  {
    "path": "roles/common/tasks/unattended-upgrades.yml",
    "content": "---\n- name: Install unattended-upgrades\n  apt:\n    name: unattended-upgrades\n    state: present\n\n- name: Configure unattended-upgrades\n  template:\n    src: 50unattended-upgrades.j2\n    dest: /etc/apt/apt.conf.d/50unattended-upgrades\n    owner: root\n    group: root\n    mode: '0644'\n\n- name: Periodic upgrades configured\n  template:\n    src: 10periodic.j2\n    dest: /etc/apt/apt.conf.d/10periodic\n    owner: root\n    group: root\n    mode: '0644'\n"
  },
  {
    "path": "roles/common/templates/10-algo-lo100.network.j2",
    "content": "[Match]\nName=lo\n\n[Network]\nDescription=lo:100\nAddress={{ local_service_ip }}/32\nAddress={{ local_service_ipv6 }}/128\n"
  },
  {
    "path": "roles/common/templates/10periodic.j2",
    "content": "APT::Periodic::Update-Package-Lists \"1\";\nAPT::Periodic::Download-Upgradeable-Packages \"1\";\nAPT::Periodic::AutocleanInterval \"7\";\nAPT::Periodic::Unattended-Upgrade \"1\";\n"
  },
  {
    "path": "roles/common/templates/50unattended-upgrades.j2",
    "content": "// Automatically upgrade packages from these (origin:archive) pairs\n//\n// Note that in Ubuntu security updates may pull in new dependencies\n// from non-security sources (e.g. chromium). By allowing the release\n// pocket these get automatically pulled in.\nUnattended-Upgrade::Allowed-Origins {\n    \"${distro_id}:${distro_codename}-security\";\n    // Extended Security Maintenance; doesn't necessarily exist for\n    // every release and this system may not have it installed, but if\n    // available, the policy for updates is such that unattended-upgrades\n    // should also install from here by default.\n    \"${distro_id}ESM:${distro_codename}\";\n    \"${distro_id}:${distro_codename}-updates\";\n    //\t\"${distro_id}:${distro_codename}-proposed\";\n    //\t\"${distro_id}:${distro_codename}-backports\";\n};\n\n// List of packages to not update (regexp are supported)\nUnattended-Upgrade::Package-Blacklist {\n//\t\"vim\";\n//\t\"libc6\";\n//\t\"libc6-dev\";\n//\t\"libc6-i686\";\n};\n\n// This option will controls whether the development release of Ubuntu will be\n// upgraded automatically.\nUnattended-Upgrade::DevRelease \"false\";\n\n// This option allows you to control if on a unclean dpkg exit\n// unattended-upgrades will automatically run\n//   dpkg --force-confold --configure -a\n// The default is true, to ensure updates keep getting installed\nUnattended-Upgrade::AutoFixInterruptedDpkg \"true\";\n\n// Split the upgrade into the smallest possible chunks so that\n// they can be interrupted with SIGTERM. This makes the upgrade\n// a bit slower but it has the benefit that shutdown while a upgrade\n// is running is possible (with a small delay)\nUnattended-Upgrade::MinimalSteps \"true\";\n\n// Install all unattended-upgrades when the machine is shutting down\n// instead of doing it in the background while the machine is running\n// This will (obviously) make shutdown slower\n//Unattended-Upgrade::InstallOnShutdown \"true\";\n\n// Send email to this address for problems or packages upgrades\n// If empty or unset then no email is sent, make sure that you\n// have a working mail setup on your system. A package that provides\n// 'mailx' must be installed. E.g. \"user@example.com\"\n//Unattended-Upgrade::Mail \"root\";\n\n// Set this value to \"true\" to get emails only on errors. Default\n// is to always send a mail if Unattended-Upgrade::Mail is set\n//Unattended-Upgrade::MailOnlyOnError \"true\";\n\n// Remove unused automatically installed kernel-related packages\n// (kernel images, kernel headers and kernel version locked tools).\nUnattended-Upgrade::Remove-Unused-Kernel-Packages \"true\";\n\n// Do automatic removal of new unused dependencies after the upgrade\n// (equivalent to apt-get autoremove)\nUnattended-Upgrade::Remove-Unused-Dependencies \"true\";\n\n// Automatically reboot *WITHOUT CONFIRMATION*\n//  if the file /var/run/reboot-required is found after the upgrade\nUnattended-Upgrade::Automatic-Reboot \"{{ unattended_reboot.enabled | lower }}\";\n\n// If automatic reboot is enabled and needed, reboot at the specific\n// time instead of immediately\n//  Default: \"now\"\nUnattended-Upgrade::Automatic-Reboot-Time \"{{ unattended_reboot.time }}\";\n\n// Use apt bandwidth limit feature, this example limits the download\n// speed to 70kb/sec\n//Acquire::http::Dl-Limit \"70\";\n\n// Enable logging to syslog. Default is False\nUnattended-Upgrade::SyslogEnable \"true\";\n\n// Specify syslog facility. Default is daemon\n// Unattended-Upgrade::SyslogFacility \"daemon\";\n\n// Download and install upgrades only on AC power\n// (i.e. skip or gracefully stop updates on battery)\n// Unattended-Upgrade::OnlyOnACPower \"true\";\n\n// Download and install upgrades only on non-metered connection\n// (i.e. skip or gracefully stop updates on a metered connection)\n// Unattended-Upgrade::Skip-Updates-On-Metered-Connections \"true\";\n\n// Keep the custom conffile when upgrading\nDpkg::Options {\n   \"--force-confdef\";\n   \"--force-confold\";\n};\n"
  },
  {
    "path": "roles/common/templates/99-algo-ipv6-egress.yaml.j2",
    "content": "network:\n    version: 2\n    ethernets:\n        {{ ansible_default_ipv6.interface }}:\n            addresses:\n              - {{ ipv6_egress_ip }}\n"
  },
  {
    "path": "roles/common/templates/rules.v4.j2",
    "content": "{% set subnets = ([strongswan_network] if ipsec_enabled | bool else []) + ([wireguard_network_ipv4] if wireguard_enabled | bool else []) %}\n{% set ports = (['500', '4500'] if ipsec_enabled | bool else []) + ([wireguard_port] if wireguard_enabled | bool else []) + ([wireguard_port_actual] if wireguard_enabled | bool and wireguard_port | int == wireguard_port_avoid | int else []) %}\n\n#### The mangle table\n# This table allows us to modify packet headers\n# Packets enter this table first\n#\n*mangle\n\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n\n{% if reduce_mtu | int > 0 and ipsec_enabled | bool %}\n-A FORWARD -s {{ strongswan_network }} -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss {{ 1360 - reduce_mtu | int }}\n{% endif %}\n\nCOMMIT\n\n\n#### The nat table\n# This table enables Network Address Translation\n# (This is technically a type of packet mangling)\n#\n*nat\n\n:PREROUTING ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n\n{% if wireguard_enabled | bool and wireguard_port | int == wireguard_port_avoid | int %}\n# Handle the special case of allowing access to WireGuard over an already used\n# port like 53\n-A PREROUTING -s {{ subnets | join(',') }} -p udp --dport {{ wireguard_port_avoid }} -j RETURN\n-A PREROUTING --in-interface {{ ansible_default_ipv4['interface'] }} -p udp --dport {{ wireguard_port_avoid }} -j REDIRECT --to-port {{ wireguard_port_actual }}\n{% endif %}\n# Allow traffic from the VPN network to the outside world, and replies\n{% if ipsec_enabled | bool %}\n# For IPsec traffic - NAT the decrypted packets from the VPN subnet\n-A POSTROUTING -s {{ strongswan_network }} -o {{ ansible_default_ipv4['interface'] }} {{ '-j SNAT --to ' + snat_aipv4 if snat_aipv4 | bool else '-j MASQUERADE' }}\n{% endif %}\n{% if wireguard_enabled | bool %}\n# For WireGuard traffic - NAT packets from the VPN subnet\n-A POSTROUTING -s {{ wireguard_network_ipv4 }} -o {{ ansible_default_ipv4['interface'] }} {{ '-j SNAT --to ' + snat_aipv4 if snat_aipv4 | bool else '-j MASQUERADE' }}\n{% endif %}\n\n\nCOMMIT\n\n\n#### The filter table\n# The default ipfilter table\n#\n*filter\n\n# By default, drop packets that are destined for this server\n:INPUT DROP [0:0]\n# By default, drop packets that request to be forwarded by this server\n:FORWARD DROP [0:0]\n# By default, accept any packets originating from this server\n:OUTPUT ACCEPT [0:0]\n\n# Accept packets destined for localhost\n-A INPUT -i lo -j ACCEPT\n# Accept any packet from an open TCP connection\n-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n# Accept packets using the encapsulation protocol\n-A INPUT -p esp -j ACCEPT\n-A INPUT -p ah -j ACCEPT\n# rate limit ICMP traffic per source\n-A INPUT -p icmp --icmp-type echo-request -m hashlimit --hashlimit-upto 5/s --hashlimit-mode srcip --hashlimit-srcmask 32 --hashlimit-name icmp-echo-drop -j ACCEPT\n# Accept IPSEC/WireGuard traffic to ports {{ subnets | join(',') }}\n-A INPUT -p udp -m multiport --dports {{ ports | join(',') }} -j ACCEPT\n# Allow new traffic to port {{ ansible_ssh_port }} (SSH)\n-A INPUT -p tcp --dport {{ ansible_ssh_port }} -m conntrack --ctstate NEW -j ACCEPT\n\n{% if ipsec_enabled | bool %}\n# Allow any traffic from the IPsec VPN\n-A INPUT -p ipencap -m policy --dir in --pol ipsec --proto esp -j ACCEPT\n{% endif %}\n\n# TODO:\n# The IP of the resolver should be bound to a DUMMY interface.\n# DUMMY interfaces are the proper way to install IPs without assigning them any\n# particular virtual (tun,tap,...) or physical (ethernet) interface.\n\n# Accept DNS traffic to the local DNS resolver from VPN clients only\n-A INPUT -s {{ subnets | join(',') }} -d {{ local_service_ip }} -p udp --dport 53 -j ACCEPT\n\n# Drop traffic between VPN clients\n-A FORWARD -s {{ subnets | join(',') }} -d {{ subnets | join(',') }} -j {{ \"DROP\" if BetweenClients_DROP else \"ACCEPT\" }}\n# Drop traffic to VPN clients from SSH tunnels\n-A OUTPUT -d {{ subnets | join(',') }} -m owner --gid-owner 15000 -j {{ \"DROP\" if BetweenClients_DROP else \"ACCEPT\" }}\n\n# Drop traffic to the link-local network\n-A FORWARD -s {{ subnets | join(',') }} -d 169.254.0.0/16 -j DROP\n# Drop traffic to the link-local network from SSH tunnels\n-A OUTPUT -d 169.254.0.0/16 -m owner --gid-owner 15000 -j DROP\n\n# Forward any packet that's part of an established connection\n-A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n# Drop SMB/CIFS traffic that requests to be forwarded\n-A FORWARD -p tcp --dport 445 -j {{ \"DROP\" if block_smb else \"ACCEPT\" }}\n# Drop NETBIOS traffic that requests to be forwarded\n-A FORWARD -p udp -m multiport --ports 137,138 -j {{ \"DROP\" if block_netbios else \"ACCEPT\" }}\n-A FORWARD -p tcp -m multiport --ports 137,139 -j {{ \"DROP\" if block_netbios else \"ACCEPT\" }}\n\n{% if ipsec_enabled | bool %}\n# Forward any IPSEC traffic from the VPN network\n-A FORWARD -m conntrack --ctstate NEW -s {{ strongswan_network }} -m policy --pol ipsec --dir in -j ACCEPT\n{% endif %}\n\n{% if wireguard_enabled | bool %}\n# Forward any traffic from the WireGuard VPN network\n-A FORWARD -m conntrack --ctstate NEW -s {{ wireguard_network_ipv4 }} -j ACCEPT\n{% endif %}\n\nCOMMIT\n"
  },
  {
    "path": "roles/common/templates/rules.v6.j2",
    "content": "{% set subnets = ([strongswan_network_ipv6] if ipsec_enabled | bool else []) + ([wireguard_network_ipv6] if wireguard_enabled | bool else []) %}\n{% set ports = (['500', '4500'] if ipsec_enabled | bool else []) + ([wireguard_port] if wireguard_enabled | bool else []) + ([wireguard_port_actual] if wireguard_enabled | bool and wireguard_port | int == wireguard_port_avoid | int else []) %}\n\n#### The mangle table\n# This table allows us to modify packet headers\n# Packets enter this table first\n#\n*mangle\n\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n\n{% if reduce_mtu | int > 0 and ipsec_enabled | bool %}\n-A FORWARD -s {{ strongswan_network_ipv6 }} -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss {{ 1340 - reduce_mtu | int }}\n{% endif %}\n\nCOMMIT\n\n#### The nat table\n# This table enables Network Address Translation\n# (This is technically a type of packet mangling)\n#\n*nat\n\n:PREROUTING ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n\n{% if wireguard_enabled | bool and wireguard_port | int == wireguard_port_avoid | int %}\n# Handle the special case of allowing access to WireGuard over an already used\n# port like 53\n-A PREROUTING -s {{ subnets | join(',') }} -p udp --dport {{ wireguard_port_avoid }} -j RETURN\n-A PREROUTING --in-interface {{ ansible_default_ipv6['interface'] }} -p udp --dport {{ wireguard_port_avoid }} -j REDIRECT --to-port {{ wireguard_port_actual }}\n{% endif %}\n# Allow traffic from the VPN network to the outside world, and replies\n{% if ipsec_enabled | bool %}\n# For IPsec traffic - NAT the decrypted packets from the VPN subnet\n-A POSTROUTING -s {{ strongswan_network_ipv6 }} -o {{ ansible_default_ipv6['interface'] }} {{ '-j SNAT --to ' + ipv6_egress_ip | ansible.utils.ipaddr('address') if alternative_ingress_ip | bool else '-j MASQUERADE' }}\n{% endif %}\n{% if wireguard_enabled | bool %}\n# For WireGuard traffic - NAT packets from the VPN subnet\n-A POSTROUTING -s {{ wireguard_network_ipv6 }} -o {{ ansible_default_ipv6['interface'] }} {{ '-j SNAT --to ' + ipv6_egress_ip | ansible.utils.ipaddr('address') if alternative_ingress_ip | bool else '-j MASQUERADE' }}\n{% endif %}\n\nCOMMIT\n\n#### The filter table\n# The default ipfilter table\n#\n*filter\n\n# By default, drop packets that are destined for this server\n:INPUT DROP [0:0]\n# By default, drop packets that request to be forwarded by this server\n:FORWARD DROP [0:0]\n# By default, accept any packets originating from this server\n:OUTPUT ACCEPT [0:0]\n\n# Create the ICMPV6-CHECK chain and its log chain\n# These chains are used later to prevent a type of bug that would\n# allow malicious traffic to reach over the server into the private network\n# An instance of such a bug on Cisco software is described here:\n# https://www.insinuator.net/2016/05/cve-2016-1409-ipv6-ndp-dos-vulnerability-in-cisco-software/\n# other software implementations might be at least as broken as the one in CISCO gear.\n:ICMPV6-CHECK - [0:0]\n:ICMPV6-CHECK-LOG - [0:0]\n\n# Accept packets destined for localhost\n-A INPUT -i lo -j ACCEPT\n# Accept any packet from an open TCP connection\n-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n# Accept packets using the encapsulation protocol\n-A INPUT -p esp -j ACCEPT\n-A INPUT -m ah -j ACCEPT\n# rate limit ICMP traffic per source\n-A INPUT -p icmpv6 --icmpv6-type echo-request -m hashlimit --hashlimit-upto 5/s --hashlimit-mode srcip --hashlimit-srcmask 32 --hashlimit-name icmp-echo-drop -j ACCEPT\n# Accept IPSEC/WireGuard traffic to ports {{ subnets | join(',') }}\n-A INPUT -p udp -m multiport --dports {{ ports | join(',') }} -j ACCEPT\n# Allow new traffic to port {{ ansible_ssh_port }} (SSH)\n-A INPUT -p tcp --dport {{ ansible_ssh_port }} -m conntrack --ctstate NEW -j ACCEPT\n\n# Accept properly formatted Neighbor Discovery Protocol packets\n-A INPUT -p icmpv6 --icmpv6-type router-advertisement -m hl --hl-eq 255 -j ACCEPT\n-A INPUT -p icmpv6 --icmpv6-type neighbor-solicitation -m hl --hl-eq 255 -j ACCEPT\n-A INPUT -p icmpv6 --icmpv6-type neighbor-advertisement -m hl --hl-eq 255 -j ACCEPT\n-A INPUT -p icmpv6 --icmpv6-type redirect -m hl --hl-eq 255 -j ACCEPT\n\n# DHCP in AWS\n-A INPUT -m conntrack --ctstate NEW -m udp -p udp --dport 546 -d fe80::/64 -j ACCEPT\n\n# TODO:\n# The IP of the resolver should be bound to a DUMMY interface.\n# DUMMY interfaces are the proper way to install IPs without assigning them any\n# particular virtual (tun,tap,...) or physical (ethernet) interface.\n\n# Accept DNS traffic to the local DNS resolver from VPN clients only\n-A INPUT -s {{ subnets | join(',') }} -d {{ local_service_ipv6 }}/128 -p udp --dport 53 -j ACCEPT\n\n# Drop traffic between VPN clients\n-A FORWARD -s {{ subnets | join(',') }} -d {{ subnets | join(',') }} -j {{ \"DROP\" if BetweenClients_DROP else \"ACCEPT\" }}\n# Drop traffic to VPN clients from SSH tunnels\n-A OUTPUT -d {{ subnets | join(',') }} -m owner --gid-owner 15000 -j {{ \"DROP\" if BetweenClients_DROP else \"ACCEPT\" }}\n\n-A FORWARD -j ICMPV6-CHECK\n-A FORWARD -p tcp --dport 445 -j {{ \"DROP\" if block_smb else \"ACCEPT\" }}\n-A FORWARD -p udp -m multiport --ports 137,138 -j {{ \"DROP\" if block_netbios else \"ACCEPT\" }}\n-A FORWARD -p tcp -m multiport --ports 137,139 -j {{ \"DROP\" if block_netbios else \"ACCEPT\" }}\n\n-A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n{% if ipsec_enabled | bool %}\n-A FORWARD -m conntrack --ctstate NEW -s {{ strongswan_network_ipv6 }} -m policy --pol ipsec --dir in -j ACCEPT\n{% endif %}\n{% if wireguard_enabled | bool %}\n-A FORWARD -m conntrack --ctstate NEW -s {{ wireguard_network_ipv6 }} -j ACCEPT\n{% endif %}\n\n# Use the ICMPV6-CHECK chain, described above\n-A ICMPV6-CHECK -p icmpv6 -m hl ! --hl-eq 255 --icmpv6-type router-solicitation -j ICMPV6-CHECK-LOG\n-A ICMPV6-CHECK -p icmpv6 -m hl ! --hl-eq 255 --icmpv6-type router-advertisement -j ICMPV6-CHECK-LOG\n-A ICMPV6-CHECK -p icmpv6 -m hl ! --hl-eq 255 --icmpv6-type neighbor-solicitation -j ICMPV6-CHECK-LOG\n-A ICMPV6-CHECK -p icmpv6 -m hl ! --hl-eq 255 --icmpv6-type neighbor-advertisement -j ICMPV6-CHECK-LOG\n-A ICMPV6-CHECK-LOG -j LOG --log-prefix \"ICMPV6-CHECK-LOG DROP \"\n-A ICMPV6-CHECK-LOG -j DROP\n\nCOMMIT\n"
  },
  {
    "path": "roles/dns/defaults/main.yml",
    "content": "---\nalgo_dns_adblocking: false\napparmor_enabled: true\ndns_encryption: true\nipv6_support: false\ndnscrypt_servers:\n  ipv4:\n    - cloudflare\n  ipv6:\n    - cloudflare-ipv6\n"
  },
  {
    "path": "roles/dns/files/50-dnscrypt-proxy-unattended-upgrades",
    "content": "// Automatically upgrade packages from these (origin:archive) pairs\nUnattended-Upgrade::Allowed-Origins {\n    \"LP-PPA-shevchuk-dnscrypt-proxy:${distro_codename}\";\n};\n"
  },
  {
    "path": "roles/dns/files/apparmor.profile.dnscrypt-proxy",
    "content": "#include <tunables/global>\n\n/usr/{s,}bin/dnscrypt-proxy flags=(attach_disconnected) {\n  #include <abstractions/base>\n  #include <abstractions/nameservice>\n  #include <abstractions/openssl>\n\n  capability chown,\n  capability dac_override,\n  capability net_bind_service,\n  capability setgid,\n  capability setuid,\n  capability sys_resource,\n\n  /etc/dnscrypt-proxy/** r,\n  /usr/bin/dnscrypt-proxy mr,\n  /var/cache/{private/,}dnscrypt-proxy/** rw,\n\n  /tmp/*.tmp w,\n  owner /tmp/*.tmp r,\n\n  /run/systemd/notify rw,\n  /lib/x86_64-linux-gnu/ld-*.so mr,\n  @{PROC}/sys/kernel/hostname r,\n  @{PROC}/sys/net/core/somaxconn r,\n  /etc/ld.so.cache r,\n  /usr/local/lib/{@{multiarch}/,}libldns.so* mr,\n  /usr/local/lib/{@{multiarch}/,}libsodium.so* mr,\n}\n"
  },
  {
    "path": "roles/dns/handlers/main.yml",
    "content": "---\n- name: daemon-reload\n  systemd:\n    daemon_reload: true\n\n- name: restart dnscrypt-proxy.socket\n  systemd:\n    name: dnscrypt-proxy.socket\n    state: restarted\n    daemon_reload: true\n  when: uses_systemd_socket | bool\n\n- name: restart dnscrypt-proxy\n  systemd:\n    name: dnscrypt-proxy\n    state: restarted\n    daemon_reload: true\n  when: uses_systemd_socket | bool\n"
  },
  {
    "path": "roles/dns/tasks/dns_adblocking.yml",
    "content": "---\n- name: Adblock script created\n  template:\n    src: adblock.sh.j2\n    dest: /usr/local/sbin/adblock.sh\n    owner: root\n    group: \"{{ root_group | default('root') }}\"\n    mode: '0755'\n\n- name: Adblock script added to cron\n  cron:\n    name: Adblock hosts update\n    minute: \"{{ range(0, 60) | random }}\"\n    hour: \"{{ range(0, 24) | random }}\"\n    job: /usr/local/sbin/adblock.sh\n    user: root\n\n- name: Update adblock hosts\n  command: /usr/local/sbin/adblock.sh\n  changed_when: false\n"
  },
  {
    "path": "roles/dns/tasks/main.yml",
    "content": "---\n- name: Include tasks for Debian/Ubuntu\n  include_tasks: ubuntu.yml\n  when: is_debian_based | bool\n\n- name: dnscrypt-proxy ip-blacklist configured\n  template:\n    src: ip-blacklist.txt.j2\n    dest: \"{{ config_prefix | default('/') }}etc/dnscrypt-proxy/ip-blacklist.txt\"\n    mode: '0644'\n  notify:\n    - restart dnscrypt-proxy\n\n- name: dnscrypt-proxy configured\n  template:\n    src: dnscrypt-proxy.toml.j2\n    dest: \"{{ config_prefix | default('/') }}etc/dnscrypt-proxy/dnscrypt-proxy.toml\"\n    mode: '0644'\n  notify:\n    - restart dnscrypt-proxy\n\n- name: Include DNS adblocking tasks\n  import_tasks: dns_adblocking.yml\n  when: algo_dns_adblocking | bool\n\n- meta: flush_handlers\n\n- name: Ensure dnscrypt-proxy socket is enabled and started\n  systemd:\n    name: dnscrypt-proxy.socket\n    enabled: true\n    state: started\n    daemon_reload: true\n  when: uses_systemd_socket | bool\n\n- name: dnscrypt-proxy enabled and started\n  service:\n    name: dnscrypt-proxy\n    state: started\n    enabled: true\n"
  },
  {
    "path": "roles/dns/tasks/ubuntu.yml",
    "content": "---\n- when: ansible_facts['distribution_version'] is version('20.04', '<')\n  block:\n    - name: Add the repository\n      apt_repository:\n        state: present\n        codename: \"{{ ansible_distribution_release }}\"\n        repo: ppa:shevchuk/dnscrypt-proxy\n      register: result\n      until: result is succeeded\n      retries: 10\n      delay: 3\n\n    - name: Configure unattended-upgrades\n      copy:\n        src: 50-dnscrypt-proxy-unattended-upgrades\n        dest: /etc/apt/apt.conf.d/50-dnscrypt-proxy-unattended-upgrades\n        owner: root\n        group: root\n        mode: '0644'\n\n- name: Install dnscrypt-proxy (individual)\n  apt:\n    name: dnscrypt-proxy\n    state: present\n    update_cache: true\n  when: not performance_parallel_packages | default(true)\n\n- when: apparmor_enabled|default(false)|bool\n  tags: apparmor\n  block:\n    - name: Ubuntu | Configure AppArmor policy for dnscrypt-proxy\n      copy:\n        src: apparmor.profile.dnscrypt-proxy\n        dest: /etc/apparmor.d/usr.bin.dnscrypt-proxy\n        owner: root\n        group: root\n        mode: '0600'\n      notify: restart dnscrypt-proxy\n\n    - name: Ubuntu | Enforce the dnscrypt-proxy AppArmor policy\n      command: aa-enforce usr.bin.dnscrypt-proxy\n      changed_when: false\n\n- name: Ubuntu | Ensure that the dnscrypt-proxy service directory exist\n  file:\n    path: /etc/systemd/system/dnscrypt-proxy.service.d/\n    state: directory\n    mode: '0755'\n    owner: root\n    group: root\n\n- name: Ubuntu | Ensure socket override directory exists\n  file:\n    path: /etc/systemd/system/dnscrypt-proxy.socket.d/\n    state: directory\n    mode: '0755'\n    owner: root\n    group: root\n\n- name: Ubuntu | Configure dnscrypt-proxy socket to listen on VPN IPs\n  copy:\n    dest: /etc/systemd/system/dnscrypt-proxy.socket.d/10-algo-override.conf\n    content: |\n      [Socket]\n      # Clear default listeners\n      ListenStream=\n      ListenDatagram=\n      # Add VPN service IPs\n      ListenStream={{ local_service_ip }}:53\n      ListenDatagram={{ local_service_ip }}:53\n      {% if ipv6_support %}\n      ListenStream=[{{ local_service_ipv6 }}]:53\n      ListenDatagram=[{{ local_service_ipv6 }}]:53\n      {% endif %}\n      NoDelay=true\n      DeferAcceptSec=1\n    mode: '0644'\n  register: socket_override\n  notify:\n    - daemon-reload\n    - restart dnscrypt-proxy.socket\n    - restart dnscrypt-proxy\n\n- name: Ubuntu | Reload systemd daemon after socket configuration\n  systemd:\n    daemon_reload: true\n  when: socket_override.changed\n\n- name: Ubuntu | Restart dnscrypt-proxy socket to apply configuration\n  systemd:\n    name: dnscrypt-proxy.socket\n    state: restarted\n  when: socket_override.changed\n\n- name: Ubuntu | Add custom requirements to successfully start the unit\n  copy:\n    dest: /etc/systemd/system/dnscrypt-proxy.service.d/99-algo.conf\n    mode: '0644'\n    content: |\n      [Unit]\n      After=systemd-resolved.service\n      Requires=systemd-resolved.service\n\n      [Service]\n      AmbientCapabilities=CAP_NET_BIND_SERVICE\n  register: dnscrypt_override\n\n- name: Ubuntu | Reload systemd daemon if override changed\n  systemd:\n    daemon_reload: true\n  when: dnscrypt_override.changed\n\n- name: Ubuntu | Apply systemd security hardening for dnscrypt-proxy\n  copy:\n    dest: /etc/systemd/system/dnscrypt-proxy.service.d/90-security-hardening.conf\n    content: |\n      # Algo VPN systemd security hardening for dnscrypt-proxy\n      # Additional hardening on top of comprehensive AppArmor\n      [Service]\n      # Privilege restrictions\n      NoNewPrivileges=yes\n\n      # Filesystem isolation (complements AppArmor)\n      ProtectSystem=strict\n      ProtectHome=yes\n      PrivateTmp=yes\n      PrivateDevices=yes\n      ProtectKernelTunables=yes\n      ProtectControlGroups=yes\n\n      # Network restrictions\n      RestrictAddressFamilies=AF_INET AF_INET6\n\n      # Allow access to dnscrypt-proxy cache (AppArmor also controls this)\n      ReadWritePaths=/var/cache/dnscrypt-proxy\n\n      # System call filtering (complements AppArmor restrictions)\n      SystemCallFilter=@system-service @network-io\n      SystemCallFilter=~@debug @mount @swap @reboot @raw-io\n      SystemCallErrorNumber=EPERM\n    owner: root\n    group: root\n    mode: '0644'\n  register: dnscrypt_hardening\n\n- name: Ubuntu | Reload systemd daemon if hardening changed\n  systemd:\n    daemon_reload: true\n  when: dnscrypt_hardening.changed\n"
  },
  {
    "path": "roles/dns/templates/adblock.sh.j2",
    "content": "#!/bin/sh\n# Block ads, malware, etc..\n\nTEMP=\"$(mktemp)\"\nTEMP_SORTED=\"$(mktemp)\"\nWHITELIST=\"/etc/dnscrypt-proxy/white.list\"\nBLACKLIST=\"/etc/dnscrypt-proxy/black.list\"\nBLOCKHOSTS=\"{{ config_prefix | default('/') }}etc/dnscrypt-proxy/blacklist.txt\"\nBLOCKLIST_URLS=\"{% for url in adblock_lists %}{{ url }} {% endfor %}\"\n\n#Delete the old block.hosts to make room for the updates\nrm -f $BLOCKHOSTS\n\necho 'Downloading hosts lists...'\n#Download and process the files needed to make the lists (enable/add more, if you want)\nfor url in $BLOCKLIST_URLS; do\n  wget --timeout=2 --tries=3 -qO- \"$url\" | grep -Ev \"(localhost)\" | grep -Ev \"#\" | sed -E \"s/(0.0.0.0 |127.0.0.1 |255.255.255.255 )//\" >> \"$TEMP\"\ndone\n\n#Add black list, if non-empty\nif [ -s \"$BLACKLIST\" ]\nthen\n    echo 'Adding blacklist...'\n    cat $BLACKLIST >> \"$TEMP\"\nfi\n\n#Sort the download/black lists\nawk '/^[^#]/ { print $1 }' \"$TEMP\" | sort -u > \"$TEMP_SORTED\"\n\n#Filter (if applicable)\nif [ -s \"$WHITELIST\" ]\nthen\n    #Filter the blacklist, suppressing whitelist matches\n    #  This is relatively slow =-(\n    echo 'Filtering white list...'\n    grep -v -E \"^[[:space:]]*$\" $WHITELIST | awk '/^[^#]/ {sub(/\\r$/,\"\");print $1}' | grep -vf - \"$TEMP_SORTED\" > $BLOCKHOSTS\nelse\n    cat \"$TEMP_SORTED\" > $BLOCKHOSTS\nfi\n\necho 'Restarting dns service...'\n#Restart the dns service\nsystemctl restart dnscrypt-proxy.service\n\nexit 0\n"
  },
  {
    "path": "roles/dns/templates/dnscrypt-proxy/cache.toml.j2",
    "content": "###########################\n#        DNS cache        #\n###########################\n\n## Enable a DNS cache to reduce latency and outgoing traffic\ncache = true\n\n## Cache size\ncache_size = 4096\n\n## Minimum TTL for cached entries\ncache_min_ttl = 2400\n\n## Maximum TTL for cached entries\ncache_max_ttl = 86400\n\n## Minimum TTL for negatively cached entries\ncache_neg_min_ttl = 60\n\n## Maximum TTL for negatively cached entries\ncache_neg_max_ttl = 600\n"
  },
  {
    "path": "roles/dns/templates/dnscrypt-proxy/filters.toml.j2",
    "content": "#########################\n#        Filters        #\n#########################\n\n## Immediately respond to IPv6-related queries with an empty response\nblock_ipv6 = false\n\n\n\n###############################\n#        Query logging        #\n###############################\n\n## Log client queries to a file\n## Privacy warning: Only enable for debugging purposes\n[query_log]\n  format = 'tsv'\n\n\n\n############################################\n#        Suspicious queries logging        #\n############################################\n\n## Log queries for nonexistent zones\n[nx_log]\n  format = 'tsv'\n\n\n\n######################################################\n#        Pattern-based blocking (blacklists)        #\n######################################################\n\n[blacklist]\n  {{ \"blacklist_file = 'blacklist.txt'\" if algo_dns_adblocking | bool else \"\" }}\n\n\n\n###########################################################\n#        Pattern-based IP blocking (IP blacklists)        #\n###########################################################\n\n[ip_blacklist]\n  blacklist_file = 'ip-blacklist.txt'\n\n\n\n######################################################\n#   Pattern-based whitelisting (blacklists bypass)   #\n######################################################\n\n[whitelist]\n  # whitelist_file = 'whitelist.txt'\n\n\n\n##########################################\n#        Time access restrictions        #\n##########################################\n\n[schedules]\n  # Time-based access rules can be defined here\n"
  },
  {
    "path": "roles/dns/templates/dnscrypt-proxy/global.toml.j2",
    "content": "##################################\n#         Global settings        #\n##################################\n\n## List of servers to use\n{# Allow either list to be empty. Output nothing if both are empty. #}\n{% set servers = [] %}\n{% if dnscrypt_servers.ipv4 %}{% set servers = dnscrypt_servers.ipv4 %}{% endif %}\n{% if ipv6_support | bool and dnscrypt_servers.ipv6 %}{% set servers = servers + dnscrypt_servers.ipv6 %}{% endif %}\n{% if servers %}server_names = ['{{ servers | join(\"', '\") }}']{% endif %}\n\n\n## List of local addresses and ports to listen to. Can be IPv4 and/or IPv6.\n## Note: When using systemd socket activation, choose an empty set (i.e. [] ).\n\n{% if uses_systemd_socket | bool %}\n# Using systemd socket activation on Debian/Ubuntu\nlisten_addresses = []\n{% else %}\n# Direct binding on non-systemd systems\nlisten_addresses  = [\n  '{{ local_service_ip }}:53'{% if ipv6_support | bool %},\n  '[{{ local_service_ipv6 }}]:53'{% endif %}\n  ]\n{% endif %}\n\n\n## Maximum number of simultaneous client connections to accept\nmax_clients = 250\n\n## Require servers (from static + remote sources) to satisfy specific properties\nipv4_servers = true\nipv6_servers = {{ ipv6_support | bool | lower }}\ndnscrypt_servers = true\ndoh_servers = true\n\n## Require servers defined by remote sources to satisfy specific properties\nrequire_dnssec = true\nrequire_nolog = true\nrequire_nofilter = true\ndisabled_server_names = []\n\n## Always use TCP to connect to upstream servers\nforce_tcp = false\n\n## How long a DNS query will wait for a response, in milliseconds\ntimeout = 2500\n\n## Keepalive for HTTP (HTTPS, HTTP/2) queries, in seconds\nkeepalive = 30\n\n## Load-balancing strategy: 'p2' (default), 'ph', 'first' or 'random'\nlb_strategy = 'p2'\n\n## Log level (0-6, default: 2 - 0 is very verbose, 6 only contains fatal errors)\n## Level 4 provides essential error information while minimizing privacy-sensitive logging\nlog_level = 4\n\n## Use the system logger (disabled for privacy)\nuse_syslog = false\n\n## Delay, in minutes, after which certificates are reloaded\ncert_refresh_delay = 240\n\n## DNSCrypt: Create a new, unique key for every single DNS query\ndnscrypt_ephemeral_keys = true\n\n## DoH: Disable TLS session tickets - increases privacy but also latency\ntls_disable_session_tickets = true\n\n## Fallback resolver (used only for initial resolver list retrieval)\nfallback_resolver = '127.0.0.53:53'\n\n## Never let dnscrypt-proxy try to use the system DNS settings\nignore_system_dns = true\n\n## Maximum time (in seconds) to wait for network connectivity\nnetprobe_timeout = 60\nnetprobe_address = \"1.1.1.1:53\"\n\n## Automatic log files rotation\nlog_files_max_size = 10\nlog_files_max_age = 7\nlog_files_max_backups = 1\n"
  },
  {
    "path": "roles/dns/templates/dnscrypt-proxy/sources.toml.j2",
    "content": "#########################\n#        Servers        #\n#########################\n\n## Remote lists of available servers\n[sources]\n\n  ## Public resolvers from https://github.com/DNSCrypt/dnscrypt-resolvers\n  [sources.'public-resolvers']\n  urls = ['https://raw.githubusercontent.com/DNSCrypt/dnscrypt-resolvers/master/v2/public-resolvers.md', 'https://download.dnscrypt.info/resolvers-list/v2/public-resolvers.md']\n  cache_file = '/var/cache/dnscrypt-proxy/public-resolvers.md'\n  minisign_key = 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3'\n  prefix = ''\n\n\n\n## Optional, local, static list of additional servers\n[static]\n\n{% if custom_server_stamps %}{% for name, stamp in custom_server_stamps.items() %}\n  [static.'{{ name }}']\n  stamp = '{{ stamp }}'\n{%- endfor %}{% endif %}\n"
  },
  {
    "path": "roles/dns/templates/dnscrypt-proxy.toml.j2",
    "content": "##############################################\n#                                            #\n#        dnscrypt-proxy configuration        #\n#                                            #\n##############################################\n\n## Online documentation: https://dnscrypt.info/doc\n\n\n{% include 'dnscrypt-proxy/global.toml.j2' %}\n\n\n{% include 'dnscrypt-proxy/cache.toml.j2' %}\n\n\n{% include 'dnscrypt-proxy/filters.toml.j2' %}\n\n\n{% include 'dnscrypt-proxy/sources.toml.j2' %}\n"
  },
  {
    "path": "roles/dns/templates/ip-blacklist.txt.j2",
    "content": "0.0.0.0\n10.*\n127.*\n169.254.*\n172.16.*\n172.17.*\n172.18.*\n172.19.*\n172.20.*\n172.21.*\n172.22.*\n172.23.*\n172.24.*\n172.25.*\n172.26.*\n172.27.*\n172.28.*\n172.29.*\n172.30.*\n172.31.*\n192.168.*\n::ffff:0.0.0.0\n::ffff:10.*\n::ffff:127.*\n::ffff:169.254.*\n::ffff:172.16.*\n::ffff:172.17.*\n::ffff:172.18.*\n::ffff:172.19.*\n::ffff:172.20.*\n::ffff:172.21.*\n::ffff:172.22.*\n::ffff:172.23.*\n::ffff:172.24.*\n::ffff:172.25.*\n::ffff:172.26.*\n::ffff:172.27.*\n::ffff:172.28.*\n::ffff:172.29.*\n::ffff:172.30.*\n::ffff:172.31.*\n::ffff:192.168.*\nfd00::*\nfe80::*\n"
  },
  {
    "path": "roles/local/tasks/main.yml",
    "content": "---\n- name: Include prompts\n  import_tasks: prompts.yml\n"
  },
  {
    "path": "roles/local/tasks/prompts.yml",
    "content": "---\n- name: Display local installation warning\n  pause:\n    prompt: |\n\n      ======================================================================\n                          *** DESTRUCTIVE OPERATION ***\n      ======================================================================\n\n      Algo is designed for DEDICATED VPN servers only.\n\n      This installation will:\n        - Replace all firewall rules (blocks most incoming traffic)\n        - Modify system network and DNS settings\n        - Install and configure VPN services\n\n      THERE IS NO UNINSTALL OPTION.\n\n      If this server runs other services (web, database, mail, etc.),\n      they will likely become inaccessible.\n\n      Recommended: Take a snapshot/backup before proceeding.\n\n      Documentation: https://trailofbits.github.io/algo/deploy-to-ubuntu.html\n\n      ======================================================================\n\n      Type 'yes' to confirm you understand the risks and want to proceed:\n  register: _local_warning_confirm\n  when:\n    - not tests | default(false) | bool\n    - not local_install_confirmed | default(false) | bool\n\n- name: Abort if not confirmed\n  fail:\n    msg: \"Installation aborted by user. Your server was not modified.\"\n  when:\n    - not tests | default(false) | bool\n    - not local_install_confirmed | default(false) | bool\n    - _local_warning_confirm.user_input | default('') | lower != 'yes'\n\n- pause:\n    prompt: |\n      Enter the IP address of your server: (or use localhost for local installation):\n      [localhost]\n  register: _algo_server\n  when: server is undefined\n\n- name: Set the facts\n  set_fact:\n    cloud_instance_ip: >-\n      {%- if server is defined -%}{{ server }}{%- elif _algo_server.user_input -%}{{ _algo_server.user_input }}{%- else -%}localhost{%- endif -%}\n\n- when: cloud_instance_ip != \"localhost\"\n  block:\n    - pause:\n        prompt: |\n          What user should we use to login on the server? (note: passwordless login required, or ignore if you're deploying to localhost)\n          [root]\n      register: _algo_ssh_user\n      when: ssh_user is undefined\n\n    - name: Set the facts\n      set_fact:\n        ansible_ssh_user: >-\n          {%- if ssh_user is defined -%}{{ ssh_user }}{%- elif _algo_ssh_user.user_input -%}{{ _algo_ssh_user.user_input }}{%- else -%}root{%- endif -%}\n\n- pause:\n    prompt: |\n      Enter the public IP address or domain name of your server: (IMPORTANT! This is used to verify the certificate)\n      [{{ cloud_instance_ip }}]\n  register: _endpoint\n  when: endpoint is undefined\n\n- name: Set the facts\n  set_fact:\n    IP_subject_alt_name: >-\n      {%- if endpoint is defined -%}{{ endpoint }}{%- elif _endpoint.user_input -%}{{ _endpoint.user_input }}{%- else -%}{{ cloud_instance_ip }}{%- endif -%}\n"
  },
  {
    "path": "roles/privacy/README.md",
    "content": "# Privacy Enhancements Role\n\nThis Ansible role implements additional privacy enhancements for Algo VPN to minimize server-side traces of VPN usage and reduce log retention. These measures help protect user privacy while maintaining system security.\n\n## Features\n\n### 1. Aggressive Log Rotation\n- Configures shorter log retention periods (default: 7 days)\n- Implements more frequent log rotation\n- Compresses rotated logs to save space\n- Automatically cleans up old log files\n\n### 2. History Clearing\n- Clears bash/shell history after deployment\n- Disables persistent command history for system users\n- Clears temporary files and caches\n- Sets up automatic history clearing on user logout\n\n### 3. VPN Log Filtering\n- Filters out VPN connection logs from rsyslog\n- Excludes WireGuard and StrongSwan messages from persistent storage\n- Filters kernel messages related to VPN traffic\n- Optional filtering of authentication logs (use with caution)\n\n### 4. Automatic Cleanup\n- Daily/weekly/monthly cleanup of old logs and temporary files\n- Package cache cleaning\n- Configurable retention policies\n- Optional shutdown cleanup for extreme privacy\n\n### 5. Advanced Privacy Settings\n- Reduced kernel log verbosity\n- Disabled successful SSH connection logging (optional)\n- Volatile systemd journal storage\n- Privacy monitoring script\n\n## Configuration\n\nAll privacy settings are configured in `config.cfg` under the \"Privacy Enhancements\" section:\n\n```yaml\n# Enable/disable all privacy enhancements\nprivacy_enhancements_enabled: true\n\n# Log rotation settings\nprivacy_log_rotation:\n  max_age: 7              # Days to keep logs\n  max_size: 10            # Max size per log file (MB)\n  rotate_count: 3         # Number of rotated files to keep\n  compress: true          # Compress rotated logs\n  daily_rotation: true    # Force daily rotation\n\n# History clearing\nprivacy_history_clearing:\n  clear_bash_history: true\n  clear_system_history: true\n  disable_service_history: true\n\n# Log filtering\nprivacy_log_filtering:\n  exclude_vpn_logs: true\n  exclude_auth_logs: false    # Use with caution\n  filter_kernel_vpn_logs: true\n\n# Automatic cleanup\nprivacy_auto_cleanup:\n  enabled: true\n  frequency: \"daily\"          # daily, weekly, monthly\n  temp_files_max_age: 1\n  clean_package_cache: true\n\n# Advanced settings\nprivacy_advanced:\n  disable_ssh_success_logs: false\n  reduce_kernel_verbosity: true\n  clear_logs_on_shutdown: false  # Extreme measure\n```\n\n## Security Considerations\n\n### Safe Settings (Default)\n- `exclude_vpn_logs: true` - Safe, only filters VPN-specific messages\n- `clear_bash_history: true` - Safe, improves privacy without affecting security\n- `reduce_kernel_verbosity: true` - Safe, reduces noise in logs\n\n### Use With Caution\n- `exclude_auth_logs: true` - Reduces security logging, makes incident investigation harder\n- `disable_ssh_success_logs: true` - Removes audit trail for successful connections\n- `clear_logs_on_shutdown: true` - Extreme measure, makes debugging very difficult\n\n## Files Created\n\n### Configuration Files\n- `/etc/logrotate.d/99-privacy-enhanced` - Main log rotation config\n- `/etc/logrotate.d/99-auth-privacy` - Auth log rotation\n- `/etc/logrotate.d/99-kern-privacy` - Kernel log rotation\n- `/etc/rsyslog.d/49-privacy-vpn-filter.conf` - VPN log filtering\n- `/etc/rsyslog.d/48-privacy-kernel-filter.conf` - Kernel log filtering\n- `/etc/rsyslog.d/47-privacy-auth-filter.conf` - Auth log filtering (optional)\n- `/etc/rsyslog.d/46-privacy-ssh-filter.conf` - SSH log filtering (optional)\n- `/etc/rsyslog.d/45-privacy-minimal.conf` - Minimal logging config\n\n### Scripts\n- `/usr/local/bin/privacy-auto-cleanup.sh` - Automatic cleanup script\n- `/usr/local/bin/privacy-log-cleanup.sh` - Initial log cleanup\n- `/usr/local/bin/privacy-monitor.sh` - Privacy status monitoring\n- `/etc/bash.bash_logout` - History clearing on logout\n\n### Systemd Services\n- `/etc/systemd/system/privacy-shutdown-cleanup.service` - Shutdown cleanup (optional)\n\n## Usage\n\n### Enable Privacy Enhancements\nPrivacy enhancements are enabled by default. To disable them:\n\n```yaml\nprivacy_enhancements_enabled: false\n```\n\n### Run Specific Privacy Tasks\nYou can run specific privacy components using tags:\n\n```bash\n# Run only log rotation setup\nansible-playbook server.yml --tags privacy-logs\n\n# Run only history clearing\nansible-playbook server.yml --tags privacy-history\n\n# Run only log filtering\nansible-playbook server.yml --tags privacy-filtering\n\n# Run only cleanup tasks\nansible-playbook server.yml --tags privacy-cleanup\n\n# Run all privacy enhancements\nansible-playbook server.yml --tags privacy\n```\n\n### Monitor Privacy Status\nCheck the status of privacy enhancements:\n\n```bash\nsudo /usr/local/bin/privacy-monitor.sh\n```\n\n### Manual Cleanup\nRun manual cleanup:\n\n```bash\nsudo /usr/local/bin/privacy-auto-cleanup.sh\n```\n\n## Debugging\n\nIf you need to debug VPN issues, temporarily disable privacy enhancements:\n\n1. Set `privacy_enhancements_enabled: false` in `config.cfg`\n2. Re-run the deployment: `./algo`\n3. Debug your issues with full logging\n4. Re-enable privacy enhancements when done\n\nAlternatively, disable specific features:\n- Set `exclude_vpn_logs: false` to see VPN connection logs\n- Set `reduce_kernel_verbosity: false` for full kernel logging\n- Check `/var/log/privacy-cleanup.log` for cleanup operation logs\n\n## Impact on System\n\n### Positive Effects\n- Improved user privacy\n- Reduced disk usage from logs\n- Faster log searches\n- Reduced attack surface\n\n### Potential Drawbacks\n- Limited debugging information\n- Shorter audit trail\n- May complicate troubleshooting\n- Could hide security incidents\n\n## Compatibility\n\n- **Ubuntu 22.04**: Fully supported\n- **Other distributions**: May require adaptation\n\n## Best Practices\n\n1. **Start Conservative**: Use default settings initially\n2. **Test Thoroughly**: Verify VPN functionality after enabling privacy features\n3. **Monitor Logs**: Check that essential security logs are still being captured\n4. **Document Changes**: Keep track of privacy settings for troubleshooting\n5. **Regular Reviews**: Periodically review privacy settings and their effectiveness\n\n## Privacy vs. Security Balance\n\nThis role aims to balance privacy with security by:\n- Keeping security-critical logs (authentication failures, system errors)\n- Filtering only VPN-specific operational logs\n- Providing granular control over what gets filtered\n- Maintaining essential audit trails while reducing VPN usage traces\n\nFor maximum privacy, consider running your own log analysis before enabling aggressive filtering options.\n"
  },
  {
    "path": "roles/privacy/defaults/main.yml",
    "content": "---\n# Privacy enhancement configuration defaults\n# These settings can be overridden in config.cfg\n\n# Enable privacy enhancements (disabled for debugging when false)\nprivacy_enhancements_enabled: true\n\n# Log rotation settings\nprivacy_log_rotation:\n  # Maximum age for system logs in days\n  max_age: 7\n  # Maximum size for individual log files in MB\n  max_size: 10\n  # Number of rotated files to keep\n  rotate_count: 3\n  # Compress rotated logs\n  compress: true\n  # Force daily rotation regardless of size\n  daily_rotation: true\n\n# History clearing settings\nprivacy_history_clearing:\n  # Clear bash history after deployment\n  clear_bash_history: true\n  # Clear system command history\n  clear_system_history: true\n  # Disable bash history persistence for service users\n  disable_service_history: true\n\n# Log filtering settings\nprivacy_log_filtering:\n  # Exclude VPN connection logs from persistent storage\n  exclude_vpn_logs: true\n  # Exclude authentication logs (be careful with this)\n  exclude_auth_logs: false\n  # Filter kernel logs related to VPN traffic\n  filter_kernel_vpn_logs: true\n\n# Automatic cleanup settings\nprivacy_auto_cleanup:\n  # Enable automatic log cleanup\n  enabled: true\n  # Cleanup frequency (daily, weekly, monthly)\n  frequency: \"daily\"\n  # Clean up temporary files older than N days\n  temp_files_max_age: 1\n  # Clean up old package cache\n  clean_package_cache: true\n\n# Advanced privacy settings\nprivacy_advanced:\n  # Disable logging of successful SSH connections\n  disable_ssh_success_logs: false\n  # Reduce kernel log verbosity\n  reduce_kernel_verbosity: true\n  # Clear logs on shutdown (use with caution)\n  clear_logs_on_shutdown: false\n"
  },
  {
    "path": "roles/privacy/handlers/main.yml",
    "content": "---\n# Privacy role handlers\n# These handlers are triggered by privacy configuration changes\n\n- name: restart rsyslog\n  systemd:\n    name: rsyslog\n    state: restarted\n    daemon_reload: yes\n  become: yes\n\n- name: restart systemd-journald\n  systemd:\n    name: systemd-journald\n    state: restarted\n    daemon_reload: yes\n  become: yes\n\n- name: reload systemd\n  systemd:\n    daemon_reload: yes\n  become: yes\n\n- name: enable privacy shutdown cleanup\n  systemd:\n    name: privacy-shutdown-cleanup.service\n    enabled: yes\n    daemon_reload: yes\n  become: yes\n"
  },
  {
    "path": "roles/privacy/tasks/advanced_privacy.yml",
    "content": "---\n# Advanced privacy settings for enhanced anonymity\n\n- name: Reduce kernel log verbosity for privacy\n  sysctl:\n    name: \"{{ item.name }}\"\n    value: \"{{ item.value }}\"\n    state: present\n    reload: yes\n  loop:\n    - { name: 'kernel.printk', value: '3 4 1 3' }\n    - { name: 'kernel.dmesg_restrict', value: '1' }\n  when: privacy_advanced.reduce_kernel_verbosity | bool\n\n- name: Configure kernel parameters for privacy\n  lineinfile:\n    path: /etc/sysctl.d/99-privacy.conf\n    line: \"{{ item }}\"\n    create: yes\n    mode: '0644'\n  loop:\n    - \"# Privacy enhancements - reduce kernel logging\"\n    - \"kernel.printk = 3 4 1 3\"\n    - \"kernel.dmesg_restrict = 1\"\n  when: privacy_advanced.reduce_kernel_verbosity | bool\n\n- name: Configure journal settings for privacy\n  lineinfile:\n    path: /etc/systemd/journald.conf\n    regexp: \"^#?{{ item.key }}=\"\n    line: \"{{ item.key }}={{ item.value }}\"\n    backup: yes\n  loop:\n    - { key: 'MaxRetentionSec', value: '{{ privacy_log_rotation.max_age * 24 * 3600 }}' }\n    - { key: 'MaxFileSec', value: '1day' }\n    - { key: 'SystemMaxUse', value: '100M' }\n    - { key: 'SystemMaxFileSize', value: '{{ privacy_log_rotation.max_size }}M' }\n    - { key: 'ForwardToSyslog', value: 'no' }\n  notify: restart systemd-journald\n\n- name: Disable persistent systemd journal\n  file:\n    path: /var/log/journal\n    state: absent\n  when: privacy_advanced.reduce_kernel_verbosity | bool\n\n- name: Create journal configuration for volatile storage only\n  lineinfile:\n    path: /etc/systemd/journald.conf\n    regexp: \"^#?Storage=\"\n    line: \"Storage=volatile\"\n    backup: yes\n  notify: restart systemd-journald\n\n- name: Configure rsyslog for minimal logging\n  template:\n    src: privacy-rsyslog.conf.j2\n    dest: /etc/rsyslog.d/45-privacy-minimal.conf\n    mode: '0644'\n    owner: root\n    group: root\n  notify: restart rsyslog\n\n- name: Set up privacy monitoring script\n  template:\n    src: privacy-monitor.sh.j2\n    dest: /usr/local/bin/privacy-monitor.sh\n    mode: '0755'\n    owner: root\n    group: root\n\n- name: Display privacy configuration summary\n  debug:\n    msg:\n      - \"Privacy enhancements applied:\"\n      - \"  - Log retention: {{ privacy_log_rotation.max_age }} days\"\n      - \"  - VPN log filtering: {{ privacy_log_filtering.exclude_vpn_logs | bool }}\"\n      - \"  - History clearing: {{ privacy_history_clearing.clear_bash_history | bool }}\"\n      - \"  - Auto cleanup: {{ privacy_auto_cleanup.enabled | bool }}\"\n      - \"  - Kernel verbosity reduction: {{ privacy_advanced.reduce_kernel_verbosity | bool }}\"\n"
  },
  {
    "path": "roles/privacy/tasks/auto_cleanup.yml",
    "content": "---\n# Automatic cleanup tasks for enhanced privacy\n\n- name: Create privacy cleanup script\n  template:\n    src: privacy-auto-cleanup.sh.j2\n    dest: /usr/local/bin/privacy-auto-cleanup.sh\n    mode: '0755'\n    owner: root\n    group: root\n\n- name: Set up automatic privacy cleanup cron job\n  cron:\n    name: \"Privacy auto cleanup\"\n    job: \"/usr/local/bin/privacy-auto-cleanup.sh\"\n    minute: \"30\"\n    hour: \"2\"\n    user: root\n    state: \"{{ 'present' if privacy_auto_cleanup.enabled else 'absent' }}\"\n  when: privacy_auto_cleanup.frequency == 'daily'\n\n- name: Set up weekly privacy cleanup cron job\n  cron:\n    name: \"Privacy auto cleanup weekly\"\n    job: \"/usr/local/bin/privacy-auto-cleanup.sh\"\n    minute: \"30\"\n    hour: \"2\"\n    weekday: \"0\"\n    user: root\n    state: \"{{ 'present' if privacy_auto_cleanup.enabled else 'absent' }}\"\n  when: privacy_auto_cleanup.frequency == 'weekly'\n\n- name: Set up monthly privacy cleanup cron job\n  cron:\n    name: \"Privacy auto cleanup monthly\"\n    job: \"/usr/local/bin/privacy-auto-cleanup.sh\"\n    minute: \"30\"\n    hour: \"2\"\n    day: \"1\"\n    user: root\n    state: \"{{ 'present' if privacy_auto_cleanup.enabled else 'absent' }}\"\n  when: privacy_auto_cleanup.frequency == 'monthly'\n\n- name: Create systemd service for privacy cleanup on shutdown\n  template:\n    src: privacy-shutdown-cleanup.service.j2\n    dest: /etc/systemd/system/privacy-shutdown-cleanup.service\n    mode: '0644'\n    owner: root\n    group: root\n  when: privacy_advanced.clear_logs_on_shutdown | bool\n  notify:\n    - reload systemd\n    - enable privacy shutdown cleanup\n\n- name: Clean up temporary files immediately\n  shell: |\n    find /tmp -type f -mtime +{{ privacy_auto_cleanup.temp_files_max_age }} -delete\n    find /var/tmp -type f -mtime +{{ privacy_auto_cleanup.temp_files_max_age }} -delete\n  changed_when: false\n  when: privacy_auto_cleanup.enabled | bool\n\n- name: Clean package cache immediately\n  apt:\n    autoclean: true\n  changed_when: false\n  when:\n    - privacy_auto_cleanup.enabled | bool\n    - privacy_auto_cleanup.clean_package_cache | bool\n"
  },
  {
    "path": "roles/privacy/tasks/clear_history.yml",
    "content": "---\n# Clear command history and disable persistent history for privacy\n\n- name: Clear bash history for all users\n  shell: |\n    for user_home in /home/* /root; do\n      if [ -d \"$user_home\" ]; then\n        rm -f \"$user_home/.bash_history\"\n        rm -f \"$user_home/.zsh_history\"\n        rm -f \"$user_home/.sh_history\"\n      fi\n    done\n  when: privacy_history_clearing.clear_bash_history | bool\n  changed_when: false\n\n- name: Clear system command history logs\n  file:\n    path: \"{{ item }}\"\n    state: absent\n  loop:\n    - /var/log/lastlog\n    - /var/log/wtmp.1\n    - /var/log/btmp.1\n    - /tmp/.X*\n    - /tmp/.font-unix\n    - /tmp/.ICE-unix\n  when: privacy_history_clearing.clear_system_history | bool\n  failed_when: false\n\n- name: Configure bash to not save history for service users\n  lineinfile:\n    path: \"{{ item }}/.bashrc\"\n    line: \"{{ history_disable_line }}\"\n    create: true\n    mode: '0644'\n  loop:\n    - /root\n    - /home/ubuntu\n  vars:\n    history_disable_line: |\n      # Privacy enhancement: disable bash history\n      export HISTFILE=/dev/null\n      export HISTSIZE=0\n      export HISTFILESIZE=0\n      unset HISTFILE\n  when: privacy_history_clearing.disable_service_history | bool\n  failed_when: false\n\n- name: Create history clearing script for logout\n  template:\n    src: clear-history-on-logout.sh.j2\n    dest: /etc/bash.bash_logout\n    mode: '0644'\n    owner: root\n    group: root\n  when: privacy_history_clearing.clear_bash_history | bool\n\n# Note: We don't clear current session history as each Ansible task\n# runs in its own shell session, making this operation ineffective\n"
  },
  {
    "path": "roles/privacy/tasks/log_filtering.yml",
    "content": "---\n# Configure rsyslog to filter out VPN-related logs for privacy\n\n- name: Create rsyslog privacy configuration directory\n  file:\n    path: /etc/rsyslog.d\n    state: directory\n    mode: '0755'\n    owner: root\n    group: root\n\n- name: Configure rsyslog to exclude VPN-related logs\n  template:\n    src: 49-privacy-vpn-filter.conf.j2\n    dest: /etc/rsyslog.d/49-privacy-vpn-filter.conf\n    mode: '0644'\n    owner: root\n    group: root\n  notify: restart rsyslog\n  when: privacy_log_filtering.exclude_vpn_logs | bool\n\n- name: Configure rsyslog to filter kernel VPN logs\n  template:\n    src: 48-privacy-kernel-filter.conf.j2\n    dest: /etc/rsyslog.d/48-privacy-kernel-filter.conf\n    mode: '0644'\n    owner: root\n    group: root\n  notify: restart rsyslog\n  when: privacy_log_filtering.filter_kernel_vpn_logs | bool\n\n- name: Configure rsyslog to exclude detailed auth logs (optional)\n  template:\n    src: 47-privacy-auth-filter.conf.j2\n    dest: /etc/rsyslog.d/47-privacy-auth-filter.conf\n    mode: '0644'\n    owner: root\n    group: root\n  notify: restart rsyslog\n  when: privacy_log_filtering.exclude_auth_logs | bool\n\n- name: Create rsyslog privacy filter for SSH success logs\n  template:\n    src: 46-privacy-ssh-filter.conf.j2\n    dest: /etc/rsyslog.d/46-privacy-ssh-filter.conf\n    mode: '0644'\n    owner: root\n    group: root\n  notify: restart rsyslog\n  when: privacy_advanced.disable_ssh_success_logs | bool\n\n- name: Test rsyslog configuration\n  command: rsyslogd -N1\n  register: rsyslog_test\n  changed_when: false\n  failed_when: rsyslog_test.rc != 0\n\n- name: Display rsyslog test results\n  debug:\n    msg: \"Rsyslog configuration test passed\"\n  when: rsyslog_test.rc == 0\n"
  },
  {
    "path": "roles/privacy/tasks/log_rotation.yml",
    "content": "---\n# Aggressive log rotation configuration for privacy\n# Reduces log retention time and implements more frequent rotation\n\n- name: Check if default rsyslog logrotate config exists\n  stat:\n    path: /etc/logrotate.d/rsyslog\n  register: rsyslog_logrotate\n\n- name: Disable default rsyslog logrotate to prevent conflicts\n  command: mv /etc/logrotate.d/rsyslog /etc/logrotate.d/rsyslog.disabled\n  when: rsyslog_logrotate.stat.exists\n  changed_when: rsyslog_logrotate.stat.exists\n\n- name: Configure aggressive logrotate for system logs\n  template:\n    src: privacy-logrotate.j2\n    dest: /etc/logrotate.d/99-privacy-enhanced\n    mode: '0644'\n    owner: root\n    group: root\n  notify: restart rsyslog\n\n- name: Configure logrotate for auth logs with shorter retention\n  template:\n    src: auth-logrotate.j2\n    dest: /etc/logrotate.d/99-auth-privacy\n    mode: '0644'\n    owner: root\n    group: root\n  notify: restart rsyslog\n\n- name: Configure logrotate for kern logs with VPN filtering\n  template:\n    src: kern-logrotate.j2\n    dest: /etc/logrotate.d/99-kern-privacy\n    mode: '0644'\n    owner: root\n    group: root\n  notify: restart rsyslog\n\n- name: Set more frequent logrotate execution\n  cron:\n    name: \"Enhanced privacy log rotation\"\n    job: \"/usr/sbin/logrotate /etc/logrotate.conf\"\n    minute: \"0\"\n    hour: \"*/6\"\n    user: root\n    state: present\n\n- name: Create privacy log cleanup script\n  template:\n    src: privacy-log-cleanup.sh.j2\n    dest: /usr/local/bin/privacy-log-cleanup.sh\n    mode: '0755'\n    owner: root\n    group: root\n\n# Note: We don't force immediate rotation as it can cause conflicts\n# The new settings will apply on the next scheduled rotation\n"
  },
  {
    "path": "roles/privacy/tasks/main.yml",
    "content": "---\n# Privacy enhancements for Algo VPN\n# This role implements additional privacy measures to reduce log retention\n# and minimize traces of VPN usage on the server\n\n- name: Display privacy enhancements status\n  debug:\n    msg: \"Privacy enhancements are {{ 'enabled' if privacy_enhancements_enabled else 'disabled' }}\"\n\n- name: Privacy enhancements block\n  when: privacy_enhancements_enabled | bool\n  block:\n    - name: Include log rotation tasks\n      include_tasks: log_rotation.yml\n      tags: privacy-logs\n\n    - name: Include history clearing tasks\n      include_tasks: clear_history.yml\n      tags: privacy-history\n\n    - name: Include log filtering tasks\n      include_tasks: log_filtering.yml\n      tags: privacy-filtering\n\n    - name: Include automatic cleanup tasks\n      include_tasks: auto_cleanup.yml\n      tags: privacy-cleanup\n\n    - name: Include advanced privacy tasks\n      include_tasks: advanced_privacy.yml\n      tags: privacy-advanced\n\n    - name: Display privacy enhancements completion\n      debug:\n        msg: \"Privacy enhancements have been successfully applied\"\n"
  },
  {
    "path": "roles/privacy/templates/46-privacy-ssh-filter.conf.j2",
    "content": "# Privacy-enhanced SSH log filtering\n# Filters successful SSH connections while keeping failures for security\n# Generated by Algo VPN privacy role\n\n{% if privacy_advanced.disable_ssh_success_logs %}\n# Filter successful SSH connections (keep failures for security monitoring)\n:msg, contains, \"sshd.*Accepted\" stop\n:msg, contains, \"sshd.*session opened\" stop\n:msg, contains, \"sshd.*session closed\" stop\n\n# Filter SSH key-based authentication success\n:msg, contains, \"sshd.*publickey\" stop\n{% endif %}\n\n# Continue processing SSH failure messages and other logs\n"
  },
  {
    "path": "roles/privacy/templates/47-privacy-auth-filter.conf.j2",
    "content": "# Privacy-enhanced authentication log filtering\n# WARNING: Use with caution - this reduces security logging\n# Only enable if you understand the security implications\n# Generated by Algo VPN privacy role\n\n{% if privacy_log_filtering.exclude_auth_logs %}\n# Filter successful authentication messages (reduces audit trail)\n:msg, contains, \"authentication success\" stop\n:msg, contains, \"session opened\" stop\n:msg, contains, \"session closed\" stop\n\n# Filter sudo success messages (keep failures for security)\n:msg, regex, \"sudo.*COMMAND\" stop\n\n# Filter cron messages\n:msg, contains, \"CRON\" stop\n{% endif %}\n\n# Continue processing other auth messages\n"
  },
  {
    "path": "roles/privacy/templates/48-privacy-kernel-filter.conf.j2",
    "content": "# Privacy-enhanced kernel log filtering\n# Filters kernel messages that may reveal VPN usage patterns\n# Generated by Algo VPN privacy role\n\n{% if privacy_log_filtering.filter_kernel_vpn_logs %}\n# Filter iptables/netfilter messages related to VPN\n:msg, contains, \"iptables\" stop\n:msg, contains, \"netfilter\" stop\n\n# Filter connection tracking messages\n:msg, contains, \"nf_conntrack\" stop\n\n# Filter network interface up/down messages for VPN interfaces\n:msg, regex, \"wg[0-9]+.*link\" stop\n:msg, regex, \"ipsec[0-9]+.*link\" stop\n\n# Filter routing table changes\n:msg, contains, \"rtnetlink\" stop\n{% endif %}\n\n# Continue processing non-filtered messages\n"
  },
  {
    "path": "roles/privacy/templates/49-privacy-vpn-filter.conf.j2",
    "content": "# Privacy-enhanced rsyslog configuration\n# Filters VPN-related log entries for enhanced privacy\n# Generated by Algo VPN privacy role\n\n# Stop processing VPN-related messages to prevent them from being logged\n# This helps maintain user privacy by not storing VPN connection details\n\n{% if privacy_log_filtering.exclude_vpn_logs %}\n# Filter WireGuard messages\n:msg, contains, \"wireguard\" stop\n\n# Filter StrongSwan/IPsec messages\n:msg, contains, \"strongswan\" stop\n:msg, contains, \"ipsec\" stop\n:msg, contains, \"charon\" stop\n:msg, contains, \"xl2tpd\" stop\n\n# Filter VPN interface messages\n:msg, contains, \"wg0\" stop\n:msg, contains, \"ipsec0\" stop\n\n# Filter VPN-related kernel messages\n:msg, regex, \"IN=wg[0-9]+\" stop\n:msg, regex, \"OUT=wg[0-9]+\" stop\n{% endif %}\n\n{% if privacy_log_filtering.filter_kernel_vpn_logs %}\n# Filter kernel messages related to VPN traffic\n:msg, contains, \"netfilter\" stop\n:msg, regex, \"FORWARD.*DPT:(51820|500|4500)\" stop\n{% endif %}\n\n# Continue processing other messages\n& stop\n"
  },
  {
    "path": "roles/privacy/templates/auth-logrotate.j2",
    "content": "# Privacy-enhanced auth log rotation\n# Reduces retention time for authentication logs\n# Generated by Algo VPN privacy role\n\n/var/log/auth.log\n{\n    # Shorter retention for auth logs (privacy)\n    rotate 2\n    maxage {{ privacy_log_rotation.max_age | int // 2 }}\n    size {{ privacy_log_rotation.max_size // 2 }}M\n\n    daily\n    missingok\n    notifempty\n    compress\n    delaycompress\n\n    create 0640 syslog adm\n    copytruncate\n\n    postrotate\n        if [ -f /var/run/rsyslogd.pid ]; then\n            kill -HUP `cat /var/run/rsyslogd.pid`\n        fi\n    endscript\n}\n"
  },
  {
    "path": "roles/privacy/templates/clear-history-on-logout.sh.j2",
    "content": "#!/bin/bash\n# Privacy-enhanced history clearing on logout\n# This script clears command history when users log out\n# Generated by Algo VPN privacy role\n\n{% if privacy_history_clearing.clear_bash_history %}\n# Clear bash history\nif [ -f ~/.bash_history ]; then\n    > ~/.bash_history\nfi\n\n# Clear zsh history\nif [ -f ~/.zsh_history ]; then\n    > ~/.zsh_history\nfi\n\n# Clear current session history\nhistory -c\nhistory -w\n\n# Clear less history\nif [ -f ~/.lesshst ]; then\n    rm -f ~/.lesshst\nfi\n\n# Clear vim history\nif [ -f ~/.viminfo ]; then\n    rm -f ~/.viminfo\nfi\n{% endif %}\n\n{% if privacy_history_clearing.clear_system_history %}\n# Clear temporary files in user directory\nfind ~/tmp -type f -delete 2>/dev/null || true\nfind ~/.cache -type f -delete 2>/dev/null || true\n{% endif %}\n"
  },
  {
    "path": "roles/privacy/templates/kern-logrotate.j2",
    "content": "# Privacy-enhanced kernel log rotation\n# Reduces retention time for kernel logs that may contain VPN traces\n# Generated by Algo VPN privacy role\n\n/var/log/kern.log\n{\n    # Aggressive rotation for kernel logs\n    rotate {{ privacy_log_rotation.rotate_count }}\n    maxage {{ privacy_log_rotation.max_age }}\n    size {{ privacy_log_rotation.max_size }}M\n\n    daily\n    missingok\n    notifempty\n    compress\n    delaycompress\n\n    create 0640 syslog adm\n    copytruncate\n\n    # Pre-rotation script to filter VPN-related entries\n    prerotate\n        # Create filtered version excluding VPN traces\n        if [ -f /var/log/kern.log ]; then\n            grep -v -E \"(wireguard|ipsec|strongswan|xl2tpd)\" /var/log/kern.log > /tmp/kern.log.filtered || true\n            if [ -s /tmp/kern.log.filtered ]; then\n                mv /tmp/kern.log.filtered /var/log/kern.log\n            fi\n        fi\n    endscript\n\n    postrotate\n        if [ -f /var/run/rsyslogd.pid ]; then\n            kill -HUP `cat /var/run/rsyslogd.pid`\n        fi\n    endscript\n}\n"
  },
  {
    "path": "roles/privacy/templates/privacy-auto-cleanup.sh.j2",
    "content": "#!/bin/bash\n# Privacy auto-cleanup script\n# Automatically cleans up logs and temporary files for enhanced privacy\n# Generated by Algo VPN privacy role\n\nset -euo pipefail\n\n# Configuration\nLOG_MAX_AGE={{ privacy_auto_cleanup.temp_files_max_age }}\nSCRIPT_LOG=\"/var/log/privacy-cleanup.log\"\n\n# Logging function\nlog_message() {\n    echo \"$(date '+%Y-%m-%d %H:%M:%S') - $1\" >> \"$SCRIPT_LOG\"\n}\n\nlog_message \"Starting privacy cleanup\"\n\n{% if privacy_auto_cleanup.enabled %}\n# Rotate log files to prevent the cleanup log from growing\nif [ -f \"$SCRIPT_LOG\" ] && [ $(wc -l < \"$SCRIPT_LOG\") -gt 1000 ]; then\n    tail -n 500 \"$SCRIPT_LOG\" > \"$SCRIPT_LOG.tmp\"\n    mv \"$SCRIPT_LOG.tmp\" \"$SCRIPT_LOG\"\nfi\n\n# Clean temporary files\nlog_message \"Cleaning temporary files older than ${LOG_MAX_AGE} days\"\nfind /tmp -type f -mtime +${LOG_MAX_AGE} -delete 2>/dev/null || true\nfind /var/tmp -type f -mtime +${LOG_MAX_AGE} -delete 2>/dev/null || true\n\n# Clean old log files that may have escaped rotation\nlog_message \"Cleaning old rotated logs\"\nfind /var/log -name \"*.log.*\" -type f -mtime +{{ privacy_log_rotation.max_age }} -delete 2>/dev/null || true\nfind /var/log -name \"*.gz\" -type f -mtime +{{ privacy_log_rotation.max_age }} -delete 2>/dev/null || true\n\n# Clean systemd journal if it exists\nif [ -d /var/log/journal ]; then\n    log_message \"Cleaning systemd journal files\"\n    journalctl --vacuum-time={{ privacy_log_rotation.max_age }}d 2>/dev/null || true\n    journalctl --vacuum-size=50M 2>/dev/null || true\nfi\n\n{% if privacy_auto_cleanup.clean_package_cache %}\n# Clean package cache\nlog_message \"Cleaning package cache\"\napt-get clean 2>/dev/null || true\napt-get autoclean 2>/dev/null || true\n{% endif %}\n\n# Clean bash history files\nlog_message \"Cleaning bash history files\"\nfor user_home in /home/* /root; do\n    if [ -d \"$user_home\" ]; then\n        rm -f \"$user_home/.bash_history\" 2>/dev/null || true\n        rm -f \"$user_home/.zsh_history\" 2>/dev/null || true\n        rm -f \"$user_home/.lesshst\" 2>/dev/null || true\n        rm -f \"$user_home/.viminfo\" 2>/dev/null || true\n    fi\ndone\n\n# Clean core dumps\nlog_message \"Cleaning core dumps\"\nfind /var/crash -type f -name \"*.crash\" -mtime +1 -delete 2>/dev/null || true\n\n# Force log rotation\nlog_message \"Forcing log rotation\"\n/usr/sbin/logrotate -f /etc/logrotate.conf 2>/dev/null || true\n\nlog_message \"Privacy cleanup completed successfully\"\n{% else %}\nlog_message \"Privacy cleanup is disabled\"\n{% endif %}\n\n# Clean up old privacy cleanup logs\nfind /var/log -name \"privacy-cleanup.log.*\" -type f -mtime +7 -delete 2>/dev/null || true\n"
  },
  {
    "path": "roles/privacy/templates/privacy-log-cleanup.sh.j2",
    "content": "#!/bin/bash\n# Privacy log cleanup script\n# Immediately cleans up existing logs and applies privacy settings\n# Generated by Algo VPN privacy role\n\nset -euo pipefail\n\necho \"Starting privacy log cleanup...\"\n\n# Truncate existing log files to apply new rotation settings immediately\nfind /var/log -type f -name \"*.log\" -size +{{ privacy_log_rotation.max_size }}M -exec truncate -s {{ privacy_log_rotation.max_size }}M {} \\; 2>/dev/null || true\n\n# Remove old rotated logs that exceed our retention policy\nfind /var/log -type f \\( -name \"*.log.*\" -o -name \"*.gz\" \\) -mtime +{{ privacy_log_rotation.max_age }} -delete 2>/dev/null || true\n\n# Clean up systemd journal to respect new settings\nif [ -d /var/log/journal ]; then\n    journalctl --vacuum-time={{ privacy_log_rotation.max_age }}d 2>/dev/null || true\n    journalctl --vacuum-size={{ privacy_log_rotation.max_size * 10 }}M 2>/dev/null || true\nfi\n\necho \"Privacy log cleanup completed\"\n"
  },
  {
    "path": "roles/privacy/templates/privacy-logrotate.j2",
    "content": "# Privacy-enhanced logrotate configuration\n# This configuration enforces aggressive log rotation for privacy\n# Generated by Algo VPN privacy role\n# Replaces the default rsyslog logrotate configuration\n\n# Main system logs (may not all exist on every system)\n/var/log/syslog\n/var/log/messages\n/var/log/daemon.log\n/var/log/debug\n/var/log/user.log\n/var/log/mail.log\n/var/log/mail.err\n/var/log/mail.warn\n{\n    # Rotate {{ privacy_log_rotation.rotate_count }} times before deletion\n    rotate {{ privacy_log_rotation.rotate_count }}\n\n    # Maximum age in days\n    maxage {{ privacy_log_rotation.max_age }}\n\n    # Maximum size per file\n    size {{ privacy_log_rotation.max_size }}M\n\n    {% if privacy_log_rotation.daily_rotation %}\n    # Force daily rotation\n    daily\n    {% endif %}\n\n    {% if privacy_log_rotation.compress %}\n    # Compress rotated files\n    compress\n    delaycompress\n    {% endif %}\n\n    # Missing files are ok (not all systems have all logs)\n    missingok\n\n    # Don't rotate if empty\n    notifempty\n\n    # Create new files with specific permissions\n    create 0640 syslog adm\n\n    # Truncate original file after rotation\n    copytruncate\n\n    # Execute after rotation\n    postrotate\n        # Send SIGHUP to rsyslog\n        /usr/bin/killall -HUP rsyslogd 2>/dev/null || true\n    endscript\n}\n"
  },
  {
    "path": "roles/privacy/templates/privacy-monitor.sh.j2",
    "content": "#!/bin/bash\n# Privacy monitoring script\n# Monitors and reports on privacy settings status\n# Generated by Algo VPN privacy role\n\nset -euo pipefail\n\n# Colors for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m' # No Color\n\necho -e \"${GREEN}Algo VPN Privacy Status Monitor${NC}\"\necho \"========================================\"\n\n# Check log rotation settings\necho -e \"\\n${YELLOW}Log Rotation Status:${NC}\"\nif [ -f /etc/logrotate.d/99-privacy-enhanced ]; then\n    echo -e \"  ${GREEN}✓${NC} Privacy log rotation configured\"\nelse\n    echo -e \"  ${RED}✗${NC} Privacy log rotation not found\"\nfi\n\n# Check rsyslog filtering\necho -e \"\\n${YELLOW}Log Filtering Status:${NC}\"\nif [ -f /etc/rsyslog.d/49-privacy-vpn-filter.conf ]; then\n    echo -e \"  ${GREEN}✓${NC} VPN log filtering enabled\"\nelse\n    echo -e \"  ${RED}✗${NC} VPN log filtering not configured\"\nfi\n\n# Check history clearing\necho -e \"\\n${YELLOW}History Clearing Status:${NC}\"\nif [ -f /etc/bash.bash_logout ]; then\n    echo -e \"  ${GREEN}✓${NC} Logout history clearing configured\"\nelse\n    echo -e \"  ${RED}✗${NC} Logout history clearing not configured\"\nfi\n\n# Check auto cleanup\necho -e \"\\n${YELLOW}Auto Cleanup Status:${NC}\"\nif [ -f /usr/local/bin/privacy-auto-cleanup.sh ]; then\n    echo -e \"  ${GREEN}✓${NC} Auto cleanup script installed\"\n    if crontab -l | grep -q \"privacy-auto-cleanup\"; then\n        echo -e \"  ${GREEN}✓${NC} Auto cleanup scheduled\"\n    else\n        echo -e \"  ${YELLOW}!${NC} Auto cleanup script exists but not scheduled\"\n    fi\nelse\n    echo -e \"  ${RED}✗${NC} Auto cleanup not configured\"\nfi\n\n# Check current log sizes\necho -e \"\\n${YELLOW}Current Log Status:${NC}\"\ntotal_log_size=$(du -sh /var/log 2>/dev/null | cut -f1 || echo \"Unknown\")\necho \"  Total log directory size: $total_log_size\"\n\nif [ -f /var/log/auth.log ]; then\n    auth_size=$(du -h /var/log/auth.log | cut -f1)\n    echo \"  Auth log size: $auth_size\"\nfi\n\nif [ -f /var/log/syslog ]; then\n    syslog_size=$(du -h /var/log/syslog | cut -f1)\n    echo \"  Syslog size: $syslog_size\"\nfi\n\n# Check systemd journal status\necho -e \"\\n${YELLOW}Journal Status:${NC}\"\nif [ -d /var/log/journal ]; then\n    journal_size=$(du -sh /var/log/journal 2>/dev/null | cut -f1 || echo \"Unknown\")\n    echo \"  Journal size: $journal_size\"\nelse\n    echo -e \"  ${GREEN}✓${NC} Persistent journal disabled (using volatile storage)\"\nfi\n\n# Privacy configuration summary\necho -e \"\\n${YELLOW}Privacy Configuration Summary:${NC}\"\necho \"  Log retention: {{ privacy_log_rotation.max_age }} days\"\necho \"  Max log size: {{ privacy_log_rotation.max_size }}MB\"\necho \"  VPN log filtering: {{ privacy_log_filtering.exclude_vpn_logs }}\"\necho -e \"  History clearing: {{ privacy_history_clearing.clear_bash_history }}\"\n\necho -e \"\\n${GREEN}Privacy monitoring complete${NC}\"\n"
  },
  {
    "path": "roles/privacy/templates/privacy-rsyslog.conf.j2",
    "content": "# Privacy-enhanced rsyslog configuration\n# Minimal logging configuration for enhanced privacy\n# Generated by Algo VPN privacy role\n\n# Global settings for privacy\n$ModLoad imuxsock # provides support for local system logging\n$ModLoad imklog   # provides kernel logging support\n\n# Reduce logging verbosity\n$KLogPermitNonKernelFacility on\n$SystemLogSocketName /run/systemd/journal/syslog\n\n# Privacy-enhanced rules\n{% if privacy_advanced.reduce_kernel_verbosity %}\n# Reduce kernel message verbosity\nkern.info;kern.!debug    /var/log/kern.log\n{% else %}\nkern.*                   /var/log/kern.log\n{% endif %}\n\n# Essential system messages only\n*.emerg                  :omusrmsg:*\n*.alert                  /var/log/alert.log\n*.crit                   /var/log/critical.log\n*.err                    /var/log/error.log\n\n# Compress and limit emergency logs\n$template PrivacyTemplate,\"%timegenerated% %hostname% %syslogtag%%msg%\\n\"\n$ActionFileDefaultTemplate PrivacyTemplate\n\n# Stop processing after essential logs to prevent detailed logging\n& stop\n"
  },
  {
    "path": "roles/privacy/templates/privacy-shutdown-cleanup.service.j2",
    "content": "# Privacy shutdown cleanup systemd service\n# Clears logs and sensitive data on system shutdown\n# Generated by Algo VPN privacy role\n\n[Unit]\nDescription=Privacy Cleanup on Shutdown\nDefaultDependencies=false\nBefore=shutdown.target reboot.target halt.target\nRequires=-.mount\n\n[Service]\nType=oneshot\nRemainAfterExit=true\nExecStart=/bin/true\nExecStop=/bin/bash -c '\n    # Clear all logs\n    find /var/log -type f -name \"*.log\" -exec truncate -s 0 {} \\; 2>/dev/null || true;\n\n    # Clear rotated logs\n    find /var/log -type f \\( -name \"*.log.*\" -o -name \"*.gz\" \\) -delete 2>/dev/null || true;\n\n    # Clear systemd journal\n    if [ -d /var/log/journal ]; then\n        rm -rf /var/log/journal/* 2>/dev/null || true;\n    fi;\n\n    # Clear bash history\n    for user_home in /home/* /root; do\n        if [ -d \"$user_home\" ]; then\n            rm -f \"$user_home\"/.bash_history 2>/dev/null || true;\n            rm -f \"$user_home\"/.zsh_history 2>/dev/null || true;\n        fi;\n    done;\n\n    # Clear temporary files\n    rm -rf /tmp/* /var/tmp/* 2>/dev/null || true;\n\n    # Sync to ensure changes are written\n    sync;\n'\nTimeoutStopSec=30\n\n[Install]\nWantedBy=shutdown.target\n"
  },
  {
    "path": "roles/ssh_tunneling/defaults/main.yml",
    "content": "---\nssh_tunnels_config_path: configs/{{ IP_subject_alt_name }}/ssh-tunnel/\n"
  },
  {
    "path": "roles/ssh_tunneling/handlers/main.yml",
    "content": "---\n- name: restart ssh\n  service: name=\"{{ ssh_service_name | default('ssh') }}\" state=restarted\n"
  },
  {
    "path": "roles/ssh_tunneling/tasks/main.yml",
    "content": "---\n- name: Ensure that the sshd_config file has desired options\n  blockinfile:\n    dest: /etc/ssh/sshd_config\n    marker: \"# {mark} ANSIBLE MANAGED BLOCK ssh_tunneling_role\"\n    block: |\n      Match Group algo\n          AllowTcpForwarding local\n          AllowAgentForwarding no\n          AllowStreamLocalForwarding no\n          PermitTunnel no\n          X11Forwarding no\n  notify:\n    - restart ssh\n\n- name: Ensure that the algo group exist\n  group:\n    name: algo\n    state: present\n    gid: 15000\n\n- name: Ensure that the jail directory exist\n  file:\n    path: /var/jail/\n    state: directory\n    mode: '0755'\n    owner: root\n    group: \"{{ root_group | default('root') }}\"\n\n- tags: update-users\n  block:\n    - name: Ensure that the SSH users exist\n      user:\n        name: \"{{ item }}\"\n        group: algo\n        groups: algo\n        home: /var/jail/{{ item }}\n        createhome: true\n        generate_ssh_key: false\n        shell: /bin/false\n        state: present\n        append: true\n      loop: \"{{ users }}\"\n\n    - become: false\n      delegate_to: localhost\n      block:\n        - name: Clean up the ssh-tunnel directory\n          file:\n            dest: \"{{ ssh_tunnels_config_path }}\"\n            state: absent\n          when: keys_clean_all|bool\n\n        - name: Ensure the config directories exist\n          file:\n            dest: \"{{ ssh_tunnels_config_path }}\"\n            state: directory\n            recurse: true\n            mode: \"0700\"\n\n        - name: Check if the private keys exist\n          stat:\n            path: \"{{ ssh_tunnels_config_path }}/{{ item }}.pem\"\n          register: privatekey\n          loop: \"{{ users }}\"\n\n        - name: Build ssh private keys\n          openssl_privatekey:\n            path: \"{{ ssh_tunnels_config_path }}/{{ item.item }}.pem\"\n            passphrase: \"{{ p12_export_password }}\"\n            cipher: auto\n            force: false\n          no_log: \"{{ algo_no_log | bool }}\"\n          when: not item.stat.exists\n          loop: \"{{ privatekey.results }}\"\n          register: openssl_privatekey\n\n        - name: Build ssh public keys\n          openssl_publickey:\n            path: \"{{ ssh_tunnels_config_path }}/{{ item.item.item }}.pub\"\n            privatekey_path: \"{{ ssh_tunnels_config_path }}/{{ item.item.item }}.pem\"\n            privatekey_passphrase: \"{{ p12_export_password }}\"\n            format: OpenSSH\n            force: true\n          no_log: \"{{ algo_no_log | bool }}\"\n          when: item.changed\n          loop: \"{{ openssl_privatekey.results }}\"\n\n        - name: Build the client ssh config\n          template:\n            src: ssh_config.j2\n            dest: \"{{ ssh_tunnels_config_path }}/{{ item }}.ssh_config\"\n            mode: '0700'\n          loop: \"{{ users }}\"\n\n    - name: The authorized keys file created\n      authorized_key:\n        user: \"{{ item }}\"\n        key: \"{{ lookup('file', ssh_tunnels_config_path + '/' + item + '.pub') }}\"\n        state: present\n        manage_dir: true\n        exclusive: true\n      loop: \"{{ users }}\"\n\n    - name: Get active users\n      getent:\n        database: group\n        key: algo\n        split: \":\"\n\n    - name: Delete non-existing users\n      user:\n        name: \"{{ item }}\"\n        state: absent\n        remove: true\n        force: true\n      when: item not in users\n      loop: \"{{ getent_group['algo'][2].split(',') }}\"\n"
  },
  {
    "path": "roles/ssh_tunneling/templates/ssh_config.j2",
    "content": "Host algo\n  DynamicForward 127.0.0.1:1080\n  LogLevel quiet\n  Compression yes\n  IdentitiesOnly yes\n  IdentityFile {{ item }}.ssh.pem\n  User {{ item }}\n  Hostname {{ IP_subject_alt_name }}\n"
  },
  {
    "path": "roles/strongswan/defaults/main.yml",
    "content": "---\nipsec_config_path: configs/{{ IP_subject_alt_name }}/ipsec\nipsec_pki_path: \"{{ ipsec_config_path }}/.pki\"\nstrongswan_shell: /usr/sbin/nologin\nstrongswan_home: /var/lib/strongswan\nstrongswan_service: \"{{ 'strongswan-starter' if ansible_facts['distribution_version'] is version('20.04', '>=') else 'strongswan' }}\"\nBetweenClients_DROP: true\nalgo_ondemand_cellular: false\nalgo_ondemand_wifi: false\nalgo_ondemand_wifi_exclude: _null\nalgo_dns_adblocking: false\nipv6_support: false\ndns_encryption: true\n# Random UUID for CA name constraints - prevents certificate reuse across different Algo deployments\n# This unique identifier ensures each CA can only issue certificates for its specific server instance\nopenssl_constraint_random_id: \"{{ IP_subject_alt_name | to_uuid }}.algo\"\n# Subject Alternative Name (SAN) configuration - CRITICAL for client compatibility\n# Modern clients (especially macOS/iOS) REQUIRE SAN extension in server certificates\n# Without SAN, IKEv2 connections will fail with certificate validation errors\nsubjectAltName_type: \"{{ 'DNS' if IP_subject_alt_name | regex_search('[a-z]') else 'IP' }}\"\nsubjectAltName: >-\n  {{ subjectAltName_type }}:{{ IP_subject_alt_name }}{%- if ipv6_support | bool -%},IP:{{ ansible_default_ipv6['address'] }}{%- endif -%}\nsubjectAltName_USER: email:{{ item }}@{{ openssl_constraint_random_id }}\n# yamllint disable rule:line-length\nnameConstraints: >-\n  critical,permitted;{{ subjectAltName_type }}:{{ IP_subject_alt_name }}{{- '/255.255.255.255' if subjectAltName_type == 'IP' else '' -}}{%- if subjectAltName_type == 'IP' -%},permitted;DNS:{{ openssl_constraint_random_id }},excluded;DNS:.com,excluded;DNS:.org,excluded;DNS:.net,excluded;DNS:.gov,excluded;DNS:.edu,excluded;DNS:.mil,excluded;DNS:.int,excluded;IP:10.0.0.0/255.0.0.0,excluded;IP:172.16.0.0/255.240.0.0,excluded;IP:192.168.0.0/255.255.0.0{%- else -%},excluded;IP:0.0.0.0/0.0.0.0{%- endif -%},permitted;email:{{ openssl_constraint_random_id }},excluded;email:.com,excluded;email:.org,excluded;email:.net,excluded;email:.gov,excluded;email:.edu,excluded;email:.mil,excluded;email:.int{%- if ipv6_support | bool -%},permitted;IP:{{ ansible_default_ipv6['address'] }}/ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff,excluded;IP:fc00:0:0:0:0:0:0:0/fe00:0:0:0:0:0:0:0,excluded;IP:fe80:0:0:0:0:0:0:0/ffc0:0:0:0:0:0:0:0,excluded;IP:2001:db8:0:0:0:0:0:0/ffff:fff8:0:0:0:0:0:0{%- else -%},excluded;IP:::/0{%- endif -%}\n# yamllint enable rule:line-length\nopenssl_bin: openssl\nstrongswan_enabled_plugins:\n  - aes\n  - gcm\n  - hmac\n  - kernel-netlink\n  - nonce\n  - openssl\n  - pem\n  - pgp\n  - pkcs12\n  - pkcs7\n  - pkcs8\n  - pubkey\n  - random\n  - revocation\n  - sha2\n  - socket-default\n  - stroke\n  - x509\n\nciphers:\n  defaults:\n    ike: aes256gcm16-prfsha512-ecp384!\n    esp: aes256gcm16-ecp384!\n\npkcs12_PayloadCertificateUUID: \"{{ 900000 | random | to_uuid | upper }}\"\nVPN_PayloadIdentifier: \"{{ 800000 | random | to_uuid | upper }}\"\nCA_PayloadIdentifier: \"{{ 700000 | random | to_uuid | upper }}\"\n"
  },
  {
    "path": "roles/strongswan/handlers/main.yml",
    "content": "---\n- name: restart strongswan\n  service: name={{ strongswan_service }} state=restarted\n\n- name: daemon-reload\n  systemd: daemon_reload=true\n\n- name: restart apparmor\n  service: name=apparmor state=restarted\n\n- name: rereadcrls\n  shell: |\n    # Check if StrongSwan is actually running\n    if ! systemctl is-active --quiet strongswan-starter 2>/dev/null && \\\n       ! systemctl is-active --quiet strongswan 2>/dev/null && \\\n       ! service strongswan status >/dev/null 2>&1; then\n      echo \"StrongSwan is not running, skipping CRL reload\"\n      exit 0\n    fi\n\n    # StrongSwan is running, wait a moment for it to stabilize\n    sleep 2\n\n    # Try to reload CRLs with retries\n    for attempt in 1 2 3; do\n      if ipsec rereadcrls 2>/dev/null && ipsec purgecrls 2>/dev/null; then\n        echo \"Successfully reloaded CRLs\"\n        exit 0\n      fi\n      echo \"Attempt $attempt failed, retrying...\"\n      sleep 2\n    done\n\n    # If StrongSwan is running but we can't reload CRLs, that's a real problem\n    echo \"Failed to reload CRLs after 3 attempts\"\n    exit 1\n  changed_when: false\n"
  },
  {
    "path": "roles/strongswan/meta/main.yml",
    "content": "---\n"
  },
  {
    "path": "roles/strongswan/tasks/client_configs.yml",
    "content": "---\n- name: Register p12 PayloadContent\n  shell: |\n    set -o pipefail\n    cat private/{{ item }}.p12 |\n    base64\n  register: PayloadContent\n  changed_when: false\n  args:\n    executable: bash\n    chdir: \"{{ ipsec_pki_path }}\"\n  loop: \"{{ users }}\"\n\n- name: Set facts for mobileconfigs\n  set_fact:\n    PayloadContentCA: \"{{ lookup('file', ipsec_pki_path + '/cacert.pem') | b64encode }}\"\n\n- name: Build the mobileconfigs\n  template:\n    src: mobileconfig.j2\n    dest: \"{{ ipsec_config_path }}/apple/{{ item.0 }}.mobileconfig\"\n    mode: '0600'\n  with_together:\n    - \"{{ users }}\"\n    - \"{{ PayloadContent.results }}\"\n  no_log: \"{{ algo_no_log | bool }}\"\n\n- name: Build the client ipsec config file\n  template:\n    src: client_ipsec.conf.j2\n    dest: \"{{ ipsec_config_path }}/manual/{{ item }}.conf\"\n    mode: '0600'\n  loop: \"{{ users }}\"\n\n\n- name: Build the client ipsec secret file\n  template:\n    src: client_ipsec.secrets.j2\n    dest: \"{{ ipsec_config_path }}/manual/{{ item }}.secrets\"\n    mode: '0600'\n  loop: \"{{ users }}\"\n\n- name: Restrict permissions for the local private directories\n  file:\n    path: \"{{ ipsec_config_path }}\"\n    state: directory\n    mode: '0700'\n"
  },
  {
    "path": "roles/strongswan/tasks/distribute_keys.yml",
    "content": "---\n- name: Copy the keys to the strongswan directory\n  copy:\n    src: \"{{ ipsec_pki_path }}/{{ item.src }}\"\n    dest: \"{{ config_prefix | default('/') }}etc/ipsec.d/{{ item.dest }}\"\n    owner: \"{{ item.owner }}\"\n    group: \"{{ item.group }}\"\n    mode: \"{{ item.mode }}\"\n  loop:\n    - src: cacert.pem\n      dest: cacerts/ca.crt\n      owner: strongswan\n      group: \"{{ root_group | default('root') }}\"\n      mode: \"0600\"\n    - src: certs/{{ IP_subject_alt_name }}.crt\n      dest: certs/{{ IP_subject_alt_name }}.crt\n      owner: strongswan\n      group: \"{{ root_group | default('root') }}\"\n      mode: \"0600\"\n    - src: private/{{ IP_subject_alt_name }}.key\n      dest: private/{{ IP_subject_alt_name }}.key\n      owner: strongswan\n      group: \"{{ root_group | default('root') }}\"\n      mode: \"0600\"\n  notify:\n    - restart strongswan\n"
  },
  {
    "path": "roles/strongswan/tasks/ipsec_configuration.yml",
    "content": "---\n- name: Setup the config files from our templates\n  template:\n    src: \"{{ item.src }}\"\n    dest: \"{{ config_prefix | default('/') }}etc/{{ item.dest }}\"\n    owner: \"{{ item.owner }}\"\n    group: \"{{ item.group }}\"\n    mode: \"{{ item.mode }}\"\n  loop:\n    - src: strongswan.conf.j2\n      dest: strongswan.conf\n      owner: root\n      group: \"{{ root_group | default('root') }}\"\n      mode: \"0644\"\n    - src: ipsec.conf.j2\n      dest: ipsec.conf\n      owner: root\n      group: \"{{ root_group | default('root') }}\"\n      mode: \"0644\"\n    - src: ipsec.secrets.j2\n      dest: ipsec.secrets\n      owner: strongswan\n      group: \"{{ root_group | default('root') }}\"\n      mode: \"0600\"\n    - src: charon.conf.j2\n      dest: strongswan.d/charon.conf\n      owner: root\n      group: \"{{ root_group | default('root') }}\"\n      mode: \"0644\"\n  notify:\n    - restart strongswan\n\n- name: Get loaded plugins\n  shell: |\n    set -o pipefail\n    find {{ config_prefix | default('/') }}etc/strongswan.d/charon/ -type f -name '*.conf' -exec basename {} \\; |\n    cut -f1 -d.\n  changed_when: false\n  args:\n    executable: bash\n  register: strongswan_plugins\n\n- name: Disable unneeded plugins\n  lineinfile:\n    dest: \"{{ config_prefix | default('/') }}etc/strongswan.d/charon/{{ item }}.conf\"\n    regexp: .*load.*\n    line: load = no\n    state: present\n  notify:\n    - restart strongswan\n  when: item not in strongswan_enabled_plugins and item not in strongswan_additional_plugins\n  loop: \"{{ strongswan_plugins.stdout_lines }}\"\n\n- name: Ensure that required plugins are enabled\n  lineinfile: dest=\"{{ config_prefix | default('/') }}etc/strongswan.d/charon/{{ item }}.conf\" regexp='.*load.*' line='load = yes' state=present\n  notify:\n    - restart strongswan\n  when: item in strongswan_enabled_plugins or item in strongswan_additional_plugins\n  loop: \"{{ strongswan_plugins.stdout_lines }}\"\n"
  },
  {
    "path": "roles/strongswan/tasks/main.yml",
    "content": "---\n- name: Include tasks for Debian/Ubuntu\n  include_tasks: ubuntu.yml\n  when: is_debian_based | bool\n\n- name: Ensure that the strongswan user exists\n  user:\n    name: strongswan\n    group: nogroup\n    shell: \"{{ strongswan_shell }}\"\n    home: \"{{ strongswan_home }}\"\n    state: present\n\n- name: Install strongSwan\n  package: name=strongswan state=present\n\n- import_tasks: ipsec_configuration.yml\n- import_tasks: openssl.yml\n  tags: update-users\n- import_tasks: distribute_keys.yml\n- import_tasks: client_configs.yml\n  delegate_to: localhost\n  become: false\n  tags: update-users\n\n- name: strongSwan started\n  service:\n    name: \"{{ strongswan_service }}\"\n    state: started\n    enabled: true\n\n- meta: flush_handlers\n\n- name: Delete the PKI directory\n  file:\n    path: \"{{ ipsec_pki_path }}\"\n    state: absent\n  become: false\n  delegate_to: localhost\n  when:\n    - not algo_store_pki\n    - not pki_in_tmpfs\n"
  },
  {
    "path": "roles/strongswan/tasks/openssl.yml",
    "content": "---\n- become: false\n  delegate_to: localhost\n  vars:\n    ansible_python_interpreter: \"{{ ansible_playbook_python }}\"\n    certificate_validity_days: 3650  # 10 years - configurable certificate lifespan\n  block:\n    - debug: var=subjectAltName\n\n    - name: Ensure the pki directory does not exist\n      file:\n        dest: \"{{ ipsec_pki_path }}\"\n        state: absent\n      when: keys_clean_all|bool\n\n    - name: Ensure the pki directories exist\n      file:\n        dest: \"{{ ipsec_pki_path }}/{{ item }}\"\n        state: directory\n        recurse: true\n        mode: \"0700\"\n      loop:\n        - certs\n        - private\n        - public\n\n    - name: Ensure the config directories exist\n      file:\n        dest: \"{{ ipsec_config_path }}/{{ item }}\"\n        state: directory\n        recurse: true\n        mode: \"0700\"\n      loop:\n        - apple\n        - manual\n\n    - name: Create private key with password protection\n      community.crypto.openssl_privatekey:\n        path: \"{{ ipsec_pki_path }}/private/cakey.pem\"\n        passphrase: \"{{ CA_password }}\"\n        type: ECC\n        curve: secp384r1\n        mode: \"0600\"\n      no_log: true\n\n    # CA certificate with name constraints to prevent certificate misuse (Issue #75)\n    - name: Create certificate signing request (CSR) for CA certificate with security constraints\n      community.crypto.openssl_csr_pipe:\n        privatekey_path: \"{{ ipsec_pki_path }}/private/cakey.pem\"\n        privatekey_passphrase: \"{{ CA_password }}\"\n        common_name: \"{{ IP_subject_alt_name }}\"\n        use_common_name_for_san: true\n        # Generate Subject Key Identifier for proper Authority Key Identifier creation\n        create_subject_key_identifier: true\n        basic_constraints:\n          - 'CA:TRUE'\n          - 'pathlen:0'        # Prevents sub-CA creation - limits certificate chain depth if CA key compromised\n        basic_constraints_critical: true\n        key_usage:\n          - keyCertSign\n          - cRLSign\n        key_usage_critical: true\n        # CA restricted to VPN certificate issuance only\n        extended_key_usage:\n          - '1.3.6.1.5.5.7.3.17'        # IPsec End Entity OID - VPN-specific usage\n        extended_key_usage_critical: true\n        # Name Constraints: Defense-in-depth security restricting certificate scope to prevent misuse\n        # Limits CA to only issue certificates for this specific VPN deployment's resources\n        # Per-deployment UUID prevents cross-deployment reuse, unique email domain isolates certificate scope\n        name_constraints_permitted: >-\n          {{ [\n            subjectAltName_type + ':' + IP_subject_alt_name + ('/255.255.255.255' if subjectAltName_type == 'IP' else ''),\n            'DNS:' + openssl_constraint_random_id,\n            'email:' + openssl_constraint_random_id\n          ] + (\n            ['IP:' + ansible_default_ipv6['address'] + '/128'] if ipv6_support | bool else []\n          ) }}\n        # Block public domains/networks to prevent certificate abuse for impersonation attacks\n        # Public TLD exclusion, Email domain exclusion, RFC 1918: prevents lateral movement\n        # IPv6: ULA/link-local/doc ranges or all\n        name_constraints_excluded: >-\n          {{ [\n            'DNS:.com', 'DNS:.org', 'DNS:.net', 'DNS:.gov', 'DNS:.edu', 'DNS:.mil', 'DNS:.int',\n            'email:.com', 'email:.org', 'email:.net', 'email:.gov', 'email:.edu', 'email:.mil', 'email:.int',\n            'IP:10.0.0.0/255.0.0.0', 'IP:172.16.0.0/255.240.0.0', 'IP:192.168.0.0/255.255.0.0'\n          ] + (\n            ['IP:fc00::/7', 'IP:fe80::/10', 'IP:2001:db8::/32'] if ipv6_support | bool else ['IP:::/0']\n          ) }}\n        name_constraints_critical: true\n      register: ca_csr\n\n    - name: Create self-signed CA certificate from CSR\n      community.crypto.x509_certificate:\n        path: \"{{ ipsec_pki_path }}/cacert.pem\"\n        csr_content: \"{{ ca_csr.csr }}\"\n        privatekey_path: \"{{ ipsec_pki_path }}/private/cakey.pem\"\n        privatekey_passphrase: \"{{ CA_password }}\"\n        provider: selfsigned\n        mode: \"0644\"\n      no_log: true\n\n    - name: Copy the CA certificate\n      copy:\n        src: \"{{ ipsec_pki_path }}/cacert.pem\"\n        dest: \"{{ ipsec_config_path }}/manual/cacert.pem\"\n        mode: '0644'\n\n    - name: Create private keys for users and server\n      community.crypto.openssl_privatekey:\n        path: \"{{ ipsec_pki_path }}/private/{{ item }}.key\"\n        type: ECC\n        curve: secp384r1\n        mode: \"0600\"\n      loop: \"{{ users + [IP_subject_alt_name] }}\"\n      register: client_key_jobs\n\n    # Server certificate with SAN extension - required for modern Apple devices\n    - name: Create CSRs for server certificate with SAN\n      community.crypto.openssl_csr_pipe:\n        privatekey_path: \"{{ ipsec_pki_path }}/private/{{ IP_subject_alt_name }}.key\"\n        subject_alt_name: \"{{ subjectAltName.split(',') }}\"\n        common_name: \"{{ IP_subject_alt_name }}\"\n        # Add Basic Constraints to prevent certificate chain validation errors\n        basic_constraints:\n          - 'CA:FALSE'\n        basic_constraints_critical: false\n        key_usage:\n          - digitalSignature\n          - keyEncipherment\n        key_usage_critical: false\n        # Server auth EKU required for IKEv2 server certificates (Issue #75)\n        # NOTE: clientAuth deliberately excluded to prevent role confusion attacks\n        extended_key_usage:\n          - serverAuth                    # Server Authentication (RFC 5280)\n          - '1.3.6.1.5.5.7.3.17'        # IPsec End Entity (RFC 4945)\n        extended_key_usage_critical: false\n      register: server_csr\n\n    - name: Create CSRs for client certificates\n      community.crypto.openssl_csr_pipe:\n        privatekey_path: \"{{ ipsec_pki_path }}/private/{{ item }}.key\"\n        subject_alt_name:\n          - \"email:{{ item }}@{{ openssl_constraint_random_id }}\"  # UUID domain prevents certificate reuse across deployments\n        common_name: \"{{ item }}\"\n        # Add Basic Constraints to client certificates for proper PKI validation\n        basic_constraints:\n          - 'CA:FALSE'\n        basic_constraints_critical: false\n        key_usage:\n          - digitalSignature\n          - keyEncipherment\n        key_usage_critical: false\n        # Client certs restricted to clientAuth only - prevents clients from impersonating the VPN server\n        # NOTE: serverAuth deliberately excluded to prevent server impersonation attacks\n        extended_key_usage:\n          - clientAuth                    # Client Authentication (RFC 5280)\n          - '1.3.6.1.5.5.7.3.17'        # IPsec End Entity (RFC 4945)\n        extended_key_usage_critical: false\n      loop: \"{{ users }}\"\n      register: client_csr_jobs\n\n    - name: Sign server certificate with CA\n      community.crypto.x509_certificate:\n        csr_content: \"{{ server_csr.csr }}\"\n        path: \"{{ ipsec_pki_path }}/certs/{{ IP_subject_alt_name }}.crt\"\n        provider: ownca\n        ownca_path: \"{{ ipsec_pki_path }}/cacert.pem\"\n        ownca_privatekey_path: \"{{ ipsec_pki_path }}/private/cakey.pem\"\n        ownca_privatekey_passphrase: \"{{ CA_password }}\"\n        ownca_not_after: \"+{{ certificate_validity_days }}d\"\n        ownca_not_before: \"-1d\"\n        mode: \"0644\"\n      no_log: true\n\n    - name: Sign client certificates with CA\n      community.crypto.x509_certificate:\n        csr_content: \"{{ item.csr }}\"\n        path: \"{{ ipsec_pki_path }}/certs/{{ item.item }}.crt\"\n        provider: ownca\n        ownca_path: \"{{ ipsec_pki_path }}/cacert.pem\"\n        ownca_privatekey_path: \"{{ ipsec_pki_path }}/private/cakey.pem\"\n        ownca_privatekey_passphrase: \"{{ CA_password }}\"\n        ownca_not_after: \"+{{ certificate_validity_days }}d\"\n        ownca_not_before: \"-1d\"\n        mode: \"0644\"\n      loop: \"{{ client_csr_jobs.results }}\"\n      register: client_sign_results\n      no_log: true\n\n    - name: Generate p12 files\n      community.crypto.openssl_pkcs12:\n        path: \"{{ ipsec_pki_path }}/private/{{ item }}.p12\"\n        friendly_name: \"{{ item }}\"\n        privatekey_path: \"{{ ipsec_pki_path }}/private/{{ item }}.key\"\n        certificate_path: \"{{ ipsec_pki_path }}/certs/{{ item }}.crt\"\n        passphrase: \"{{ p12_export_password }}\"\n        mode: \"0600\"\n        encryption_level: \"compatibility2022\"  # Apple device compatibility\n      loop: \"{{ users }}\"\n      no_log: true\n\n    - name: Generate p12 files with CA certificate included\n      community.crypto.openssl_pkcs12:\n        path: \"{{ ipsec_pki_path }}/private/{{ item }}_ca.p12\"\n        friendly_name: \"{{ item }}\"\n        privatekey_path: \"{{ ipsec_pki_path }}/private/{{ item }}.key\"\n        certificate_path: \"{{ ipsec_pki_path }}/certs/{{ item }}.crt\"\n        other_certificates:\n          - \"{{ ipsec_pki_path }}/cacert.pem\"\n        passphrase: \"{{ p12_export_password }}\"\n        mode: \"0600\"\n        encryption_level: \"compatibility2022\"  # Apple device compatibility\n      loop: \"{{ users }}\"\n      no_log: true\n\n    - name: Copy the p12 certificates\n      copy:\n        src: \"{{ ipsec_pki_path }}/private/{{ item }}.p12\"\n        dest: \"{{ ipsec_config_path }}/manual/{{ item }}.p12\"\n        mode: '0600'\n      loop: \"{{ users }}\"\n\n    - name: Build openssh public keys\n      community.crypto.openssl_publickey:\n        path: \"{{ ipsec_pki_path }}/public/{{ item }}.pub\"\n        privatekey_path: \"{{ ipsec_pki_path }}/private/{{ item }}.key\"\n        format: OpenSSH\n      loop: \"{{ users }}\"\n\n    - name: Add all users to the file\n      ansible.builtin.lineinfile:\n        path: \"{{ ipsec_pki_path }}/all-users\"\n        line: \"{{ item }}\"\n        mode: '0644'\n        create: true\n      loop: \"{{ users }}\"\n      register: users_file\n\n    - name: Set all users as a fact\n      set_fact:\n        all_users: \"{{ lookup('file', ipsec_pki_path + '/all-users').splitlines() }}\"\n\n    # Certificate Revocation List (CRL) for removed users\n    - name: Calculate current timestamp for CRL\n      set_fact:\n        crl_timestamp: \"{{ '%Y%m%d%H%M%SZ' | strftime(ansible_date_time.epoch | int) }}\"\n\n    - name: Identify users whose certificates need revocation\n      set_fact:\n        users_to_revoke: \"{{ all_users | difference(users) }}\"\n\n    - name: Build revoked certificates list\n      set_fact:\n        revoked_certificates: >-\n          {{ users_to_revoke | map('regex_replace', '^(.*)$',\n             '{\"path\": \"' + ipsec_pki_path + '/certs/\\1.crt\", \"revocation_date\": \"' + crl_timestamp + '\"}') | list }}\n\n    - name: Generate a CRL\n      community.crypto.x509_crl:\n        path: \"{{ ipsec_pki_path }}/crl.pem\"\n        privatekey_path: \"{{ ipsec_pki_path }}/private/cakey.pem\"\n        privatekey_passphrase: \"{{ CA_password }}\"\n        last_update: \"{{ '%Y%m%d%H%M%SZ' | strftime(ansible_date_time.epoch | int) }}\"\n        next_update: \"{{ '%Y%m%d%H%M%SZ' | strftime((ansible_date_time.epoch | int) + (10 * 365 * 24 * 60 * 60)) }}\"\n        crl_mode: generate\n        issuer:\n          CN: \"{{ IP_subject_alt_name }}\"\n        revoked_certificates: \"{{ revoked_certificates }}\"\n      no_log: true\n\n    - name: Set CRL file permissions\n      file:\n        path: \"{{ ipsec_pki_path }}/crl.pem\"\n        mode: \"0644\"\n\n- name: Copy the CRL to the vpn server\n  copy:\n    src: \"{{ ipsec_pki_path }}/crl.pem\"\n    dest: \"{{ config_prefix | default('/') }}etc/ipsec.d/crls/algo.root.pem\"\n    mode: '0644'\n  notify:\n    - rereadcrls\n"
  },
  {
    "path": "roles/strongswan/tasks/ubuntu.yml",
    "content": "---\n- name: Set OS specific facts\n  set_fact:\n    strongswan_additional_plugins: []\n\n- name: Ubuntu | Ensure af_key kernel module is loaded\n  modprobe:\n    name: af_key\n    state: present\n    persistent: present\n\n- name: Ubuntu | Install strongSwan (individual)\n  apt:\n    name: strongswan\n    state: present\n    update_cache: true\n    install_recommends: true\n  when: not performance_parallel_packages | default(true)\n\n- when: apparmor_enabled|default(false)|bool\n  tags: apparmor\n  block:\n    # https://bugs.launchpad.net/ubuntu/+source/strongswan/+bug/1826238\n    - name: Ubuntu | Charon profile for apparmor configured\n      copy:\n        dest: /etc/apparmor.d/local/usr.lib.ipsec.charon\n        content: \" capability setpcap,\"\n        owner: root\n        group: root\n        mode: '0644'\n      notify: restart strongswan\n\n    - name: Ubuntu | Enforcing ipsec with apparmor\n      command: aa-enforce \"{{ item }}\"\n      changed_when: false\n      loop:\n        - /usr/lib/ipsec/charon\n        - /usr/lib/ipsec/lookip\n        - /usr/lib/ipsec/stroke\n\n- name: Ubuntu | Enable services\n  service: name={{ item }} enabled=yes\n  loop:\n    - apparmor\n    - \"{{ strongswan_service }}\"\n    - netfilter-persistent\n\n- name: Ubuntu | Ensure that the strongswan service directory exists\n  file:\n    path: /etc/systemd/system/{{ strongswan_service }}.service.d/\n    state: directory\n    mode: '0755'\n    owner: root\n    group: root\n\n- name: Ubuntu | Setup the cgroup limitations for the ipsec daemon\n  template:\n    src: 100-CustomLimitations.conf.j2\n    dest: /etc/systemd/system/{{ strongswan_service }}.service.d/100-CustomLimitations.conf\n    mode: '0644'\n  notify:\n    - daemon-reload\n    - restart strongswan\n"
  },
  {
    "path": "roles/strongswan/templates/100-CustomLimitations.conf.j2",
    "content": "# Algo VPN systemd security hardening for StrongSwan\n# Enhanced hardening on top of existing AppArmor\n[Service]\n# Privilege restrictions\nNoNewPrivileges=yes\n\n# Filesystem isolation (complements AppArmor)\nProtectHome=yes\nPrivateTmp=yes\nProtectKernelTunables=yes\nProtectControlGroups=yes\n\n# Network restrictions - include IPsec kernel communication requirements\n# AF_UNIX required for VICI socket communication (swanctl/modern StrongSwan interface)\nRestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK AF_PACKET AF_UNIX\n\n# Allow access to IPsec configuration, state, and kernel interfaces\nReadWritePaths=/etc/ipsec.d /var/lib/strongswan\nReadOnlyPaths=/proc/net/pfkey\n\n# System call filtering (complements AppArmor restrictions)\n# Allow crypto operations, remove cpu-emulation restriction for crypto algorithms\nSystemCallFilter=@system-service @network-io\nSystemCallFilter=~@debug @mount @swap @reboot\nSystemCallErrorNumber=EPERM\n"
  },
  {
    "path": "roles/strongswan/templates/charon.conf.j2",
    "content": "# Options for the charon IKE daemon.\ncharon {\n\n    # Accept unencrypted ID and HASH payloads in IKEv1 Main Mode.\n    # accept_unencrypted_mainmode_messages = no\n\n    # Maximum number of half-open IKE_SAs for a single peer IP.\n    # block_threshold = 5\n\n    # Whether Certificate Revocation Lists (CRLs) fetched via HTTP or LDAP\n    # should be saved under a unique file name derived from the public key of\n    # the Certification Authority (CA) to /etc/ipsec.d/crls (stroke) or\n    # /etc/swanctl/x509crl (vici), respectively.\n    # cache_crls = no\n\n    # Whether relations in validated certificate chains should be cached in\n    # memory.\n    # cert_cache = yes\n\n    # Send Cisco Unity vendor ID payload (IKEv1 only).\n    # cisco_unity = no\n\n    # Close the IKE_SA if setup of the CHILD_SA along with IKE_AUTH failed.\n     close_ike_on_child_failure = yes\n\n    # Number of half-open IKE_SAs that activate the cookie mechanism.\n    # cookie_threshold = 10\n\n    # Delete CHILD_SAs right after they got successfully rekeyed (IKEv1 only).\n    # delete_rekeyed = no\n\n    # Delay in seconds until inbound IPsec SAs are deleted after rekeyings\n    # (IKEv2 only).\n    # delete_rekeyed_delay = 5\n\n    # Use ANSI X9.42 DH exponent size or optimum size matched to cryptographic\n    # strength.\n    # dh_exponent_ansi_x9_42 = yes\n\n    # Use RTLD_NOW with dlopen when loading plugins and IMV/IMCs to reveal\n    # missing symbols immediately.\n    # dlopen_use_rtld_now = no\n\n    # DNS server assigned to peer via configuration payload (CP).\n    # dns1 =\n\n    # DNS server assigned to peer via configuration payload (CP).\n    # dns2 =\n\n    # Enable Denial of Service protection using cookies and aggressiveness\n    # checks.\n    # dos_protection = yes\n\n    # Compliance with the errata for RFC 4753.\n    # ecp_x_coordinate_only = yes\n\n    # Free objects during authentication (might conflict with plugins).\n    # flush_auth_cfg = no\n\n    # Whether to follow IKEv2 redirects (RFC 5685).\n    # follow_redirects = yes\n\n    # Maximum size (complete IP datagram size in bytes) of a sent IKE fragment\n    # when using proprietary IKEv1 or standardized IKEv2 fragmentation, defaults\n    # to 1280 (use 0 for address family specific default values, which uses a\n    # lower value for IPv4).  If specified this limit is used for both IPv4 and\n    # IPv6.\n    # fragment_size = 1280\n\n    # Name of the group the daemon changes to after startup.\n    # group =\n\n    # Timeout in seconds for connecting IKE_SAs (also see IKE_SA_INIT DROPPING).\n     half_open_timeout = 5\n\n    # Enable hash and URL support.\n    # hash_and_url = no\n\n    # Allow IKEv1 Aggressive Mode with pre-shared keys as responder.\n    # i_dont_care_about_security_and_use_aggressive_mode_psk = no\n\n    # Whether to ignore the traffic selectors from the kernel's acquire events\n    # for IKEv2 connections (they are not used for IKEv1).\n    # ignore_acquire_ts = no\n\n    # A space-separated list of routing tables to be excluded from route\n    # lookups.\n    # ignore_routing_tables =\n\n    # Maximum number of IKE_SAs that can be established at the same time before\n    # new connection attempts are blocked.\n    # ikesa_limit = 0\n\n    # Number of exclusively locked segments in the hash table.\n    # ikesa_table_segments = 1\n\n    # Size of the IKE_SA hash table.\n    # ikesa_table_size = 1\n\n    # Whether to close IKE_SA if the only CHILD_SA closed due to inactivity.\n     inactivity_close_ike = yes\n\n    # Limit new connections based on the current number of half open IKE_SAs,\n    # see IKE_SA_INIT DROPPING in strongswan.conf(5).\n    # init_limit_half_open = 0\n\n    # Limit new connections based on the number of queued jobs.\n    # init_limit_job_load = 0\n\n    # Causes charon daemon to ignore IKE initiation requests.\n    # initiator_only = no\n\n    # Install routes into a separate routing table for established IPsec\n    # tunnels.\n    # install_routes = yes\n\n    # Install virtual IP addresses.\n    # install_virtual_ip = yes\n\n    # The name of the interface on which virtual IP addresses should be\n    # installed.\n    # install_virtual_ip_on =\n\n    # Check daemon, libstrongswan and plugin integrity at startup.\n    # integrity_test = no\n\n    # A comma-separated list of network interfaces that should be ignored, if\n    # interfaces_use is specified this option has no effect.\n    # interfaces_ignore =\n\n    # A comma-separated list of network interfaces that should be used by\n    # charon. All other interfaces are ignored.\n    # interfaces_use =\n\n    # NAT keep alive interval.\n     keep_alive = 25s\n\n    # Plugins to load in the IKE daemon charon.\n    # load =\n\n    # Determine plugins to load via each plugin's load option.\n    # load_modular = no\n\n    # Initiate IKEv2 reauthentication with a make-before-break scheme.\n    # make_before_break = no\n\n    # Maximum number of IKEv1 phase 2 exchanges per IKE_SA to keep state about\n    # and track concurrently.\n    # max_ikev1_exchanges = 3\n\n    # Maximum packet size accepted by charon.\n    # max_packet = 10000\n\n    # Enable multiple authentication exchanges (RFC 4739).\n    # multiple_authentication = yes\n\n    # WINS servers assigned to peer via configuration payload (CP).\n    # nbns1 =\n\n    # WINS servers assigned to peer via configuration payload (CP).\n    # nbns2 =\n\n    # UDP port used locally. If set to 0 a random port will be allocated.\n    # port = 500\n\n    # UDP port used locally in case of NAT-T. If set to 0 a random port will be\n    # allocated.  Has to be different from charon.port, otherwise a random port\n    # will be allocated.\n    # port_nat_t = 4500\n\n    # Whether to prefer updating SAs to the path with the best route.\n    # prefer_best_path = no\n\n    # Prefer locally configured proposals for IKE/IPsec over supplied ones as\n    # responder (disabling this can avoid keying retries due to\n    # INVALID_KE_PAYLOAD notifies).\n    # prefer_configured_proposals = yes\n\n    # By default public IPv6 addresses are preferred over temporary ones (RFC\n    # 4941), to make connections more stable. Enable this option to reverse\n    # this.\n    # prefer_temporary_addrs = no\n\n    # Process RTM_NEWROUTE and RTM_DELROUTE events.\n    # process_route = yes\n\n    # Delay in ms for receiving packets, to simulate larger RTT.\n    # receive_delay = 0\n\n    # Delay request messages.\n    # receive_delay_request = yes\n\n    # Delay response messages.\n    # receive_delay_response = yes\n\n    # Specific IKEv2 message type to delay, 0 for any.\n    # receive_delay_type = 0\n\n    # Size of the AH/ESP replay window, in packets.\n    # replay_window = 32\n\n    # Base to use for calculating exponential back off, see IKEv2 RETRANSMISSION\n    # in strongswan.conf(5).\n    # retransmit_base = 1.8\n\n    # Maximum jitter in percent to apply randomly to calculated retransmission\n    # timeout (0 to disable).\n    # retransmit_jitter = 0\n\n    # Upper limit in seconds for calculated retransmission timeout (0 to\n    # disable).\n    # retransmit_limit = 0\n\n    # Timeout in seconds before sending first retransmit.\n    # retransmit_timeout = 4.0\n\n    # Number of times to retransmit a packet before giving up.\n    # retransmit_tries = 5\n\n    # Interval in seconds to use when retrying to initiate an IKE_SA (e.g. if\n    # DNS resolution failed), 0 to disable retries.\n    # retry_initiate_interval = 0\n\n    # Initiate CHILD_SA within existing IKE_SAs (always enabled for IKEv1).\n     reuse_ikesa = yes\n\n    # Numerical routing table to install routes to.\n    # routing_table =\n\n    # Priority of the routing table.\n    # routing_table_prio =\n\n    # Whether to use RSA with PSS padding instead of PKCS#1 padding by default.\n    # rsa_pss = no\n\n    # Delay in ms for sending packets, to simulate larger RTT.\n    # send_delay = 0\n\n    # Delay request messages.\n    # send_delay_request = yes\n\n    # Delay response messages.\n    # send_delay_response = yes\n\n    # Specific IKEv2 message type to delay, 0 for any.\n    # send_delay_type = 0\n\n    # Send strongSwan vendor ID payload\n    # send_vendor_id = no\n\n    # Whether to enable Signature Authentication as per RFC 7427.\n    # signature_authentication = yes\n\n    # Whether to enable constraints against IKEv2 signature schemes.\n    # signature_authentication_constraints = yes\n\n    # The upper limit for SPIs requested from the kernel for IPsec SAs.\n    # spi_max = 0xcfffffff\n\n    # The lower limit for SPIs requested from the kernel for IPsec SAs.\n    # spi_min = 0xc0000000\n\n    # Number of worker threads in charon.\n    # threads = 16\n\n    # Name of the user the daemon changes to after startup.\n    # user =\n\n    crypto_test {\n\n        # Benchmark crypto algorithms and order them by efficiency.\n        # bench = no\n\n        # Buffer size used for crypto benchmark.\n        # bench_size = 1024\n\n        # Number of iterations to test each algorithm.\n        # bench_time = 50\n\n        # Test crypto algorithms during registration (requires test vectors\n        # provided by the test-vectors plugin).\n        # on_add = no\n\n        # Test crypto algorithms on each crypto primitive instantiation.\n        # on_create = no\n\n        # Strictly require at least one test vector to enable an algorithm.\n        # required = no\n\n        # Whether to test RNG with TRUE quality; requires a lot of entropy.\n        # rng_true = no\n\n    }\n\n    host_resolver {\n\n        # Maximum number of concurrent resolver threads (they are terminated if\n        # unused).\n        # max_threads = 3\n\n        # Minimum number of resolver threads to keep around.\n        # min_threads = 0\n\n    }\n\n    leak_detective {\n\n        # Includes source file names and line numbers in leak detective output.\n        # detailed = yes\n\n        # Threshold in bytes for leaks to be reported (0 to report all).\n        # usage_threshold = 10240\n\n        # Threshold in number of allocations for leaks to be reported (0 to\n        # report all).\n        # usage_threshold_count = 0\n\n    }\n\n    processor {\n\n        # Section to configure the number of reserved threads per priority class\n        # see JOB PRIORITY MANAGEMENT in strongswan.conf(5).\n        priority_threads {\n\n        }\n\n    }\n\n    # Section containing a list of scripts (name = path) that are executed when\n    # the daemon is started.\n    start-scripts {\n\n    }\n\n    # Section containing a list of scripts (name = path) that are executed when\n    # the daemon is terminated.\n    stop-scripts {\n\n    }\n\n    tls {\n\n        # List of TLS encryption ciphers.\n        # cipher =\n\n        # List of TLS key exchange methods.\n        # key_exchange =\n\n        # List of TLS MAC algorithms.\n        # mac =\n\n        # List of TLS cipher suites.\n        # suites =\n\n    }\n\n    x509 {\n\n        # Discard certificates with unsupported or unknown critical extensions.\n        enforce_critical = no\n\n    }\n\n}\n"
  },
  {
    "path": "roles/strongswan/templates/client_ipsec.conf.j2",
    "content": "conn algovpn-{{ IP_subject_alt_name }}\n    fragmentation=yes\n    rekey=no\n    dpdaction=clear\n    keyexchange=ikev2\n    compress=no\n    dpddelay=35s\n\n    ike={{ ciphers.defaults.ike }}\n    esp={{ ciphers.defaults.esp }}\n\n    right={{ IP_subject_alt_name }}\n    rightid={{ IP_subject_alt_name }}\n    rightsubnet={{ rightsubnet | default('0.0.0.0/0') }}\n    rightauth=pubkey\n\n    leftsourceip=%config\n    leftauth=pubkey\n    leftcert={{ item }}.crt\n    leftfirewall=yes\n    left=%defaultroute\n\n    auto=add\n"
  },
  {
    "path": "roles/strongswan/templates/client_ipsec.secrets.j2",
    "content": "{{ IP_subject_alt_name }} : ECDSA {{ item }}.key\n"
  },
  {
    "path": "roles/strongswan/templates/ipsec.conf.j2",
    "content": "config setup\n    uniqueids=never # allow multiple connections per user\n    charondebug=\"ike {{ strongswan_log_level }}, knl {{ strongswan_log_level }}, cfg {{ strongswan_log_level }}, net {{ strongswan_log_level }}, esp {{ strongswan_log_level }}, dmn {{ strongswan_log_level }},  mgr {{ strongswan_log_level }}\"\n\nconn %default\n    fragmentation=yes\n    rekey=no\n    dpdaction=clear\n    keyexchange=ikev2\n    compress=yes\n    dpddelay=35s\n    lifetime=3h\n    ikelifetime=12h\n\n    ike={{ ciphers.defaults.ike }}\n    esp={{ ciphers.defaults.esp }}\n\n    left=%any\n    leftauth=pubkey\n    leftid={{ IP_subject_alt_name }}\n    leftcert={{ IP_subject_alt_name }}.crt\n    leftsendcert=always\n    leftsubnet=0.0.0.0/0,::/0\n\n    right=%any\n    rightauth=pubkey\n    rightsourceip={{ strongswan_network }},{{ strongswan_network_ipv6 }}\n{% if algo_dns_adblocking | bool or dns_encryption | bool %}\n    rightdns={{ local_service_ip }}{{ ',' + local_service_ipv6 if ipv6_support | bool else '' }}\n{% else %}\n    rightdns={% for host in dns_servers.ipv4 %}{{ host }}{% if not loop.last %},{% endif %}{% endfor %}{% if ipv6_support | bool %},{% for host in dns_servers.ipv6 %}{{ host }}{% if not loop.last %},{% endif %}{% endfor %}{% endif %}\n{% endif %}\n\nconn ikev2-pubkey\n    auto=add\n"
  },
  {
    "path": "roles/strongswan/templates/ipsec.secrets.j2",
    "content": ": ECDSA {{ IP_subject_alt_name }}.key\n"
  },
  {
    "path": "roles/strongswan/templates/mobileconfig.j2",
    "content": "#jinja2:lstrip_blocks: True\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>PayloadContent</key>\n    <array>\n        <dict>\n            <key>IKEv2</key>\n            <dict>\n              <key>OnDemandEnabled</key>\n              <integer>{{ 1 if algo_ondemand_wifi or algo_ondemand_cellular else 0 }}</integer>\n              <key>OnDemandRules</key>\n              <array>\n{% if algo_ondemand_wifi or algo_ondemand_cellular %}\n  {% if algo_ondemand_wifi_exclude | b64decode != '_null' %}\n    {% set WIFI_EXCLUDE_LIST = (algo_ondemand_wifi_exclude | b64decode | string).split(',') %}\n                  <dict>\n                      <key>Action</key>\n                      <string>Disconnect</string>\n                      <key>InterfaceTypeMatch</key>\n                      <string>WiFi</string>\n                      <key>SSIDMatch</key>\n                      <array>\n    {% for network_name in WIFI_EXCLUDE_LIST %}\n                          <string>{{ network_name | e }}</string>\n    {% endfor %}\n                      </array>\n                  </dict>\n  {% endif %}\n                  <dict>\n                    <key>Action</key>\n  {% if algo_ondemand_wifi %}\n                      <string>Connect</string>\n  {% else %}\n                      <string>Disconnect</string>\n  {% endif %}\n                    <key>InterfaceTypeMatch</key>\n                      <string>WiFi</string>\n                    <key>URLStringProbe</key>\n                      <string>http://captive.apple.com/hotspot-detect.html</string>\n                  </dict>\n                  <dict>\n                    <key>Action</key>\n  {% if algo_ondemand_cellular %}\n                      <string>Connect</string>\n  {% else %}\n                      <string>Disconnect</string>\n  {% endif %}\n                    <key>InterfaceTypeMatch</key>\n                      <string>Cellular</string>\n                    <key>URLStringProbe</key>\n                      <string>http://captive.apple.com/hotspot-detect.html</string>\n                  </dict>\n{% endif %}\n                  <dict>\n                    <key>Action</key>\n                      <string>{{ 'Disconnect' if algo_ondemand_wifi or algo_ondemand_cellular else 'Connect' }}</string>\n                  </dict>\n                </array>\n                <key>AuthenticationMethod</key>\n                <string>Certificate</string>\n                <key>ChildSecurityAssociationParameters</key>\n                <dict>\n                    <key>DiffieHellmanGroup</key>\n                    <integer>20</integer>\n                    <key>EncryptionAlgorithm</key>\n                    <string>AES-256-GCM</string>\n                    <key>IntegrityAlgorithm</key>\n                    <string>SHA2-512</string>\n                    <key>LifeTimeInMinutes</key>\n                    <integer>1440</integer>\n                </dict>\n                <key>DeadPeerDetectionRate</key>\n                <string>Medium</string>\n                <!-- MOBIKE allows VPN to survive network changes (WiFi to cellular) -->\n                <key>DisableMOBIKE</key>\n                <integer>0</integer>\n                <!-- Disable IKEv2 redirects for security -->\n                <key>DisableRedirect</key>\n                <integer>1</integer>\n                <!-- Disable CRL checking for performance and reliability -->\n                <key>EnableCertificateRevocationCheck</key>\n                <integer>0</integer>\n                <key>EnablePFS</key>\n                <true/>\n                <key>IKESecurityAssociationParameters</key>\n                <dict>\n                    <key>DiffieHellmanGroup</key>\n                    <integer>20</integer>\n                    <key>EncryptionAlgorithm</key>\n                    <string>AES-256-GCM</string>\n                    <key>IntegrityAlgorithm</key>\n                    <string>SHA2-512</string>\n                    <key>LifeTimeInMinutes</key>\n                    <integer>1440</integer>\n                </dict>\n                <key>LocalIdentifier</key>\n                <string>{{ item.0 }}@{{ openssl_constraint_random_id }}</string>\n                <key>PayloadCertificateUUID</key>\n                <string>{{ pkcs12_PayloadCertificateUUID }}</string>\n                <!-- Use ECDSA P-384 certificates for strong security -->\n                <key>CertificateType</key>\n                <string>ECDSA384</string>\n                <key>ServerCertificateIssuerCommonName</key>\n                <string>{{ IP_subject_alt_name }}</string>\n                <key>ServerCertificateCommonName</key>\n                <string>{{ IP_subject_alt_name }}</string>\n                <key>RemoteAddress</key>\n                <string>{{ IP_subject_alt_name }}</string>\n                <key>RemoteIdentifier</key>\n                <string>{{ IP_subject_alt_name }}</string>\n                <!-- Use server-provided internal IP assignment -->\n                <key>UseConfigurationAttributeInternalIPSubnet</key>\n                <integer>0</integer>\n            </dict>\n            <key>IPv4</key>\n            <dict>\n                <!-- Override primary network interface for full VPN routing -->\n                <key>OverridePrimary</key>\n                <integer>1</integer>\n            </dict>\n            <key>PayloadDescription</key>\n            <string>Configures VPN settings</string>\n            <key>PayloadDisplayName</key>\n            <string>{{ algo_server_name }}</string>\n            <key>PayloadIdentifier</key>\n            <string>com.apple.vpn.managed.{{ VPN_PayloadIdentifier }}</string>\n            <key>PayloadType</key>\n            <string>com.apple.vpn.managed</string>\n            <key>PayloadUUID</key>\n            <string>{{ VPN_PayloadIdentifier }}</string>\n            <key>PayloadVersion</key>\n            <real>1</real>\n            <key>Proxies</key>\n            <dict>\n                <key>HTTPEnable</key>\n                <integer>0</integer>\n                <key>HTTPSEnable</key>\n                <integer>0</integer>\n            </dict>\n            <key>UserDefinedName</key>\n            <string>AlgoVPN {{ algo_server_name }} IKEv2</string>\n            <key>VPNType</key>\n            <string>IKEv2</string>\n        </dict>\n        <dict>\n            <key>Password</key>\n            <string>{{ p12_export_password }}</string>\n            <key>PayloadCertificateFileName</key>\n            <string>{{ item.0 }}.p12</string>\n            <key>PayloadContent</key>\n            <data>\n            {{ item.1.stdout }}\n            </data>\n            <key>PayloadDescription</key>\n            <string>Adds a PKCS#12-formatted certificate</string>\n            <key>PayloadDisplayName</key>\n            <string>{{ algo_server_name }}</string>\n            <key>PayloadIdentifier</key>\n            <string>com.apple.security.pkcs12.{{ pkcs12_PayloadCertificateUUID }}</string>\n            <key>PayloadType</key>\n            <string>com.apple.security.pkcs12</string>\n            <key>PayloadUUID</key>\n            <string>{{ pkcs12_PayloadCertificateUUID }}</string>\n            <key>PayloadVersion</key>\n            <integer>1</integer>\n        </dict>\n        <dict>\n            <key>PayloadCertificateFileName</key>\n            <string>ca.crt</string>\n            <key>PayloadContent</key>\n            <data>\n            {{ PayloadContentCA }}\n            </data>\n            <key>PayloadDescription</key>\n            <string>Adds a CA root certificate</string>\n            <key>PayloadDisplayName</key>\n            <string>{{ algo_server_name }}</string>\n            <key>PayloadIdentifier</key>\n            <string>com.apple.security.root.{{ CA_PayloadIdentifier }}</string>\n            <key>PayloadType</key>\n            <string>com.apple.security.root</string>\n            <key>PayloadUUID</key>\n            <string>{{ CA_PayloadIdentifier }}</string>\n            <key>PayloadVersion</key>\n            <integer>1</integer>\n        </dict>\n    </array>\n    <key>PayloadDisplayName</key>\n    <string>AlgoVPN {{ algo_server_name }} IKEv2</string>\n    <key>PayloadIdentifier</key>\n    <string>donut.local.{{ 500000 | random | to_uuid | upper }}</string>\n    <key>PayloadOrganization</key>\n\t<string>AlgoVPN</string>\n    <key>PayloadRemovalDisallowed</key>\n    <false/>\n    <key>PayloadType</key>\n    <string>Configuration</string>\n    <key>PayloadUUID</key>\n    <string>{{ 400000 | random | to_uuid | upper }}</string>\n    <key>PayloadVersion</key>\n    <integer>1</integer>\n</dict>\n</plist>\n"
  },
  {
    "path": "roles/strongswan/templates/strongswan.conf.j2",
    "content": "# strongswan.conf - strongSwan configuration file\n#\n# Refer to the strongswan.conf(5) manpage for details\n#\n# Configuration changes should be made in the included files\n\ncharon {\n\tload_modular = yes\n\tplugins {\n\t\tinclude strongswan.d/charon/*.conf\n\t}\n\tuser = strongswan\n\tgroup = nogroup\n}\n\ninclude strongswan.d/*.conf\n"
  },
  {
    "path": "roles/wireguard/defaults/main.yml",
    "content": "---\nwireguard_PersistentKeepalive: 0\nwireguard_config_path: configs/{{ IP_subject_alt_name }}/wireguard\nwireguard_pki_path: \"{{ wireguard_config_path }}/.pki\"\nwireguard_interface: wg0\nwireguard_port_avoid: 53\nwireguard_port_actual: 51820\nkeys_clean_all: false\nwireguard_dns_servers: >-\n  {%- if algo_dns_adblocking | default(false) | bool or dns_encryption | default(false) | bool -%}\n  {{ local_service_ip }}{{ ', ' + local_service_ipv6 if ipv6_support | bool else '' }}{%-\n  else -%}\n  {%- for host in dns_servers.ipv4 -%}{{ host }}{% if not loop.last %},{% endif %}{%- endfor -%}\n  {%- if ipv6_support | bool -%},{%- for host in dns_servers.ipv6 -%}{{ host }}{% if not loop.last %},{% endif %}{%- endfor -%}\n  {%- endif -%}\n  {%- endif -%}\nwireguard_client_ip: >-\n  {{ wireguard_network_ipv4 | ansible.utils.ipmath(index | int + 2) }}\n  {{ ',' + wireguard_network_ipv6 | ansible.utils.ipmath(index | int + 2) if ipv6_support | bool else '' }}\nwireguard_server_ip: >-\n  {{ wireguard_network_ipv4 | ansible.utils.ipaddr('1') }}\n  {{ ',' + wireguard_network_ipv6 | ansible.utils.ipaddr('1') if ipv6_support | bool else '' }}\n"
  },
  {
    "path": "roles/wireguard/files/wireguard.sh",
    "content": "#!/bin/sh\n\n# PROVIDE: wireguard\n# REQUIRE: LOGIN\n# BEFORE:  securelevel\n# KEYWORD: shutdown\n\n# shellcheck source=/dev/null\n. /etc/rc.subr\n\nname=\"wg\"\n# shellcheck disable=SC2034\nrcvar=wg_enable\n\ncommand=\"/usr/local/bin/wg-quick\"\n# shellcheck disable=SC2034\nstart_cmd=wg_up\n# shellcheck disable=SC2034\nstop_cmd=wg_down\n# shellcheck disable=SC2034\nstatus_cmd=wg_status\npidfile=\"/var/run/$name.pid\"\nload_rc_config \"$name\"\n\n: \"${wg_enable=NO}\"\n: \"${wg_interface=wg0}\"\n\nwg_up() {\n  echo \"Starting WireGuard...\"\n  /usr/sbin/daemon -cS -p \"${pidfile}\" \"${command}\" up \"${wg_interface}\"\n}\n\nwg_down() {\n  echo \"Stopping WireGuard...\"\n  \"${command}\" down \"${wg_interface}\"\n}\n\nwg_status () {\n  not_running () {\n    echo \"WireGuard is not running on $wg_interface\" && exit 1\n  }\n  if /usr/local/bin/wg show wg0; then\n    echo \"WireGuard is running on $wg_interface\"\n  else\n    not_running\n  fi\n}\n\nrun_rc_command \"$1\"\n"
  },
  {
    "path": "roles/wireguard/handlers/main.yml",
    "content": "---\n- name: daemon-reload\n  systemd:\n    daemon_reload: true\n\n- name: restart wireguard\n  service:\n    name: \"{{ service_name }}\"\n    state: restarted\n"
  },
  {
    "path": "roles/wireguard/tasks/keys.yml",
    "content": "---\n- name: Ensure the WireGuard pki directory does not exist\n  file:\n    dest: \"{{ wireguard_pki_path }}\"\n    state: absent\n  when: keys_clean_all | bool\n\n- name: Ensure the WireGuard pki directories exist\n  file:\n    dest: \"{{ wireguard_pki_path }}/{{ item }}\"\n    state: directory\n    recurse: true\n    mode: \"0700\"\n  loop:\n    - preshared\n    - private\n    - public\n\n- name: Generate raw private keys\n  community.crypto.openssl_privatekey:\n    type: X25519\n    path: \"{{ wireguard_pki_path }}/private/{{ item }}.raw\"\n    format: raw\n    mode: \"0600\"\n  loop: \"{{ users + [IP_subject_alt_name] }}\"\n\n- name: Save base64 encoded private key\n  copy:\n    dest: \"{{ wireguard_pki_path }}/private/{{ item }}\"\n    content: \"{{ lookup('file', wireguard_pki_path + '/private/' + item + '.raw') | b64encode }}\"\n    mode: \"0600\"\n  loop: \"{{ users + [IP_subject_alt_name] }}\"\n  no_log: true\n\n- name: Generate raw preshared keys\n  community.crypto.openssl_privatekey:\n    type: X25519\n    path: \"{{ wireguard_pki_path }}/preshared/{{ item }}.raw\"\n    format: raw\n    mode: \"0600\"\n  loop: \"{{ users + [IP_subject_alt_name] }}\"\n\n- name: Save base64 encoded preshared keys\n  copy:\n    dest: \"{{ wireguard_pki_path }}/preshared/{{ item }}\"\n    content: \"{{ lookup('file', wireguard_pki_path + '/preshared/' + item + '.raw') | b64encode }}\"\n    mode: \"0600\"\n  loop: \"{{ users + [IP_subject_alt_name] }}\"\n  no_log: true\n\n- name: Generate public keys\n  x25519_pubkey:\n    private_key_path: \"{{ wireguard_pki_path }}/private/{{ item }}.raw\"\n    public_key_path: \"{{ wireguard_pki_path }}/public/{{ item }}\"\n  loop: \"{{ users + [IP_subject_alt_name] }}\"\n  no_log: true\n\n- name: Set permissions for public keys\n  file:\n    path: \"{{ wireguard_pki_path }}/public/{{ item }}\"\n    mode: '0644'\n  loop: \"{{ users + [IP_subject_alt_name] }}\"\n  no_log: true\n"
  },
  {
    "path": "roles/wireguard/tasks/main.yml",
    "content": "---\n- name: Ensure the required config directories exist\n  file:\n    dest: \"{{ item }}\"\n    state: directory\n    recurse: true\n    mode: \"0755\"\n  loop:\n    - \"{{ wireguard_config_path }}/apple/ios\"\n    - \"{{ wireguard_config_path }}/apple/macos\"\n  delegate_to: localhost\n  become: false\n\n- name: Include tasks for Debian/Ubuntu\n  include_tasks: ubuntu.yml\n  when: is_debian_based | bool\n  tags: always\n\n\n- name: Generate keys\n  import_tasks: keys.yml\n  delegate_to: localhost\n  become: false\n  tags: update-users\n\n- tags: update-users\n  block:\n    - become: false\n      delegate_to: localhost\n      block:\n        - name: WireGuard user list updated\n          lineinfile:\n            dest: \"{{ wireguard_pki_path }}/index.txt\"\n            create: true\n            mode: \"0600\"\n            insertafter: EOF\n            line: \"{{ item }}\"\n          register: lineinfile\n          loop: \"{{ users }}\"\n\n        - set_fact:\n            wireguard_users: \"{{ (lookup('file', wireguard_pki_path + '/index.txt')).split('\\n') }}\"\n\n        - name: WireGuard users config generated\n          template:\n            src: client.conf.j2\n            dest: \"{{ wireguard_config_path }}/{{ item }}.conf\"\n            mode: \"0600\"\n          loop: \"{{ wireguard_users }}\"\n          loop_control:\n            index_var: index\n          when: item in users\n\n        - include_tasks: mobileconfig.yml\n          loop:\n            - ios\n            - macos\n          loop_control:\n            loop_var: system\n\n        - name: Generate QR codes\n          shell: >\n            umask 077;\n            which segno &&\n            segno --scale=5 --output={{ item }}.png \\\n              \"{{ lookup('template', 'client.conf.j2') }}\" || true\n          changed_when: false\n          loop: \"{{ wireguard_users }}\"\n          loop_control:\n            index_var: index\n          when: item in users\n          vars:\n            ansible_python_interpreter: \"{{ ansible_playbook_python }}\"\n          args:\n            chdir: \"{{ wireguard_config_path }}\"\n            executable: bash\n          no_log: true\n\n    - name: WireGuard configured\n      template:\n        src: server.conf.j2\n        dest: \"{{ config_prefix | default('/') }}etc/wireguard/{{ wireguard_interface }}.conf\"\n        mode: \"0600\"\n      notify: restart wireguard\n\n- name: WireGuard enabled and started\n  service:\n    name: \"{{ service_name }}\"\n    state: started\n    enabled: true\n\n- name: Delete the PKI directory\n  file:\n    path: \"{{ wireguard_pki_path }}\"\n    state: absent\n  become: false\n  delegate_to: localhost\n  when:\n    - not algo_store_pki\n    - not pki_in_tmpfs\n\n- meta: flush_handlers\n"
  },
  {
    "path": "roles/wireguard/tasks/mobileconfig.yml",
    "content": "---\n- name: WireGuard apple mobileconfig generated\n  template:\n    src: mobileconfig.j2\n    dest: \"{{ wireguard_config_path }}/apple/{{ system }}/{{ item }}.mobileconfig\"\n    mode: \"0600\"\n  loop: \"{{ wireguard_users }}\"\n  loop_control:\n    index_var: index\n  when: item in users\n"
  },
  {
    "path": "roles/wireguard/tasks/ubuntu.yml",
    "content": "---\n- name: WireGuard installed (individual)\n  apt:\n    name: wireguard\n    state: present\n    update_cache: true\n  when: not performance_parallel_packages | default(true)\n\n- name: Set OS specific facts\n  set_fact:\n    service_name: wg-quick@{{ wireguard_interface }}\n  tags: always\n\n- name: Ubuntu | Ensure that the WireGuard service directory exists\n  file:\n    path: /etc/systemd/system/wg-quick@{{ wireguard_interface }}.service.d/\n    state: directory\n    mode: '0755'\n    owner: root\n    group: root\n\n- name: Ubuntu | Apply systemd security hardening for WireGuard\n  copy:\n    dest: /etc/systemd/system/wg-quick@{{ wireguard_interface }}.service.d/90-security-hardening.conf\n    content: |\n      # Algo VPN systemd security hardening for WireGuard\n      [Service]\n      # Privilege restrictions\n      NoNewPrivileges=yes\n\n      # Filesystem isolation\n      ProtectSystem=strict\n      ProtectHome=yes\n      PrivateTmp=yes\n      ProtectKernelTunables=yes\n      ProtectControlGroups=yes\n\n      # Network restrictions - WireGuard needs NETLINK for interface management\n      RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK\n\n      # Allow access to WireGuard configuration\n      ReadWritePaths=/etc/wireguard\n      ReadOnlyPaths=/etc/resolv.conf\n\n      # System call filtering - allow network and system service calls\n      SystemCallFilter=@system-service @network-io\n      SystemCallFilter=~@debug @mount @swap @reboot @raw-io\n      SystemCallErrorNumber=EPERM\n    owner: root\n    group: root\n    mode: '0644'\n  notify:\n    - daemon-reload\n    - restart wireguard\n"
  },
  {
    "path": "roles/wireguard/templates/client.conf.j2",
    "content": "[Interface]\nPrivateKey = {{ lookup('file', wireguard_pki_path + '/private/' + item) }}\nAddress = {{ wireguard_client_ip }}\nDNS = {{ wireguard_dns_servers }}\n{% if reduce_mtu | int > 0 %}MTU = {{ 1420 - reduce_mtu | int }}\n{% endif %}\n\n[Peer]\nPublicKey = {{ lookup('file', wireguard_pki_path + '/public/' + IP_subject_alt_name) }}\nPresharedKey = {{ lookup('file', wireguard_pki_path + '/preshared/' + item) }}\nAllowedIPs = 0.0.0.0/0,::/0\nEndpoint = {% if ':' in IP_subject_alt_name %}[{{ IP_subject_alt_name }}]:{{ wireguard_port }}{% else %}{{ IP_subject_alt_name }}:{{ wireguard_port }}{% endif %}\n{{ 'PersistentKeepalive = ' + wireguard_PersistentKeepalive | string if wireguard_PersistentKeepalive > 0 else '' }}\n"
  },
  {
    "path": "roles/wireguard/templates/mobileconfig.j2",
    "content": "#jinja2:lstrip_blocks: True\n<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>PayloadContent</key>\n\t<array>\n    {% include 'vpn-dict.j2' %}\n  </array>\n  <key>PayloadDisplayName</key>\n  <string>AlgoVPN {{ algo_server_name }} WireGuard</string>\n  <key>PayloadIdentifier</key>\n  <string>donut.local.{{ 500000 | random | to_uuid | upper }}</string>\n  <key>PayloadOrganization</key>\n  <string>AlgoVPN</string>\n  <key>PayloadRemovalDisallowed</key>\n  <false/>\n  <key>PayloadType</key>\n  <string>Configuration</string>\n  <key>PayloadUUID</key>\n  <string>{{ 400000 | random | to_uuid | upper }}</string>\n  <key>PayloadVersion</key>\n  <integer>1</integer>\n</dict>\n</plist>\n"
  },
  {
    "path": "roles/wireguard/templates/server.conf.j2",
    "content": "[Interface]\nAddress = {{ wireguard_server_ip }}\nListenPort = {{ wireguard_port_actual if wireguard_port | int == wireguard_port_avoid | int else wireguard_port }}\nPrivateKey = {{ lookup('file', wireguard_pki_path + '/private/' + IP_subject_alt_name) }}\nSaveConfig = false\n\n{% for u in wireguard_users %}\n{% if u in users %}\n{% set index = loop.index %}\n\n[Peer]\n# {{ u }}\nPublicKey = {{ lookup('file', wireguard_pki_path + '/public/' + u) }}\nPresharedKey = {{ lookup('file', wireguard_pki_path + '/preshared/' + u) }}\nAllowedIPs = {{ wireguard_network_ipv4 | ansible.utils.ipmath(index | int + 1) | ansible.utils.ipv4('address') }}/32{{ ',' + wireguard_network_ipv6 | ansible.utils.ipmath(index | int + 1) | ansible.utils.ipv6('address') + '/128' if ipv6_support | bool else '' }}\n{% endif %}\n{% endfor %}\n"
  },
  {
    "path": "roles/wireguard/templates/vpn-dict.j2",
    "content": "<dict>\n\t<key>IPv4</key>\n  <dict>\n      <key>OverridePrimary</key>\n      <integer>1</integer>\n  </dict>\n\t<key>PayloadDescription</key>\n\t<string>Configures VPN settings</string>\n\t<key>PayloadDisplayName</key>\n\t<string>{{ algo_server_name }}</string>\n\t<key>PayloadIdentifier</key>\n\t<string>com.apple.vpn.managed.{{ algo_server_name + system | to_uuid | upper }}</string>\n\t<key>PayloadType</key>\n\t<string>com.apple.vpn.managed</string>\n\t<key>PayloadUUID</key>\n\t<string>{{ algo_server_name + system | to_uuid | upper }}</string>\n\t<key>PayloadVersion</key>\n\t<integer>1</integer>\n\t<key>Proxies</key>\n\t<dict>\n\t\t<key>HTTPEnable</key>\n\t\t<integer>0</integer>\n\t\t<key>HTTPSEnable</key>\n\t\t<integer>0</integer>\n\t</dict>\n\t<key>UserDefinedName</key>\n\t<string>AlgoVPN {{ algo_server_name }}</string>\n\t<key>VPN</key>\n\t<dict>\n    <key>OnDemandEnabled</key>\n    <integer>{{ 1 if algo_ondemand_wifi or algo_ondemand_cellular else 0 }}</integer>\n    <key>OnDemandRules</key>\n    <array>\n      {% if algo_ondemand_wifi or algo_ondemand_cellular %}\n      {% if algo_ondemand_wifi_exclude | b64decode != '_null' %}\n      {% set WIFI_EXCLUDE_LIST = (algo_ondemand_wifi_exclude | b64decode | string).split(',') %}\n      <dict>\n        <key>Action</key>\n        <string>Disconnect</string>\n        <key>InterfaceTypeMatch</key>\n        <string>WiFi</string>\n        <key>SSIDMatch</key>\n        <array>\n          {% for network_name in WIFI_EXCLUDE_LIST %}\n          <string>{{ network_name | e }}</string>\n          {% endfor %}\n        </array>\n      </dict>\n      {% endif %}\n      <dict>\n        <key>Action</key>\n        {% if algo_ondemand_wifi %}\n        <string>Connect</string>\n        {% else %}\n        <string>Disconnect</string>\n        {% endif %}\n        <key>InterfaceTypeMatch</key>\n        <string>WiFi</string>\n        <key>URLStringProbe</key>\n        <string>http://captive.apple.com/hotspot-detect.html</string>\n      </dict>\n      <dict>\n        <key>Action</key>\n        {% if algo_ondemand_cellular %}\n        <string>Connect</string>\n        {% else %}\n        <string>Disconnect</string>\n        {% endif %}\n        <key>InterfaceTypeMatch</key>\n        <string>Cellular</string>\n        <key>URLStringProbe</key>\n        <string>http://captive.apple.com/hotspot-detect.html</string>\n      </dict>\n      {% endif %}\n      <dict>\n        <key>Action</key>\n        <string>{{ 'Disconnect' if algo_ondemand_wifi or algo_ondemand_cellular else 'Connect' }}</string>\n      </dict>\n    </array>\n\t\t<key>AuthenticationMethod</key>\n\t\t<string>Password</string>\n\t\t<key>RemoteAddress</key>\n\t\t<string>{{ IP_subject_alt_name }}:{{ wireguard_port }}</string>\n\t</dict>\n\t<key>VPNSubType</key>\n\t<string>com.wireguard.{{ system }}</string>\n\t<key>VPNType</key>\n\t<string>VPN</string>\n\t<key>VendorConfig</key>\n\t<dict>\n\t\t<key>WgQuickConfig</key>\n\t\t<string>{{- lookup('template', 'client.conf.j2') | indent(8) }}</string>\n\t</dict>\n</dict>\n"
  },
  {
    "path": "scripts/annotate-test-failure.sh",
    "content": "#!/bin/bash\n# Annotate test failures with metadata for tracking\n\n# This script should be called when a test fails in CI\n# Usage: ./annotate-test-failure.sh <test-name> <context>\n\nTEST_NAME=\"$1\"\nCONTEXT=\"$2\"\nTIMESTAMP=$(date -u +\"%Y-%m-%dT%H:%M:%SZ\")\n\n# Create failures log if it doesn't exist\nmkdir -p .metrics\nFAILURE_LOG=\".metrics/test-failures.jsonl\"\n\n# Add failure record\ncat >> \"$FAILURE_LOG\" << EOF\n{\"test\": \"$TEST_NAME\", \"context\": \"$CONTEXT\", \"timestamp\": \"$TIMESTAMP\", \"commit\": \"$GITHUB_SHA\", \"pr\": \"$GITHUB_PR_NUMBER\", \"branch\": \"$GITHUB_REF_NAME\"}\nEOF\n\n# Also add as GitHub annotation if in CI\nif [ -n \"$GITHUB_ACTIONS\" ]; then\n    echo \"::warning title=Test Failure::$TEST_NAME failed in $CONTEXT\"\nfi\n"
  },
  {
    "path": "scripts/lint.sh",
    "content": "#!/bin/bash\nset -euo pipefail\n\n# Run the same linting as CI\necho \"Running ansible-lint...\"\nansible-lint .\n\necho \"Running playbook dry-run check...\"\n# Test main playbook logic without making changes - catches runtime issues\nansible-playbook main.yml --check --connection=local \\\n  -e \"server_ip=test\" \\\n  -e \"server_name=ci-test\" \\\n  -e \"IP_subject_alt_name=192.168.1.1\" \\\n  || echo \"Dry-run completed with issues - check output above\"\n\necho \"Running yamllint...\"\nyamllint -c .yamllint .\n\necho \"Running ruff...\"\nruff check . || true  # Start with warnings only\n\necho \"Running shellcheck...\"\nfind . -type f -name \"*.sh\" -not -path \"./.git/*\" -exec shellcheck {} \\;\n\necho \"All linting completed!\"\n"
  },
  {
    "path": "scripts/list_servers.py",
    "content": "#!/usr/bin/env python3\n\"\"\"List deployed Algo VPN servers as JSON.\"\"\"\n\nimport json\nimport sys\nfrom pathlib import Path\n\nimport yaml\n\n\ndef list_servers(configs_dir: Path) -> list[dict]:\n    \"\"\"Scan configs directory for deployed server metadata.\"\"\"\n    servers = []\n    for config_file in sorted(configs_dir.glob(\"*/.config.yml\")):\n        with open(config_file) as f:\n            config = yaml.safe_load(f)\n        if config:\n            servers.append(config)\n    return servers\n\n\ndef main() -> None:\n    configs_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(\"configs\")\n    if not configs_dir.is_dir():\n        json.dump([], sys.stdout)\n        print()\n        sys.exit(0)\n    servers = list_servers(configs_dir)\n    json.dump(servers, sys.stdout, indent=2, default=str)\n    print()\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "scripts/test-templates.sh",
    "content": "#!/bin/bash\n# Test all Jinja2 templates in the Algo codebase\n# This script is called by CI and can be run locally\n\nset -e\n\necho \"======================================\"\necho \"Running Jinja2 Template Tests\"\necho \"======================================\"\necho \"\"\n\n# Color codes for output\nRED='\\033[0;31m'\nGREEN='\\033[0;32m'\nYELLOW='\\033[1;33m'\nNC='\\033[0m' # No Color\n\nFAILED=0\n\n# 1. Run the template syntax validator\necho \"1. Validating Jinja2 template syntax...\"\necho \"----------------------------------------\"\nif python tests/validate_jinja2_templates.py; then\n    echo -e \"${GREEN}✓ Template syntax validation passed${NC}\"\nelse\n    echo -e \"${RED}✗ Template syntax validation failed${NC}\"\n    FAILED=$((FAILED + 1))\nfi\necho \"\"\n\n# 2. Run the template rendering tests\necho \"2. Testing template rendering...\"\necho \"--------------------------------\"\nif python tests/unit/test_template_rendering.py; then\n    echo -e \"${GREEN}✓ Template rendering tests passed${NC}\"\nelse\n    echo -e \"${RED}✗ Template rendering tests failed${NC}\"\n    FAILED=$((FAILED + 1))\nfi\necho \"\"\n\n# 3. Run the StrongSwan template tests\necho \"3. Testing StrongSwan templates...\"\necho \"----------------------------------\"\nif python tests/unit/test_strongswan_templates.py; then\n    echo -e \"${GREEN}✓ StrongSwan template tests passed${NC}\"\nelse\n    echo -e \"${RED}✗ StrongSwan template tests failed${NC}\"\n    FAILED=$((FAILED + 1))\nfi\necho \"\"\n\n# 4. Run ansible-lint with Jinja2 checks enabled\necho \"4. Running ansible-lint Jinja2 checks...\"\necho \"----------------------------------------\"\n# Check only for jinja[invalid] errors, not spacing warnings\nif ansible-lint --nocolor 2>&1 | grep -E \"jinja\\[invalid\\]\"; then\n    echo -e \"${RED}✗ ansible-lint found Jinja2 syntax errors${NC}\"\n    ansible-lint --nocolor 2>&1 | grep -E \"jinja\\[invalid\\]\" | head -10\n    FAILED=$((FAILED + 1))\nelse\n    echo -e \"${GREEN}✓ No Jinja2 syntax errors found${NC}\"\n    # Show spacing warnings as info only\n    if ansible-lint --nocolor 2>&1 | grep -E \"jinja\\[spacing\\]\" | head -1 > /dev/null; then\n        echo -e \"${YELLOW}ℹ Note: Some spacing style issues exist (not failures)${NC}\"\n    fi\nfi\necho \"\"\n\n# Summary\necho \"======================================\"\nif [ $FAILED -eq 0 ]; then\n    echo -e \"${GREEN}All template tests passed!${NC}\"\n    exit 0\nelse\n    echo -e \"${RED}$FAILED test suite(s) failed${NC}\"\n    echo \"\"\n    echo \"To debug failures, run individually:\"\n    echo \"  python tests/validate_jinja2_templates.py\"\n    echo \"  python tests/unit/test_template_rendering.py\"\n    echo \"  python tests/unit/test_strongswan_templates.py\"\n    echo \"  ansible-lint\"\n    exit 1\nfi\n"
  },
  {
    "path": "scripts/track-test-effectiveness.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTrack test effectiveness by analyzing CI failures and correlating with issues/PRs\nThis helps identify which tests actually catch bugs vs just failing randomly\n\"\"\"\n\nimport json\nimport subprocess\nfrom collections import defaultdict\nfrom datetime import datetime, timedelta\nfrom pathlib import Path\n\n\ndef get_github_api_data(endpoint):\n    \"\"\"Fetch data from GitHub API\"\"\"\n    cmd = [\"gh\", \"api\", endpoint]\n    result = subprocess.run(cmd, capture_output=True, text=True)\n    if result.returncode != 0:\n        print(f\"Error fetching {endpoint}: {result.stderr}\")\n        return None\n    return json.loads(result.stdout)\n\n\ndef analyze_workflow_runs(repo_owner, repo_name, days_back=30):\n    \"\"\"Analyze workflow runs to find test failures\"\"\"\n    since = (datetime.now() - timedelta(days=days_back)).isoformat()\n\n    # Get workflow runs\n    runs = get_github_api_data(f\"/repos/{repo_owner}/{repo_name}/actions/runs?created=>{since}&status=failure\")\n\n    if not runs:\n        return {}\n\n    test_failures = defaultdict(list)\n\n    for run in runs.get(\"workflow_runs\", []):\n        # Get jobs for this run\n        jobs = get_github_api_data(f\"/repos/{repo_owner}/{repo_name}/actions/runs/{run['id']}/jobs\")\n\n        if not jobs:\n            continue\n\n        for job in jobs.get(\"jobs\", []):\n            if job[\"conclusion\"] == \"failure\":\n                # Try to extract which test failed from logs\n                logs_url = job.get(\"logs_url\")\n                if logs_url:\n                    # Parse logs to find test failures\n                    test_name = extract_failed_test(job[\"name\"], run[\"id\"])\n                    if test_name:\n                        test_failures[test_name].append(\n                            {\n                                \"run_id\": run[\"id\"],\n                                \"run_number\": run[\"run_number\"],\n                                \"date\": run[\"created_at\"],\n                                \"branch\": run[\"head_branch\"],\n                                \"commit\": run[\"head_sha\"][:7],\n                                \"pr\": extract_pr_number(run),\n                            }\n                        )\n\n    return test_failures\n\n\ndef extract_failed_test(job_name, run_id):\n    \"\"\"Extract test name from job - this is simplified\"\"\"\n    # Map job names to test categories\n    job_to_tests = {\n        \"Basic sanity tests\": \"test_basic_sanity\",\n        \"Ansible syntax check\": \"ansible_syntax\",\n        \"Docker build test\": \"docker_tests\",\n        \"Configuration generation test\": \"config_generation\",\n        \"Ansible dry-run validation\": \"ansible_dry_run\",\n    }\n    return job_to_tests.get(job_name, job_name)\n\n\ndef extract_pr_number(run):\n    \"\"\"Extract PR number from workflow run\"\"\"\n    for pr in run.get(\"pull_requests\", []):\n        return pr[\"number\"]\n    return None\n\n\ndef correlate_with_issues(repo_owner, repo_name, test_failures):\n    \"\"\"Correlate test failures with issues/PRs that fixed them\"\"\"\n    correlations = defaultdict(lambda: {\"caught_bugs\": 0, \"false_positives\": 0})\n\n    for test_name, failures in test_failures.items():\n        for failure in failures:\n            if failure[\"pr\"]:\n                # Check if PR was merged (indicating it fixed a real issue)\n                pr = get_github_api_data(f\"/repos/{repo_owner}/{repo_name}/pulls/{failure['pr']}\")\n\n                if pr and pr.get(\"merged\"):\n                    # Check PR title/body for bug indicators\n                    title = pr.get(\"title\", \"\").lower()\n                    body = pr.get(\"body\", \"\").lower()\n\n                    bug_keywords = [\"fix\", \"bug\", \"error\", \"issue\", \"broken\", \"fail\"]\n                    is_bug_fix = any(keyword in title or keyword in body for keyword in bug_keywords)\n\n                    if is_bug_fix:\n                        correlations[test_name][\"caught_bugs\"] += 1\n                    else:\n                        correlations[test_name][\"false_positives\"] += 1\n\n    return correlations\n\n\ndef generate_effectiveness_report(test_failures, correlations):\n    \"\"\"Generate test effectiveness report\"\"\"\n    report = []\n    report.append(\"# Test Effectiveness Report\")\n    report.append(f\"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\\n\")\n\n    # Summary\n    report.append(\"## Summary\")\n    total_failures = sum(len(f) for f in test_failures.values())\n    report.append(f\"- Total test failures: {total_failures}\")\n    report.append(f\"- Unique tests that failed: {len(test_failures)}\")\n    report.append(\"\")\n\n    # Effectiveness scores\n    report.append(\"## Test Effectiveness Scores\")\n    report.append(\"| Test | Failures | Caught Bugs | False Positives | Effectiveness |\")\n    report.append(\"|------|----------|-------------|-----------------|---------------|\")\n\n    scores = []\n    for test_name, failures in test_failures.items():\n        failure_count = len(failures)\n        caught = correlations[test_name][\"caught_bugs\"]\n        false_pos = correlations[test_name][\"false_positives\"]\n\n        # Calculate effectiveness (bugs caught / total failures)\n        if failure_count > 0:\n            effectiveness = caught / failure_count\n        else:\n            effectiveness = 0\n\n        scores.append((test_name, failure_count, caught, false_pos, effectiveness))\n\n    # Sort by effectiveness\n    scores.sort(key=lambda x: x[4], reverse=True)\n\n    for test_name, failures, caught, false_pos, effectiveness in scores:\n        report.append(f\"| {test_name} | {failures} | {caught} | {false_pos} | {effectiveness:.1%} |\")\n\n    # Recommendations\n    report.append(\"\\n## Recommendations\")\n\n    for test_name, failures, _caught, _false_pos, effectiveness in scores:\n        if effectiveness < 0.2 and failures > 5:\n            report.append(f\"- ⚠️  Consider improving or removing `{test_name}` (only {effectiveness:.0%} effective)\")\n        elif effectiveness > 0.8:\n            report.append(f\"- ✅ `{test_name}` is highly effective ({effectiveness:.0%})\")\n\n    return \"\\n\".join(report)\n\n\ndef save_metrics(test_failures, correlations):\n    \"\"\"Save metrics to JSON for historical tracking\"\"\"\n    metrics_file = Path(\".metrics/test-effectiveness.json\")\n    metrics_file.parent.mkdir(exist_ok=True)\n\n    # Load existing metrics\n    if metrics_file.exists():\n        with open(metrics_file) as f:\n            historical = json.load(f)\n    else:\n        historical = []\n\n    # Add current metrics\n    current = {\n        \"date\": datetime.now().isoformat(),\n        \"test_failures\": {test: len(failures) for test, failures in test_failures.items()},\n        \"effectiveness\": {\n            test: {\n                \"caught_bugs\": data[\"caught_bugs\"],\n                \"false_positives\": data[\"false_positives\"],\n                \"score\": data[\"caught_bugs\"] / (data[\"caught_bugs\"] + data[\"false_positives\"])\n                if (data[\"caught_bugs\"] + data[\"false_positives\"]) > 0\n                else 0,\n            }\n            for test, data in correlations.items()\n        },\n    }\n\n    historical.append(current)\n\n    # Keep last 12 months of data\n    cutoff = datetime.now() - timedelta(days=365)\n    historical = [h for h in historical if datetime.fromisoformat(h[\"date\"]) > cutoff]\n\n    with open(metrics_file, \"w\") as f:\n        json.dump(historical, f, indent=2)\n\n\nif __name__ == \"__main__\":\n    # Configure these for your repo\n    REPO_OWNER = \"trailofbits\"\n    REPO_NAME = \"algo\"\n\n    print(\"Analyzing test effectiveness...\")\n\n    # Analyze last 30 days of CI runs\n    test_failures = analyze_workflow_runs(REPO_OWNER, REPO_NAME, days_back=30)\n\n    # Correlate with issues/PRs\n    correlations = correlate_with_issues(REPO_OWNER, REPO_NAME, test_failures)\n\n    # Generate report\n    report = generate_effectiveness_report(test_failures, correlations)\n\n    print(\"\\n\" + report)\n\n    # Save report\n    report_file = Path(\".metrics/test-effectiveness-report.md\")\n    report_file.parent.mkdir(exist_ok=True)\n    with open(report_file, \"w\") as f:\n        f.write(report)\n    print(f\"\\nReport saved to: {report_file}\")\n\n    # Save metrics for tracking\n    save_metrics(test_failures, correlations)\n    print(\"Metrics saved to: .metrics/test-effectiveness.json\")\n"
  },
  {
    "path": "server.yml",
    "content": "---\n- name: Configure the server and install required software\n  hosts: vpn-host\n  gather_facts: false\n  become: true\n  vars_files:\n    - config.cfg\n  tasks:\n    - block:\n        - name: Wait until the cloud-init completed\n          wait_for:\n            path: /var/lib/cloud/data/result.json\n            delay: 10         # Conservative 10 second initial delay\n            timeout: 480      # Reduce from 600 to 480 seconds (8 minutes)\n            sleep: 10         # Check every 10 seconds (less aggressive)\n            state: present\n          become: false\n          when: cloudinit | bool\n\n        - when: inventory_hostname != 'localhost'\n          become: false\n          delegate_to: localhost\n          block:\n            - name: Ensure the config directory exists\n              file:\n                dest: configs/{{ IP_subject_alt_name }}\n                state: directory\n                mode: \"0700\"\n\n            - name: Dump the ssh config\n              copy:\n                dest: configs/{{ IP_subject_alt_name }}/ssh_config\n                mode: \"0600\"\n                content: |\n                  Host {{ IP_subject_alt_name }} {{ algo_server_name }}\n                    HostName {{ IP_subject_alt_name }}\n                    User {{ ansible_ssh_user }}\n                    Port {{ ansible_ssh_port }}\n                    IdentitiesOnly yes\n                    IdentityFile {{ SSH_keys.private | realpath }}\n                    KeepAlive yes\n                    ServerAliveInterval 30\n\n        - import_role:\n            name: common\n          tags: common\n\n        # ============================================================\n        # VPN Service Configuration\n        # Parallel mode (default): Services run concurrently for speed\n        # Sequential mode: Services run one at a time (fallback)\n        # ============================================================\n\n        - name: Configure VPN services (parallel mode)\n          when: performance_parallel_services | default(true)\n          tags: [dns, wireguard, ipsec, ssh_tunneling]\n          block:\n            # --- Launch all services asynchronously ---\n            - import_role: {name: dns}\n              async: 300\n              poll: 0\n              register: dns_job\n              when: algo_dns_adblocking | bool or dns_encryption | bool\n              tags: dns\n\n            - import_role: {name: wireguard}\n              async: 300\n              poll: 0\n              register: wireguard_job\n              when: wireguard_enabled | bool\n              tags: wireguard\n\n            - import_role: {name: strongswan}\n              async: 300\n              poll: 0\n              register: strongswan_job\n              when: ipsec_enabled | bool\n              tags: ipsec\n\n            - import_role: {name: ssh_tunneling}\n              async: 300\n              poll: 0\n              register: ssh_tunneling_job\n              when: algo_ssh_tunneling | bool\n              tags: ssh_tunneling\n\n            # --- Build job list and wait for completion ---\n            - name: Build async job list\n              set_fact:\n                _vpn_jobs:\n                  - {name: dns, job: \"{{ dns_job | default({}) }}\"}\n                  - {name: wireguard, job: \"{{ wireguard_job | default({}) }}\"}\n                  - {name: strongswan, job: \"{{ strongswan_job | default({}) }}\"}\n                  - {name: ssh_tunneling, job: \"{{ ssh_tunneling_job | default({}) }}\"}\n\n            - name: Wait for VPN services to complete\n              async_status:\n                jid: \"{{ item.job.ansible_job_id }}\"\n              register: _vpn_results\n              until: _vpn_results.finished\n              retries: 60\n              delay: 5\n              loop: \"{{ _vpn_jobs | selectattr('job.ansible_job_id', 'defined') | list }}\"\n              loop_control:\n                label: \"{{ item.name }}\"\n\n            # --- Verify all services completed successfully ---\n            - name: Check for service failures\n              fail:\n                msg: \"{{ item.item.name }} service failed. Check logs above.\"\n              when: item.rc | default(0) != 0\n              loop: \"{{ _vpn_results.results | default([]) }}\"\n              loop_control:\n                label: \"{{ item.item.name | default('service') }}\"\n\n        # --- Sequential mode (fallback when parallel disabled) ---\n        - name: Configure VPN services (sequential mode)\n          when: not (performance_parallel_services | default(true))\n          tags: [dns, wireguard, ipsec, ssh_tunneling]\n          block:\n            - import_role: {name: dns}\n              when: algo_dns_adblocking | bool or dns_encryption | bool\n              tags: dns\n\n            - import_role: {name: wireguard}\n              when: wireguard_enabled | bool\n              tags: wireguard\n\n            - import_role: {name: strongswan}\n              when: ipsec_enabled | bool\n              tags: ipsec\n\n            - import_role: {name: ssh_tunneling}\n              when: algo_ssh_tunneling | bool\n              tags: ssh_tunneling\n\n        - import_role:\n            name: privacy\n          when: privacy_enhancements_enabled | default(true)\n          tags: privacy\n\n        - tags: always\n          block:\n            - name: Dump the configuration\n              copy:\n                dest: configs/{{ IP_subject_alt_name }}/.config.yml\n                mode: '0644'\n                content: |\n                  server: {{ 'localhost' if inventory_hostname == 'localhost' else inventory_hostname }}\n                  server_user: {{ ansible_ssh_user }}\n                  ansible_ssh_port: \"{{ ansible_ssh_port | default(22) }}\"\n                  {% if algo_provider != \"local\" %}\n                  ansible_ssh_private_key_file: {{ SSH_keys.private }}\n                  {% endif %}\n                  algo_provider: {{ algo_provider }}\n                  algo_server_name: {{ algo_server_name }}\n                  algo_region: {{ algo_region | default('') }}\n                  algo_ondemand_cellular: {{ algo_ondemand_cellular }}\n                  algo_ondemand_wifi: {{ algo_ondemand_wifi }}\n                  algo_ondemand_wifi_exclude: {{ algo_ondemand_wifi_exclude }}\n                  algo_dns_adblocking: {{ algo_dns_adblocking }}\n                  algo_ssh_tunneling: {{ algo_ssh_tunneling }}\n                  algo_store_pki: {{ algo_store_pki }}\n                  IP_subject_alt_name: {{ IP_subject_alt_name }}\n                  ipsec_enabled: {{ ipsec_enabled }}\n                  wireguard_enabled: {{ wireguard_enabled }}\n                  local_service_ip: {{ local_service_ip }}\n                  local_service_ipv6: {{ local_service_ipv6 }}\n                  {% if tests | default(false) | bool %}\n                  ca_password: '{{ CA_password }}'\n                  p12_password: '{{ p12_export_password }}'\n                  {% endif %}\n              become: false\n              delegate_to: localhost\n\n            - name: Create a symlink if deploying to localhost\n              file:\n                src: \"{{ IP_subject_alt_name }}\"\n                dest: configs/localhost\n                state: link\n                force: true\n              when: inventory_hostname == 'localhost'\n\n            - name: Import tmpfs tasks\n              import_tasks: playbooks/tmpfs/umount.yml\n              become: false\n              delegate_to: localhost\n              vars:\n                facts: \"{{ hostvars['localhost'] }}\"\n              when:\n                - pki_in_tmpfs\n                - not algo_store_pki\n\n            - debug:\n                msg:\n                  - \"{{ congrats.common.split('\\n') }}\"\n                  - \"    {{ congrats.p12_pass if algo_ssh_tunneling or ipsec_enabled else '' }}\"\n                  - \"    {{ congrats.ca_key_pass if algo_store_pki and ipsec_enabled else '' }}\"\n                  - \"    {{ congrats.ssh_access if algo_provider != 'local' else '' }}\"\n      rescue:\n        - include_tasks: playbooks/rescue.yml\n"
  },
  {
    "path": "tests/README.md",
    "content": "# Tests\n\n## Running Tests\n\n```bash\n# Run all linters (same as CI)\nansible-lint . && yamllint . && ruff check . && shellcheck scripts/*.sh\n\n# Run Python unit tests\npytest tests/unit/ -q\n\n# Run E2E connectivity tests (requires deployed Algo on localhost)\nsudo tests/e2e/test-vpn-connectivity.sh both\n```\n\n## Directory Structure\n\n```\ntests/\n├── unit/                    # Python unit tests (pytest)\n│   ├── test_basic_sanity.py\n│   ├── test_config_validation.py\n│   ├── test_template_rendering.py\n│   └── ...\n├── e2e/                     # End-to-end connectivity tests\n│   └── test-vpn-connectivity.sh\n├── integration/             # Integration test helpers\n│   └── mock_modules/\n├── fixtures/                # Shared test data\n│   └── test_variables.yml\n└── conftest.py              # Pytest configuration\n```\n\n## Test Coverage\n\n| Category | Tests | What's Verified |\n|----------|-------|-----------------|\n| Sanity | `test_basic_sanity.py` | Python version, config syntax, playbook validity |\n| Config | `test_config_validation.py` | WireGuard/IPsec config formats, key validation |\n| Templates | `test_template_rendering.py` | Jinja2 template syntax, filter compatibility |\n| Certificates | `test_certificate_validation.py` | OpenSSL compatibility, PKCS#12 export |\n| Cloud Providers | `test_cloud_provider_configs.py` | Region formats, instance types, OS images |\n| E2E | `test-vpn-connectivity.sh` | WireGuard handshake, IPsec connection, DNS through VPN |\n\n## CI Workflows\n\n| Workflow | Trigger | What It Does |\n|----------|---------|--------------|\n| `lint.yml` | All PRs | ansible-lint, yamllint, ruff, shellcheck |\n| `main.yml` | Push to master | Syntax check, unit tests, Docker build |\n| `integration-tests.yml` | PRs to roles/ | Full localhost deployment + E2E tests |\n| `smart-tests.yml` | All PRs | Runs subset based on changed files |\n\n## Writing Tests\n\n### Python Unit Tests\n\nPlace in `tests/unit/`. Use fixtures from `conftest.py`:\n\n```python\ndef test_something(mock_ansible_module, jinja_env):\n    # mock_ansible_module - mocked AnsibleModule\n    # jinja_env - Jinja2 environment with Ansible filters\n    pass\n```\n\n### Shell Scripts\n\nUse bash strict mode and pass shellcheck:\n\n```bash\n#!/bin/bash\nset -euo pipefail\n```\n\n## Troubleshooting\n\n**E2E tests fail with \"namespace already exists\"**\n```bash\nsudo ip netns del algo-client\n```\n\n**Template tests fail with \"filter not found\"**\nAdd the filter to the mock in `conftest.py`.\n\n**CI fails but local passes**\nCheck Python/Ansible versions match CI (Python 3.11, Ansible 12+).\n"
  },
  {
    "path": "tests/conftest.py",
    "content": "\"\"\"Shared pytest fixtures for Algo VPN tests.\"\"\"\n\nimport base64\nimport secrets\nimport sys\nimport tempfile\nfrom pathlib import Path\n\nimport pytest\nimport yaml\n\n# Add library directory to path for custom module imports\nsys.path.insert(0, str(Path(__file__).parent.parent / \"library\"))\n\n\n@pytest.fixture\ndef test_variables():\n    \"\"\"Load test variables from YAML fixture.\"\"\"\n    fixture_path = Path(__file__).parent / \"fixtures\" / \"test_variables.yml\"\n    with open(fixture_path) as f:\n        return yaml.safe_load(f)\n\n\n@pytest.fixture\ndef test_config(test_variables):\n    \"\"\"Get test configuration with common defaults.\"\"\"\n    return test_variables.copy()\n\n\n@pytest.fixture\ndef temp_directory():\n    \"\"\"Create a temporary directory for test files.\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        yield Path(tmpdir)\n\n\n@pytest.fixture\ndef wireguard_private_key():\n    \"\"\"Generate a random WireGuard-compatible private key.\"\"\"\n    raw_key = secrets.token_bytes(32)\n    return base64.b64encode(raw_key).decode()\n\n\n@pytest.fixture\ndef wireguard_key_pair(temp_directory):\n    \"\"\"Generate a WireGuard key pair and return paths and values.\"\"\"\n    raw_key = secrets.token_bytes(32)\n    b64_key = base64.b64encode(raw_key).decode()\n\n    private_key_path = temp_directory / \"private.key\"\n    private_key_path.write_bytes(raw_key)\n\n    return {\n        \"private_key_raw\": raw_key,\n        \"private_key_b64\": b64_key,\n        \"private_key_path\": str(private_key_path),\n    }\n\n\nclass MockAnsibleModule:\n    \"\"\"Mock AnsibleModule for testing custom Ansible modules.\"\"\"\n\n    def __init__(self, params):\n        \"\"\"Initialize with module parameters.\"\"\"\n        self.params = params\n        self.result = {}\n        self.failed = False\n        self.fail_msg = None\n\n    def fail_json(self, **kwargs):\n        \"\"\"Record failure and raise exception.\"\"\"\n        self.failed = True\n        self.fail_msg = kwargs.get(\"msg\", \"Unknown error\")\n        raise Exception(f\"Module failed: {self.fail_msg}\")\n\n    def exit_json(self, **kwargs):\n        \"\"\"Record successful result.\"\"\"\n        self.result = kwargs\n\n\n@pytest.fixture\ndef mock_ansible_module():\n    \"\"\"Fixture providing MockAnsibleModule class.\"\"\"\n    return MockAnsibleModule\n\n\n# Jinja2 mock filters for template testing\ndef mock_to_uuid(value):\n    \"\"\"Mock the to_uuid filter.\"\"\"\n    return \"12345678-1234-5678-1234-567812345678\"\n\n\ndef mock_bool(value):\n    \"\"\"Mock the bool filter.\"\"\"\n    return str(value).lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef mock_lookup(lookup_type, path):\n    \"\"\"Mock the lookup function.\"\"\"\n    if lookup_type == \"file\":\n        if \"private\" in path:\n            return \"MOCK_PRIVATE_KEY_BASE64==\"\n        elif \"public\" in path:\n            return \"MOCK_PUBLIC_KEY_BASE64==\"\n        elif \"preshared\" in path:\n            return \"MOCK_PRESHARED_KEY_BASE64==\"\n    return \"MOCK_LOOKUP_DATA\"\n\n\n@pytest.fixture\ndef jinja2_env():\n    \"\"\"Create a Jinja2 environment with mock Ansible filters.\"\"\"\n    from jinja2 import Environment, FileSystemLoader, StrictUndefined\n\n    def create_env(template_dir):\n        env = Environment(loader=FileSystemLoader(template_dir), undefined=StrictUndefined)\n        env.globals[\"lookup\"] = mock_lookup\n        env.filters[\"to_uuid\"] = mock_to_uuid\n        env.filters[\"bool\"] = mock_bool\n        return env\n\n    return create_env\n\n\n@pytest.fixture\ndef project_root():\n    \"\"\"Return the project root directory.\"\"\"\n    return Path(__file__).parent.parent\n\n\n@pytest.fixture\ndef roles_dir(project_root):\n    \"\"\"Return the roles directory.\"\"\"\n    return project_root / \"roles\"\n\n\n# Skip markers for conditional tests\ndef pytest_configure(config):\n    \"\"\"Register custom markers.\"\"\"\n    config.addinivalue_line(\"markers\", \"requires_wireguard: mark test as requiring WireGuard tools\")\n    config.addinivalue_line(\"markers\", \"slow: mark test as slow running\")\n\n\n@pytest.fixture(autouse=True)\ndef skip_wireguard_tests(request):\n    \"\"\"Skip tests marked with requires_wireguard if WireGuard tools aren't available.\"\"\"\n    if request.node.get_closest_marker(\"requires_wireguard\"):\n        import shutil\n\n        if not shutil.which(\"wg\"):\n            pytest.skip(\"WireGuard tools not available\")\n"
  },
  {
    "path": "tests/e2e/README.md",
    "content": "# End-to-End VPN Connectivity Tests\n\nThis directory contains end-to-end tests that verify actual VPN connectivity\nusing Linux network namespaces.\n\n## Architecture\n\n```\n+---------------------------+     veth pair      +---------------------------+\n|   Main Namespace          |                    |   Client Namespace        |\n|   (VPN Server)            |                    |   (algo-client)           |\n|                           |                    |                           |\n|   wg0: 10.49.0.1         |   veth-algo-srv    |   veth-algo-cli           |\n|   strongswan listening    |<----------------->|   10.99.0.2/24            |\n|   dns: 172.16.0.1        |   10.99.0.1/24     |                           |\n|                           |                    |   wg0 (after connection)  |\n+---------------------------+                    +---------------------------+\n```\n\nThe test creates a network namespace that simulates a VPN client. Traffic from\nthe namespace routes through a veth pair to the host, which NATs it to allow\nthe client to connect to the VPN server running on localhost.\n\n## Running Locally (Linux)\n\nThese tests require Linux (network namespaces are a Linux kernel feature).\n\n```bash\n# Deploy Algo first\nansible-playbook main.yml -e \"provider=local\"\n\n# Run all connectivity tests\nsudo tests/e2e/test-vpn-connectivity.sh both\n\n# Run only WireGuard tests\nsudo tests/e2e/test-vpn-connectivity.sh wireguard\n\n# Run only IPsec tests\nsudo tests/e2e/test-vpn-connectivity.sh ipsec\n```\n\n## Running on macOS (via Multipass)\n\nUse [Multipass](https://multipass.run/) to run an Ubuntu VM:\n\n```bash\n# Launch and mount algo directory\nmultipass launch 22.04 --name algo-test --cpus 2 --memory 4G --disk 20G\nmultipass mount ~/path/to/algo algo-test:/home/ubuntu/algo\nmultipass shell algo-test\n\n# Inside VM: install dependencies and deploy\nsudo apt-get update\nsudo apt-get install -y python3-pip wireguard-tools strongswan libxml2-utils dnsutils\ncurl -LsSf https://astral.sh/uv/install.sh | sh && source ~/.bashrc\ncd ~/algo && uv sync\nuv run ansible-playbook main.yml -e \"provider=local\"\n\n# Run tests\nsudo tests/e2e/test-vpn-connectivity.sh both\n\n# Cleanup (from macOS)\nmultipass delete algo-test && multipass purge\n```\n\n## Requirements\n\n- Root access (for network namespace operations)\n- Linux (network namespaces are a kernel feature)\n- Deployed Algo VPN on localhost (configs in `configs/localhost/`)\n- Required tools:\n  - `iproute2` (ip netns)\n  - `wireguard-tools` (wg, wg-quick)\n  - `strongswan` (ipsec, swanctl)\n  - `libxml2-utils` (xmllint)\n  - `openssl`\n  - `dnsutils` (host)\n\n## Configuration Assumptions\n\nThe tests assume Algo's default network configuration:\n\n| Setting | Default Value | Environment Variable |\n|---------|---------------|---------------------|\n| Test user | `alice` | `TEST_USER=username` |\n| WireGuard server IP | `10.49.0.1` | (hardcoded) |\n| DNS service IP | `172.16.0.1` | (hardcoded) |\n\nIf you've customized `wireguard_network_ipv4` or `local_service_ip` in your\ndeployment, the tests will fail. The CI workflow creates users `alice` and `bob`\nspecifically for testing.\n\n## What Gets Tested\n\n### Validation Tests (No Namespace Required)\n- mobileconfig XML syntax validation (`xmllint`)\n- CA certificate chain verification (`openssl verify`)\n\n### WireGuard Tests\n1. Client config file exists and is parseable\n2. WireGuard interface comes up in namespace\n3. Cryptographic handshake completes (checks `latest handshake`)\n4. Ping to server VPN IP (10.49.0.1) succeeds\n5. DNS resolution through VPN (172.16.0.1) works\n\n### IPsec Tests\n1. Certificate and key files exist\n2. Certificate chain validates\n3. IPsec service is running and listening\n4. IPsec ports (500, 4500) are reachable\n5. DNS service is responding\n\n## Test Flow\n\n1. **Setup**: Create `algo-client` network namespace with veth pair\n2. **Validate**: Check mobileconfig XML and certificates\n3. **WireGuard**: Start wg-quick in namespace, verify handshake and connectivity\n4. **IPsec**: Verify certificates and service status\n5. **Cleanup**: Remove namespace, NAT rules, and temp files\n\n## Troubleshooting\n\n### Common Issues\n\n**Namespace already exists**\n```bash\nsudo ip netns del algo-client\n```\n\n**WireGuard handshake timeout**\n- Check firewall allows UDP 51820\n- Verify wg0 interface exists on host: `sudo wg show`\n- Check server public key matches config\n\n**IPsec connection failed**\n- Verify strongswan service: `sudo systemctl status strongswan-starter`\n- Check certificates: `openssl verify -CAfile cacert.pem user.crt`\n- Review logs: `sudo journalctl -u strongswan -n 50`\n\n**DNS resolution failed**\n- Check dnscrypt-proxy: `sudo systemctl status dnscrypt-proxy`\n- Verify DNS IP is routed: `ip route get 172.16.0.1`\n- Test from host: `host google.com 172.16.0.1`\n\n### Debug Mode\n\nIf tests fail, debug information is automatically collected including:\n- Network interfaces and routes\n- WireGuard and IPsec status\n- iptables NAT rules\n- DNS service status\n- Recent system logs\n\n## CI Integration\n\nThese tests run automatically in GitHub Actions after Algo deployment:\n\n```yaml\n- name: Run E2E VPN connectivity tests\n  run: sudo tests/e2e/test-vpn-connectivity.sh \"${{ matrix.vpn_type }}\"\n```\n\nThe tests are matrix-aware and run for `wireguard`, `ipsec`, or `both`\nconfigurations.\n"
  },
  {
    "path": "tests/e2e/test-vpn-connectivity.sh",
    "content": "#!/bin/bash\nset -euo pipefail\nIFS=$'\\n\\t'\n\n# =============================================================================\n# Algo VPN End-to-End Connectivity Tests\n#\n# Uses Linux network namespaces to simulate a VPN client connecting to the\n# server deployed on localhost. Tests both WireGuard and IPsec connectivity.\n#\n# Usage: sudo ./test-vpn-connectivity.sh [wireguard|ipsec|both]\n# =============================================================================\n\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nALGO_ROOT=\"$(cd \"${SCRIPT_DIR}/../..\" && pwd)\"\n\n# Configuration\nNAMESPACE=\"algo-client\"\nVETH_SERVER=\"veth-algo-srv\"\nVETH_CLIENT=\"veth-algo-cli\"\nSERVER_BRIDGE_IP=\"10.99.0.1\"\nCLIENT_BRIDGE_IP=\"10.99.0.2\"\nCONFIG_DIR=\"${ALGO_ROOT}/configs/localhost\"\nTEST_USER=\"${TEST_USER:-alice}\"\nVPN_TYPE=\"${1:-both}\"\n\n# WireGuard network from config.cfg defaults\nWG_SERVER_IP=\"10.49.0.1\"\nDNS_SERVICE_IP=\"172.16.0.1\"\n\n# Colors for output (disabled if not a terminal)\nif [[ -t 1 ]]; then\n    RED='\\033[0;31m'\n    GREEN='\\033[0;32m'\n    YELLOW='\\033[1;33m'\n    NC='\\033[0m'\nelse\n    RED='' GREEN='' YELLOW='' NC=''\nfi\n\nlog_info()  { echo -e \"${GREEN}[INFO]${NC} $*\"; }\nlog_warn()  { echo -e \"${YELLOW}[WARN]${NC} $*\"; }\nlog_error() { echo -e \"${RED}[ERROR]${NC} $*\"; }\nlog_step()  { echo -e \"\\n${GREEN}==>${NC} $*\"; }\n\n# =============================================================================\n# Cleanup Functions\n# =============================================================================\n\n# shellcheck disable=SC2317,SC2329  # Function is invoked indirectly via trap\ncleanup() {\n    local exit_code=$?\n    log_step \"Cleaning up test environment...\"\n\n    # Tear down WireGuard in namespace (if running)\n    ip netns exec \"${NAMESPACE}\" wg-quick down /tmp/algo-test-wg.conf 2>/dev/null || true\n\n    # Tear down IPsec in namespace (if running)\n    ip netns exec \"${NAMESPACE}\" ipsec stroke down-nb \"algovpn\" 2>/dev/null || true\n    ip netns exec \"${NAMESPACE}\" ipsec stop 2>/dev/null || true\n\n    # Remove firewall rules we added\n    iptables -t nat -D POSTROUTING -s \"${CLIENT_BRIDGE_IP}/32\" ! -d 10.99.0.0/24 -j MASQUERADE 2>/dev/null || true\n    iptables -D INPUT -i \"${VETH_SERVER}\" -p udp --dport 51820 -j ACCEPT 2>/dev/null || true\n    iptables -D INPUT -i \"${VETH_SERVER}\" -p udp --dport 500 -j ACCEPT 2>/dev/null || true\n    iptables -D INPUT -i \"${VETH_SERVER}\" -p udp --dport 4500 -j ACCEPT 2>/dev/null || true\n\n    # Delete namespace (also removes veth pair)\n    ip netns del \"${NAMESPACE}\" 2>/dev/null || true\n\n    # Clean up server-side veth if orphaned\n    ip link del \"${VETH_SERVER}\" 2>/dev/null || true\n\n    # Clean up temp files\n    rm -f /tmp/algo-test-wg.conf /tmp/algo-ipsec-test-* /tmp/algo-tcpdump.log 2>/dev/null || true\n    rm -rf /tmp/algo-ipsec-test 2>/dev/null || true\n    pkill -f \"tcpdump.*port 51820\" 2>/dev/null || true\n\n    log_info \"Cleanup complete\"\n    exit \"${exit_code}\"\n}\n\ntrap cleanup EXIT INT TERM\n\n# =============================================================================\n# Namespace Setup\n# =============================================================================\n\nsetup_namespace() {\n    log_step \"Setting up network namespace...\"\n\n    # Clean up any existing namespace first\n    if ip netns list | grep -q \"^${NAMESPACE}\"; then\n        log_warn \"Namespace ${NAMESPACE} already exists, cleaning up first...\"\n        ip netns del \"${NAMESPACE}\" 2>/dev/null || true\n        ip link del \"${VETH_SERVER}\" 2>/dev/null || true\n    fi\n\n    # Create namespace\n    ip netns add \"${NAMESPACE}\"\n\n    # Create veth pair\n    ip link add \"${VETH_SERVER}\" type veth peer name \"${VETH_CLIENT}\"\n\n    # Move client end to namespace\n    ip link set \"${VETH_CLIENT}\" netns \"${NAMESPACE}\"\n\n    # Configure server side\n    ip addr add \"${SERVER_BRIDGE_IP}/24\" dev \"${VETH_SERVER}\"\n    ip link set \"${VETH_SERVER}\" up\n\n    # Configure client side (in namespace)\n    ip netns exec \"${NAMESPACE}\" ip addr add \"${CLIENT_BRIDGE_IP}/24\" dev \"${VETH_CLIENT}\"\n    ip netns exec \"${NAMESPACE}\" ip link set \"${VETH_CLIENT}\" up\n    ip netns exec \"${NAMESPACE}\" ip link set lo up\n\n    # Set default route in namespace to go through the veth to server\n    ip netns exec \"${NAMESPACE}\" ip route add default via \"${SERVER_BRIDGE_IP}\"\n\n    # Enable forwarding on the server for NAT\n    sysctl -w net.ipv4.ip_forward=1 > /dev/null\n\n    # Add MASQUERADE for the client namespace traffic going to external networks\n    iptables -t nat -A POSTROUTING -s \"${CLIENT_BRIDGE_IP}/32\" ! -d 10.99.0.0/24 -j MASQUERADE\n\n    # Allow WireGuard and IPsec traffic on the veth interface\n    # Use -I to insert at beginning of chain (before any DROP rules)\n    iptables -I INPUT -i \"${VETH_SERVER}\" -p udp --dport 51820 -j ACCEPT\n    iptables -I INPUT -i \"${VETH_SERVER}\" -p udp --dport 500 -j ACCEPT\n    iptables -I INPUT -i \"${VETH_SERVER}\" -p udp --dport 4500 -j ACCEPT\n\n    log_info \"Namespace ${NAMESPACE} created with IP ${CLIENT_BRIDGE_IP}\"\n\n    # Verify connectivity to server\n    if ip netns exec \"${NAMESPACE}\" ping -c 1 -W 2 \"${SERVER_BRIDGE_IP}\" > /dev/null 2>&1; then\n        log_info \"Namespace can reach server bridge at ${SERVER_BRIDGE_IP}\"\n    else\n        log_error \"Namespace cannot reach server bridge. Network setup failed.\"\n        log_error \"Fix: Check veth configuration and firewall rules\"\n        exit 1\n    fi\n\n    # Verify client can reach WireGuard port on localhost (through NAT)\n    if ip netns exec \"${NAMESPACE}\" timeout 2 bash -c \"echo >/dev/udp/127.0.0.1/51820\" 2>/dev/null; then\n        log_info \"Client can reach WireGuard port (UDP 51820)\"\n    else\n        log_warn \"Cannot verify WireGuard port reachability (may be expected)\"\n    fi\n}\n\n# =============================================================================\n# Mobileconfig Validation\n# =============================================================================\n\ntest_mobileconfig_validation() {\n    log_step \"Validating mobileconfig files...\"\n\n    local failed=0\n\n    # WireGuard mobileconfig (if exists)\n    if [[ -d \"${CONFIG_DIR}/wireguard/apple\" ]]; then\n        while IFS= read -r -d '' f; do\n            if xmllint --noout \"${f}\" 2>/dev/null; then\n                log_info \"Valid XML: $(basename \"${f}\")\"\n            else\n                log_error \"Invalid XML: ${f}\"\n                ((failed++))\n            fi\n        done < <(find \"${CONFIG_DIR}/wireguard/apple\" -name \"*.mobileconfig\" -print0 2>/dev/null)\n    fi\n\n    # IPsec mobileconfig\n    if [[ -d \"${CONFIG_DIR}/ipsec/apple\" ]]; then\n        while IFS= read -r -d '' f; do\n            if xmllint --noout \"${f}\" 2>/dev/null; then\n                log_info \"Valid XML: $(basename \"${f}\")\"\n            else\n                log_error \"Invalid XML: ${f}\"\n                ((failed++))\n            fi\n        done < <(find \"${CONFIG_DIR}/ipsec/apple\" -name \"*.mobileconfig\" -print0 2>/dev/null)\n    fi\n\n    if [[ ${failed} -eq 0 ]]; then\n        log_info \"All mobileconfig files valid\"\n        return 0\n    else\n        log_error \"${failed} mobileconfig file(s) invalid\"\n        return 1\n    fi\n}\n\n# =============================================================================\n# CA Name Constraints Test\n# =============================================================================\n\ntest_ca_name_constraints() {\n    log_step \"Testing CA name constraints...\"\n\n    local cacert=\"${CONFIG_DIR}/ipsec/.pki/cacert.pem\"\n    local server_cert\n    server_cert=$(find \"${CONFIG_DIR}/ipsec/.pki/certs\" -name \"*.crt\" ! -name \"${TEST_USER}.crt\" | head -1)\n\n    if [[ ! -f \"${cacert}\" ]]; then\n        log_warn \"Skipping CA name constraints test (CA cert not found)\"\n        return 0\n    fi\n\n    if [[ -z \"${server_cert}\" ]] || [[ ! -f \"${server_cert}\" ]]; then\n        log_warn \"Skipping CA name constraints test (server cert not found)\"\n        return 0\n    fi\n\n    # The CA should verify the server certificate\n    local verify_output\n    verify_output=$(openssl verify -verbose -CAfile \"${cacert}\" \"${server_cert}\" 2>&1) || true\n\n    if echo \"${verify_output}\" | grep -q \"OK\"; then\n        log_info \"Server certificate verification passed\"\n    else\n        log_warn \"Server certificate verification: ${verify_output}\"\n    fi\n\n    log_info \"CA name constraints test completed\"\n    return 0\n}\n\n# =============================================================================\n# WireGuard Tests\n# =============================================================================\n\ntest_wireguard() {\n    log_step \"Testing WireGuard connectivity...\"\n\n    local wg_config=\"${CONFIG_DIR}/wireguard/${TEST_USER}.conf\"\n\n    if [[ ! -f \"${wg_config}\" ]]; then\n        log_error \"WireGuard config not found: ${wg_config}\"\n        log_error \"Fix: Ensure Algo deployed with wireguard_enabled: true\"\n        return 1\n    fi\n\n    # Copy and modify config for namespace use\n    local ns_config=\"/tmp/algo-test-wg.conf\"\n    cp \"${wg_config}\" \"${ns_config}\"\n\n    # Modify config:\n    # - Change Endpoint to use bridge IP (client namespace routes through veth)\n    # - Set Table=off to prevent routing table changes conflicting with namespace\n    # - Remove DNS line to avoid resolvconf dependency (we test DNS separately)\n    sed -i \"s/Endpoint = 127.0.0.1:/Endpoint = ${SERVER_BRIDGE_IP}:/\" \"${ns_config}\"\n    sed -i \"s/Endpoint = localhost:/Endpoint = ${SERVER_BRIDGE_IP}:/\" \"${ns_config}\"\n    sed -i '/^DNS = /d' \"${ns_config}\"\n\n    # Add Table=off if not present (prevent routing table changes in namespace)\n    if ! grep -q \"^Table\" \"${ns_config}\"; then\n        sed -i '/^\\[Interface\\]/a Table = off' \"${ns_config}\"\n    fi\n\n    # Add PersistentKeepalive to trigger handshake initiation\n    # Without this, WireGuard waits for outgoing traffic before initiating\n    if ! grep -q \"^PersistentKeepalive\" \"${ns_config}\"; then\n        sed -i '/^\\[Peer\\]/a PersistentKeepalive = 1' \"${ns_config}\"\n    fi\n\n    log_info \"Modified WireGuard config for namespace testing\"\n    log_info \"Endpoint changed to ${SERVER_BRIDGE_IP}\"\n\n    # Debug: Show server WireGuard state before client connects\n    log_info \"Server WireGuard peers:\"\n    local server_peers\n    server_peers=$(wg show wg0 peers 2>/dev/null || echo \"\")\n    if [[ -z \"${server_peers}\" ]]; then\n        # Workaround: Deployment bug causes handlers not to fire with async roles\n        # Restart WireGuard to load the peer configuration\n        log_warn \"No peers found - restarting WireGuard to load config (deployment handler bug workaround)\"\n        systemctl restart wg-quick@wg0 || log_error \"Failed to restart WireGuard\"\n        sleep 2\n        server_peers=$(wg show wg0 peers 2>/dev/null || echo \"\")\n    fi\n    if [[ -n \"${server_peers}\" ]]; then\n        log_info \"Found peers: ${server_peers}\"\n    else\n        log_error \"Server WireGuard has no peers configured!\"\n        log_error \"Check that deployment created /etc/wireguard/wg0.conf with [Peer] sections\"\n        return 1\n    fi\n    log_info \"Server WireGuard listening:\"\n    ss -ulnp | grep 51820 || log_warn \"WireGuard port not found in ss output\"\n\n    # Disable reverse path filtering on veth (can cause packet drops in some environments)\n    sysctl -w net.ipv4.conf.all.rp_filter=0 > /dev/null 2>&1 || true\n    sysctl -w net.ipv4.conf.\"${VETH_SERVER}\".rp_filter=0 > /dev/null 2>&1 || true\n\n    # Start packet capture in background for failure diagnosis\n    local tcpdump_log=\"/tmp/algo-tcpdump.log\"\n    timeout 20 tcpdump -i any -n port 51820 -c 20 > \"${tcpdump_log}\" 2>&1 &\n    local tcpdump_pid=$!\n\n    # Start WireGuard in the namespace\n    log_info \"Starting WireGuard in namespace...\"\n    if ! ip netns exec \"${NAMESPACE}\" wg-quick up \"${ns_config}\" 2>&1; then\n        log_error \"Failed to start WireGuard in namespace\"\n        kill \"${tcpdump_pid}\" 2>/dev/null || true\n        return 1\n    fi\n\n    # Get the WireGuard interface name\n    local wg_interface\n    wg_interface=$(ip netns exec \"${NAMESPACE}\" wg show interfaces 2>/dev/null || echo \"\")\n\n    if [[ -z \"${wg_interface}\" ]]; then\n        log_error \"WireGuard interface not created in namespace\"\n        return 1\n    fi\n    log_info \"WireGuard interface '${wg_interface}' is up\"\n\n    # Add routes for VPN traffic through wg interface\n    ip netns exec \"${NAMESPACE}\" ip route add \"${WG_SERVER_IP}/32\" dev \"${wg_interface}\" 2>/dev/null || true\n    ip netns exec \"${NAMESPACE}\" ip route add \"${DNS_SERVICE_IP}/32\" dev \"${wg_interface}\" 2>/dev/null || true\n\n    # Wait for handshake (with timeout)\n    log_info \"Waiting for WireGuard handshake...\"\n    local attempts=0\n    local max_attempts=15\n    while [[ ${attempts} -lt ${max_attempts} ]]; do\n        if ip netns exec \"${NAMESPACE}\" wg show 2>/dev/null | grep -q \"latest handshake\"; then\n            log_info \"WireGuard handshake completed!\"\n            break\n        fi\n        sleep 1\n        ((attempts++))\n    done\n\n    if [[ ${attempts} -ge ${max_attempts} ]]; then\n        log_error \"WireGuard handshake timeout after ${max_attempts} seconds\"\n        log_error \"Debug - client wg show:\"\n        ip netns exec \"${NAMESPACE}\" wg show 2>&1 || true\n        log_error \"Debug - server wg0 state:\"\n        wg show wg0 2>&1 || true\n        log_error \"Debug - iptables INPUT chain (first 15 rules):\"\n        iptables -L INPUT -n -v --line-numbers 2>&1 | head -20 || true\n        log_error \"Debug - packet capture (tcpdump):\"\n        kill \"${tcpdump_pid}\" 2>/dev/null || true\n        sleep 1\n        cat \"${tcpdump_log}\" 2>/dev/null || echo \"No capture available\"\n        log_error \"Debug - host route to 10.99.0.0/24:\"\n        ip route get 10.99.0.2 2>&1 || true\n        log_error \"Debug - namespace route to server:\"\n        ip netns exec \"${NAMESPACE}\" ip route get 10.99.0.1 2>&1 || true\n        return 1\n    fi\n\n    # Stop packet capture\n    kill \"${tcpdump_pid}\" 2>/dev/null || true\n\n    # Show WireGuard status\n    ip netns exec \"${NAMESPACE}\" wg show\n\n    # Test connectivity to VPN server IP\n    log_info \"Testing ping to WireGuard server (${WG_SERVER_IP})...\"\n    if ip netns exec \"${NAMESPACE}\" ping -c 3 -W 3 \"${WG_SERVER_IP}\" 2>&1; then\n        log_info \"Ping to WireGuard server successful\"\n    else\n        log_error \"Cannot ping WireGuard server IP ${WG_SERVER_IP}\"\n        return 1\n    fi\n\n    # Test DNS through VPN (hard fail as per user decision)\n    log_info \"Testing DNS resolution through VPN (${DNS_SERVICE_IP})...\"\n    if ip netns exec \"${NAMESPACE}\" host -W 5 google.com \"${DNS_SERVICE_IP}\" 2>&1; then\n        log_info \"DNS resolution through VPN successful\"\n    else\n        log_error \"DNS resolution through VPN failed\"\n        log_error \"Fix: Check dnscrypt-proxy service and routing to ${DNS_SERVICE_IP}\"\n        return 1\n    fi\n\n    # Cleanup WireGuard\n    ip netns exec \"${NAMESPACE}\" wg-quick down \"${ns_config}\" 2>/dev/null || true\n    rm -f \"${ns_config}\"\n\n    log_info \"WireGuard E2E tests PASSED\"\n    return 0\n}\n\n# =============================================================================\n# IPsec Tests\n# =============================================================================\n\ntest_ipsec() {\n    log_step \"Testing IPsec/StrongSwan connectivity...\"\n\n    local cacert=\"${CONFIG_DIR}/ipsec/.pki/cacert.pem\"\n    local user_cert=\"${CONFIG_DIR}/ipsec/.pki/certs/${TEST_USER}.crt\"\n    local user_key=\"${CONFIG_DIR}/ipsec/.pki/private/${TEST_USER}.key\"\n\n    # Verify required files exist\n    for f in \"${cacert}\" \"${user_cert}\" \"${user_key}\"; do\n        if [[ ! -f \"${f}\" ]]; then\n            log_error \"IPsec file not found: ${f}\"\n            log_error \"Fix: Ensure Algo deployed with ipsec_enabled: true\"\n            return 1\n        fi\n    done\n\n    log_info \"All IPsec certificates found\"\n\n    # Create temporary directory for namespace StrongSwan config\n    local ns_ipsec_dir=\"/tmp/algo-ipsec-test\"\n    rm -rf \"${ns_ipsec_dir}\"\n    mkdir -p \"${ns_ipsec_dir}\"/{ipsec.d/certs,ipsec.d/private,ipsec.d/cacerts}\n\n    # Copy certificates\n    cp \"${cacert}\" \"${ns_ipsec_dir}/ipsec.d/cacerts/\"\n    cp \"${user_cert}\" \"${ns_ipsec_dir}/ipsec.d/certs/\"\n    cp \"${user_key}\" \"${ns_ipsec_dir}/ipsec.d/private/\"\n    chmod 600 \"${ns_ipsec_dir}/ipsec.d/private/${TEST_USER}.key\"\n\n    # Create swanctl.conf for the client\n    cat > \"${ns_ipsec_dir}/swanctl.conf\" << EOF\nconnections {\n    algovpn {\n        version = 2\n        proposals = aes256gcm16-prfsha512-ecp384\n        rekey_time = 0\n        dpd_delay = 35s\n        remote_addrs = ${SERVER_BRIDGE_IP}\n        vips = 0.0.0.0\n\n        local {\n            auth = pubkey\n            certs = ${TEST_USER}.crt\n        }\n        remote {\n            auth = pubkey\n            id = ${SERVER_BRIDGE_IP}\n        }\n        children {\n            algovpn {\n                esp_proposals = aes256gcm16-ecp384\n                remote_ts = ${DNS_SERVICE_IP}/32\n                rekey_time = 0\n                dpd_action = clear\n            }\n        }\n    }\n}\n\nsecrets {\n    ecdsa-${TEST_USER} {\n        file = ${TEST_USER}.key\n    }\n}\nEOF\n\n    log_info \"StrongSwan configuration created\"\n\n    # Start a minimal charon in the namespace\n    log_info \"Starting StrongSwan in namespace...\"\n\n    # Create a minimal strongswan.conf\n    cat > \"${ns_ipsec_dir}/strongswan.conf\" << EOF\ncharon {\n    load_modular = yes\n    plugins {\n        include /etc/strongswan.d/charon/*.conf\n    }\n    filelog {\n        /tmp/algo-ipsec-test/charon.log {\n            default = 2\n            ike = 2\n            net = 1\n        }\n    }\n}\n\nswanctl {\n    load = pem pkcs1 x509 revocation constraints pubkey openssl kernel-netlink socket-default updown vici\n}\nEOF\n\n    # Try to initiate IPsec connection using swanctl\n    # First, we need charon running in the namespace\n    log_info \"Initiating IPsec connection...\"\n\n    # Use the host's charon but connect to server via bridge\n    # This is simpler than running charon in a namespace\n    # Instead, test that the certificates are valid and connection can be established\n\n    # Test certificate chain validity\n    log_info \"Verifying certificate chain...\"\n    if openssl verify -CAfile \"${cacert}\" \"${user_cert}\" 2>&1 | grep -q \"OK\"; then\n        log_info \"Client certificate verification passed\"\n    else\n        log_error \"Client certificate verification failed\"\n        openssl verify -CAfile \"${cacert}\" \"${user_cert}\" 2>&1\n        return 1\n    fi\n\n    # Check if IPsec service is running on host\n    if ! ipsec status >/dev/null 2>&1; then\n        log_error \"IPsec service not running on host\"\n        return 1\n    fi\n    log_info \"IPsec service is running on host\"\n\n    # Show current IPsec status\n    log_info \"Current IPsec status:\"\n    ipsec statusall | head -20 || true\n\n    # For a true E2E test, we would connect from the namespace\n    # But IPsec in namespaces requires running charon which is complex\n    # Instead, verify the server is accepting connections by checking logs\n\n    # Test connectivity to IPsec ports\n    log_info \"Testing IPsec port reachability...\"\n    if ip netns exec \"${NAMESPACE}\" timeout 2 bash -c \\\n        \"echo >/dev/udp/${SERVER_BRIDGE_IP}/500\" 2>/dev/null; then\n        log_info \"IKE port (UDP 500) reachable\"\n    else\n        log_warn \"IKE port (UDP 500) not reachable through namespace\"\n    fi\n\n    if ip netns exec \"${NAMESPACE}\" timeout 2 bash -c \\\n        \"echo >/dev/udp/${SERVER_BRIDGE_IP}/4500\" 2>/dev/null; then\n        log_info \"NAT-T port (UDP 4500) reachable\"\n    else\n        log_warn \"NAT-T port (UDP 4500) not reachable through namespace\"\n    fi\n\n    # Verify strongswan is configured correctly on server\n    log_info \"Checking StrongSwan server configuration...\"\n    if ipsec statusall | grep -q \"Listening\"; then\n        log_info \"StrongSwan is listening for connections\"\n    fi\n\n    # Test DNS service is accessible (for when IPsec tunnel would be up)\n    log_info \"Testing DNS service accessibility...\"\n    if host -W 5 google.com \"${DNS_SERVICE_IP}\" 2>&1 | grep -q \"has address\"; then\n        log_info \"DNS service at ${DNS_SERVICE_IP} is responding\"\n    else\n        log_error \"DNS service at ${DNS_SERVICE_IP} is not responding\"\n        log_error \"Fix: Check dnscrypt-proxy service status\"\n        return 1\n    fi\n\n    # Cleanup\n    rm -rf \"${ns_ipsec_dir}\"\n\n    log_info \"IPsec E2E tests PASSED\"\n    log_info \"Note: Full tunnel test requires running charon in namespace (complex)\"\n    return 0\n}\n\n# =============================================================================\n# Debug Information Collection\n# =============================================================================\n\ncollect_debug_info() {\n    log_step \"Collecting debug information...\"\n\n    echo \"=== Network Interfaces (Host) ===\"\n    ip addr || true\n\n    echo \"=== Routing Table (Host) ===\"\n    ip route || true\n\n    echo \"=== Network Namespaces ===\"\n    ip netns list || true\n\n    echo \"=== Network Interfaces (Namespace) ===\"\n    ip netns exec \"${NAMESPACE}\" ip addr 2>/dev/null || echo \"Namespace not available\"\n\n    echo \"=== Routing Table (Namespace) ===\"\n    ip netns exec \"${NAMESPACE}\" ip route 2>/dev/null || echo \"Namespace not available\"\n\n    echo \"=== WireGuard Status (Host) ===\"\n    wg show || true\n\n    echo \"=== WireGuard Status (Namespace) ===\"\n    ip netns exec \"${NAMESPACE}\" wg show 2>/dev/null || echo \"Not running\"\n\n    echo \"=== IPsec Status (Host) ===\"\n    ipsec statusall || true\n\n    echo \"=== Listening Ports ===\"\n    ss -tulnp | grep -E ':(51820|500|4500|53)\\s' || true\n\n    echo \"=== iptables NAT rules ===\"\n    iptables -t nat -L POSTROUTING -n -v || true\n\n    echo \"=== DNS Service Status ===\"\n    systemctl status dnscrypt-proxy --no-pager 2>/dev/null || true\n\n    echo \"=== Recent System Logs ===\"\n    journalctl -n 50 --no-pager 2>/dev/null || true\n}\n\n# =============================================================================\n# Main\n# =============================================================================\n\nmain() {\n    log_step \"Algo VPN End-to-End Connectivity Tests\"\n    log_info \"VPN type: ${VPN_TYPE}\"\n    log_info \"Config directory: ${CONFIG_DIR}\"\n    log_info \"Test user: ${TEST_USER}\"\n\n    # Check root\n    if [[ ${EUID} -ne 0 ]]; then\n        log_error \"This script must be run as root (for namespace operations)\"\n        log_error \"Fix: sudo $0 ${VPN_TYPE}\"\n        exit 1\n    fi\n\n    # Check required commands\n    local missing_cmds=()\n    for cmd in ip wg wg-quick ipsec xmllint openssl host; do\n        if ! command -v \"${cmd}\" &> /dev/null; then\n            missing_cmds+=(\"${cmd}\")\n        fi\n    done\n\n    if [[ ${#missing_cmds[@]} -gt 0 ]]; then\n        log_error \"Required command(s) not found: ${missing_cmds[*]}\"\n        log_error \"Fix: apt-get install iproute2 wireguard-tools strongswan libxml2-utils openssl dnsutils\"\n        exit 1\n    fi\n\n    # Check config directory exists\n    if [[ ! -d \"${CONFIG_DIR}\" ]]; then\n        log_error \"Config directory not found: ${CONFIG_DIR}\"\n        log_error \"Fix: Deploy Algo first: ansible-playbook main.yml -e provider=local\"\n        exit 1\n    fi\n\n    local exit_code=0\n\n    # Run validation tests first (no namespace needed)\n    test_mobileconfig_validation || ((exit_code++))\n    test_ca_name_constraints || ((exit_code++))\n\n    # Setup namespace for connectivity tests\n    setup_namespace\n\n    # Run protocol-specific tests\n    case \"${VPN_TYPE}\" in\n        wireguard)\n            test_wireguard || ((exit_code++))\n            ;;\n        ipsec)\n            test_ipsec || ((exit_code++))\n            ;;\n        both)\n            test_wireguard || ((exit_code++))\n            test_ipsec || ((exit_code++))\n            ;;\n        *)\n            log_error \"Unknown VPN type: ${VPN_TYPE}\"\n            log_error \"Usage: $0 [wireguard|ipsec|both]\"\n            exit 1\n            ;;\n    esac\n\n    # Summary\n    log_step \"Test Summary\"\n    if [[ ${exit_code} -eq 0 ]]; then\n        log_info \"All E2E tests PASSED\"\n    else\n        log_error \"${exit_code} test(s) FAILED\"\n        collect_debug_info\n    fi\n\n    exit ${exit_code}\n}\n\nmain \"$@\"\n"
  },
  {
    "path": "tests/fixtures/__init__.py",
    "content": "\"\"\"Test fixtures for Algo unit tests\"\"\"\n\nfrom pathlib import Path\n\nimport yaml\n\n\ndef load_test_variables():\n    \"\"\"Load test variables from YAML fixture\"\"\"\n    fixture_path = Path(__file__).parent / \"test_variables.yml\"\n    with open(fixture_path) as f:\n        return yaml.safe_load(f)\n\n\ndef get_test_config(overrides=None):\n    \"\"\"Get test configuration with optional overrides\"\"\"\n    config = load_test_variables()\n    if overrides:\n        config.update(overrides)\n    return config\n"
  },
  {
    "path": "tests/fixtures/test_variables.yml",
    "content": "---\n# Shared test variables for unit tests\n# This ensures consistency across all tests and easier maintenance\n\n# Server/Network basics\nserver_name: test-algo-vpn\nIP_subject_alt_name: 10.0.0.1\nipv4_network_prefix: 10.19.49\nipv4_network: 10.19.49.0\nipv4_range: 10.19.49.2/24\nipv6_network: fd9d:bc11:4020::/48\nipv6_range: fd9d:bc11:4020::/64\nwireguard_enabled: true\nwireguard_port: 51820\nwireguard_PersistentKeepalive: 0\nwireguard_network: 10.19.49.0/24\nwireguard_network_ipv6: fd9d:bc11:4020::/48\n\n# Additional WireGuard variables\nwireguard_pki_path: /etc/wireguard/pki\nwireguard_port_avoid: 53\nwireguard_port_actual: 51820\nwireguard_network_ipv4: 10.19.49.0/24\nwireguard_client_ip: 10.19.49.2/32,fd9d:bc11:4020::2/128\nwireguard_dns_servers: 1.1.1.1,1.0.0.1\n\n# IPsec variables\nipsec_enabled: true\nstrongswan_enabled: true\nstrongswan_af: ipv4\nstrongswan_log_level: '2'\nstrongswan_network: 10.19.48.0/24\nstrongswan_network_ipv6: fd9d:bc11:4021::/64\nalgo_ondemand_cellular: 'false'\nalgo_ondemand_wifi: 'false'\nalgo_ondemand_wifi_exclude: X251bGw=\n\n# DNS\ndns_adblocking: true\nalgo_dns_adblocking: true\nadblock_lists:\n  - https://someblacklist.com\ndns_encryption: true\ndns_servers:\n  - 1.1.1.1\n  - 1.0.0.1\nlocal_dns: true\nalternative_ingress_ip: false\nlocal_service_ip: 10.19.49.1\nlocal_service_ipv6: fd9d:bc11:4020::1\nipv6_support: true\n\n# Security/Firewall\nalgo_ssh_tunneling: false\nssh_tunneling: false\nsnat_aipv4: false\nsnat_aipv6: false\nblock_smb: true\nblock_netbios: true\n\n# Users and auth\nusers:\n  - alice\n  - bob\n  - charlie\nexisting_users:\n  - alice\neasyrsa_CA_password: test-ca-pass\np12_export_password: test-export-pass\nCA_password: test-ca-pass\n\n# System\nansible_ssh_port: 4160\nansible_python_interpreter: /usr/bin/python3\nansible_default_ipv4:\n  interface: eth0\n  address: 10.0.0.1\nansible_default_ipv6:\n  interface: eth0\n  address: 'fd9d:bc11:4020::1'\nBetweenClients_DROP: 'Y'\nssh_tunnels_config_path: /etc/ssh/ssh_tunnels\nconfig_prefix: /etc/algo\nserver_user: algo\nIP: 10.0.0.1\nreduce_mtu: 0\nalgo_ssh_port: 4160\nalgo_store_pki: true\n\n# Ciphers\nciphers:\n  defaults:\n    ike: aes128gcm16-prfsha512-ecp256,aes128-sha2_256-modp2048\n    esp: aes128gcm16-ecp256,aes128-sha2_256-modp2048\n  ike: aes128gcm16-prfsha512-ecp256,aes128-sha2_256-modp2048\n  esp: aes128gcm16-ecp256,aes128-sha2_256-modp2048\n\n# Cloud provider specific\nalgo_provider: local\ncloud_providers:\n  - ec2\n  - gce\n  - azure\n  - do\n  - lightsail\n  - scaleway\n  - openstack\n  - cloudstack\n  - hetzner\n  - linode\n  - vultr\nprovider_dns_servers:\n  - 1.1.1.1\n  - 1.0.0.1\nansible_ssh_private_key_file: ~/.ssh/id_rsa\n\n# Defaults\ninventory_hostname: localhost\nhostvars:\n  localhost: {}\ngroups:\n  vpn-host:\n    - localhost\nomit: OMIT_PLACEHOLDER\n"
  },
  {
    "path": "tests/integration/ansible-service-wrapper.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nWrapper for Ansible's service module that always succeeds for known services\n\"\"\"\n\nimport json\nimport sys\n\n# Parse module arguments\nargs = json.loads(sys.stdin.read())\nmodule_args = args.get(\"ANSIBLE_MODULE_ARGS\", {})\n\nservice_name = module_args.get(\"name\", \"\")\nstate = module_args.get(\"state\", \"started\")\n\n# Known services that should always succeed\nknown_services = [\n    \"netfilter-persistent\",\n    \"iptables\",\n    \"wg-quick@wg0\",\n    \"strongswan-starter\",\n    \"ipsec\",\n    \"apparmor\",\n    \"unattended-upgrades\",\n    \"systemd-networkd\",\n    \"systemd-resolved\",\n    \"rsyslog\",\n    \"ipfw\",\n    \"cron\",\n]\n\n# Check if it's a known service\nservice_found = False\nfor svc in known_services:\n    if service_name == svc or service_name.startswith(svc + \".\"):\n        service_found = True\n        break\n\nif service_found:\n    # Return success\n    result = {\n        \"changed\": state in [\"started\", \"stopped\", \"restarted\", \"reloaded\"],\n        \"name\": service_name,\n        \"state\": state,\n        \"status\": {\n            \"LoadState\": \"loaded\",\n            \"ActiveState\": \"active\" if state != \"stopped\" else \"inactive\",\n            \"SubState\": \"running\" if state != \"stopped\" else \"dead\",\n        },\n    }\n    print(json.dumps(result))\n    sys.exit(0)\nelse:\n    # Service not found\n    error = {\"failed\": True, \"msg\": f\"Could not find the requested service {service_name}: \"}\n    print(json.dumps(error))\n    sys.exit(1)\n"
  },
  {
    "path": "tests/integration/ansible.cfg",
    "content": "[defaults]\nlibrary = /algo/tests/integration/mock_modules\nroles_path = /algo/roles\nhost_key_checking = False\nstdout_callback = debug\n"
  },
  {
    "path": "tests/integration/mock-apparmor_status.sh",
    "content": "#!/bin/bash\n# Mock apparmor_status for Docker testing\n# Return error code to indicate AppArmor is not available\nexit 1\n"
  },
  {
    "path": "tests/integration/mock_modules/apt.py",
    "content": "#!/usr/bin/python\n# Mock apt module for Docker testing\n\nimport subprocess\n\nfrom ansible.module_utils.basic import AnsibleModule\n\n\ndef main():\n    module = AnsibleModule(\n        argument_spec={\n            \"name\": {\"type\": \"list\", \"aliases\": [\"pkg\", \"package\"]},\n            \"state\": {\n                \"type\": \"str\",\n                \"default\": \"present\",\n                \"choices\": [\"present\", \"absent\", \"latest\", \"build-dep\", \"fixed\"],\n            },\n            \"update_cache\": {\"type\": \"bool\", \"default\": False},\n            \"cache_valid_time\": {\"type\": \"int\", \"default\": 0},\n            \"install_recommends\": {\"type\": \"bool\"},\n            \"force\": {\"type\": \"bool\", \"default\": False},\n            \"allow_unauthenticated\": {\"type\": \"bool\", \"default\": False},\n            \"allow_downgrade\": {\"type\": \"bool\", \"default\": False},\n            \"allow_change_held_packages\": {\"type\": \"bool\", \"default\": False},\n            \"dpkg_options\": {\"type\": \"str\", \"default\": \"force-confdef,force-confold\"},\n            \"autoremove\": {\"type\": \"bool\", \"default\": False},\n            \"purge\": {\"type\": \"bool\", \"default\": False},\n            \"force_apt_get\": {\"type\": \"bool\", \"default\": False},\n        },\n        supports_check_mode=True,\n    )\n\n    name = module.params[\"name\"]\n    state = module.params[\"state\"]\n    update_cache = module.params[\"update_cache\"]\n\n    result = {\"changed\": False, \"cache_updated\": False, \"cache_update_time\": 0}\n\n    # Log the operation\n    with open(\"/var/log/mock-apt-module.log\", \"a\") as f:\n        f.write(f\"apt module called: name={name}, state={state}, update_cache={update_cache}\\n\")\n\n    # Handle cache update\n    if update_cache:\n        # In Docker, apt-get update was already run in entrypoint\n        # Just pretend it succeeded\n        result[\"cache_updated\"] = True\n        result[\"cache_update_time\"] = 1754231778  # Fixed timestamp\n        result[\"changed\"] = True\n\n    # Handle package installation/removal\n    if name:\n        packages = name if isinstance(name, list) else [name]\n\n        # Check which packages are already installed\n        installed_packages = []\n        for pkg in packages:\n            # Use dpkg to check if package is installed\n            check_cmd = [\"dpkg\", \"-s\", pkg]\n            rc = subprocess.run(check_cmd, capture_output=True)\n            if rc.returncode == 0:\n                installed_packages.append(pkg)\n\n        if state in [\"present\", \"latest\"]:\n            # Check if we need to install anything\n            missing_packages = [p for p in packages if p not in installed_packages]\n\n            if missing_packages:\n                # Log what we would install\n                with open(\"/var/log/mock-apt-module.log\", \"a\") as f:\n                    f.write(f\"Would install packages: {missing_packages}\\n\")\n\n                # For our test purposes, these packages are pre-installed in Docker\n                # Just report success\n                result[\"changed\"] = True\n                result[\"stdout\"] = f\"Mock: Packages {missing_packages} are already available\"\n                result[\"stderr\"] = \"\"\n            else:\n                result[\"stdout\"] = \"All packages are already installed\"\n\n        elif state == \"absent\":\n            # Check if we need to remove anything\n            present_packages = [p for p in packages if p in installed_packages]\n\n            if present_packages:\n                result[\"changed\"] = True\n                result[\"stdout\"] = f\"Mock: Would remove packages {present_packages}\"\n            else:\n                result[\"stdout\"] = \"No packages to remove\"\n\n    # Always report success for our testing\n    module.exit_json(**result)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "tests/integration/mock_modules/command.py",
    "content": "#!/usr/bin/python\n# Mock command module for Docker testing\n\nimport subprocess\n\nfrom ansible.module_utils.basic import AnsibleModule\n\n\ndef main():\n    module = AnsibleModule(\n        argument_spec={\n            \"_raw_params\": {\"type\": \"str\"},\n            \"cmd\": {\"type\": \"str\"},\n            \"creates\": {\"type\": \"path\"},\n            \"removes\": {\"type\": \"path\"},\n            \"chdir\": {\"type\": \"path\"},\n            \"executable\": {\"type\": \"path\"},\n            \"warn\": {\"type\": \"bool\", \"default\": False},\n            \"stdin\": {\"type\": \"str\"},\n            \"stdin_add_newline\": {\"type\": \"bool\", \"default\": True},\n            \"strip_empty_ends\": {\"type\": \"bool\", \"default\": True},\n            \"_uses_shell\": {\"type\": \"bool\", \"default\": False},\n        },\n        supports_check_mode=True,\n    )\n\n    # Get the command\n    raw_params = module.params.get(\"_raw_params\")\n    cmd = module.params.get(\"cmd\") or raw_params\n\n    if not cmd:\n        module.fail_json(msg=\"no command given\")\n\n    result = {\"changed\": False, \"cmd\": cmd, \"rc\": 0, \"stdout\": \"\", \"stderr\": \"\", \"stdout_lines\": [], \"stderr_lines\": []}\n\n    # Log the operation\n    with open(\"/var/log/mock-command-module.log\", \"a\") as f:\n        f.write(f\"command module called: cmd={cmd}\\n\")\n\n    # Handle specific commands\n    if \"apparmor_status\" in cmd:\n        # Pretend apparmor is not installed/active\n        result[\"rc\"] = 127\n        result[\"stderr\"] = \"apparmor_status: command not found\"\n        result[\"msg\"] = \"[Errno 2] No such file or directory: b'apparmor_status'\"\n        module.fail_json(msg=result[\"msg\"], **result)\n    elif \"netplan apply\" in cmd:\n        # Pretend netplan succeeded\n        result[\"stdout\"] = \"Mock: netplan configuration applied\"\n        result[\"changed\"] = True\n    elif \"echo 1 > /proc/sys/net/ipv4/route/flush\" in cmd:\n        # Routing cache flush\n        result[\"stdout\"] = \"1\"\n        result[\"changed\"] = True\n    else:\n        # For other commands, try to run them\n        try:\n            proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, cwd=module.params.get(\"chdir\"))\n            result[\"rc\"] = proc.returncode\n            result[\"stdout\"] = proc.stdout\n            result[\"stderr\"] = proc.stderr\n            result[\"stdout_lines\"] = proc.stdout.splitlines()\n            result[\"stderr_lines\"] = proc.stderr.splitlines()\n            result[\"changed\"] = True\n        except Exception as e:\n            result[\"rc\"] = 1\n            result[\"stderr\"] = str(e)\n            result[\"msg\"] = str(e)\n            module.fail_json(msg=result[\"msg\"], **result)\n\n    if result[\"rc\"] == 0:\n        module.exit_json(**result)\n    else:\n        if \"msg\" not in result:\n            result[\"msg\"] = f\"Command failed with return code {result['rc']}\"\n        module.fail_json(msg=result[\"msg\"], **result)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "tests/integration/mock_modules/shell.py",
    "content": "#!/usr/bin/python\n# Mock shell module for Docker testing\n\nimport subprocess\n\nfrom ansible.module_utils.basic import AnsibleModule\n\n\ndef main():\n    module = AnsibleModule(\n        argument_spec={\n            \"_raw_params\": {\"type\": \"str\"},\n            \"cmd\": {\"type\": \"str\"},\n            \"creates\": {\"type\": \"path\"},\n            \"removes\": {\"type\": \"path\"},\n            \"chdir\": {\"type\": \"path\"},\n            \"executable\": {\"type\": \"path\", \"default\": \"/bin/sh\"},\n            \"warn\": {\"type\": \"bool\", \"default\": False},\n            \"stdin\": {\"type\": \"str\"},\n            \"stdin_add_newline\": {\"type\": \"bool\", \"default\": True},\n        },\n        supports_check_mode=True,\n    )\n\n    # Get the command\n    raw_params = module.params.get(\"_raw_params\")\n    cmd = module.params.get(\"cmd\") or raw_params\n\n    if not cmd:\n        module.fail_json(msg=\"no command given\")\n\n    result = {\"changed\": False, \"cmd\": cmd, \"rc\": 0, \"stdout\": \"\", \"stderr\": \"\", \"stdout_lines\": [], \"stderr_lines\": []}\n\n    # Log the operation\n    with open(\"/var/log/mock-shell-module.log\", \"a\") as f:\n        f.write(f\"shell module called: cmd={cmd}\\n\")\n\n    # Handle specific commands\n    if \"echo 1 > /proc/sys/net/ipv4/route/flush\" in cmd:\n        # Routing cache flush - just pretend it worked\n        result[\"stdout\"] = \"\"\n        result[\"changed\"] = True\n    else:\n        # For other commands, try to run them\n        try:\n            proc = subprocess.run(\n                cmd,\n                shell=True,\n                capture_output=True,\n                text=True,\n                executable=module.params.get(\"executable\"),\n                cwd=module.params.get(\"chdir\"),\n            )\n            result[\"rc\"] = proc.returncode\n            result[\"stdout\"] = proc.stdout\n            result[\"stderr\"] = proc.stderr\n            result[\"stdout_lines\"] = proc.stdout.splitlines()\n            result[\"stderr_lines\"] = proc.stderr.splitlines()\n            result[\"changed\"] = True\n        except Exception as e:\n            result[\"rc\"] = 1\n            result[\"stderr\"] = str(e)\n            result[\"msg\"] = str(e)\n            module.fail_json(msg=result[\"msg\"], **result)\n\n    if result[\"rc\"] == 0:\n        module.exit_json(**result)\n    else:\n        if \"msg\" not in result:\n            result[\"msg\"] = f\"Command failed with return code {result['rc']}\"\n        module.fail_json(msg=result[\"msg\"], **result)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "tests/integration/test-configs/.provisioned",
    "content": ""
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/.config.yml",
    "content": "server: localhost\nserver_user: root\nansible_ssh_port: \"22\"\nalgo_provider: local\nalgo_server_name: algo-test-server\nalgo_ondemand_cellular: False\nalgo_ondemand_wifi: False\nalgo_ondemand_wifi_exclude: X251bGw=\nalgo_dns_adblocking: False\nalgo_ssh_tunneling: False\nalgo_store_pki: True\nIP_subject_alt_name: 10.99.0.10\nipsec_enabled: True\nwireguard_enabled: True\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/10.99.0.10_ca_generated",
    "content": ""
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/cacert.pem",
    "content": "-----BEGIN CERTIFICATE-----\nMIICoTCCAiagAwIBAgIUYQ99YGsE7jDrDq93WTTWMkaM7IUwCgYIKoZIzj0EAwIw\nFTETMBEGA1UEAwwKMTAuOTkuMC4xMDAeFw0yNTA4MDMxMjU5MjdaFw0zNTA4MDEx\nMjU5MjdaMBUxEzARBgNVBAMMCjEwLjk5LjAuMTAwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAASA2JYIRHTHqMnrGCoIFg8RVz3v2QdjGJnkF3f2Ia4s/V5LaP+WP0PhDEF3\npVHRzHKd2ntk0DBRNOih+/BiQ+lQhfET8tWH+mfAk0HemsgRzRIGadxPVxi1piqJ\nsL8uWU6jggE1MIIBMTAdBgNVHQ4EFgQUv/5pOGOAGenWXTgdI+dhjK9K6K0wUAYD\nVR0jBEkwR4AUv/5pOGOAGenWXTgdI+dhjK9K6K2hGaQXMBUxEzARBgNVBAMMCjEw\nLjk5LjAuMTCCFGEPfWBrBO4w6w6vd1k01jJGjOyFMBIGA1UdEwEB/wQIMAYBAf8C\nAQAwgZwGA1UdHgEB/wSBkTCBjqBmMAqHCApjAAr/////MCuCKTk1NDZkNTc0LTNm\nZDYtNThmNC05YWM2LTJjNjljYjc1NWRiNy5hbGdvMCuBKTk1NDZkNTc0LTNmZDYt\nNThmNC05YWM2LTJjNjljYjc1NWRiNy5hbGdvoSQwIocgAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAwCwYDVR0PBAQDAgEGMAoGCCqGSM49BAMCA2kAMGYC\nMQCsWQOinhhs4yZSOvupPIQKw7hMpKkEiKS6RtRfrvZohGQK92OKXsETLd7YPh3N\nRBACMQC8WAe35PXcg+JY8padri4d/u2ITreCXARuhUjypm+Ucy1qQ5A18wjj6/KV\nJJYlbfk=\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/01.pem",
    "content": "Certificate:\n    Data:\n        Version: 3 (0x2)\n        Serial Number: 1 (0x1)\n        Signature Algorithm: ecdsa-with-SHA256\n        Issuer: CN=10.99.0.10\n        Validity\n            Not Before: Aug  3 12:59:27 2025 GMT\n            Not After : Aug  1 12:59:27 2035 GMT\n        Subject: CN=10.99.0.10\n        Subject Public Key Info:\n            Public Key Algorithm: id-ecPublicKey\n                Public-Key: (384 bit)\n                pub:\n                    04:61:19:b3:d6:a3:52:5a:ff:33:7d:a6:7b:ee:bc:\n                    67:c0:d1:b1:80:bb:c0:72:06:fb:43:86:2d:2b:76:\n                    6a:b9:de:02:f8:2d:30:21:d1:1b:b6:d7:d7:e3:69:\n                    92:e3:d9:91:65:47:82:24:69:e1:4a:cc:d1:2b:c5:\n                    49:30:5d:35:7f:1f:63:bf:52:ae:85:52:da:5f:e9:\n                    5e:27:45:b9:dc:cd:e3:99:1b:d1:f6:24:72:35:28:\n                    bf:e4:51:0d:71:64:2e\n                ASN1 OID: secp384r1\n                NIST CURVE: P-384\n        X509v3 extensions:\n            X509v3 Basic Constraints:\n                CA:FALSE\n            X509v3 Subject Key Identifier:\n                8A:CC:04:6D:D6:3F:3A:3B:BF:06:01:92:27:48:76:08:26:D2:CB:22\n            X509v3 Authority Key Identifier:\n                keyid:BF:FE:69:38:63:80:19:E9:D6:5D:38:1D:23:E7:61:8C:AF:4A:E8:AD\n                DirName:/CN=10.99.0.10\n                serial:61:0F:7D:60:6B:04:EE:30:EB:0E:AF:77:59:34:D6:32:46:8C:EC:85\n            X509v3 Extended Key Usage:\n                TLS Web Server Authentication, TLS Web Client Authentication, ipsec Internet Key Exchange\n            X509v3 Key Usage:\n                Digital Signature, Key Encipherment\n            X509v3 Subject Alternative Name:\n                IP Address:10.99.0.10\n    Signature Algorithm: ecdsa-with-SHA256\n    Signature Value:\n        30:65:02:31:00:dd:ed:97:1f:0c:f7:24:38:e8:2d:52:0a:26:\n        70:45:23:4e:28:43:0f:d2:18:c8:50:c4:82:77:8f:72:44:cb:\n        6f:60:ce:84:a1:30:e6:df:f0:90:8f:e0:a5:07:49:a0:51:02:\n        30:2c:2b:02:f7:b2:6e:4a:7a:9f:f9:cc:39:b7:a2:8c:b7:04:\n        d0:b6:ad:1d:c6:a5:58:e3:74:d7:b0:76:99:7f:06:d3:7a:59:\n        7e:22:63:6b:19:db:f9:ac:5f:be:00:f8:60\n-----BEGIN CERTIFICATE-----\nMIICHTCCAaOgAwIBAgIBATAKBggqhkjOPQQDAjAVMRMwEQYDVQQDDAoxMC45OS4w\nLjEwMB4XDTI1MDgwMzEyNTkyN1oXDTM1MDgwMTEyNTkyN1owFTETMBEGA1UEAwwK\nMTAuOTkuMC4xMDB2MBAGByqGSM49AgEGBSuBBAAiA2IABGEZs9ajUlr/M32me+68\nZ8DRsYC7wHIG+0OGLSt2arneAvgtMCHRG7bX1+NpkuPZkWVHgiRp4UrM0SvFSTBd\nNX8fY79SroVS2l/pXidFudzN45kb0fYkcjUov+RRDXFkLqOBxjCBwzAJBgNVHRME\nAjAAMB0GA1UdDgQWBBSKzARt1j86O78GAZInSHYIJtLLIjBQBgNVHSMESTBHgBS/\n/mk4Y4AZ6dZdOB0j52GMr0roraEZpBcwFTETMBEGA1UEAwwKMTAuOTkuMC4xMIIU\nYQ99YGsE7jDrDq93WTTWMkaM7IUwJwYDVR0lBCAwHgYIKwYBBQUHAwEGCCsGAQUF\nBwMCBggrBgEFBQcDETALBgNVHQ8EBAMCBaAwDwYDVR0RBAgwBocECmMACjAKBggq\nhkjOPQQDAgNoADBlAjEA3e2XHwz3JDjoLVIKJnBFI04oQw/SGMhQxIJ3j3JEy29g\nzoShMObf8JCP4KUHSaBRAjAsKwL3sm5Kep/5zDm3ooy3BNC2rR3GpVjjdNewdpl/\nBtN6WX4iY2sZ2/msX74A+GA=\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/02.pem",
    "content": "Certificate:\n    Data:\n        Version: 3 (0x2)\n        Serial Number: 2 (0x2)\n        Signature Algorithm: ecdsa-with-SHA256\n        Issuer: CN=10.99.0.10\n        Validity\n            Not Before: Aug  3 12:59:27 2025 GMT\n            Not After : Aug  1 12:59:27 2035 GMT\n        Subject: CN=testuser1\n        Subject Public Key Info:\n            Public Key Algorithm: id-ecPublicKey\n                Public-Key: (384 bit)\n                pub:\n                    04:81:4d:22:72:2d:c5:f8:cf:52:e3:e0:ef:d8:86:\n                    a9:c6:cb:7c:c9:64:04:18:9d:ce:31:c3:99:98:4e:\n                    1a:8e:19:5c:25:56:78:9f:b4:87:9b:c2:51:ec:81:\n                    11:1e:80:6d:fb:47:d6:b1:49:25:72:98:da:c7:2e:\n                    48:23:d6:60:cc:d8:a9:a5:a9:3f:13:33:81:02:11:\n                    ad:70:17:f6:ee:41:ed:0b:be:d5:39:25:1f:0c:81:\n                    a2:a0:25:a7:4a:e5:e7\n                ASN1 OID: secp384r1\n                NIST CURVE: P-384\n        X509v3 extensions:\n            X509v3 Basic Constraints:\n                CA:FALSE\n            X509v3 Subject Key Identifier:\n                51:8F:14:BA:87:16:14:B2:23:33:69:2A:2A:A6:C4:26:80:E3:A0:61\n            X509v3 Authority Key Identifier:\n                keyid:BF:FE:69:38:63:80:19:E9:D6:5D:38:1D:23:E7:61:8C:AF:4A:E8:AD\n                DirName:/CN=10.99.0.10\n                serial:61:0F:7D:60:6B:04:EE:30:EB:0E:AF:77:59:34:D6:32:46:8C:EC:85\n            X509v3 Extended Key Usage:\n                TLS Web Server Authentication, TLS Web Client Authentication, ipsec Internet Key Exchange\n            X509v3 Key Usage:\n                Digital Signature, Key Encipherment\n            X509v3 Subject Alternative Name:\n                email:testuser1@9546d574-3fd6-58f4-9ac6-2c69cb755db7.algo\n    Signature Algorithm: ecdsa-with-SHA256\n    Signature Value:\n        30:66:02:31:00:a5:f7:05:17:78:b5:ce:dc:24:44:ba:57:ad:\n        65:d8:ce:37:f3:60:7c:55:37:9f:c0:58:7c:6b:09:67:cd:01:\n        3c:8f:56:32:c8:60:e5:52:2f:47:63:ae:2f:5f:2a:0e:f6:02:\n        31:00:ff:54:6f:3a:56:df:04:b0:ec:08:b8:c2:d7:20:2c:78:\n        c7:80:b2:40:fe:54:eb:e5:a5:29:d6:53:9d:db:78:17:30:2d:\n        db:09:ea:37:a8:ea:2e:11:23:e0:eb:b5:f5:86\n-----BEGIN CERTIFICATE-----\nMIICTDCCAdGgAwIBAgIBAjAKBggqhkjOPQQDAjAVMRMwEQYDVQQDDAoxMC45OS4w\nLjEwMB4XDTI1MDgwMzEyNTkyN1oXDTM1MDgwMTEyNTkyN1owFDESMBAGA1UEAwwJ\ndGVzdHVzZXIxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEgU0ici3F+M9S4+Dv2Iap\nxst8yWQEGJ3OMcOZmE4ajhlcJVZ4n7SHm8JR7IERHoBt+0fWsUklcpjaxy5II9Zg\nzNippak/EzOBAhGtcBf27kHtC77VOSUfDIGioCWnSuXno4H1MIHyMAkGA1UdEwQC\nMAAwHQYDVR0OBBYEFFGPFLqHFhSyIzNpKiqmxCaA46BhMFAGA1UdIwRJMEeAFL/+\naThjgBnp1l04HSPnYYyvSuitoRmkFzAVMRMwEQYDVQQDDAoxMC45OS4wLjEwghRh\nD31gawTuMOsOr3dZNNYyRozshTAnBgNVHSUEIDAeBggrBgEFBQcDAQYIKwYBBQUH\nAwIGCCsGAQUFBwMRMAsGA1UdDwQEAwIFoDA+BgNVHREENzA1gTN0ZXN0dXNlcjFA\nOTU0NmQ1NzQtM2ZkNi01OGY0LTlhYzYtMmM2OWNiNzU1ZGI3LmFsZ28wCgYIKoZI\nzj0EAwIDaQAwZgIxAKX3BRd4tc7cJES6V61l2M4382B8VTefwFh8awlnzQE8j1Yy\nyGDlUi9HY64vXyoO9gIxAP9UbzpW3wSw7Ai4wtcgLHjHgLJA/lTr5aUp1lOd23gX\nMC3bCeo3qOouESPg67X1hg==\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/03.pem",
    "content": "Certificate:\n    Data:\n        Version: 3 (0x2)\n        Serial Number: 3 (0x3)\n        Signature Algorithm: ecdsa-with-SHA256\n        Issuer: CN=10.99.0.10\n        Validity\n            Not Before: Aug  3 12:59:27 2025 GMT\n            Not After : Aug  1 12:59:27 2035 GMT\n        Subject: CN=testuser2\n        Subject Public Key Info:\n            Public Key Algorithm: id-ecPublicKey\n                Public-Key: (384 bit)\n                pub:\n                    04:88:ed:fc:6d:44:0e:5f:f4:73:13:51:6c:58:cf:\n                    3f:97:c6:b3:3f:c5:12:fe:40:0a:cf:ff:46:da:73:\n                    9a:34:bd:c1:b8:e6:7f:21:d5:ad:39:37:7b:0f:c0:\n                    cc:00:17:5c:2a:3e:3a:cf:42:7d:72:7e:2b:82:82:\n                    9d:19:a2:25:e7:0e:3c:b7:67:66:84:15:89:8e:66:\n                    4d:c7:d5:be:00:e9:75:f3:43:c6:94:c9:c8:3a:b1:\n                    d1:e7:c0:19:d4:a7:e1\n                ASN1 OID: secp384r1\n                NIST CURVE: P-384\n        X509v3 extensions:\n            X509v3 Basic Constraints:\n                CA:FALSE\n            X509v3 Subject Key Identifier:\n                08:4E:A2:3F:07:36:D6:DD:91:11:4B:43:CF:0E:75:68:BF:49:AC:A2\n            X509v3 Authority Key Identifier:\n                keyid:BF:FE:69:38:63:80:19:E9:D6:5D:38:1D:23:E7:61:8C:AF:4A:E8:AD\n                DirName:/CN=10.99.0.10\n                serial:61:0F:7D:60:6B:04:EE:30:EB:0E:AF:77:59:34:D6:32:46:8C:EC:85\n            X509v3 Extended Key Usage:\n                TLS Web Server Authentication, TLS Web Client Authentication, ipsec Internet Key Exchange\n            X509v3 Key Usage:\n                Digital Signature, Key Encipherment\n            X509v3 Subject Alternative Name:\n                email:testuser2@9546d574-3fd6-58f4-9ac6-2c69cb755db7.algo\n    Signature Algorithm: ecdsa-with-SHA256\n    Signature Value:\n        30:64:02:30:60:16:23:85:91:fc:40:f6:10:bc:5a:08:91:77:\n        30:5d:11:30:ac:8f:c7:6d:87:fd:b2:a0:c1:21:d6:2b:31:7e:\n        68:0e:b1:a1:86:91:0c:5b:b7:f5:a1:67:e8:11:2f:14:02:30:\n        62:f9:35:64:44:2b:c9:28:67:35:61:20:1e:2c:b2:25:cb:88:\n        1e:8c:d7:b6:6d:0f:aa:3d:62:3f:20:87:d6:86:36:25:5e:2a:\n        27:3a:2a:5e:97:0c:f8:8a:95:f6:b5:72\n-----BEGIN CERTIFICATE-----\nMIICSjCCAdGgAwIBAgIBAzAKBggqhkjOPQQDAjAVMRMwEQYDVQQDDAoxMC45OS4w\nLjEwMB4XDTI1MDgwMzEyNTkyN1oXDTM1MDgwMTEyNTkyN1owFDESMBAGA1UEAwwJ\ndGVzdHVzZXIyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEiO38bUQOX/RzE1FsWM8/\nl8azP8US/kAKz/9G2nOaNL3BuOZ/IdWtOTd7D8DMABdcKj46z0J9cn4rgoKdGaIl\n5w48t2dmhBWJjmZNx9W+AOl180PGlMnIOrHR58AZ1Kfho4H1MIHyMAkGA1UdEwQC\nMAAwHQYDVR0OBBYEFAhOoj8HNtbdkRFLQ88OdWi/SayiMFAGA1UdIwRJMEeAFL/+\naThjgBnp1l04HSPnYYyvSuitoRmkFzAVMRMwEQYDVQQDDAoxMC45OS4wLjEwghRh\nD31gawTuMOsOr3dZNNYyRozshTAnBgNVHSUEIDAeBggrBgEFBQcDAQYIKwYBBQUH\nAwIGCCsGAQUFBwMRMAsGA1UdDwQEAwIFoDA+BgNVHREENzA1gTN0ZXN0dXNlcjJA\nOTU0NmQ1NzQtM2ZkNi01OGY0LTlhYzYtMmM2OWNiNzU1ZGI3LmFsZ28wCgYIKoZI\nzj0EAwIDZwAwZAIwYBYjhZH8QPYQvFoIkXcwXREwrI/HbYf9sqDBIdYrMX5oDrGh\nhpEMW7f1oWfoES8UAjBi+TVkRCvJKGc1YSAeLLIly4gejNe2bQ+qPWI/IIfWhjYl\nXionOipelwz4ipX2tXI=\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/10.99.0.10.crt",
    "content": "Certificate:\n    Data:\n        Version: 3 (0x2)\n        Serial Number: 1 (0x1)\n        Signature Algorithm: ecdsa-with-SHA256\n        Issuer: CN=10.99.0.10\n        Validity\n            Not Before: Aug  3 12:59:27 2025 GMT\n            Not After : Aug  1 12:59:27 2035 GMT\n        Subject: CN=10.99.0.10\n        Subject Public Key Info:\n            Public Key Algorithm: id-ecPublicKey\n                Public-Key: (384 bit)\n                pub:\n                    04:61:19:b3:d6:a3:52:5a:ff:33:7d:a6:7b:ee:bc:\n                    67:c0:d1:b1:80:bb:c0:72:06:fb:43:86:2d:2b:76:\n                    6a:b9:de:02:f8:2d:30:21:d1:1b:b6:d7:d7:e3:69:\n                    92:e3:d9:91:65:47:82:24:69:e1:4a:cc:d1:2b:c5:\n                    49:30:5d:35:7f:1f:63:bf:52:ae:85:52:da:5f:e9:\n                    5e:27:45:b9:dc:cd:e3:99:1b:d1:f6:24:72:35:28:\n                    bf:e4:51:0d:71:64:2e\n                ASN1 OID: secp384r1\n                NIST CURVE: P-384\n        X509v3 extensions:\n            X509v3 Basic Constraints:\n                CA:FALSE\n            X509v3 Subject Key Identifier:\n                8A:CC:04:6D:D6:3F:3A:3B:BF:06:01:92:27:48:76:08:26:D2:CB:22\n            X509v3 Authority Key Identifier:\n                keyid:BF:FE:69:38:63:80:19:E9:D6:5D:38:1D:23:E7:61:8C:AF:4A:E8:AD\n                DirName:/CN=10.99.0.10\n                serial:61:0F:7D:60:6B:04:EE:30:EB:0E:AF:77:59:34:D6:32:46:8C:EC:85\n            X509v3 Extended Key Usage:\n                TLS Web Server Authentication, TLS Web Client Authentication, ipsec Internet Key Exchange\n            X509v3 Key Usage:\n                Digital Signature, Key Encipherment\n            X509v3 Subject Alternative Name:\n                IP Address:10.99.0.10\n    Signature Algorithm: ecdsa-with-SHA256\n    Signature Value:\n        30:65:02:31:00:dd:ed:97:1f:0c:f7:24:38:e8:2d:52:0a:26:\n        70:45:23:4e:28:43:0f:d2:18:c8:50:c4:82:77:8f:72:44:cb:\n        6f:60:ce:84:a1:30:e6:df:f0:90:8f:e0:a5:07:49:a0:51:02:\n        30:2c:2b:02:f7:b2:6e:4a:7a:9f:f9:cc:39:b7:a2:8c:b7:04:\n        d0:b6:ad:1d:c6:a5:58:e3:74:d7:b0:76:99:7f:06:d3:7a:59:\n        7e:22:63:6b:19:db:f9:ac:5f:be:00:f8:60\n-----BEGIN CERTIFICATE-----\nMIICHTCCAaOgAwIBAgIBATAKBggqhkjOPQQDAjAVMRMwEQYDVQQDDAoxMC45OS4w\nLjEwMB4XDTI1MDgwMzEyNTkyN1oXDTM1MDgwMTEyNTkyN1owFTETMBEGA1UEAwwK\nMTAuOTkuMC4xMDB2MBAGByqGSM49AgEGBSuBBAAiA2IABGEZs9ajUlr/M32me+68\nZ8DRsYC7wHIG+0OGLSt2arneAvgtMCHRG7bX1+NpkuPZkWVHgiRp4UrM0SvFSTBd\nNX8fY79SroVS2l/pXidFudzN45kb0fYkcjUov+RRDXFkLqOBxjCBwzAJBgNVHRME\nAjAAMB0GA1UdDgQWBBSKzARt1j86O78GAZInSHYIJtLLIjBQBgNVHSMESTBHgBS/\n/mk4Y4AZ6dZdOB0j52GMr0roraEZpBcwFTETMBEGA1UEAwwKMTAuOTkuMC4xMIIU\nYQ99YGsE7jDrDq93WTTWMkaM7IUwJwYDVR0lBCAwHgYIKwYBBQUHAwEGCCsGAQUF\nBwMCBggrBgEFBQcDETALBgNVHQ8EBAMCBaAwDwYDVR0RBAgwBocECmMACjAKBggq\nhkjOPQQDAgNoADBlAjEA3e2XHwz3JDjoLVIKJnBFI04oQw/SGMhQxIJ3j3JEy29g\nzoShMObf8JCP4KUHSaBRAjAsKwL3sm5Kep/5zDm3ooy3BNC2rR3GpVjjdNewdpl/\nBtN6WX4iY2sZ2/msX74A+GA=\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/10.99.0.10_crt_generated",
    "content": ""
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/testuser1.crt",
    "content": "Certificate:\n    Data:\n        Version: 3 (0x2)\n        Serial Number: 2 (0x2)\n        Signature Algorithm: ecdsa-with-SHA256\n        Issuer: CN=10.99.0.10\n        Validity\n            Not Before: Aug  3 12:59:27 2025 GMT\n            Not After : Aug  1 12:59:27 2035 GMT\n        Subject: CN=testuser1\n        Subject Public Key Info:\n            Public Key Algorithm: id-ecPublicKey\n                Public-Key: (384 bit)\n                pub:\n                    04:81:4d:22:72:2d:c5:f8:cf:52:e3:e0:ef:d8:86:\n                    a9:c6:cb:7c:c9:64:04:18:9d:ce:31:c3:99:98:4e:\n                    1a:8e:19:5c:25:56:78:9f:b4:87:9b:c2:51:ec:81:\n                    11:1e:80:6d:fb:47:d6:b1:49:25:72:98:da:c7:2e:\n                    48:23:d6:60:cc:d8:a9:a5:a9:3f:13:33:81:02:11:\n                    ad:70:17:f6:ee:41:ed:0b:be:d5:39:25:1f:0c:81:\n                    a2:a0:25:a7:4a:e5:e7\n                ASN1 OID: secp384r1\n                NIST CURVE: P-384\n        X509v3 extensions:\n            X509v3 Basic Constraints:\n                CA:FALSE\n            X509v3 Subject Key Identifier:\n                51:8F:14:BA:87:16:14:B2:23:33:69:2A:2A:A6:C4:26:80:E3:A0:61\n            X509v3 Authority Key Identifier:\n                keyid:BF:FE:69:38:63:80:19:E9:D6:5D:38:1D:23:E7:61:8C:AF:4A:E8:AD\n                DirName:/CN=10.99.0.10\n                serial:61:0F:7D:60:6B:04:EE:30:EB:0E:AF:77:59:34:D6:32:46:8C:EC:85\n            X509v3 Extended Key Usage:\n                TLS Web Server Authentication, TLS Web Client Authentication, ipsec Internet Key Exchange\n            X509v3 Key Usage:\n                Digital Signature, Key Encipherment\n            X509v3 Subject Alternative Name:\n                email:testuser1@9546d574-3fd6-58f4-9ac6-2c69cb755db7.algo\n    Signature Algorithm: ecdsa-with-SHA256\n    Signature Value:\n        30:66:02:31:00:a5:f7:05:17:78:b5:ce:dc:24:44:ba:57:ad:\n        65:d8:ce:37:f3:60:7c:55:37:9f:c0:58:7c:6b:09:67:cd:01:\n        3c:8f:56:32:c8:60:e5:52:2f:47:63:ae:2f:5f:2a:0e:f6:02:\n        31:00:ff:54:6f:3a:56:df:04:b0:ec:08:b8:c2:d7:20:2c:78:\n        c7:80:b2:40:fe:54:eb:e5:a5:29:d6:53:9d:db:78:17:30:2d:\n        db:09:ea:37:a8:ea:2e:11:23:e0:eb:b5:f5:86\n-----BEGIN CERTIFICATE-----\nMIICTDCCAdGgAwIBAgIBAjAKBggqhkjOPQQDAjAVMRMwEQYDVQQDDAoxMC45OS4w\nLjEwMB4XDTI1MDgwMzEyNTkyN1oXDTM1MDgwMTEyNTkyN1owFDESMBAGA1UEAwwJ\ndGVzdHVzZXIxMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEgU0ici3F+M9S4+Dv2Iap\nxst8yWQEGJ3OMcOZmE4ajhlcJVZ4n7SHm8JR7IERHoBt+0fWsUklcpjaxy5II9Zg\nzNippak/EzOBAhGtcBf27kHtC77VOSUfDIGioCWnSuXno4H1MIHyMAkGA1UdEwQC\nMAAwHQYDVR0OBBYEFFGPFLqHFhSyIzNpKiqmxCaA46BhMFAGA1UdIwRJMEeAFL/+\naThjgBnp1l04HSPnYYyvSuitoRmkFzAVMRMwEQYDVQQDDAoxMC45OS4wLjEwghRh\nD31gawTuMOsOr3dZNNYyRozshTAnBgNVHSUEIDAeBggrBgEFBQcDAQYIKwYBBQUH\nAwIGCCsGAQUFBwMRMAsGA1UdDwQEAwIFoDA+BgNVHREENzA1gTN0ZXN0dXNlcjFA\nOTU0NmQ1NzQtM2ZkNi01OGY0LTlhYzYtMmM2OWNiNzU1ZGI3LmFsZ28wCgYIKoZI\nzj0EAwIDaQAwZgIxAKX3BRd4tc7cJES6V61l2M4382B8VTefwFh8awlnzQE8j1Yy\nyGDlUi9HY64vXyoO9gIxAP9UbzpW3wSw7Ai4wtcgLHjHgLJA/lTr5aUp1lOd23gX\nMC3bCeo3qOouESPg67X1hg==\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/testuser1_crt_generated",
    "content": ""
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/testuser2.crt",
    "content": "Certificate:\n    Data:\n        Version: 3 (0x2)\n        Serial Number: 3 (0x3)\n        Signature Algorithm: ecdsa-with-SHA256\n        Issuer: CN=10.99.0.10\n        Validity\n            Not Before: Aug  3 12:59:27 2025 GMT\n            Not After : Aug  1 12:59:27 2035 GMT\n        Subject: CN=testuser2\n        Subject Public Key Info:\n            Public Key Algorithm: id-ecPublicKey\n                Public-Key: (384 bit)\n                pub:\n                    04:88:ed:fc:6d:44:0e:5f:f4:73:13:51:6c:58:cf:\n                    3f:97:c6:b3:3f:c5:12:fe:40:0a:cf:ff:46:da:73:\n                    9a:34:bd:c1:b8:e6:7f:21:d5:ad:39:37:7b:0f:c0:\n                    cc:00:17:5c:2a:3e:3a:cf:42:7d:72:7e:2b:82:82:\n                    9d:19:a2:25:e7:0e:3c:b7:67:66:84:15:89:8e:66:\n                    4d:c7:d5:be:00:e9:75:f3:43:c6:94:c9:c8:3a:b1:\n                    d1:e7:c0:19:d4:a7:e1\n                ASN1 OID: secp384r1\n                NIST CURVE: P-384\n        X509v3 extensions:\n            X509v3 Basic Constraints:\n                CA:FALSE\n            X509v3 Subject Key Identifier:\n                08:4E:A2:3F:07:36:D6:DD:91:11:4B:43:CF:0E:75:68:BF:49:AC:A2\n            X509v3 Authority Key Identifier:\n                keyid:BF:FE:69:38:63:80:19:E9:D6:5D:38:1D:23:E7:61:8C:AF:4A:E8:AD\n                DirName:/CN=10.99.0.10\n                serial:61:0F:7D:60:6B:04:EE:30:EB:0E:AF:77:59:34:D6:32:46:8C:EC:85\n            X509v3 Extended Key Usage:\n                TLS Web Server Authentication, TLS Web Client Authentication, ipsec Internet Key Exchange\n            X509v3 Key Usage:\n                Digital Signature, Key Encipherment\n            X509v3 Subject Alternative Name:\n                email:testuser2@9546d574-3fd6-58f4-9ac6-2c69cb755db7.algo\n    Signature Algorithm: ecdsa-with-SHA256\n    Signature Value:\n        30:64:02:30:60:16:23:85:91:fc:40:f6:10:bc:5a:08:91:77:\n        30:5d:11:30:ac:8f:c7:6d:87:fd:b2:a0:c1:21:d6:2b:31:7e:\n        68:0e:b1:a1:86:91:0c:5b:b7:f5:a1:67:e8:11:2f:14:02:30:\n        62:f9:35:64:44:2b:c9:28:67:35:61:20:1e:2c:b2:25:cb:88:\n        1e:8c:d7:b6:6d:0f:aa:3d:62:3f:20:87:d6:86:36:25:5e:2a:\n        27:3a:2a:5e:97:0c:f8:8a:95:f6:b5:72\n-----BEGIN CERTIFICATE-----\nMIICSjCCAdGgAwIBAgIBAzAKBggqhkjOPQQDAjAVMRMwEQYDVQQDDAoxMC45OS4w\nLjEwMB4XDTI1MDgwMzEyNTkyN1oXDTM1MDgwMTEyNTkyN1owFDESMBAGA1UEAwwJ\ndGVzdHVzZXIyMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEiO38bUQOX/RzE1FsWM8/\nl8azP8US/kAKz/9G2nOaNL3BuOZ/IdWtOTd7D8DMABdcKj46z0J9cn4rgoKdGaIl\n5w48t2dmhBWJjmZNx9W+AOl180PGlMnIOrHR58AZ1Kfho4H1MIHyMAkGA1UdEwQC\nMAAwHQYDVR0OBBYEFAhOoj8HNtbdkRFLQ88OdWi/SayiMFAGA1UdIwRJMEeAFL/+\naThjgBnp1l04HSPnYYyvSuitoRmkFzAVMRMwEQYDVQQDDAoxMC45OS4wLjEwghRh\nD31gawTuMOsOr3dZNNYyRozshTAnBgNVHSUEIDAeBggrBgEFBQcDAQYIKwYBBQUH\nAwIGCCsGAQUFBwMRMAsGA1UdDwQEAwIFoDA+BgNVHREENzA1gTN0ZXN0dXNlcjJA\nOTU0NmQ1NzQtM2ZkNi01OGY0LTlhYzYtMmM2OWNiNzU1ZGI3LmFsZ28wCgYIKoZI\nzj0EAwIDZwAwZAIwYBYjhZH8QPYQvFoIkXcwXREwrI/HbYf9sqDBIdYrMX5oDrGh\nhpEMW7f1oWfoES8UAjBi+TVkRCvJKGc1YSAeLLIly4gejNe2bQ+qPWI/IIfWhjYl\nXionOipelwz4ipX2tXI=\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/testuser2_crt_generated",
    "content": ""
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/ecparams/secp384r1.pem",
    "content": "-----BEGIN EC PARAMETERS-----\nBgUrgQQAIg==\n-----END EC PARAMETERS-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/index.txt",
    "content": "V\t350801125927Z\t\t01\tunknown\t/CN=10.99.0.10\nV\t350801125927Z\t\t02\tunknown\t/CN=testuser1\nV\t350801125927Z\t\t03\tunknown\t/CN=testuser2\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/index.txt.attr",
    "content": "unique_subject = yes\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/index.txt.attr.old",
    "content": "unique_subject = yes\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/index.txt.old",
    "content": "V\t350801125927Z\t\t01\tunknown\t/CN=10.99.0.10\nV\t350801125927Z\t\t02\tunknown\t/CN=testuser1\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/openssl.cnf",
    "content": "# For use with Easy-RSA 3.0 and OpenSSL 1.0.*\n\nRANDFILE\t\t= .rnd\n\n####################################################################\n[ ca ]\ndefault_ca\t= CA_default\t\t# The default ca section\n\n####################################################################\n[ CA_default ]\n\ndir\t\t= .\t# Where everything is kept\ncerts\t\t= $dir\t\t\t# Where the issued certs are kept\ncrl_dir\t\t= $dir\t\t\t# Where the issued crl are kept\ndatabase\t= $dir/index.txt\t# database index file.\nnew_certs_dir\t= $dir/certs\t# default place for new certs.\n\ncertificate\t= $dir/cacert.pem\t \t# The CA certificate\nserial\t\t= $dir/serial \t\t# The current serial number\ncrl\t\t= $dir/crl.pem \t\t# The current CRL\nprivate_key\t= $dir/private/cakey.pem\t# The private key\nRANDFILE\t= $dir/private/.rand\t\t# private random number file\n\nx509_extensions\t= basic_exts\t\t# The extensions to add to the cert\n\n# This allows a V2 CRL. Ancient browsers don't like it, but anything Easy-RSA\n# is designed for will. In return, we get the Issuer attached to CRLs.\ncrl_extensions\t= crl_ext\n\ndefault_days\t= 3650\t# how long to certify for\ndefault_crl_days= 3650\t# how long before next CRL\ndefault_md\t= sha256\t\t# use public key default MD\npreserve\t= no\t\t\t# keep passed DN ordering\n\n# A few difference way of specifying how similar the request should look\n# For type CA, the listed attributes must be the same, and the optional\n# and supplied fields are just that :-)\npolicy\t\t= policy_anything\n\n# For the 'anything' policy, which defines allowed DN fields\n[ policy_anything ]\ncountryName\t\t= optional\nstateOrProvinceName\t= optional\nlocalityName\t\t= optional\norganizationName\t= optional\norganizationalUnitName\t= optional\ncommonName\t\t= supplied\nname\t\t\t= optional\nemailAddress\t\t= optional\n\n####################################################################\n# Easy-RSA request handling\n# We key off $DN_MODE to determine how to format the DN\n[ req ]\ndefault_bits\t\t= 2048\ndefault_keyfile \t= privkey.pem\ndefault_md\t\t= sha256\ndistinguished_name\t= cn_only\nx509_extensions\t\t= easyrsa_ca\t# The extensions to add to the self signed cert\n\n# A placeholder to handle the $EXTRA_EXTS feature:\n#%EXTRA_EXTS%\t# Do NOT remove or change this line as $EXTRA_EXTS support requires it\n\n####################################################################\n# Easy-RSA DN (Subject) handling\n\n# Easy-RSA DN for cn_only support:\n[ cn_only ]\ncommonName\t\t= Common Name (eg: your user, host, or server name)\ncommonName_max\t\t= 64\ncommonName_default\t= 10.99.0.10\n\n# Easy-RSA DN for org support:\n[ org ]\ncountryName\t\t\t= Country Name (2 letter code)\ncountryName_default\t\t= US\ncountryName_min\t\t\t= 2\ncountryName_max\t\t\t= 2\n\nstateOrProvinceName\t\t= State or Province Name (full name)\nstateOrProvinceName_default\t= California\n\nlocalityName\t\t\t= Locality Name (eg, city)\nlocalityName_default\t\t= San Francisco\n\n0.organizationName\t\t= Organization Name (eg, company)\n0.organizationName_default\t= Copyleft Certificate Co\n\norganizationalUnitName\t\t= Organizational Unit Name (eg, section)\norganizationalUnitName_default\t= My Organizational Unit\n\ncommonName\t\t\t= Common Name (eg: your user, host, or server name)\ncommonName_max\t\t\t= 64\ncommonName_default\t\t= 10.99.0.10\n\nemailAddress\t\t\t= Email Address\nemailAddress_default\t\t= me@example.net\nemailAddress_max\t\t= 64\n\n####################################################################\n# Easy-RSA cert extension handling\n\n# This section is effectively unused as the main script sets extensions\n# dynamically. This core section is left to support the odd usecase where\n# a user calls openssl directly.\n[ basic_exts ]\nbasicConstraints\t= CA:FALSE\nsubjectKeyIdentifier\t= hash\nauthorityKeyIdentifier\t= keyid,issuer:always\n\nextendedKeyUsage = serverAuth,clientAuth,1.3.6.1.5.5.7.3.17\nkeyUsage = digitalSignature, keyEncipherment\n\n# The Easy-RSA CA extensions\n[ easyrsa_ca ]\n\n# PKIX recommendations:\n\nsubjectKeyIdentifier=hash\nauthorityKeyIdentifier=keyid:always,issuer:always\n\nbasicConstraints = critical,CA:true,pathlen:0\nnameConstraints = critical,permitted;IP:10.99.0.10/255.255.255.255,permitted;DNS:9546d574-3fd6-58f4-9ac6-2c69cb755db7.algo,permitted;email:9546d574-3fd6-58f4-9ac6-2c69cb755db7.algo,excluded;IP:0:0:0:0:0:0:0:0/0:0:0:0:0:0:0:0\n\n\n# Limit key usage to CA tasks. If you really want to use the generated pair as\n# a self-signed cert, comment this out.\nkeyUsage = cRLSign, keyCertSign\n\n# nsCertType omitted by default. Let's try to let the deprecated stuff die.\n# nsCertType = sslCA\n\n# CRL extensions.\n[ crl_ext ]\n\n# Only issuerAltName and authorityKeyIdentifier make any sense in a CRL.\n\n# issuerAltName=issuer:copy\nauthorityKeyIdentifier=keyid:always,issuer:always\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/.rnd",
    "content": ""
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/10.99.0.10.key",
    "content": "-----BEGIN PRIVATE KEY-----\nMIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCzl0q5oCboLdR2z2+f\n8vva98ZlmXOoJUoQ2PolcmYzsXLsrN9IJ5FA0dxwGSPSkGShZANiAARhGbPWo1Ja\n/zN9pnvuvGfA0bGAu8ByBvtDhi0rdmq53gL4LTAh0Ru219fjaZLj2ZFlR4IkaeFK\nzNErxUkwXTV/H2O/Uq6FUtpf6V4nRbnczeOZG9H2JHI1KL/kUQ1xZC4=\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/cakey.pem",
    "content": "-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIIBEzBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIfcz2CPzqvHQCAggA\nMAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECGnSWGAxVxG5BIHAug0MQFAsaf6G\nusnpuTDOgIq5RGgeHhakkknU/RQ2zsPlxOpM3y3c7fURahWqC6Po21M3Az37pRHs\nbf35e8/8Gxp7eRSyVoPF88MmxGVxFIDeP/YuzoGILLjIWDZ2E89SSP7GnzO1a4UV\npoHWMV4hZvpT/Ey+1LK2cu7zLbQ5chBZ4aeButXxDHLl5ylPe+yBCoforpLAr3iA\nzI0DNoOe25EoIBWPycT+c3tExVLGE0MN9RusBlaB6f0go2kSWhQU\n-----END ENCRYPTED PRIVATE KEY-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser1.key",
    "content": "-----BEGIN PRIVATE KEY-----\nMIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDqYZekavWtJL939gPZ\nUGvuag2088jWmu3Iic5hp1QfgdFqSiuk69Xmgc3nzin8ulGhZANiAASBTSJyLcX4\nz1Lj4O/YhqnGy3zJZAQYnc4xw5mYThqOGVwlVniftIebwlHsgREegG37R9axSSVy\nmNrHLkgj1mDM2KmlqT8TM4ECEa1wF/buQe0LvtU5JR8MgaKgJadK5ec=\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser2.key",
    "content": "-----BEGIN PRIVATE KEY-----\nMIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCNgvEKQSidzP9DtA5q\nbj3qD3sWPeNTZhJ319E9NTgGvU6GPSxssiPZglgDziO0ALqhZANiAASI7fxtRA5f\n9HMTUWxYzz+XxrM/xRL+QArP/0bac5o0vcG45n8h1a05N3sPwMwAF1wqPjrPQn1y\nfiuCgp0ZoiXnDjy3Z2aEFYmOZk3H1b4A6XXzQ8aUycg6sdHnwBnUp+E=\n-----END PRIVATE KEY-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/public/testuser1.pub",
    "content": "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBIFNInItxfjPUuPg79iGqcbLfMlkBBidzjHDmZhOGo4ZXCVWeJ+0h5vCUeyBER6AbftH1rFJJXKY2scuSCPWYMzYqaWpPxMzgQIRrXAX9u5B7Qu+1TklHwyBoqAlp0rl5w==\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/public/testuser2.pub",
    "content": "ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBIjt/G1EDl/0cxNRbFjPP5fGsz/FEv5ACs//RtpzmjS9wbjmfyHVrTk3ew/AzAAXXCo+Os9CfXJ+K4KCnRmiJecOPLdnZoQViY5mTcfVvgDpdfNDxpTJyDqx0efAGdSn4Q==\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/reqs/10.99.0.10.req",
    "content": "-----BEGIN CERTIFICATE REQUEST-----\nMIIBDTCBlAIBADAVMRMwEQYDVQQDDAoxMC45OS4wLjEwMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEYRmz1qNSWv8zfaZ77rxnwNGxgLvAcgb7Q4YtK3Zqud4C+C0wIdEb\nttfX42mS49mRZUeCJGnhSszRK8VJMF01fx9jv1KuhVLaX+leJ0W53M3jmRvR9iRy\nNSi/5FENcWQuoAAwCgYIKoZIzj0EAwIDaAAwZQIxANzofzNNOzBP5IxqtGOs9l53\naNpmDf638Ho6lXdXRtGynUyZ9ORoeIANVN4Kb/HbTQIwQndvZ4PIPvCp1QW1LmP5\nkPd+OFyoyiJavLa9zRJsuAsYaj5NQucZJHKxLqWdGB94\n-----END CERTIFICATE REQUEST-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/reqs/testuser1.req",
    "content": "-----BEGIN CERTIFICATE REQUEST-----\nMIIBDDCBkwIBADAUMRIwEAYDVQQDDAl0ZXN0dXNlcjEwdjAQBgcqhkjOPQIBBgUr\ngQQAIgNiAASBTSJyLcX4z1Lj4O/YhqnGy3zJZAQYnc4xw5mYThqOGVwlVniftIeb\nwlHsgREegG37R9axSSVymNrHLkgj1mDM2KmlqT8TM4ECEa1wF/buQe0LvtU5JR8M\ngaKgJadK5eegADAKBggqhkjOPQQDAgNoADBlAjEA6fukMpfRV9EguhFUu2ArTEUi\ny3wjuRlz0oOX1Al4bDdl0fI8fdGPhfWMkCFV99h1AjASgmIyTUBBShipXCq1zXYG\nyneN1AXvkW4sbdFZ55GC++fGyZo9uOiTj/NEpn52a1E=\n-----END CERTIFICATE REQUEST-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/reqs/testuser2.req",
    "content": "-----BEGIN CERTIFICATE REQUEST-----\nMIIBDTCBkwIBADAUMRIwEAYDVQQDDAl0ZXN0dXNlcjIwdjAQBgcqhkjOPQIBBgUr\ngQQAIgNiAASI7fxtRA5f9HMTUWxYzz+XxrM/xRL+QArP/0bac5o0vcG45n8h1a05\nN3sPwMwAF1wqPjrPQn1yfiuCgp0ZoiXnDjy3Z2aEFYmOZk3H1b4A6XXzQ8aUycg6\nsdHnwBnUp+GgADAKBggqhkjOPQQDAgNpADBmAjEA/pC5b5Ei8Hmfgsl5WHfOhV/r\niReLin1RESK29Lcsxi6z2pvEGNkOFCq8tPJHr1L6AjEAuq9eBom5P0D8d+9MJcKt\n3Zjtfb6Liyyupd2euSytyFKuY6NnbjMvAR4kZ3jhdw30\n-----END CERTIFICATE REQUEST-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/serial",
    "content": "04\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/serial.old",
    "content": "03\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/.pki/serial_generated",
    "content": ""
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/apple/testuser1.mobileconfig",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>PayloadContent</key>\n    <array>\n        <dict>\n            <key>IKEv2</key>\n            <dict>\n              <key>OnDemandEnabled</key>\n              <integer>0</integer>\n              <key>OnDemandRules</key>\n              <array>\n                  <dict>\n                    <key>Action</key>\n                      <string>Connect</string>\n                  </dict>\n                </array>\n                <key>AuthenticationMethod</key>\n                <string>Certificate</string>\n                <key>ChildSecurityAssociationParameters</key>\n                <dict>\n                    <key>DiffieHellmanGroup</key>\n                    <integer>20</integer>\n                    <key>EncryptionAlgorithm</key>\n                    <string>AES-256-GCM</string>\n                    <key>IntegrityAlgorithm</key>\n                    <string>SHA2-512</string>\n                    <key>LifeTimeInMinutes</key>\n                    <integer>1440</integer>\n                </dict>\n                <key>DeadPeerDetectionRate</key>\n                <string>Medium</string>\n                <key>DisableMOBIKE</key>\n                <integer>0</integer>\n                <key>DisableRedirect</key>\n                <integer>1</integer>\n                <key>EnableCertificateRevocationCheck</key>\n                <integer>0</integer>\n                <key>EnablePFS</key>\n                <true/>\n                <key>IKESecurityAssociationParameters</key>\n                <dict>\n                    <key>DiffieHellmanGroup</key>\n                    <integer>20</integer>\n                    <key>EncryptionAlgorithm</key>\n                    <string>AES-256-GCM</string>\n                    <key>IntegrityAlgorithm</key>\n                    <string>SHA2-512</string>\n                    <key>LifeTimeInMinutes</key>\n                    <integer>1440</integer>\n                </dict>\n                <key>LocalIdentifier</key>\n                <string>testuser1@9546d574-3fd6-58f4-9ac6-2c69cb755db7.algo</string>\n                <key>PayloadCertificateUUID</key>\n                <string>4D4440E7-BA3F-57CE-AC2A-8599F30E0D0F</string>\n                <key>CertificateType</key>\n                <string>ECDSA384</string>\n                <key>ServerCertificateIssuerCommonName</key>\n                <string>10.99.0.10</string>\n                <key>RemoteAddress</key>\n                <string>10.99.0.10</string>\n                <key>RemoteIdentifier</key>\n                <string>10.99.0.10</string>\n                <key>UseConfigurationAttributeInternalIPSubnet</key>\n                <integer>0</integer>\n            </dict>\n            <key>IPv4</key>\n            <dict>\n                <key>OverridePrimary</key>\n                <integer>1</integer>\n            </dict>\n            <key>PayloadDescription</key>\n            <string>Configures VPN settings</string>\n            <key>PayloadDisplayName</key>\n            <string>algo-test-server</string>\n            <key>PayloadIdentifier</key>\n            <string>com.apple.vpn.managed.839A7948-0024-5CE8-B26D-051C798F53F2</string>\n            <key>PayloadType</key>\n            <string>com.apple.vpn.managed</string>\n            <key>PayloadUUID</key>\n            <string>839A7948-0024-5CE8-B26D-051C798F53F2</string>\n            <key>PayloadVersion</key>\n            <real>1</real>\n            <key>Proxies</key>\n            <dict>\n                <key>HTTPEnable</key>\n                <integer>0</integer>\n                <key>HTTPSEnable</key>\n                <integer>0</integer>\n            </dict>\n            <key>UserDefinedName</key>\n            <string>AlgoVPN algo-test-server IKEv2</string>\n            <key>VPNType</key>\n            <string>IKEv2</string>\n        </dict>\n        <dict>\n            <key>Password</key>\n            <string>test_p12_password_123</string>\n            <key>PayloadCertificateFileName</key>\n            <string>testuser1.p12</string>\n            <key>PayloadContent</key>\n            <data>\n            MIIEyQIBAzCCBI8GCSqGSIb3DQEHAaCCBIAEggR8MIIEeDCCAxcGCSqGSIb3DQEHBqCCAwgwggME\nAgEAMIIC/QYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQYwDgQI28pyp8KzMlACAggAgIIC0M5dcNtX\n/i2WQqXbb0momxCL+Vhyv4wUg/a5sjcV2n/P36NtG073yfPMek2lr+1yJNpTvmp/ur577DBPT7gN\nr7+FSHeq56TWIyakAKDipt0NTg+FuUJk4FYvlCmeZwtD1hZIzit293s6/+ZFDvkFnGDIv3eRzblh\nKE9zcK6UT+euDRnhOPPER8H9qJ9/yepcRIOS8jy7jF2NfLyMlyme/9q7062zhi4gejmlX5p/qoco\ncJgvbEEH8EpwakNmfaVx+LZS5zMIRSjomcTS2R70HoFJkaI051iZs/nTUhdWSULxr6VpeyVA/rJZ\nAr6WEYrV+Pp6DWIA+hos6R5T8qb57zA989xjKckly7Ac5PXPScvIaxg9nxNcunsRDAgajsFkJ5EN\nItzmEUt5eb0IcAIGwA2Y6PGn6UB6+65EnTp6yaB2UYA6E0U+sYO87ztnXfR3Qq8bHNDul26Gy4Va\nFkZ1wGuvUiFb7Kp96fGtWP1RHQ+W/Nh62dmTy1F2KleQ+F8APbSlZ0NkGsnkvWqVHh0igIL8YQCu\nSlAK6OM7OLWC0ep8q4+KoRZlpzZV4ubW1H7RVNPO/aaib++2h1aLWdhqxkeT9+X8Ux1s9pROLXKE\nj4DLWkjRoNd/ahN5wFdjS2qe59i3/EbNtp+hJJtlhsEVKwTjfFV8pKMJ0iRBQMtMkeUcQbtHhXid\nIqoHDFRJ7UL5r7odu1/3cqQLDSBApoJQp5I1gqsdzr3N7a+mJM98wVVPutL7DdfWTbEkDeYX0ItX\ngr5eus+0ZJoSB2d8zXlxyDQMvgNKiAxS2nnlyL8W45lhT6Gab1EkNnNLJs4aM9F2xcueo/DX9PpD\nbTI72l5u8km0ZB457tpF3HcK4uTcwqbYohUHK5gnKhQHtkQGxjOAk1mYrAJic/pl21R47R44WcJg\nSPlfPyCBu181R4ttQzI+wWaUWw0doOYMDXBQIoodZzCCAVkGCSqGSIb3DQEHAaCCAUoEggFGMIIB\nQjCCAT4GCyqGSIb3DQEMCgECoIHkMIHhMBwGCiqGSIb3DQEMAQMwDgQI147nWZq98I0CAggABIHA\nK3r5QS9SZIqobu0QbwvRzKGevyi7Xdau6ytuvKxb5HzEl1h9OjeoErgDAf4QCxGr2zq+KHjE78BP\n2LOJrHd34bYhuZl1R19tZWDL40wlwspjfhsFvKiG6lg4o2KaCv5QNzApDaUvj0vg52R0IRF1qEol\nzpUS6+7/JA6IQ0dZMX/VWItPbNxxj5eHtFryz362QlMoqOiej5xWGoAaJcAHU44gtouL5SUEpA+m\nlt/L8Sqm3f8R9PoU803udHPMH7twMUgwIQYJKoZIhvcNAQkUMRQeEgB0AGUAcwB0AHUAcwBlAHIA\nMTAjBgkqhkiG9w0BCRUxFgQUQCD/6T8AKsHvofJ4aXb4gw6a/SwwMTAhMAkGBSsOAwIaBQAEFOj7\n6u6lFCM5E2gXomAOVFPWqjwxBAgfPysUA1IgqgICCAA=\n            </data>\n            <key>PayloadDescription</key>\n            <string>Adds a PKCS#12-formatted certificate</string>\n            <key>PayloadDisplayName</key>\n            <string>algo-test-server</string>\n            <key>PayloadIdentifier</key>\n            <string>com.apple.security.pkcs12.4D4440E7-BA3F-57CE-AC2A-8599F30E0D0F</string>\n            <key>PayloadType</key>\n            <string>com.apple.security.pkcs12</string>\n            <key>PayloadUUID</key>\n            <string>4D4440E7-BA3F-57CE-AC2A-8599F30E0D0F</string>\n            <key>PayloadVersion</key>\n            <integer>1</integer>\n        </dict>\n        <dict>\n            <key>PayloadCertificateFileName</key>\n            <string>ca.crt</string>\n            <key>PayloadContent</key>\n            <data>\n            LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNvVENDQWlhZ0F3SUJBZ0lVWVE5OVlHc0U3akRyRHE5M1dUVFdNa2FNN0lVd0NnWUlLb1pJemowRUF3SXcKRlRFVE1CRUdBMVVFQXd3S01UQXVPVGt1TUM0eE1EQWVGdzB5TlRBNE1ETXhNalU1TWpkYUZ3MHpOVEE0TURFeApNalU1TWpkYU1CVXhFekFSQmdOVkJBTU1DakV3TGprNUxqQXVNVEF3ZGpBUUJnY3Foa2pPUFFJQkJnVXJnUVFBCklnTmlBQVNBMkpZSVJIVEhxTW5yR0NvSUZnOFJWejN2MlFkakdKbmtGM2YySWE0cy9WNUxhUCtXUDBQaERFRjMKcFZIUnpIS2QybnRrMERCUk5PaWgrL0JpUStsUWhmRVQ4dFdIK21mQWswSGVtc2dSelJJR2FkeFBWeGkxcGlxSgpzTDh1V1U2amdnRTFNSUlCTVRBZEJnTlZIUTRFRmdRVXYvNXBPR09BR2VuV1hUZ2RJK2Roaks5SzZLMHdVQVlEClZSMGpCRWt3UjRBVXYvNXBPR09BR2VuV1hUZ2RJK2Roaks5SzZLMmhHYVFYTUJVeEV6QVJCZ05WQkFNTUNqRXcKTGprNUxqQXVNVENDRkdFUGZXQnJCTzR3Nnc2dmQxazAxakpHak95Rk1CSUdBMVVkRXdFQi93UUlNQVlCQWY4QwpBUUF3Z1p3R0ExVWRIZ0VCL3dTQmtUQ0JqcUJtTUFxSENBcGpBQXIvLy8vL01DdUNLVGsxTkRaa05UYzBMVE5tClpEWXROVGhtTkMwNVlXTTJMVEpqTmpsallqYzFOV1JpTnk1aGJHZHZNQ3VCS1RrMU5EWmtOVGMwTFRObVpEWXQKTlRobU5DMDVZV00yTFRKak5qbGpZamMxTldSaU55NWhiR2R2b1NRd0lvY2dBQUFBQUFBQUFBQUFBQUFBQUFBQQpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQXdDd1lEVlIwUEJBUURBZ0VHTUFvR0NDcUdTTTQ5QkFNQ0Eya0FNR1lDCk1RQ3NXUU9pbmhoczR5WlNPdnVwUElRS3c3aE1wS2tFaUtTNlJ0UmZydlpvaEdRSzkyT0tYc0VUTGQ3WVBoM04KUkJBQ01RQzhXQWUzNVBYY2crSlk4cGFkcmk0ZC91MklUcmVDWEFSdWhVanlwbStVY3kxcVE1QTE4d2pqNi9LVgpKSllsYmZrPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t\n            </data>\n            <key>PayloadDescription</key>\n            <string>Adds a CA root certificate</string>\n            <key>PayloadDisplayName</key>\n            <string>algo-test-server</string>\n            <key>PayloadIdentifier</key>\n            <string>com.apple.security.root.E7564B4A-5330-5501-B969-5D4E0B05D3D4</string>\n            <key>PayloadType</key>\n            <string>com.apple.security.root</string>\n            <key>PayloadUUID</key>\n            <string>E7564B4A-5330-5501-B969-5D4E0B05D3D4</string>\n            <key>PayloadVersion</key>\n            <integer>1</integer>\n        </dict>\n    </array>\n    <key>PayloadDisplayName</key>\n    <string>AlgoVPN algo-test-server IKEv2</string>\n    <key>PayloadIdentifier</key>\n    <string>donut.local.E3BD8AD2-344A-5707-9514-8898A03E555C</string>\n    <key>PayloadOrganization</key>\n\t<string>AlgoVPN</string>\n    <key>PayloadRemovalDisallowed</key>\n    <false/>\n    <key>PayloadType</key>\n    <string>Configuration</string>\n    <key>PayloadUUID</key>\n    <string>ACE477A4-56E1-59E8-9D4E-C695588E71BB</string>\n    <key>PayloadVersion</key>\n    <integer>1</integer>\n</dict>\n</plist>\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/apple/testuser2.mobileconfig",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>PayloadContent</key>\n    <array>\n        <dict>\n            <key>IKEv2</key>\n            <dict>\n              <key>OnDemandEnabled</key>\n              <integer>0</integer>\n              <key>OnDemandRules</key>\n              <array>\n                  <dict>\n                    <key>Action</key>\n                      <string>Connect</string>\n                  </dict>\n                </array>\n                <key>AuthenticationMethod</key>\n                <string>Certificate</string>\n                <key>ChildSecurityAssociationParameters</key>\n                <dict>\n                    <key>DiffieHellmanGroup</key>\n                    <integer>20</integer>\n                    <key>EncryptionAlgorithm</key>\n                    <string>AES-256-GCM</string>\n                    <key>IntegrityAlgorithm</key>\n                    <string>SHA2-512</string>\n                    <key>LifeTimeInMinutes</key>\n                    <integer>1440</integer>\n                </dict>\n                <key>DeadPeerDetectionRate</key>\n                <string>Medium</string>\n                <key>DisableMOBIKE</key>\n                <integer>0</integer>\n                <key>DisableRedirect</key>\n                <integer>1</integer>\n                <key>EnableCertificateRevocationCheck</key>\n                <integer>0</integer>\n                <key>EnablePFS</key>\n                <true/>\n                <key>IKESecurityAssociationParameters</key>\n                <dict>\n                    <key>DiffieHellmanGroup</key>\n                    <integer>20</integer>\n                    <key>EncryptionAlgorithm</key>\n                    <string>AES-256-GCM</string>\n                    <key>IntegrityAlgorithm</key>\n                    <string>SHA2-512</string>\n                    <key>LifeTimeInMinutes</key>\n                    <integer>1440</integer>\n                </dict>\n                <key>LocalIdentifier</key>\n                <string>testuser2@9546d574-3fd6-58f4-9ac6-2c69cb755db7.algo</string>\n                <key>PayloadCertificateUUID</key>\n                <string>9866F575-85ED-5B44-80DE-BB927BD9613D</string>\n                <key>CertificateType</key>\n                <string>ECDSA384</string>\n                <key>ServerCertificateIssuerCommonName</key>\n                <string>10.99.0.10</string>\n                <key>RemoteAddress</key>\n                <string>10.99.0.10</string>\n                <key>RemoteIdentifier</key>\n                <string>10.99.0.10</string>\n                <key>UseConfigurationAttributeInternalIPSubnet</key>\n                <integer>0</integer>\n            </dict>\n            <key>IPv4</key>\n            <dict>\n                <key>OverridePrimary</key>\n                <integer>1</integer>\n            </dict>\n            <key>PayloadDescription</key>\n            <string>Configures VPN settings</string>\n            <key>PayloadDisplayName</key>\n            <string>algo-test-server</string>\n            <key>PayloadIdentifier</key>\n            <string>com.apple.vpn.managed.A50D0C60-F894-5E6A-85F3-404CB79BF2E6</string>\n            <key>PayloadType</key>\n            <string>com.apple.vpn.managed</string>\n            <key>PayloadUUID</key>\n            <string>A50D0C60-F894-5E6A-85F3-404CB79BF2E6</string>\n            <key>PayloadVersion</key>\n            <real>1</real>\n            <key>Proxies</key>\n            <dict>\n                <key>HTTPEnable</key>\n                <integer>0</integer>\n                <key>HTTPSEnable</key>\n                <integer>0</integer>\n            </dict>\n            <key>UserDefinedName</key>\n            <string>AlgoVPN algo-test-server IKEv2</string>\n            <key>VPNType</key>\n            <string>IKEv2</string>\n        </dict>\n        <dict>\n            <key>Password</key>\n            <string>test_p12_password_123</string>\n            <key>PayloadCertificateFileName</key>\n            <string>testuser2.p12</string>\n            <key>PayloadContent</key>\n            <data>\n            MIIEyQIBAzCCBI8GCSqGSIb3DQEHAaCCBIAEggR8MIIEeDCCAxcGCSqGSIb3DQEHBqCCAwgwggME\nAgEAMIIC/QYJKoZIhvcNAQcBMBwGCiqGSIb3DQEMAQYwDgQI7AzVUTPoH5gCAggAgIIC0Eh7jsVz\nwJ4vttaZ17WC5tLqxMUCZJs35Vs/hidCKrTnSRw9/Sc4kIiNRrP5tPZIKpldKeDmPdCUFM+n0mP6\n2rg7pI3nJm2kZ/9BusAwrokbekpHEJJRPJ4B2T5g+935Rc8xdNAasX1n6a+oyIwhVnAJ+4UAUF4u\nISdQqxtPvl9oxfALSQD7CatKmvYqtNNsFbL0+5tGaRXhhB7IcHUy1Vyg7XWSgOXUtZEpYKcI60MB\nBKVCW0RA9pPniSu8WqDa+lADRrohK0+w2M40IiZtMpXAYQ+1MDIRj7ELVDdeUd+55dR4Y3qSz2lW\nJn9rOk89bSQL4mO/Mfc7lZpWgp9R2HvTqL/MjDgzax3JsUNlotSM2dWDXvcoe/cLTnFD6GTi46O2\nVCpPcVDF27urhKH9pR6ny3xdHNkMmKPzzxzQK+5uepZOK5MkkBqGql/hmO1nqrNAc4k9kwd2zwNb\nPzNiavtd1IpBjRUZkbZhqj2QzStTdtw+y5AdGhwBdDRn+vYmbjZQPMQ7VNKtiZrw8L+SSM6X5LE6\nUvSZYJCNDCsH73UZl8UuCxajBOz3eEvkjyCTjjJt6L93ImXCUN5ilOyUReks8oaf8xp6X3mjI2JA\nQUhozvpItwMdwHJov6l8VhduXz4E0v2JxsN5hVhXRD4+5+DYGwn73j1BOIMeCdSF8WtYZa9IfBoZ\nKO1YRg72zHJrsPgrbU0BaOG6AtPTlmqN7VmWIY3vWr2HC5wiWVokfXPBSra+ZIQWU/gnQmXncaHu\nDm6kqLjKrvhfS02mIT8znS1ugaJTW7MwfK66grMV/x38c8RMVwZMQxERuB4It8fh0zzlLv3AQVSZ\nFEzopwiDnfLgv6vrBuggs1tv82n8stFMen8DXavdPNSfQKyzBlnYm5z5FNlrhAxUYL4MdRNkKnhW\n0Jf8mGzYt8pBFvcfKcsoQlM1EQn9/sWGXPJdZ8UXWzCCAVkGCSqGSIb3DQEHAaCCAUoEggFGMIIB\nQjCCAT4GCyqGSIb3DQEMCgECoIHkMIHhMBwGCiqGSIb3DQEMAQMwDgQIvFLqFNDnPxACAggABIHA\nRi++0U1QcA0tnOkTPmCpWiw9qsm7AOJHzuzawhfmaB86H4/ACo4Aav1bM09Bqg+MZEgfNuieM4aO\nJT+SNAbRnVwkM/RAljnbW8tSpUlvBcFFoj4v740LfDjG/iPyJenvfsbRJ+7VnxqlsDIrtIiZ7F0t\nt4GH42OrjSRUmhqKYCcl+fOb6/ySkvCT9SCLaLDbsUlI4cpX1/xz0rx2CgB7P/BeqUMgOvUS0c9g\nAmrBS4MBX8XQHr3LeHU/gThLOzwuMUgwIQYJKoZIhvcNAQkUMRQeEgB0AGUAcwB0AHUAcwBlAHIA\nMjAjBgkqhkiG9w0BCRUxFgQUAiLn79PjWAqhawRgUdGc+ONhzlgwMTAhMAkGBSsOAwIaBQAEFMxN\nNnIveviZbPfIBVfQwieHIxmuBAj+zP2SHawgAAICCAA=\n            </data>\n            <key>PayloadDescription</key>\n            <string>Adds a PKCS#12-formatted certificate</string>\n            <key>PayloadDisplayName</key>\n            <string>algo-test-server</string>\n            <key>PayloadIdentifier</key>\n            <string>com.apple.security.pkcs12.9866F575-85ED-5B44-80DE-BB927BD9613D</string>\n            <key>PayloadType</key>\n            <string>com.apple.security.pkcs12</string>\n            <key>PayloadUUID</key>\n            <string>9866F575-85ED-5B44-80DE-BB927BD9613D</string>\n            <key>PayloadVersion</key>\n            <integer>1</integer>\n        </dict>\n        <dict>\n            <key>PayloadCertificateFileName</key>\n            <string>ca.crt</string>\n            <key>PayloadContent</key>\n            <data>\n            LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNvVENDQWlhZ0F3SUJBZ0lVWVE5OVlHc0U3akRyRHE5M1dUVFdNa2FNN0lVd0NnWUlLb1pJemowRUF3SXcKRlRFVE1CRUdBMVVFQXd3S01UQXVPVGt1TUM0eE1EQWVGdzB5TlRBNE1ETXhNalU1TWpkYUZ3MHpOVEE0TURFeApNalU1TWpkYU1CVXhFekFSQmdOVkJBTU1DakV3TGprNUxqQXVNVEF3ZGpBUUJnY3Foa2pPUFFJQkJnVXJnUVFBCklnTmlBQVNBMkpZSVJIVEhxTW5yR0NvSUZnOFJWejN2MlFkakdKbmtGM2YySWE0cy9WNUxhUCtXUDBQaERFRjMKcFZIUnpIS2QybnRrMERCUk5PaWgrL0JpUStsUWhmRVQ4dFdIK21mQWswSGVtc2dSelJJR2FkeFBWeGkxcGlxSgpzTDh1V1U2amdnRTFNSUlCTVRBZEJnTlZIUTRFRmdRVXYvNXBPR09BR2VuV1hUZ2RJK2Roaks5SzZLMHdVQVlEClZSMGpCRWt3UjRBVXYvNXBPR09BR2VuV1hUZ2RJK2Roaks5SzZLMmhHYVFYTUJVeEV6QVJCZ05WQkFNTUNqRXcKTGprNUxqQXVNVENDRkdFUGZXQnJCTzR3Nnc2dmQxazAxakpHak95Rk1CSUdBMVVkRXdFQi93UUlNQVlCQWY4QwpBUUF3Z1p3R0ExVWRIZ0VCL3dTQmtUQ0JqcUJtTUFxSENBcGpBQXIvLy8vL01DdUNLVGsxTkRaa05UYzBMVE5tClpEWXROVGhtTkMwNVlXTTJMVEpqTmpsallqYzFOV1JpTnk1aGJHZHZNQ3VCS1RrMU5EWmtOVGMwTFRObVpEWXQKTlRobU5DMDVZV00yTFRKak5qbGpZamMxTldSaU55NWhiR2R2b1NRd0lvY2dBQUFBQUFBQUFBQUFBQUFBQUFBQQpBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQXdDd1lEVlIwUEJBUURBZ0VHTUFvR0NDcUdTTTQ5QkFNQ0Eya0FNR1lDCk1RQ3NXUU9pbmhoczR5WlNPdnVwUElRS3c3aE1wS2tFaUtTNlJ0UmZydlpvaEdRSzkyT0tYc0VUTGQ3WVBoM04KUkJBQ01RQzhXQWUzNVBYY2crSlk4cGFkcmk0ZC91MklUcmVDWEFSdWhVanlwbStVY3kxcVE1QTE4d2pqNi9LVgpKSllsYmZrPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t\n            </data>\n            <key>PayloadDescription</key>\n            <string>Adds a CA root certificate</string>\n            <key>PayloadDisplayName</key>\n            <string>algo-test-server</string>\n            <key>PayloadIdentifier</key>\n            <string>com.apple.security.root.C09491AA-8ED1-5327-A61F-81CE4E3A686C</string>\n            <key>PayloadType</key>\n            <string>com.apple.security.root</string>\n            <key>PayloadUUID</key>\n            <string>C09491AA-8ED1-5327-A61F-81CE4E3A686C</string>\n            <key>PayloadVersion</key>\n            <integer>1</integer>\n        </dict>\n    </array>\n    <key>PayloadDisplayName</key>\n    <string>AlgoVPN algo-test-server IKEv2</string>\n    <key>PayloadIdentifier</key>\n    <string>donut.local.95AC5C22-8E5B-5049-A6E0-247F765E8548</string>\n    <key>PayloadOrganization</key>\n\t<string>AlgoVPN</string>\n    <key>PayloadRemovalDisallowed</key>\n    <false/>\n    <key>PayloadType</key>\n    <string>Configuration</string>\n    <key>PayloadUUID</key>\n    <string>79370BB1-869C-5F0E-B5E4-F8332DF530E9</string>\n    <key>PayloadVersion</key>\n    <integer>1</integer>\n</dict>\n</plist>\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/manual/cacert.pem",
    "content": "-----BEGIN CERTIFICATE-----\nMIICoTCCAiagAwIBAgIUYQ99YGsE7jDrDq93WTTWMkaM7IUwCgYIKoZIzj0EAwIw\nFTETMBEGA1UEAwwKMTAuOTkuMC4xMDAeFw0yNTA4MDMxMjU5MjdaFw0zNTA4MDEx\nMjU5MjdaMBUxEzARBgNVBAMMCjEwLjk5LjAuMTAwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAASA2JYIRHTHqMnrGCoIFg8RVz3v2QdjGJnkF3f2Ia4s/V5LaP+WP0PhDEF3\npVHRzHKd2ntk0DBRNOih+/BiQ+lQhfET8tWH+mfAk0HemsgRzRIGadxPVxi1piqJ\nsL8uWU6jggE1MIIBMTAdBgNVHQ4EFgQUv/5pOGOAGenWXTgdI+dhjK9K6K0wUAYD\nVR0jBEkwR4AUv/5pOGOAGenWXTgdI+dhjK9K6K2hGaQXMBUxEzARBgNVBAMMCjEw\nLjk5LjAuMTCCFGEPfWBrBO4w6w6vd1k01jJGjOyFMBIGA1UdEwEB/wQIMAYBAf8C\nAQAwgZwGA1UdHgEB/wSBkTCBjqBmMAqHCApjAAr/////MCuCKTk1NDZkNTc0LTNm\nZDYtNThmNC05YWM2LTJjNjljYjc1NWRiNy5hbGdvMCuBKTk1NDZkNTc0LTNmZDYt\nNThmNC05YWM2LTJjNjljYjc1NWRiNy5hbGdvoSQwIocgAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAwCwYDVR0PBAQDAgEGMAoGCCqGSM49BAMCA2kAMGYC\nMQCsWQOinhhs4yZSOvupPIQKw7hMpKkEiKS6RtRfrvZohGQK92OKXsETLd7YPh3N\nRBACMQC8WAe35PXcg+JY8padri4d/u2ITreCXARuhUjypm+Ucy1qQ5A18wjj6/KV\nJJYlbfk=\n-----END CERTIFICATE-----\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser1.conf",
    "content": "conn algovpn-10.99.0.10\n    fragmentation=yes\n    rekey=no\n    dpdaction=clear\n    keyexchange=ikev2\n    compress=no\n    dpddelay=35s\n\n    ike=aes256gcm16-prfsha512-ecp384!\n    esp=aes256gcm16-ecp384!\n\n    right=10.99.0.10\n    rightid=10.99.0.10\n    rightsubnet=0.0.0.0/0\n    rightauth=pubkey\n\n    leftsourceip=%config\n    leftauth=pubkey\n    leftcert=testuser1.crt\n    leftfirewall=yes\n    left=%defaultroute\n\n    auto=add\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser1.secrets",
    "content": "10.99.0.10 : ECDSA testuser1.key\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser2.conf",
    "content": "conn algovpn-10.99.0.10\n    fragmentation=yes\n    rekey=no\n    dpdaction=clear\n    keyexchange=ikev2\n    compress=no\n    dpddelay=35s\n\n    ike=aes256gcm16-prfsha512-ecp384!\n    esp=aes256gcm16-ecp384!\n\n    right=10.99.0.10\n    rightid=10.99.0.10\n    rightsubnet=0.0.0.0/0\n    rightauth=pubkey\n\n    leftsourceip=%config\n    leftauth=pubkey\n    leftcert=testuser2.crt\n    leftfirewall=yes\n    left=%defaultroute\n\n    auto=add\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser2.secrets",
    "content": "10.99.0.10 : ECDSA testuser2.key\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/.pki/index.txt",
    "content": "testuser1\ntestuser2\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/.pki/preshared/10.99.0.10",
    "content": "Ggpzqj5CnamCMBaKQCC+xih3lfj+I1tOfImOizyDLkA=\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/.pki/preshared/testuser1",
    "content": "CSLeU66thNW52DkY6MVJ2A5VodnxbsC7EklcrHPCKco=\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/.pki/preshared/testuser2",
    "content": "WUuCbhaOJfPtCrwU4EnlpqVmmPuaJJYYyzc2sy+afVQ=\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/.pki/private/10.99.0.10",
    "content": "EPokMfsIC6Heg4/tm9gaMt2rRwXjACwvmdJAXO/byH8=\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/.pki/private/testuser1",
    "content": "OC3EYbHLzszqmYqFlC22bnPDoTHp4ORULg9vCphbcEY=\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/.pki/private/testuser2",
    "content": "yKU40Lrt5xutKuXHcJipej0wdqPVExuGmjoPzBar/GI=\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/.pki/public/10.99.0.10",
    "content": "IJFSpegTMGKoK5EtJaX2uH/hBWxq8ZpNOJIBMZnE4w0=\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/.pki/public/testuser1",
    "content": "yoUuE/xoUE4bbR4enH9lmOc+lLB0mecK6ifMwiajDz4=\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/.pki/public/testuser2",
    "content": "zJ76JrM4mYQk8QIGMIZy9V9lORvw75lh3ByhgXbH1kA=\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/apple/ios/testuser1.mobileconfig",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>PayloadContent</key>\n\t<array>\n<dict>\n\t<key>IPv4</key>\n  <dict>\n      <key>OverridePrimary</key>\n      <integer>1</integer>\n  </dict>\n\t<key>PayloadDescription</key>\n\t<string>Configures VPN settings</string>\n\t<key>PayloadDisplayName</key>\n\t<string>algo-test-server</string>\n\t<key>PayloadIdentifier</key>\n\t<string>com.apple.vpn.managed.algo-test-server9D18FBEE-C185-5F8B-9A48-C873BB65BB8E</string>\n\t<key>PayloadType</key>\n\t<string>com.apple.vpn.managed</string>\n\t<key>PayloadUUID</key>\n\t<string>algo-test-server9D18FBEE-C185-5F8B-9A48-C873BB65BB8E</string>\n\t<key>PayloadVersion</key>\n\t<integer>1</integer>\n\t<key>Proxies</key>\n\t<dict>\n\t\t<key>HTTPEnable</key>\n\t\t<integer>0</integer>\n\t\t<key>HTTPSEnable</key>\n\t\t<integer>0</integer>\n\t</dict>\n\t<key>UserDefinedName</key>\n\t<string>AlgoVPN algo-test-server</string>\n\t<key>VPN</key>\n\t<dict>\n    <key>OnDemandEnabled</key>\n    <integer>0</integer>\n    <key>OnDemandRules</key>\n    <array>\n      <dict>\n        <key>Action</key>\n        <string>Connect</string>\n      </dict>\n    </array>\n\t\t<key>AuthenticationMethod</key>\n\t\t<string>Password</string>\n\t\t<key>RemoteAddress</key>\n\t\t<string>10.99.0.10:51820</string>\n\t</dict>\n\t<key>VPNSubType</key>\n\t<string>com.wireguard.ios</string>\n\t<key>VPNType</key>\n\t<string>VPN</string>\n\t<key>VendorConfig</key>\n\t<dict>\n\t\t<key>WgQuickConfig</key>\n\t\t<string>[Interface]\n        PrivateKey = OC3EYbHLzszqmYqFlC22bnPDoTHp4ORULg9vCphbcEY=\n        Address = 10.19.49.2\n        DNS =  8.8.8.8,8.8.4.4\n\n        [Peer]\n        PublicKey = IJFSpegTMGKoK5EtJaX2uH/hBWxq8ZpNOJIBMZnE4w0=\n        PresharedKey = CSLeU66thNW52DkY6MVJ2A5VodnxbsC7EklcrHPCKco=\n        AllowedIPs = 0.0.0.0/0,::/0\n        Endpoint = 10.99.0.10:51820\n</string>\n\t</dict>\n</dict>  </array>\n  <key>PayloadDisplayName</key>\n  <string>AlgoVPN algo-test-server WireGuard</string>\n  <key>PayloadIdentifier</key>\n  <string>donut.local.D503FCD6-107F-5C1A-94C2-EE8821F144CD</string>\n  <key>PayloadOrganization</key>\n  <string>AlgoVPN</string>\n  <key>PayloadRemovalDisallowed</key>\n  <false/>\n  <key>PayloadType</key>\n  <string>Configuration</string>\n  <key>PayloadUUID</key>\n  <string>2B1947FC-5FDD-56F5-8C60-E553E7F8C788</string>\n  <key>PayloadVersion</key>\n  <integer>1</integer>\n</dict>\n</plist>\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/apple/ios/testuser2.mobileconfig",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>PayloadContent</key>\n\t<array>\n<dict>\n\t<key>IPv4</key>\n  <dict>\n      <key>OverridePrimary</key>\n      <integer>1</integer>\n  </dict>\n\t<key>PayloadDescription</key>\n\t<string>Configures VPN settings</string>\n\t<key>PayloadDisplayName</key>\n\t<string>algo-test-server</string>\n\t<key>PayloadIdentifier</key>\n\t<string>com.apple.vpn.managed.algo-test-server9D18FBEE-C185-5F8B-9A48-C873BB65BB8E</string>\n\t<key>PayloadType</key>\n\t<string>com.apple.vpn.managed</string>\n\t<key>PayloadUUID</key>\n\t<string>algo-test-server9D18FBEE-C185-5F8B-9A48-C873BB65BB8E</string>\n\t<key>PayloadVersion</key>\n\t<integer>1</integer>\n\t<key>Proxies</key>\n\t<dict>\n\t\t<key>HTTPEnable</key>\n\t\t<integer>0</integer>\n\t\t<key>HTTPSEnable</key>\n\t\t<integer>0</integer>\n\t</dict>\n\t<key>UserDefinedName</key>\n\t<string>AlgoVPN algo-test-server</string>\n\t<key>VPN</key>\n\t<dict>\n    <key>OnDemandEnabled</key>\n    <integer>0</integer>\n    <key>OnDemandRules</key>\n    <array>\n      <dict>\n        <key>Action</key>\n        <string>Connect</string>\n      </dict>\n    </array>\n\t\t<key>AuthenticationMethod</key>\n\t\t<string>Password</string>\n\t\t<key>RemoteAddress</key>\n\t\t<string>10.99.0.10:51820</string>\n\t</dict>\n\t<key>VPNSubType</key>\n\t<string>com.wireguard.ios</string>\n\t<key>VPNType</key>\n\t<string>VPN</string>\n\t<key>VendorConfig</key>\n\t<dict>\n\t\t<key>WgQuickConfig</key>\n\t\t<string>[Interface]\n        PrivateKey = yKU40Lrt5xutKuXHcJipej0wdqPVExuGmjoPzBar/GI=\n        Address = 10.19.49.3\n        DNS =  8.8.8.8,8.8.4.4\n\n        [Peer]\n        PublicKey = IJFSpegTMGKoK5EtJaX2uH/hBWxq8ZpNOJIBMZnE4w0=\n        PresharedKey = WUuCbhaOJfPtCrwU4EnlpqVmmPuaJJYYyzc2sy+afVQ=\n        AllowedIPs = 0.0.0.0/0,::/0\n        Endpoint = 10.99.0.10:51820\n</string>\n\t</dict>\n</dict>  </array>\n  <key>PayloadDisplayName</key>\n  <string>AlgoVPN algo-test-server WireGuard</string>\n  <key>PayloadIdentifier</key>\n  <string>donut.local.45803596-C851-5118-8AD2-563672058D8F</string>\n  <key>PayloadOrganization</key>\n  <string>AlgoVPN</string>\n  <key>PayloadRemovalDisallowed</key>\n  <false/>\n  <key>PayloadType</key>\n  <string>Configuration</string>\n  <key>PayloadUUID</key>\n  <string>AAAFB2ED-1F04-5973-85B6-5C0F8E63ABF1</string>\n  <key>PayloadVersion</key>\n  <integer>1</integer>\n</dict>\n</plist>\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/apple/macos/testuser1.mobileconfig",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>PayloadContent</key>\n\t<array>\n<dict>\n\t<key>IPv4</key>\n  <dict>\n      <key>OverridePrimary</key>\n      <integer>1</integer>\n  </dict>\n\t<key>PayloadDescription</key>\n\t<string>Configures VPN settings</string>\n\t<key>PayloadDisplayName</key>\n\t<string>algo-test-server</string>\n\t<key>PayloadIdentifier</key>\n\t<string>com.apple.vpn.managed.algo-test-server3B9A4690-0B5D-5BC3-A5C5-21305566D87F</string>\n\t<key>PayloadType</key>\n\t<string>com.apple.vpn.managed</string>\n\t<key>PayloadUUID</key>\n\t<string>algo-test-server3B9A4690-0B5D-5BC3-A5C5-21305566D87F</string>\n\t<key>PayloadVersion</key>\n\t<integer>1</integer>\n\t<key>Proxies</key>\n\t<dict>\n\t\t<key>HTTPEnable</key>\n\t\t<integer>0</integer>\n\t\t<key>HTTPSEnable</key>\n\t\t<integer>0</integer>\n\t</dict>\n\t<key>UserDefinedName</key>\n\t<string>AlgoVPN algo-test-server</string>\n\t<key>VPN</key>\n\t<dict>\n    <key>OnDemandEnabled</key>\n    <integer>0</integer>\n    <key>OnDemandRules</key>\n    <array>\n      <dict>\n        <key>Action</key>\n        <string>Connect</string>\n      </dict>\n    </array>\n\t\t<key>AuthenticationMethod</key>\n\t\t<string>Password</string>\n\t\t<key>RemoteAddress</key>\n\t\t<string>10.99.0.10:51820</string>\n\t</dict>\n\t<key>VPNSubType</key>\n\t<string>com.wireguard.macos</string>\n\t<key>VPNType</key>\n\t<string>VPN</string>\n\t<key>VendorConfig</key>\n\t<dict>\n\t\t<key>WgQuickConfig</key>\n\t\t<string>[Interface]\n        PrivateKey = OC3EYbHLzszqmYqFlC22bnPDoTHp4ORULg9vCphbcEY=\n        Address = 10.19.49.2\n        DNS =  8.8.8.8,8.8.4.4\n\n        [Peer]\n        PublicKey = IJFSpegTMGKoK5EtJaX2uH/hBWxq8ZpNOJIBMZnE4w0=\n        PresharedKey = CSLeU66thNW52DkY6MVJ2A5VodnxbsC7EklcrHPCKco=\n        AllowedIPs = 0.0.0.0/0,::/0\n        Endpoint = 10.99.0.10:51820\n</string>\n\t</dict>\n</dict>  </array>\n  <key>PayloadDisplayName</key>\n  <string>AlgoVPN algo-test-server WireGuard</string>\n  <key>PayloadIdentifier</key>\n  <string>donut.local.9D99E158-71EF-58AB-A74C-CC609791EEBF</string>\n  <key>PayloadOrganization</key>\n  <string>AlgoVPN</string>\n  <key>PayloadRemovalDisallowed</key>\n  <false/>\n  <key>PayloadType</key>\n  <string>Configuration</string>\n  <key>PayloadUUID</key>\n  <string>ADE17621-2434-5FE8-995B-C2531F35DCCB</string>\n  <key>PayloadVersion</key>\n  <integer>1</integer>\n</dict>\n</plist>\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/apple/macos/testuser2.mobileconfig",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>PayloadContent</key>\n\t<array>\n<dict>\n\t<key>IPv4</key>\n  <dict>\n      <key>OverridePrimary</key>\n      <integer>1</integer>\n  </dict>\n\t<key>PayloadDescription</key>\n\t<string>Configures VPN settings</string>\n\t<key>PayloadDisplayName</key>\n\t<string>algo-test-server</string>\n\t<key>PayloadIdentifier</key>\n\t<string>com.apple.vpn.managed.algo-test-server3B9A4690-0B5D-5BC3-A5C5-21305566D87F</string>\n\t<key>PayloadType</key>\n\t<string>com.apple.vpn.managed</string>\n\t<key>PayloadUUID</key>\n\t<string>algo-test-server3B9A4690-0B5D-5BC3-A5C5-21305566D87F</string>\n\t<key>PayloadVersion</key>\n\t<integer>1</integer>\n\t<key>Proxies</key>\n\t<dict>\n\t\t<key>HTTPEnable</key>\n\t\t<integer>0</integer>\n\t\t<key>HTTPSEnable</key>\n\t\t<integer>0</integer>\n\t</dict>\n\t<key>UserDefinedName</key>\n\t<string>AlgoVPN algo-test-server</string>\n\t<key>VPN</key>\n\t<dict>\n    <key>OnDemandEnabled</key>\n    <integer>0</integer>\n    <key>OnDemandRules</key>\n    <array>\n      <dict>\n        <key>Action</key>\n        <string>Connect</string>\n      </dict>\n    </array>\n\t\t<key>AuthenticationMethod</key>\n\t\t<string>Password</string>\n\t\t<key>RemoteAddress</key>\n\t\t<string>10.99.0.10:51820</string>\n\t</dict>\n\t<key>VPNSubType</key>\n\t<string>com.wireguard.macos</string>\n\t<key>VPNType</key>\n\t<string>VPN</string>\n\t<key>VendorConfig</key>\n\t<dict>\n\t\t<key>WgQuickConfig</key>\n\t\t<string>[Interface]\n        PrivateKey = yKU40Lrt5xutKuXHcJipej0wdqPVExuGmjoPzBar/GI=\n        Address = 10.19.49.3\n        DNS =  8.8.8.8,8.8.4.4\n\n        [Peer]\n        PublicKey = IJFSpegTMGKoK5EtJaX2uH/hBWxq8ZpNOJIBMZnE4w0=\n        PresharedKey = WUuCbhaOJfPtCrwU4EnlpqVmmPuaJJYYyzc2sy+afVQ=\n        AllowedIPs = 0.0.0.0/0,::/0\n        Endpoint = 10.99.0.10:51820\n</string>\n\t</dict>\n</dict>  </array>\n  <key>PayloadDisplayName</key>\n  <string>AlgoVPN algo-test-server WireGuard</string>\n  <key>PayloadIdentifier</key>\n  <string>donut.local.B177D923-93FA-5491-8B28-20964A3892A6</string>\n  <key>PayloadOrganization</key>\n  <string>AlgoVPN</string>\n  <key>PayloadRemovalDisallowed</key>\n  <false/>\n  <key>PayloadType</key>\n  <string>Configuration</string>\n  <key>PayloadUUID</key>\n  <string>B1842E16-8F73-571B-A3FE-5A150D955F29</string>\n  <key>PayloadVersion</key>\n  <integer>1</integer>\n</dict>\n</plist>\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/testuser1.conf",
    "content": "[Interface]\nPrivateKey = OC3EYbHLzszqmYqFlC22bnPDoTHp4ORULg9vCphbcEY=\nAddress = 10.19.49.2\nDNS =  8.8.8.8,8.8.4.4\n\n[Peer]\nPublicKey = IJFSpegTMGKoK5EtJaX2uH/hBWxq8ZpNOJIBMZnE4w0=\nPresharedKey = CSLeU66thNW52DkY6MVJ2A5VodnxbsC7EklcrHPCKco=\nAllowedIPs = 0.0.0.0/0,::/0\nEndpoint = 10.99.0.10:51820\n"
  },
  {
    "path": "tests/integration/test-configs/10.99.0.10/wireguard/testuser2.conf",
    "content": "[Interface]\nPrivateKey = yKU40Lrt5xutKuXHcJipej0wdqPVExuGmjoPzBar/GI=\nAddress = 10.19.49.3\nDNS =  8.8.8.8,8.8.4.4\n\n[Peer]\nPublicKey = IJFSpegTMGKoK5EtJaX2uH/hBWxq8ZpNOJIBMZnE4w0=\nPresharedKey = WUuCbhaOJfPtCrwU4EnlpqVmmPuaJJYYyzc2sy+afVQ=\nAllowedIPs = 0.0.0.0/0,::/0\nEndpoint = 10.99.0.10:51820\n"
  },
  {
    "path": "tests/integration/test-run.log",
    "content": "Building Docker images...\n#1 [internal] load local bake definitions\n#1 reading from stdin 1.57kB done\n#1 DONE 0.0s\n\n#2 [client-ubuntu internal] load build definition from Dockerfile.client-ubuntu\n#2 transferring dockerfile: 716B done\n#2 DONE 0.0s\n\n#3 [algo-server internal] load build definition from Dockerfile.server\n#3 transferring dockerfile: 1.01kB done\n#3 DONE 0.0s\n\n#4 [client-debian internal] load build definition from Dockerfile.client-debian\n#4 transferring dockerfile: 713B done\n#4 DONE 0.0s\n\n#5 [algo-server internal] load metadata for docker.io/library/ubuntu:22.04\n#5 DONE 0.3s\n\n#6 [client-ubuntu internal] load .dockerignore\n#6 transferring context: 248B done\n#6 DONE 0.0s\n\n#7 [client-ubuntu 1/6] FROM docker.io/library/ubuntu:22.04@sha256:1ec65b2719518e27d4d25f104d93f9fac60dc437f81452302406825c46fcc9cb\n#7 DONE 0.0s\n\n#8 [client-ubuntu internal] load build context\n#8 transferring context: 122B 0.0s done\n#8 DONE 0.0s\n\n#9 [client-ubuntu 4/6] COPY tests/integration/client-test-utils.sh /usr/local/bin/test-utils.sh\n#9 CACHED\n\n#10 [client-ubuntu 3/6] RUN mkdir -p /etc/wireguard /etc/swanctl\n#10 CACHED\n\n#11 [client-ubuntu 2/6] RUN apt-get update && apt-get install -y     wireguard     wireguard-tools     strongswan     strongswan-swanctl     iproute2     iputils-ping     dnsutils     curl     tcpdump     net-tools     iptables     && rm -rf /var/lib/apt/lists/*\n#11 CACHED\n\n#12 [client-ubuntu 5/6] RUN chmod +x /usr/local/bin/test-utils.sh\n#12 CACHED\n\n#13 [client-ubuntu 6/6] WORKDIR /root\n#13 CACHED\n\n#14 [algo-server internal] load build context\n#14 transferring context: 23.33kB 0.0s done\n#14 DONE 0.0s\n\n#15 [algo-server 2/8] RUN apt-get update && apt-get install -y     python3     python3-pip     python3-venv     ansible     wireguard     wireguard-tools     strongswan     strongswan-swanctl     strongswan-pki     iptables     iproute2     openssl     curl     dnsutils     iputils-ping     net-tools     sudo     kmod     && rm -rf /var/lib/apt/lists/*\n#15 CACHED\n\n#16 [algo-server 3/8] WORKDIR /algo\n#16 CACHED\n\n#17 [client-ubuntu] exporting to image\n#17 exporting layers done\n#17 writing image sha256:059dedd3689f34422b93aa1debd7772e35ebb1d79c249d46a8dedde010b72272 done\n#17 naming to docker.io/library/integration-client-ubuntu done\n#17 DONE 0.0s\n\n#18 [client-ubuntu] resolving provenance for metadata file\n#18 DONE 0.0s\n\n#19 [algo-server 4/8] COPY . /algo/\n#19 DONE 0.0s\n\n#20 [client-debian internal] load metadata for docker.io/library/debian:12\n#20 DONE 0.5s\n\n#6 [client-debian internal] load .dockerignore\n#6 transferring context: 248B done\n#6 DONE 0.0s\n\n#21 [client-debian 1/6] FROM docker.io/library/debian:12@sha256:b6507e340c43553136f5078284c8c68d86ec8262b1724dde73c325e8d3dcdeba\n#21 DONE 0.0s\n\n#8 [client-debian internal] load build context\n#8 DONE 0.0s\n\n#22 [client-debian 2/6] RUN apt-get update && apt-get install -y     wireguard     wireguard-tools     strongswan     strongswan-swanctl     iproute2     iputils-ping     dnsutils     curl     tcpdump     net-tools     iptables     && rm -rf /var/lib/apt/lists/*\n#22 CACHED\n\n#23 [client-debian 5/6] RUN chmod +x /usr/local/bin/test-utils.sh\n#23 CACHED\n\n#24 [client-debian 3/6] RUN mkdir -p /etc/wireguard /etc/swanctl\n#24 CACHED\n\n#25 [client-debian 4/6] COPY tests/integration/client-test-utils.sh /usr/local/bin/test-utils.sh\n#25 CACHED\n\n#26 [client-debian 6/6] WORKDIR /root\n#26 CACHED\n\n#27 [client-debian] exporting to image\n#27 exporting layers done\n#27 writing image sha256:729aab21a679247723575dee700bcf3a114b84293bfe165cd2f733312b3bf016 done\n#27 naming to docker.io/library/integration-client-debian done\n#27 DONE 0.0s\n\n#28 [client-debian] resolving provenance for metadata file\n#28 DONE 0.0s\n\n#29 [algo-server 5/8] RUN python3 -m pip install --upgrade pip &&     pip3 install -r requirements.txt\n#29 0.263 Requirement already satisfied: pip in /usr/lib/python3/dist-packages (22.0.2)\n#29 0.352 Collecting pip\n#29 0.442   Downloading pip-25.2-py3-none-any.whl (1.8 MB)\n#29 0.589      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.8/1.8 MB 12.0 MB/s eta 0:00:00\n#29 0.613 Installing collected packages: pip\n#29 0.613   Attempting uninstall: pip\n#29 0.613     Found existing installation: pip 22.0.2\n#29 0.614     Not uninstalling pip at /usr/lib/python3/dist-packages, outside environment /usr\n#29 0.614     Can't uninstall 'pip'. No files were found to uninstall.\n#29 0.934 Successfully installed pip-25.2\n#29 0.934 WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv\n#29 1.212 Collecting ansible==9.1.0 (from -r requirements.txt (line 1))\n#29 1.240   Downloading ansible-9.1.0-py3-none-any.whl.metadata (7.9 kB)\n#29 1.256 Collecting jinja2~=3.1.3 (from -r requirements.txt (line 2))\n#29 1.265   Downloading jinja2-3.1.6-py3-none-any.whl.metadata (2.9 kB)\n#29 1.267 Requirement already satisfied: netaddr in /usr/lib/python3/dist-packages (from -r requirements.txt (line 3)) (0.8.0)\n#29 1.298 Collecting ansible-core~=2.16.1 (from ansible==9.1.0->-r requirements.txt (line 1))\n#29 1.305   Downloading ansible_core-2.16.14-py3-none-any.whl.metadata (6.9 kB)\n#29 1.308 Requirement already satisfied: MarkupSafe>=2.0 in /usr/lib/python3/dist-packages (from jinja2~=3.1.3->-r requirements.txt (line 2)) (2.0.1)\n#29 1.311 Requirement already satisfied: PyYAML>=5.1 in /usr/lib/python3/dist-packages (from ansible-core~=2.16.1->ansible==9.1.0->-r requirements.txt (line 1)) (5.4.1)\n#29 1.311 Requirement already satisfied: cryptography in /usr/lib/python3/dist-packages (from ansible-core~=2.16.1->ansible==9.1.0->-r requirements.txt (line 1)) (3.4.8)\n#29 1.311 Requirement already satisfied: packaging in /usr/lib/python3/dist-packages (from ansible-core~=2.16.1->ansible==9.1.0->-r requirements.txt (line 1)) (21.3)\n#29 1.327 Collecting resolvelib<1.1.0,>=0.5.3 (from ansible-core~=2.16.1->ansible==9.1.0->-r requirements.txt (line 1))\n#29 1.336   Downloading resolvelib-1.0.1-py2.py3-none-any.whl.metadata (4.0 kB)\n#29 1.349 Downloading ansible-9.1.0-py3-none-any.whl (48.1 MB)\n#29 4.967    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 48.1/48.1 MB 13.3 MB/s  0:00:03\n#29 4.979 Downloading jinja2-3.1.6-py3-none-any.whl (134 kB)\n#29 4.997 Downloading ansible_core-2.16.14-py3-none-any.whl (2.3 MB)\n#29 5.182    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2.3/2.3 MB 12.2 MB/s  0:00:00\n#29 5.197 Downloading resolvelib-1.0.1-py2.py3-none-any.whl (17 kB)\n#29 5.348 Installing collected packages: resolvelib, jinja2, ansible-core, ansible\n#29 5.354   Attempting uninstall: jinja2\n#29 5.354     Found existing installation: Jinja2 3.0.3\n#29 5.355     Uninstalling Jinja2-3.0.3:\n#29 5.660       Successfully uninstalled Jinja2-3.0.3\n#29 16.17\n#29 16.17 Successfully installed ansible-9.1.0 ansible-core-2.16.14 jinja2-3.1.6 resolvelib-1.0.1\n#29 16.17 WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.\n#29 DONE 16.3s\n\n#30 [algo-server 6/8] RUN mkdir -p /etc/algo\n#30 DONE 0.1s\n\n#31 [algo-server 7/8] COPY tests/integration/server-entrypoint.sh /entrypoint.sh\n#31 DONE 0.0s\n\n#32 [algo-server 8/8] RUN chmod +x /entrypoint.sh\n#32 DONE 0.1s\n\n#33 [algo-server] exporting to image\n#33 exporting layers\n#33 exporting layers 2.7s done\n#33 writing image sha256:cc11c548d399d99c372404653a95784693b8472baadb19d1d0d8b9294143a921 done\n#33 naming to docker.io/library/integration-algo-server done\n#33 DONE 2.7s\n\n#34 [algo-server] resolving provenance for metadata file\n#34 DONE 0.0s\n integration-algo-server  Built\n integration-client-ubuntu  Built\n integration-client-debian  Built\nStarting test environment...\n Container algo-server  Recreate\n Container algo-server  Recreated\n Container algo-server  Starting\n Container algo-server  Started\nWaiting for Algo server to provision...\nWaiting for provisioning to complete... (0/300 seconds)\nWaiting for provisioning to complete... (10/300 seconds)\nWaiting for provisioning to complete... (20/300 seconds)\nWaiting for provisioning to complete... (30/300 seconds)\nWaiting for provisioning to complete... (40/300 seconds)\nWaiting for provisioning to complete... (50/300 seconds)\nWaiting for provisioning to complete... (60/300 seconds)\nWaiting for provisioning to complete... (70/300 seconds)\nWaiting for provisioning to complete... (80/300 seconds)\nERROR: Algo server container stopped unexpectedly\nContainer logs:\nalgo-server  | Starting Algo server container...\nalgo-server  | Running Algo provisioning...\nalgo-server  | Get:1 http://ports.ubuntu.com/ubuntu-ports jammy InRelease [270 kB]\nalgo-server  | Get:2 http://ports.ubuntu.com/ubuntu-ports jammy-updates InRelease [128 kB]\nalgo-server  | Get:3 http://ports.ubuntu.com/ubuntu-ports jammy-backports InRelease [127 kB]\nalgo-server  | Get:4 http://ports.ubuntu.com/ubuntu-ports jammy-security InRelease [129 kB]\nalgo-server  | Get:5 http://ports.ubuntu.com/ubuntu-ports jammy/main arm64 Packages [1758 kB]\nalgo-server  | Get:6 http://ports.ubuntu.com/ubuntu-ports jammy/restricted arm64 Packages [24.2 kB]\nalgo-server  | Get:7 http://ports.ubuntu.com/ubuntu-ports jammy/multiverse arm64 Packages [224 kB]\nalgo-server  | Get:8 http://ports.ubuntu.com/ubuntu-ports jammy/universe arm64 Packages [17.2 MB]\nalgo-server  | Get:9 http://ports.ubuntu.com/ubuntu-ports jammy-updates/universe arm64 Packages [1572 kB]\nalgo-server  | Get:10 http://ports.ubuntu.com/ubuntu-ports jammy-updates/multiverse arm64 Packages [52.7 kB]\nalgo-server  | Get:11 http://ports.ubuntu.com/ubuntu-ports jammy-updates/restricted arm64 Packages [4694 kB]\nalgo-server  | Get:12 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 Packages [3220 kB]\nalgo-server  | Get:13 http://ports.ubuntu.com/ubuntu-ports jammy-backports/universe arm64 Packages [33.3 kB]\nalgo-server  | Get:14 http://ports.ubuntu.com/ubuntu-ports jammy-backports/main arm64 Packages [82.8 kB]\nalgo-server  | Get:15 http://ports.ubuntu.com/ubuntu-ports jammy-security/restricted arm64 Packages [4510 kB]\nalgo-server  | Get:16 http://ports.ubuntu.com/ubuntu-ports jammy-security/multiverse arm64 Packages [27.2 kB]\nalgo-server  | Get:17 http://ports.ubuntu.com/ubuntu-ports jammy-security/universe arm64 Packages [1275 kB]\nalgo-server  | Get:18 http://ports.ubuntu.com/ubuntu-ports jammy-security/main arm64 Packages [2916 kB]\nalgo-server  | Fetched 38.3 MB in 3s (12.0 MB/s)\nalgo-server  | Reading package lists...\nalgo-server  | [WARNING]: Found variable using reserved name: no_log\nalgo-server  | Using /algo/ansible.cfg as config file\nalgo-server  |\nalgo-server  | PLAY [Algo VPN Setup] **********************************************************\nalgo-server  |\nalgo-server  | TASK [Gathering Facts] *********************************************************\nalgo-server  | ok: [localhost]\nalgo-server  |\nalgo-server  | TASK [Playbook dir stat] *******************************************************\nalgo-server  | ok: [localhost] => {\"changed\": false, \"stat\": {\"atime\": 1754231772.3685358, \"attr_flags\": \"\", \"attributes\": [], \"block_size\": 4096, \"blocks\": 8, \"charset\": \"unknown\", \"ctime\": 1754231775.268547, \"dev\": 48, \"device_type\": 0, \"executable\": true, \"exists\": true, \"gid\": 0, \"gr_name\": \"root\", \"inode\": 5790103, \"isblk\": false, \"ischr\": false, \"isdir\": true, \"isfifo\": false, \"isgid\": false, \"islnk\": false, \"isreg\": false, \"issock\": false, \"isuid\": false, \"mimetype\": \"unknown\", \"mode\": \"0755\", \"mtime\": 1754231775.268547, \"nlink\": 1, \"path\": \"/algo\", \"pw_name\": \"root\", \"readable\": true, \"rgrp\": true, \"roth\": true, \"rusr\": true, \"size\": 4096, \"uid\": 0, \"version\": null, \"wgrp\": false, \"woth\": false, \"writeable\": true, \"wusr\": true, \"xgrp\": true, \"xoth\": true, \"xusr\": true}}\nalgo-server  |\nalgo-server  | TASK [Ensure Ansible is not being run in a world writable directory] ***********\nalgo-server  | ok: [localhost] => {\nalgo-server  |     \"changed\": false,\nalgo-server  |     \"msg\": \"All assertions passed\"\nalgo-server  | }\nalgo-server  | [DEPRECATION WARNING]: Use 'ansible.utils.ipaddr' module instead. This feature\nalgo-server  | will be removed from ansible.netcommon in a release after 2024-01-01.\nalgo-server  | Deprecation warnings can be disabled by setting deprecation_warnings=False in\nalgo-server  | ansible.cfg.\nalgo-server  | [WARNING]: The value '' is not a valid IP address or network, passing this\nalgo-server  | value to ipaddr filter might result in breaking change in future.\nalgo-server  |\nalgo-server  | TASK [Ensure the requirements installed] ***************************************\nalgo-server  | ok: [localhost] => {\"censored\": \"the output has been hidden due to the fact that 'no_log: true' was specified for this result\"}\nalgo-server  |\nalgo-server  | TASK [Set required ansible version as a fact] **********************************\nalgo-server  | ok: [localhost] => (item=ansible==9.1.0) => {\"ansible_facts\": {\"required_ansible_version\": {\"op\": \"==\", \"ver\": \"9.1.0\"}}, \"ansible_loop_var\": \"item\", \"changed\": false, \"item\": \"ansible==9.1.0\"}\nalgo-server  |\nalgo-server  | TASK [Just get the list from default pip] **************************************\nalgo-server  | ok: [localhost] => {\"changed\": false, \"packages\": {\"pip\": {\"Babel\": [{\"name\": \"Babel\", \"source\": \"pip\", \"version\": \"2.8.0\"}], \"Jinja2\": [{\"name\": \"Jinja2\", \"source\": \"pip\", \"version\": \"3.1.6\"}], \"MarkupSafe\": [{\"name\": \"MarkupSafe\", \"source\": \"pip\", \"version\": \"2.0.1\"}], \"PyGObject\": [{\"name\": \"PyGObject\", \"source\": \"pip\", \"version\": \"3.42.1\"}], \"PyYAML\": [{\"name\": \"PyYAML\", \"source\": \"pip\", \"version\": \"5.4.1\"}], \"ansible\": [{\"name\": \"ansible\", \"source\": \"pip\", \"version\": \"9.1.0\"}], \"ansible-base\": [{\"name\": \"ansible-base\", \"source\": \"pip\", \"version\": \"2.10.8\"}], \"ansible-core\": [{\"name\": \"ansible-core\", \"source\": \"pip\", \"version\": \"2.16.14\"}], \"apache-libcloud\": [{\"name\": \"apache-libcloud\", \"source\": \"pip\", \"version\": \"3.2.0\"}], \"argcomplete\": [{\"name\": \"argcomplete\", \"source\": \"pip\", \"version\": \"1.8.1\"}], \"certifi\": [{\"name\": \"certifi\", \"source\": \"pip\", \"version\": \"2020.6.20\"}], \"chardet\": [{\"name\": \"chardet\", \"source\": \"pip\", \"version\": \"4.0.0\"}], \"cryptography\": [{\"name\": \"cryptography\", \"source\": \"pip\", \"version\": \"3.4.8\"}], \"dbus-python\": [{\"name\": \"dbus-python\", \"source\": \"pip\", \"version\": \"1.2.18\"}], \"dnspython\": [{\"name\": \"dnspython\", \"source\": \"pip\", \"version\": \"2.1.0\"}], \"httplib2\": [{\"name\": \"httplib2\", \"source\": \"pip\", \"version\": \"0.20.2\"}], \"idna\": [{\"name\": \"idna\", \"source\": \"pip\", \"version\": \"3.3\"}], \"jmespath\": [{\"name\": \"jmespath\", \"source\": \"pip\", \"version\": \"0.10.0\"}], \"lockfile\": [{\"name\": \"lockfile\", \"source\": \"pip\", \"version\": \"0.12.2\"}], \"netaddr\": [{\"name\": \"netaddr\", \"source\": \"pip\", \"version\": \"0.8.0\"}], \"ntlm-auth\": [{\"name\": \"ntlm-auth\", \"source\": \"pip\", \"version\": \"1.4.0\"}], \"packaging\": [{\"name\": \"packaging\", \"source\": \"pip\", \"version\": \"21.3\"}], \"pip\": [{\"name\": \"pip\", \"source\": \"pip\", \"version\": \"25.2\"}], \"pycryptodomex\": [{\"name\": \"pycryptodomex\", \"source\": \"pip\", \"version\": \"3.11.0\"}], \"pykerberos\": [{\"name\": \"pykerberos\", \"source\": \"pip\", \"version\": \"1.1.14\"}], \"pyparsing\": [{\"name\": \"pyparsing\", \"source\": \"pip\", \"version\": \"2.4.7\"}], \"pytz\": [{\"name\": \"pytz\", \"source\": \"pip\", \"version\": \"2022.1\"}], \"pywinrm\": [{\"name\": \"pywinrm\", \"source\": \"pip\", \"version\": \"0.3.0\"}], \"requests\": [{\"name\": \"requests\", \"source\": \"pip\", \"version\": \"2.25.1\"}], \"requests-kerberos\": [{\"name\": \"requests-kerberos\", \"source\": \"pip\", \"version\": \"0.12.0\"}], \"requests-ntlm\": [{\"name\": \"requests-ntlm\", \"source\": \"pip\", \"version\": \"1.1.0\"}], \"requests-toolbelt\": [{\"name\": \"requests-toolbelt\", \"source\": \"pip\", \"version\": \"0.9.1\"}], \"resolvelib\": [{\"name\": \"resolvelib\", \"source\": \"pip\", \"version\": \"1.0.1\"}], \"selinux\": [{\"name\": \"selinux\", \"source\": \"pip\", \"version\": \"3.3\"}], \"setuptools\": [{\"name\": \"setuptools\", \"source\": \"pip\", \"version\": \"59.6.0\"}], \"simplejson\": [{\"name\": \"simplejson\", \"source\": \"pip\", \"version\": \"3.17.6\"}], \"six\": [{\"name\": \"six\", \"source\": \"pip\", \"version\": \"1.16.0\"}], \"urllib3\": [{\"name\": \"urllib3\", \"source\": \"pip\", \"version\": \"1.26.5\"}], \"wheel\": [{\"name\": \"wheel\", \"source\": \"pip\", \"version\": \"0.37.1\"}], \"xmltodict\": [{\"name\": \"xmltodict\", \"source\": \"pip\", \"version\": \"0.12.0\"}]}}}\nalgo-server  |\nalgo-server  | TASK [Verify Python meets Algo VPN requirements] *******************************\nalgo-server  | ok: [localhost] => {\nalgo-server  |     \"changed\": false,\nalgo-server  |     \"msg\": \"All assertions passed\"\nalgo-server  | }\nalgo-server  |\nalgo-server  | TASK [Verify Ansible meets Algo VPN requirements] ******************************\nalgo-server  | ok: [localhost] => {\nalgo-server  |     \"changed\": false,\nalgo-server  |     \"msg\": \"All assertions passed\"\nalgo-server  | }\nalgo-server  |\nalgo-server  | PLAY [Ask user for the input] **************************************************\nalgo-server  |\nalgo-server  | TASK [Gathering Facts] *********************************************************\nalgo-server  | ok: [localhost]\nalgo-server  |\nalgo-server  | TASK [Set facts based on the input] ********************************************\nalgo-server  | ok: [localhost] => {\"ansible_facts\": {\"algo_provider\": \"local\"}, \"changed\": false}\nalgo-server  | [WARNING]: Not waiting for response to prompt as stdin is not interactive\nalgo-server  |\nalgo-server  | TASK [Cellular On Demand prompt] ***********************************************\nalgo-server  | ok: [localhost] => {\"changed\": false, \"delta\": 0, \"echo\": true, \"rc\": 0, \"start\": \"2025-08-03 14:36:20.857514\", \"stderr\": \"\", \"stdout\": \"Paused for 0.0 minutes\", \"stop\": \"2025-08-03 14:36:20.858168\", \"user_input\": \"\"}\nalgo-server  |\nalgo-server  | TASK [Wi-Fi On Demand prompt] **************************************************\nalgo-server  | ok: [localhost] => {\"changed\": false, \"delta\": 0, \"echo\": true, \"rc\": 0, \"start\": \"2025-08-03 14:36:20.869924\", \"stderr\": \"\", \"stdout\": \"Paused for 0.0 minutes\", \"stop\": \"2025-08-03 14:36:20.870503\", \"user_input\": \"\"}\nalgo-server  |\nalgo-server  | TASK [Set facts based on the input] ********************************************\nalgo-server  | ok: [localhost] => {\"ansible_facts\": {\"algo_dns_adblocking\": false, \"algo_ondemand_cellular\": false, \"algo_ondemand_wifi\": false, \"algo_ondemand_wifi_exclude\": \"X251bGw=\", \"algo_server_name\": \"algo\", \"algo_ssh_tunneling\": false, \"algo_store_pki\": true}, \"changed\": false}\nalgo-server  |\nalgo-server  | PLAY [Provision the server] ****************************************************\nalgo-server  |\nalgo-server  | TASK [Gathering Facts] *********************************************************\nalgo-server  | ok: [localhost]\nalgo-server  |\nalgo-server  | TASK [Display the invocation environment] **************************************\nalgo-server  | changed: [localhost] => {\"changed\": true, \"cmd\": \"./algo-showenv.sh  'algo_provider \\\"local\\\"'    'algo_ondemand_cellular \\\"False\\\"'  'algo_ondemand_wifi \\\"False\\\"'  'algo_ondemand_wifi_exclude \\\"X251bGw=\\\"'    'algo_dns_adblocking \\\"False\\\"'  'algo_ssh_tunneling \\\"False\\\"'  'wireguard_enabled \\\"True\\\"'  'dns_encryption \\\"False\\\"'  > /dev/tty || true\\n\", \"delta\": \"0:00:00.000892\", \"end\": \"2025-08-03 14:36:21.491766\", \"msg\": \"\", \"rc\": 0, \"start\": \"2025-08-03 14:36:21.490874\", \"stderr\": \"/bin/sh: 1: cannot create /dev/tty: No such device or address\", \"stderr_lines\": [\"/bin/sh: 1: cannot create /dev/tty: No such device or address\"], \"stdout\": \"\", \"stdout_lines\": []}\nalgo-server  |\nalgo-server  | TASK [Install the requirements] ************************************************\nalgo-server  | changed: [localhost] => {\"changed\": true, \"cmd\": [\"/usr/bin/python3\", \"-m\", \"pip.__main__\", \"install\", \"pyOpenSSL>=0.15\", \"segno\"], \"name\": [\"pyOpenSSL>=0.15\", \"segno\"], \"requirements\": null, \"state\": \"present\", \"stderr\": \"WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.\\n\", \"stderr_lines\": [\"WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.\"], \"stdout\": \"Collecting pyOpenSSL>=0.15\\n  Downloading pyopenssl-25.1.0-py3-none-any.whl.metadata (17 kB)\\nCollecting segno\\n  Downloading segno-1.6.6-py3-none-any.whl.metadata (7.7 kB)\\nCollecting cryptography<46,>=41.0.5 (from pyOpenSSL>=0.15)\\n  Downloading cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl.metadata (5.7 kB)\\nCollecting typing-extensions>=4.9 (from pyOpenSSL>=0.15)\\n  Downloading typing_extensions-4.14.1-py3-none-any.whl.metadata (3.0 kB)\\nCollecting cffi>=1.14 (from cryptography<46,>=41.0.5->pyOpenSSL>=0.15)\\n  Downloading cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.metadata (1.5 kB)\\nCollecting pycparser (from cffi>=1.14->cryptography<46,>=41.0.5->pyOpenSSL>=0.15)\\n  Downloading pycparser-2.22-py3-none-any.whl.metadata (943 bytes)\\nDownloading pyopenssl-25.1.0-py3-none-any.whl (56 kB)\\nDownloading cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl (4.2 MB)\\n   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.2/4.2 MB 11.6 MB/s  0:00:00\\nDownloading segno-1.6.6-py3-none-any.whl (76 kB)\\nDownloading cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (448 kB)\\nDownloading typing_extensions-4.14.1-py3-none-any.whl (43 kB)\\nDownloading pycparser-2.22-py3-none-any.whl (117 kB)\\nInstalling collected packages: typing-extensions, segno, pycparser, cffi, cryptography, pyOpenSSL\\n  Attempting uninstall: cryptography\\n    Found existing installation: cryptography 3.4.8\\n    Uninstalling cryptography-3.4.8:\\n      Successfully uninstalled cryptography-3.4.8\\n\\nSuccessfully installed cffi-1.17.1 cryptography-45.0.5 pyOpenSSL-25.1.0 pycparser-2.22 segno-1.6.6 typing-extensions-4.14.1\\n\", \"stdout_lines\": [\"Collecting pyOpenSSL>=0.15\", \"  Downloading pyopenssl-25.1.0-py3-none-any.whl.metadata (17 kB)\", \"Collecting segno\", \"  Downloading segno-1.6.6-py3-none-any.whl.metadata (7.7 kB)\", \"Collecting cryptography<46,>=41.0.5 (from pyOpenSSL>=0.15)\", \"  Downloading cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl.metadata (5.7 kB)\", \"Collecting typing-extensions>=4.9 (from pyOpenSSL>=0.15)\", \"  Downloading typing_extensions-4.14.1-py3-none-any.whl.metadata (3.0 kB)\", \"Collecting cffi>=1.14 (from cryptography<46,>=41.0.5->pyOpenSSL>=0.15)\", \"  Downloading cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.metadata (1.5 kB)\", \"Collecting pycparser (from cffi>=1.14->cryptography<46,>=41.0.5->pyOpenSSL>=0.15)\", \"  Downloading pycparser-2.22-py3-none-any.whl.metadata (943 bytes)\", \"Downloading pyopenssl-25.1.0-py3-none-any.whl (56 kB)\", \"Downloading cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl (4.2 MB)\", \"   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 4.2/4.2 MB 11.6 MB/s  0:00:00\", \"Downloading segno-1.6.6-py3-none-any.whl (76 kB)\", \"Downloading cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (448 kB)\", \"Downloading typing_extensions-4.14.1-py3-none-any.whl (43 kB)\", \"Downloading pycparser-2.22-py3-none-any.whl (117 kB)\", \"Installing collected packages: typing-extensions, segno, pycparser, cffi, cryptography, pyOpenSSL\", \"  Attempting uninstall: cryptography\", \"    Found existing installation: cryptography 3.4.8\", \"    Uninstalling cryptography-3.4.8:\", \"      Successfully uninstalled cryptography-3.4.8\", \"\", \"Successfully installed cffi-1.17.1 cryptography-45.0.5 pyOpenSSL-25.1.0 pycparser-2.22 segno-1.6.6 typing-extensions-4.14.1\"], \"version\": null, \"virtualenv\": null}\nalgo-server  |\nalgo-server  | TASK [Include a provisioning role] *********************************************\nalgo-server  |\nalgo-server  | TASK [local : pause] ***********************************************************\nalgo-server  | ok: [localhost] => {\"changed\": false, \"delta\": 0, \"echo\": true, \"rc\": 0, \"start\": \"2025-08-03 14:36:23.852371\", \"stderr\": \"\", \"stdout\": \"Paused for 0.0 minutes\", \"stop\": \"2025-08-03 14:36:23.852907\", \"user_input\": \"\"}\nalgo-server  |\nalgo-server  | TASK [local : Set the facts] ***************************************************\nalgo-server  | ok: [localhost] => {\"ansible_facts\": {\"cloud_instance_ip\": \"localhost\"}, \"changed\": false}\nalgo-server  |\nalgo-server  | TASK [local : Set the facts] ***************************************************\nalgo-server  | ok: [localhost] => {\"ansible_facts\": {\"IP_subject_alt_name\": \"10.99.0.10\"}, \"changed\": false}\nalgo-server  |\nalgo-server  | TASK [Set subjectAltName as a fact] ********************************************\nalgo-server  | ok: [localhost] => {\"ansible_facts\": {\"IP_subject_alt_name\": \"10.99.0.10\"}, \"changed\": false}\nalgo-server  |\nalgo-server  | TASK [Add the server to an inventory group] ************************************\nalgo-server  | changed: [localhost] => {\"add_host\": {\"groups\": [\"vpn-host\"], \"host_name\": \"localhost\", \"host_vars\": {\"IP_subject_alt_name\": \"10.99.0.10\", \"algo_dns_adblocking\": false, \"algo_ondemand_cellular\": false, \"algo_ondemand_wifi\": false, \"algo_ondemand_wifi_exclude\": \"X251bGw=\", \"algo_provider\": \"local\", \"algo_server_name\": \"algo-test-server\", \"algo_ssh_tunneling\": false, \"algo_store_pki\": true, \"alternative_ingress_ip\": false, \"ansible_connection\": \"local\", \"ansible_python_interpreter\": \"/usr/bin/python3\", \"ansible_ssh_port\": \"22\", \"ansible_ssh_user\": \"root\", \"cloudinit\": false}}, \"changed\": true}\nalgo-server  |\nalgo-server  | TASK [debug] *******************************************************************\nalgo-server  | ok: [localhost] => {\nalgo-server  |     \"IP_subject_alt_name\": \"10.99.0.10\"\nalgo-server  | }\nalgo-server  | [WARNING]: Reset is not implemented for this connection\nalgo-server  |\nalgo-server  | TASK [Wait 600 seconds for target connection to become reachable/usable] *******\nalgo-server  | ok: [localhost] => (item=localhost) => {\"ansible_loop_var\": \"item\", \"changed\": false, \"elapsed\": 0, \"item\": \"localhost\"}\nalgo-server  |\nalgo-server  | PLAY [Configure the server and install required software] **********************\nalgo-server  |\nalgo-server  | TASK [common : Check the system] ***********************************************\nalgo-server  | ok: [localhost] => {\"changed\": false, \"rc\": 0, \"stderr\": \"\", \"stderr_lines\": [], \"stdout\": \"Linux algo-server 6.8.0-50-generic #51-Ubuntu SMP PREEMPT_DYNAMIC Sat Nov  9 18:03:35 UTC 2024 aarch64 aarch64 aarch64 GNU/Linux\\n\", \"stdout_lines\": [\"Linux algo-server 6.8.0-50-generic #51-Ubuntu SMP PREEMPT_DYNAMIC Sat Nov  9 18:03:35 UTC 2024 aarch64 aarch64 aarch64 GNU/Linux\"]}\nalgo-server  |\nalgo-server  | TASK [common : include_tasks] **************************************************\nalgo-server  | included: /algo/roles/common/tasks/ubuntu.yml for localhost\nalgo-server  |\nalgo-server  | TASK [common : Gather facts] ***************************************************\nalgo-server  | ok: [localhost]\nalgo-server  |\nalgo-server  | TASK [common : Install unattended-upgrades] ************************************\nalgo-server  | changed: [localhost] => {\"cache_update_time\": 1754231778, \"cache_updated\": false, \"changed\": true, \"stderr\": \"debconf: delaying package configuration, since apt-utils is not installed\\n\", \"stderr_lines\": [\"debconf: delaying package configuration, since apt-utils is not installed\"], \"stdout\": \"Reading package lists...\\nBuilding dependency tree...\\nReading state information...\\nThe following additional packages will be installed:\\n  libnss-systemd libpam-systemd python3-distro-info systemd-sysv\\nSuggested packages:\\n  bsd-mailx default-mta | mail-transport-agent needrestart powermgmt-base\\nThe following NEW packages will be installed:\\n  libnss-systemd libpam-systemd python3-distro-info systemd-sysv\\n  unattended-upgrades\\n0 upgraded, 5 newly installed, 0 to remove and 0 not upgraded.\\nNeed to get 405 kB of archives.\\nAfter this operation, 1853 kB of additional disk space will be used.\\nGet:1 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 systemd-sysv arm64 249.11-0ubuntu3.16 [10.5 kB]\\nGet:2 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 libnss-systemd arm64 249.11-0ubuntu3.16 [133 kB]\\nGet:3 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 libpam-systemd arm64 249.11-0ubuntu3.16 [205 kB]\\nGet:4 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 python3-distro-info all 1.1ubuntu0.2 [6554 B]\\nGet:5 http://ports.ubuntu.com/ubuntu-ports jammy/main arm64 unattended-upgrades all 2.8ubuntu1 [49.4 kB]\\nFetched 405 kB in 0s (3673 kB/s)\\nSelecting previously unselected package systemd-sysv.\\r\\n(Reading database ... \\r(Reading database ... 5%\\r(Reading database ... 10%\\r(Reading database ... 15%\\r(Reading database ... 20%\\r(Reading database ... 25%\\r(Reading database ... 30%\\r(Reading database ... 35%\\r(Reading database ... 40%\\r(Reading database ... 45%\\r(Reading database ... 50%\\r(Reading database ... 55%\\r(Reading database ... 60%\\r(Reading database ... 65%\\r(Reading database ... 70%\\r(Reading database ... 75%\\r(Reading database ... 80%\\r(Reading database ... 85%\\r(Reading database ... 90%\\r(Reading database ... 95%\\r(Reading database ... 100%\\r(Reading database ... 73818 files and directories currently installed.)\\r\\nPreparing to unpack .../systemd-sysv_249.11-0ubuntu3.16_arm64.deb ...\\r\\nUnpacking systemd-sysv (249.11-0ubuntu3.16) ...\\r\\nSelecting previously unselected package libnss-systemd:arm64.\\r\\nPreparing to unpack .../libnss-systemd_249.11-0ubuntu3.16_arm64.deb ...\\r\\nUnpacking libnss-systemd:arm64 (249.11-0ubuntu3.16) ...\\r\\nSelecting previously unselected package libpam-systemd:arm64.\\r\\nPreparing to unpack .../libpam-systemd_249.11-0ubuntu3.16_arm64.deb ...\\r\\nUnpacking libpam-systemd:arm64 (249.11-0ubuntu3.16) ...\\r\\nSelecting previously unselected package python3-distro-info.\\r\\nPreparing to unpack .../python3-distro-info_1.1ubuntu0.2_all.deb ...\\r\\nUnpacking python3-distro-info (1.1ubuntu0.2) ...\\r\\nSelecting previously unselected package unattended-upgrades.\\r\\nPreparing to unpack .../unattended-upgrades_2.8ubuntu1_all.deb ...\\r\\nUnpacking unattended-upgrades (2.8ubuntu1) ...\\r\\nSetting up systemd-sysv (249.11-0ubuntu3.16) ...\\r\\nSetting up libnss-systemd:arm64 (249.11-0ubuntu3.16) ...\\r\\nFirst installation detected...\\r\\nChecking NSS setup...\\r\\nSetting up libpam-systemd:arm64 (249.11-0ubuntu3.16) ...\\r\\nSetting up python3-distro-info (1.1ubuntu0.2) ...\\r\\nSetting up unattended-upgrades (2.8ubuntu1) ...\\r\\n\\r\\nCreating config file /etc/apt/apt.conf.d/20auto-upgrades with new version\\r\\n\\r\\nCreating config file /etc/apt/apt.conf.d/50unattended-upgrades with new version\\r\\nProcessing triggers for libc-bin (2.35-0ubuntu3.10) ...\\r\\n\", \"stdout_lines\": [\"Reading package lists...\", \"Building dependency tree...\", \"Reading state information...\", \"The following additional packages will be installed:\", \"  libnss-systemd libpam-systemd python3-distro-info systemd-sysv\", \"Suggested packages:\", \"  bsd-mailx default-mta | mail-transport-agent needrestart powermgmt-base\", \"The following NEW packages will be installed:\", \"  libnss-systemd libpam-systemd python3-distro-info systemd-sysv\", \"  unattended-upgrades\", \"0 upgraded, 5 newly installed, 0 to remove and 0 not upgraded.\", \"Need to get 405 kB of archives.\", \"After this operation, 1853 kB of additional disk space will be used.\", \"Get:1 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 systemd-sysv arm64 249.11-0ubuntu3.16 [10.5 kB]\", \"Get:2 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 libnss-systemd arm64 249.11-0ubuntu3.16 [133 kB]\", \"Get:3 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 libpam-systemd arm64 249.11-0ubuntu3.16 [205 kB]\", \"Get:4 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 python3-distro-info all 1.1ubuntu0.2 [6554 B]\", \"Get:5 http://ports.ubuntu.com/ubuntu-ports jammy/main arm64 unattended-upgrades all 2.8ubuntu1 [49.4 kB]\", \"Fetched 405 kB in 0s (3673 kB/s)\", \"Selecting previously unselected package systemd-sysv.\", \"(Reading database ... \", \"(Reading database ... 5%\", \"(Reading database ... 10%\", \"(Reading database ... 15%\", \"(Reading database ... 20%\", \"(Reading database ... 25%\", \"(Reading database ... 30%\", \"(Reading database ... 35%\", \"(Reading database ... 40%\", \"(Reading database ... 45%\", \"(Reading database ... 50%\", \"(Reading database ... 55%\", \"(Reading database ... 60%\", \"(Reading database ... 65%\", \"(Reading database ... 70%\", \"(Reading database ... 75%\", \"(Reading database ... 80%\", \"(Reading database ... 85%\", \"(Reading database ... 90%\", \"(Reading database ... 95%\", \"(Reading database ... 100%\", \"(Reading database ... 73818 files and directories currently installed.)\", \"Preparing to unpack .../systemd-sysv_249.11-0ubuntu3.16_arm64.deb ...\", \"Unpacking systemd-sysv (249.11-0ubuntu3.16) ...\", \"Selecting previously unselected package libnss-systemd:arm64.\", \"Preparing to unpack .../libnss-systemd_249.11-0ubuntu3.16_arm64.deb ...\", \"Unpacking libnss-systemd:arm64 (249.11-0ubuntu3.16) ...\", \"Selecting previously unselected package libpam-systemd:arm64.\", \"Preparing to unpack .../libpam-systemd_249.11-0ubuntu3.16_arm64.deb ...\", \"Unpacking libpam-systemd:arm64 (249.11-0ubuntu3.16) ...\", \"Selecting previously unselected package python3-distro-info.\", \"Preparing to unpack .../python3-distro-info_1.1ubuntu0.2_all.deb ...\", \"Unpacking python3-distro-info (1.1ubuntu0.2) ...\", \"Selecting previously unselected package unattended-upgrades.\", \"Preparing to unpack .../unattended-upgrades_2.8ubuntu1_all.deb ...\", \"Unpacking unattended-upgrades (2.8ubuntu1) ...\", \"Setting up systemd-sysv (249.11-0ubuntu3.16) ...\", \"Setting up libnss-systemd:arm64 (249.11-0ubuntu3.16) ...\", \"First installation detected...\", \"Checking NSS setup...\", \"Setting up libpam-systemd:arm64 (249.11-0ubuntu3.16) ...\", \"Setting up python3-distro-info (1.1ubuntu0.2) ...\", \"Setting up unattended-upgrades (2.8ubuntu1) ...\", \"\", \"Creating config file /etc/apt/apt.conf.d/20auto-upgrades with new version\", \"\", \"Creating config file /etc/apt/apt.conf.d/50unattended-upgrades with new version\", \"Processing triggers for libc-bin (2.35-0ubuntu3.10) ...\"]}\nalgo-server  |\nalgo-server  | TASK [common : Configure unattended-upgrades] **********************************\nalgo-server  | changed: [localhost] => {\"changed\": true, \"checksum\": \"ed74b36a000a932393d6d1f4c47186bcd895a515\", \"dest\": \"/etc/apt/apt.conf.d/50unattended-upgrades\", \"gid\": 0, \"group\": \"root\", \"md5sum\": \"ead855b06ced87c51e33071acab35bf3\", \"mode\": \"0644\", \"owner\": \"root\", \"size\": 3797, \"src\": \"/root/.ansible/tmp/ansible-tmp-1754231789.302889-995-157903953471024/source\", \"state\": \"file\", \"uid\": 0}\nalgo-server  |\nalgo-server  | TASK [common : Periodic upgrades configured] ***********************************\nalgo-server  | changed: [localhost] => {\"changed\": true, \"checksum\": \"eac74547eec217a356899a6d8a377d3f1522851a\", \"dest\": \"/etc/apt/apt.conf.d/10periodic\", \"gid\": 0, \"group\": \"root\", \"md5sum\": \"4e7d1cf7c590a703ad853f2658fb8eeb\", \"mode\": \"0644\", \"owner\": \"root\", \"size\": 168, \"src\": \"/root/.ansible/tmp/ansible-tmp-1754231789.5252056-1025-265113230477430/source\", \"state\": \"file\", \"uid\": 0}\nalgo-server  |\nalgo-server  | TASK [common : Disable MOTD on login and SSHD] *********************************\nalgo-server  | changed: [localhost] => (item={'regexp': '^session.*optional.*pam_motd.so.*', 'line': '# MOTD DISABLED', 'file': '/etc/pam.d/login'}) => {\"ansible_loop_var\": \"item\", \"changed\": true, \"item\": {\"file\": \"/etc/pam.d/login\", \"line\": \"# MOTD DISABLED\", \"regexp\": \"^session.*optional.*pam_motd.so.*\"}, \"msg\": \"2 replacements made\", \"rc\": 0}\nalgo-server  | ok: [localhost] => (item={'regexp': '^session.*optional.*pam_motd.so.*', 'line': '# MOTD DISABLED', 'file': '/etc/pam.d/sshd'}) => {\"ansible_loop_var\": \"item\", \"changed\": false, \"item\": {\"file\": \"/etc/pam.d/sshd\", \"line\": \"# MOTD DISABLED\", \"regexp\": \"^session.*optional.*pam_motd.so.*\"}, \"msg\": \"\", \"rc\": 0}\nalgo-server  |\nalgo-server  | TASK [common : Ensure fallback resolvers are set] ******************************\nalgo-server  | changed: [localhost] => {\"changed\": true, \"gid\": 0, \"group\": \"root\", \"mode\": \"0644\", \"msg\": \"option changed\", \"owner\": \"root\", \"path\": \"/etc/systemd/resolved.conf\", \"size\": 1422, \"state\": \"file\", \"uid\": 0}\nalgo-server  |\nalgo-server  | TASK [common : Loopback for services configured] *******************************\nalgo-server  | changed: [localhost] => {\"changed\": true, \"checksum\": \"38bd687506a689a469b3dd4a25190324d6b8f146\", \"dest\": \"/etc/systemd/network/10-algo-lo100.network\", \"gid\": 0, \"group\": \"root\", \"md5sum\": \"274ffd4d7caf66f754ca3c5e5cad049e\", \"mode\": \"0644\", \"owner\": \"root\", \"size\": 97, \"src\": \"/root/.ansible/tmp/ansible-tmp-1754231789.9961534-1068-186978330039061/source\", \"state\": \"file\", \"uid\": 0}\nalgo-server  |\nalgo-server  | TASK [common : systemd services enabled and started] ***************************\nalgo-server  | changed: [localhost] => (item=systemd-networkd) => {\"ansible_loop_var\": \"item\", \"changed\": true, \"enabled\": true, \"item\": \"systemd-networkd\", \"name\": \"systemd-networkd\", \"state\": \"started\", \"status\": {\"ActiveState\": \"active\", \"LoadState\": \"loaded\", \"SubState\": \"running\"}}\nalgo-server  | changed: [localhost] => (item=systemd-resolved) => {\"ansible_loop_var\": \"item\", \"changed\": true, \"enabled\": true, \"item\": \"systemd-resolved\", \"name\": \"systemd-resolved\", \"state\": \"started\", \"status\": {\"ActiveState\": \"active\", \"LoadState\": \"loaded\", \"SubState\": \"running\"}}\nalgo-server  |\nalgo-server  | RUNNING HANDLER [common : restart systemd-networkd] ****************************\nalgo-server  | changed: [localhost] => {\"changed\": true, \"name\": \"systemd-networkd\", \"state\": \"restarted\", \"status\": {\"ActiveState\": \"active\", \"LoadState\": \"loaded\", \"SubState\": \"running\"}}\nalgo-server  |\nalgo-server  | RUNNING HANDLER [common : restart systemd-resolved] ****************************\nalgo-server  | changed: [localhost] => {\"changed\": true, \"name\": \"systemd-resolved\", \"state\": \"restarted\", \"status\": {\"ActiveState\": \"active\", \"LoadState\": \"loaded\", \"SubState\": \"running\"}}\nalgo-server  |\nalgo-server  | TASK [common : Check apparmor support] *****************************************\nalgo-server  | fatal: [localhost]: FAILED! => {\"changed\": false, \"cmd\": \"apparmor_status\", \"msg\": \"[Errno 2] No such file or directory: b'apparmor_status'\", \"rc\": 2, \"stderr\": \"\", \"stderr_lines\": [], \"stdout\": \"\", \"stdout_lines\": []}\nalgo-server  | ...ignoring\nalgo-server  |\nalgo-server  | TASK [common : Define facts] ***************************************************\nalgo-server  | ok: [localhost] => {\"ansible_facts\": {\"p12_export_password\": \"j6xqfQax2\"}, \"changed\": false}\nalgo-server  |\nalgo-server  | TASK [common : Set facts] ******************************************************\nalgo-server  | ok: [localhost] => {\"ansible_facts\": {\"CA_password\": \"cGJkMmasHgeGjkg6\", \"IP_subject_alt_name\": \"10.99.0.10\"}, \"changed\": false}\nalgo-server  |\nalgo-server  | TASK [common : Set IPv6 support as a fact] *************************************\nalgo-server  | ok: [localhost] => {\"ansible_facts\": {\"ipv6_support\": false}, \"changed\": false}\nalgo-server  |\nalgo-server  | TASK [common : Check size of MTU] **********************************************\nalgo-server  | ok: [localhost] => {\"ansible_facts\": {\"reduce_mtu\": \"0\"}, \"changed\": false}\nalgo-server  |\nalgo-server  | TASK [common : Set OS specific facts] ******************************************\nalgo-server  | ok: [localhost] => {\"ansible_facts\": {\"sysctl\": [{\"item\": \"net.ipv4.ip_forward\", \"value\": 1}, {\"item\": \"net.ipv4.conf.all.forwarding\", \"value\": 1}, {\"item\": \"\", \"value\": 1}], \"tools\": [\"git\", \"screen\", \"apparmor-utils\", \"uuid-runtime\", \"coreutils\", \"iptables-persistent\", \"cgroup-tools\", \"openssl\", \"gnupg2\", \"cron\"]}, \"changed\": false}\nalgo-server  |\nalgo-server  | TASK [common : Install tools] **************************************************\nalgo-server  | changed: [localhost] => {\"cache_update_time\": 1754231778, \"cache_updated\": false, \"changed\": true, \"stderr\": \"debconf: delaying package configuration, since apt-utils is not installed\\n\", \"stderr_lines\": [\"debconf: delaying package configuration, since apt-utils is not installed\"], \"stdout\": \"Reading package lists...\\nBuilding dependency tree...\\nReading state information...\\nThe following additional packages will be installed:\\n  apparmor git-man less libcgroup1 libcurl3-gnutls liberror-perl libutempter0\\n  netfilter-persistent python3-apparmor python3-libapparmor\\nSuggested packages:\\n  apparmor-profiles-extra vim-addon-manager anacron logrotate checksecurity\\n  default-mta | mail-transport-agent gettext-base git-daemon-run\\n  | git-daemon-sysvinit git-doc git-email git-gui gitk gitweb git-cvs\\n  git-mediawiki git-svn byobu | screenie | iselect ncurses-term\\nThe following NEW packages will be installed:\\n  apparmor apparmor-utils cgroup-tools cron git git-man gnupg2\\n  iptables-persistent less libcgroup1 libcurl3-gnutls liberror-perl\\n  libutempter0 netfilter-persistent python3-apparmor python3-libapparmor\\n  screen uuid-runtime\\n0 upgraded, 18 newly installed, 0 to remove and 0 not upgraded.\\nNeed to get 6288 kB of archives.\\nAfter this operation, 27.5 MB of additional disk space will be used.\\nGet:1 http://ports.ubuntu.com/ubuntu-ports jammy/main arm64 cron arm64 3.0pl1-137ubuntu3 [73.1 kB]\\nGet:2 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 uuid-runtime arm64 2.37.2-4ubuntu3.4 [31.8 kB]\\nGet:3 http://ports.ubuntu.com/ubuntu-ports jammy/universe arm64 netfilter-persistent all 1.0.16 [7440 B]\\nGet:4 http://ports.ubuntu.com/ubuntu-ports jammy/universe arm64 iptables-persistent all 1.0.16 [6488 B]\\nGet:5 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 less arm64 590-1ubuntu0.22.04.3 [141 kB]\\nGet:6 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 apparmor arm64 3.0.4-2ubuntu2.4 [576 kB]\\nGet:7 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 python3-libapparmor arm64 3.0.4-2ubuntu2.4 [29.4 kB]\\nGet:8 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 python3-apparmor all 3.0.4-2ubuntu2.4 [81.1 kB]\\nGet:9 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 apparmor-utils all 3.0.4-2ubuntu2.4 [59.5 kB]\\nGet:10 http://ports.ubuntu.com/ubuntu-ports jammy/universe arm64 libcgroup1 arm64 2.0-2 [50.3 kB]\\nGet:11 http://ports.ubuntu.com/ubuntu-ports jammy/universe arm64 cgroup-tools arm64 2.0-2 [72.1 kB]\\nGet:12 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 libcurl3-gnutls arm64 7.81.0-1ubuntu1.20 [279 kB]\\nGet:13 http://ports.ubuntu.com/ubuntu-ports jammy/main arm64 liberror-perl all 0.17029-1 [26.5 kB]\\nGet:14 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 git-man all 1:2.34.1-1ubuntu1.15 [955 kB]\\nGet:15 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 git arm64 1:2.34.1-1ubuntu1.15 [3224 kB]\\nGet:16 http://ports.ubuntu.com/ubuntu-ports jammy/main arm64 libutempter0 arm64 1.2.1-2build2 [8774 B]\\nGet:17 http://ports.ubuntu.com/ubuntu-ports jammy/main arm64 screen arm64 4.9.0-1 [661 kB]\\nGet:18 http://ports.ubuntu.com/ubuntu-ports jammy-updates/universe arm64 gnupg2 all 2.2.27-3ubuntu2.4 [5544 B]\\nFetched 6288 kB in 1s (10.6 MB/s)\\nSelecting previously unselected package cron.\\r\\n(Reading database ... \\r(Reading database ... 5%\\r(Reading database ... 10%\\r(Reading database ... 15%\\r(Reading database ... 20%\\r(Reading database ... 25%\\r(Reading database ... 30%\\r(Reading database ... 35%\\r(Reading database ... 40%\\r(Reading database ... 45%\\r(Reading database ... 50%\\r(Reading database ... 55%\\r(Reading database ... 60%\\r(Reading database ... 65%\\r(Reading database ... 70%\\r(Reading database ... 75%\\r(Reading database ... 80%\\r(Reading database ... 85%\\r(Reading database ... 90%\\r(Reading database ... 95%\\r(Reading database ... 100%\\r(Reading database ... 73890 files and directories currently installed.)\\r\\nPreparing to unpack .../00-cron_3.0pl1-137ubuntu3_arm64.deb ...\\r\\nUnpacking cron (3.0pl1-137ubuntu3) ...\\r\\nSelecting previously unselected package uuid-runtime.\\r\\nPreparing to unpack .../01-uuid-runtime_2.37.2-4ubuntu3.4_arm64.deb ...\\r\\nUnpacking uuid-runtime (2.37.2-4ubuntu3.4) ...\\r\\nSelecting previously unselected package netfilter-persistent.\\r\\nPreparing to unpack .../02-netfilter-persistent_1.0.16_all.deb ...\\r\\nUnpacking netfilter-persistent (1.0.16) ...\\r\\nSelecting previously unselected package iptables-persistent.\\r\\nPreparing to unpack .../03-iptables-persistent_1.0.16_all.deb ...\\r\\nUnpacking iptables-persistent (1.0.16) ...\\r\\nSelecting previously unselected package less.\\r\\nPreparing to unpack .../04-less_590-1ubuntu0.22.04.3_arm64.deb ...\\r\\nUnpacking less (590-1ubuntu0.22.04.3) ...\\r\\nSelecting previously unselected package apparmor.\\r\\nPreparing to unpack .../05-apparmor_3.0.4-2ubuntu2.4_arm64.deb ...\\r\\nUnpacking apparmor (3.0.4-2ubuntu2.4) ...\\r\\nSelecting previously unselected package python3-libapparmor.\\r\\nPreparing to unpack .../06-python3-libapparmor_3.0.4-2ubuntu2.4_arm64.deb ...\\r\\nUnpacking python3-libapparmor (3.0.4-2ubuntu2.4) ...\\r\\nSelecting previously unselected package python3-apparmor.\\r\\nPreparing to unpack .../07-python3-apparmor_3.0.4-2ubuntu2.4_all.deb ...\\r\\nUnpacking python3-apparmor (3.0.4-2ubuntu2.4) ...\\r\\nSelecting previously unselected package apparmor-utils.\\r\\nPreparing to unpack .../08-apparmor-utils_3.0.4-2ubuntu2.4_all.deb ...\\r\\nUnpacking apparmor-utils (3.0.4-2ubuntu2.4) ...\\r\\nSelecting previously unselected package libcgroup1:arm64.\\r\\nPreparing to unpack .../09-libcgroup1_2.0-2_arm64.deb ...\\r\\nUnpacking libcgroup1:arm64 (2.0-2) ...\\r\\nSelecting previously unselected package cgroup-tools.\\r\\nPreparing to unpack .../10-cgroup-tools_2.0-2_arm64.deb ...\\r\\nUnpacking cgroup-tools (2.0-2) ...\\r\\nSelecting previously unselected package libcurl3-gnutls:arm64.\\r\\nPreparing to unpack .../11-libcurl3-gnutls_7.81.0-1ubuntu1.20_arm64.deb ...\\r\\nUnpacking libcurl3-gnutls:arm64 (7.81.0-1ubuntu1.20) ...\\r\\nSelecting previously unselected package liberror-perl.\\r\\nPreparing to unpack .../12-liberror-perl_0.17029-1_all.deb ...\\r\\nUnpacking liberror-perl (0.17029-1) ...\\r\\nSelecting previously unselected package git-man.\\r\\nPreparing to unpack .../13-git-man_1%3a2.34.1-1ubuntu1.15_all.deb ...\\r\\nUnpacking git-man (1:2.34.1-1ubuntu1.15) ...\\r\\nSelecting previously unselected package git.\\r\\nPreparing to unpack .../14-git_1%3a2.34.1-1ubuntu1.15_arm64.deb ...\\r\\nUnpacking git (1:2.34.1-1ubuntu1.15) ...\\r\\nSelecting previously unselected package libutempter0:arm64.\\r\\nPreparing to unpack .../15-libutempter0_1.2.1-2build2_arm64.deb ...\\r\\nUnpacking libutempter0:arm64 (1.2.1-2build2) ...\\r\\nSelecting previously unselected package screen.\\r\\nPreparing to unpack .../16-screen_4.9.0-1_arm64.deb ...\\r\\nUnpacking screen (4.9.0-1) ...\\r\\nSelecting previously unselected package gnupg2.\\r\\nPreparing to unpack .../17-gnupg2_2.2.27-3ubuntu2.4_all.deb ...\\r\\nUnpacking gnupg2 (2.2.27-3ubuntu2.4) ...\\r\\nSetting up python3-libapparmor (3.0.4-2ubuntu2.4) ...\\r\\nSetting up gnupg2 (2.2.27-3ubuntu2.4) ...\\r\\nSetting up cron (3.0pl1-137ubuntu3) ...\\r\\nAdding group `crontab' (GID 111) ...\\r\\nDone.\\r\\nMock systemctl: operation '' recorded but not implemented\\r\\ninvoke-rc.d: policy-rc.d denied execution of start.\\r\\nSetting up less (590-1ubuntu0.22.04.3) ...\\r\\nSetting up libcurl3-gnutls:arm64 (7.81.0-1ubuntu1.20) ...\\r\\nSetting up liberror-perl (0.17029-1) ...\\r\\nSetting up apparmor (3.0.4-2ubuntu2.4) ...\\r\\nSetting up libutempter0:arm64 (1.2.1-2build2) ...\\r\\nSetting up netfilter-persistent (1.0.16) ...\\r\\nMock systemctl: operation '' recorded but not implemented\\r\\ninvoke-rc.d: policy-rc.d denied execution of restart.\\r\\nSetting up uuid-runtime (2.37.2-4ubuntu3.4) ...\\r\\nAdding group `uuidd' (GID 112) ...\\r\\nDone.\\r\\nWarning: The home dir /run/uuidd you specified can't be accessed: No such file or directory\\r\\nAdding system user `uuidd' (UID 106) ...\\r\\nAdding new user `uuidd' (UID 106) with group `uuidd' ...\\r\\nNot creating home directory `/run/uuidd'.\\r\\nMock systemctl: operation '' recorded but not implemented\\r\\ninvoke-rc.d: policy-rc.d denied execution of start.\\r\\nSetting up python3-apparmor (3.0.4-2ubuntu2.4) ...\\r\\nSetting up git-man (1:2.34.1-1ubuntu1.15) ...\\r\\nSetting up libcgroup1:arm64 (2.0-2) ...\\r\\nSetting up cgroup-tools (2.0-2) ...\\r\\nSetting up screen (4.9.0-1) ...\\r\\nSetting up iptables-persistent (1.0.16) ...\\r\\nupdate-alternatives: using /lib/systemd/system/netfilter-persistent.service to provide /lib/systemd/system/iptables.service (iptables.service) in auto mode\\r\\nSetting up apparmor-utils (3.0.4-2ubuntu2.4) ...\\r\\nSetting up git (1:2.34.1-1ubuntu1.15) ...\\r\\nProcessing triggers for libc-bin (2.35-0ubuntu3.10) ...\\r\\n\", \"stdout_lines\": [\"Reading package lists...\", \"Building dependency tree...\", \"Reading state information...\", \"The following additional packages will be installed:\", \"  apparmor git-man less libcgroup1 libcurl3-gnutls liberror-perl libutempter0\", \"  netfilter-persistent python3-apparmor python3-libapparmor\", \"Suggested packages:\", \"  apparmor-profiles-extra vim-addon-manager anacron logrotate checksecurity\", \"  default-mta | mail-transport-agent gettext-base git-daemon-run\", \"  | git-daemon-sysvinit git-doc git-email git-gui gitk gitweb git-cvs\", \"  git-mediawiki git-svn byobu | screenie | iselect ncurses-term\", \"The following NEW packages will be installed:\", \"  apparmor apparmor-utils cgroup-tools cron git git-man gnupg2\", \"  iptables-persistent less libcgroup1 libcurl3-gnutls liberror-perl\", \"  libutempter0 netfilter-persistent python3-apparmor python3-libapparmor\", \"  screen uuid-runtime\", \"0 upgraded, 18 newly installed, 0 to remove and 0 not upgraded.\", \"Need to get 6288 kB of archives.\", \"After this operation, 27.5 MB of additional disk space will be used.\", \"Get:1 http://ports.ubuntu.com/ubuntu-ports jammy/main arm64 cron arm64 3.0pl1-137ubuntu3 [73.1 kB]\", \"Get:2 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 uuid-runtime arm64 2.37.2-4ubuntu3.4 [31.8 kB]\", \"Get:3 http://ports.ubuntu.com/ubuntu-ports jammy/universe arm64 netfilter-persistent all 1.0.16 [7440 B]\", \"Get:4 http://ports.ubuntu.com/ubuntu-ports jammy/universe arm64 iptables-persistent all 1.0.16 [6488 B]\", \"Get:5 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 less arm64 590-1ubuntu0.22.04.3 [141 kB]\", \"Get:6 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 apparmor arm64 3.0.4-2ubuntu2.4 [576 kB]\", \"Get:7 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 python3-libapparmor arm64 3.0.4-2ubuntu2.4 [29.4 kB]\", \"Get:8 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 python3-apparmor all 3.0.4-2ubuntu2.4 [81.1 kB]\", \"Get:9 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 apparmor-utils all 3.0.4-2ubuntu2.4 [59.5 kB]\", \"Get:10 http://ports.ubuntu.com/ubuntu-ports jammy/universe arm64 libcgroup1 arm64 2.0-2 [50.3 kB]\", \"Get:11 http://ports.ubuntu.com/ubuntu-ports jammy/universe arm64 cgroup-tools arm64 2.0-2 [72.1 kB]\", \"Get:12 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 libcurl3-gnutls arm64 7.81.0-1ubuntu1.20 [279 kB]\", \"Get:13 http://ports.ubuntu.com/ubuntu-ports jammy/main arm64 liberror-perl all 0.17029-1 [26.5 kB]\", \"Get:14 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 git-man all 1:2.34.1-1ubuntu1.15 [955 kB]\", \"Get:15 http://ports.ubuntu.com/ubuntu-ports jammy-updates/main arm64 git arm64 1:2.34.1-1ubuntu1.15 [3224 kB]\", \"Get:16 http://ports.ubuntu.com/ubuntu-ports jammy/main arm64 libutempter0 arm64 1.2.1-2build2 [8774 B]\", \"Get:17 http://ports.ubuntu.com/ubuntu-ports jammy/main arm64 screen arm64 4.9.0-1 [661 kB]\", \"Get:18 http://ports.ubuntu.com/ubuntu-ports jammy-updates/universe arm64 gnupg2 all 2.2.27-3ubuntu2.4 [5544 B]\", \"Fetched 6288 kB in 1s (10.6 MB/s)\", \"Selecting previously unselected package cron.\", \"(Reading database ... \", \"(Reading database ... 5%\", \"(Reading database ... 10%\", \"(Reading database ... 15%\", \"(Reading database ... 20%\", \"(Reading database ... 25%\", \"(Reading database ... 30%\", \"(Reading database ... 35%\", \"(Reading database ... 40%\", \"(Reading database ... 45%\", \"(Reading database ... 50%\", \"(Reading database ... 55%\", \"(Reading database ... 60%\", \"(Reading database ... 65%\", \"(Reading database ... 70%\", \"(Reading database ... 75%\", \"(Reading database ... 80%\", \"(Reading database ... 85%\", \"(Reading database ... 90%\", \"(Reading database ... 95%\", \"(Reading database ... 100%\", \"(Reading database ... 73890 files and directories currently installed.)\", \"Preparing to unpack .../00-cron_3.0pl1-137ubuntu3_arm64.deb ...\", \"Unpacking cron (3.0pl1-137ubuntu3) ...\", \"Selecting previously unselected package uuid-runtime.\", \"Preparing to unpack .../01-uuid-runtime_2.37.2-4ubuntu3.4_arm64.deb ...\", \"Unpacking uuid-runtime (2.37.2-4ubuntu3.4) ...\", \"Selecting previously unselected package netfilter-persistent.\", \"Preparing to unpack .../02-netfilter-persistent_1.0.16_all.deb ...\", \"Unpacking netfilter-persistent (1.0.16) ...\", \"Selecting previously unselected package iptables-persistent.\", \"Preparing to unpack .../03-iptables-persistent_1.0.16_all.deb ...\", \"Unpacking iptables-persistent (1.0.16) ...\", \"Selecting previously unselected package less.\", \"Preparing to unpack .../04-less_590-1ubuntu0.22.04.3_arm64.deb ...\", \"Unpacking less (590-1ubuntu0.22.04.3) ...\", \"Selecting previously unselected package apparmor.\", \"Preparing to unpack .../05-apparmor_3.0.4-2ubuntu2.4_arm64.deb ...\", \"Unpacking apparmor (3.0.4-2ubuntu2.4) ...\", \"Selecting previously unselected package python3-libapparmor.\", \"Preparing to unpack .../06-python3-libapparmor_3.0.4-2ubuntu2.4_arm64.deb ...\", \"Unpacking python3-libapparmor (3.0.4-2ubuntu2.4) ...\", \"Selecting previously unselected package python3-apparmor.\", \"Preparing to unpack .../07-python3-apparmor_3.0.4-2ubuntu2.4_all.deb ...\", \"Unpacking python3-apparmor (3.0.4-2ubuntu2.4) ...\", \"Selecting previously unselected package apparmor-utils.\", \"Preparing to unpack .../08-apparmor-utils_3.0.4-2ubuntu2.4_all.deb ...\", \"Unpacking apparmor-utils (3.0.4-2ubuntu2.4) ...\", \"Selecting previously unselected package libcgroup1:arm64.\", \"Preparing to unpack .../09-libcgroup1_2.0-2_arm64.deb ...\", \"Unpacking libcgroup1:arm64 (2.0-2) ...\", \"Selecting previously unselected package cgroup-tools.\", \"Preparing to unpack .../10-cgroup-tools_2.0-2_arm64.deb ...\", \"Unpacking cgroup-tools (2.0-2) ...\", \"Selecting previously unselected package libcurl3-gnutls:arm64.\", \"Preparing to unpack .../11-libcurl3-gnutls_7.81.0-1ubuntu1.20_arm64.deb ...\", \"Unpacking libcurl3-gnutls:arm64 (7.81.0-1ubuntu1.20) ...\", \"Selecting previously unselected package liberror-perl.\", \"Preparing to unpack .../12-liberror-perl_0.17029-1_all.deb ...\", \"Unpacking liberror-perl (0.17029-1) ...\", \"Selecting previously unselected package git-man.\", \"Preparing to unpack .../13-git-man_1%3a2.34.1-1ubuntu1.15_all.deb ...\", \"Unpacking git-man (1:2.34.1-1ubuntu1.15) ...\", \"Selecting previously unselected package git.\", \"Preparing to unpack .../14-git_1%3a2.34.1-1ubuntu1.15_arm64.deb ...\", \"Unpacking git (1:2.34.1-1ubuntu1.15) ...\", \"Selecting previously unselected package libutempter0:arm64.\", \"Preparing to unpack .../15-libutempter0_1.2.1-2build2_arm64.deb ...\", \"Unpacking libutempter0:arm64 (1.2.1-2build2) ...\", \"Selecting previously unselected package screen.\", \"Preparing to unpack .../16-screen_4.9.0-1_arm64.deb ...\", \"Unpacking screen (4.9.0-1) ...\", \"Selecting previously unselected package gnupg2.\", \"Preparing to unpack .../17-gnupg2_2.2.27-3ubuntu2.4_all.deb ...\", \"Unpacking gnupg2 (2.2.27-3ubuntu2.4) ...\", \"Setting up python3-libapparmor (3.0.4-2ubuntu2.4) ...\", \"Setting up gnupg2 (2.2.27-3ubuntu2.4) ...\", \"Setting up cron (3.0pl1-137ubuntu3) ...\", \"Adding group `crontab' (GID 111) ...\", \"Done.\", \"Mock systemctl: operation '' recorded but not implemented\", \"invoke-rc.d: policy-rc.d denied execution of start.\", \"Setting up less (590-1ubuntu0.22.04.3) ...\", \"Setting up libcurl3-gnutls:arm64 (7.81.0-1ubuntu1.20) ...\", \"Setting up liberror-perl (0.17029-1) ...\", \"Setting up apparmor (3.0.4-2ubuntu2.4) ...\", \"Setting up libutempter0:arm64 (1.2.1-2build2) ...\", \"Setting up netfilter-persistent (1.0.16) ...\", \"Mock systemctl: operation '' recorded but not implemented\", \"invoke-rc.d: policy-rc.d denied execution of restart.\", \"Setting up uuid-runtime (2.37.2-4ubuntu3.4) ...\", \"Adding group `uuidd' (GID 112) ...\", \"Done.\", \"Warning: The home dir /run/uuidd you specified can't be accessed: No such file or directory\", \"Adding system user `uuidd' (UID 106) ...\", \"Adding new user `uuidd' (UID 106) with group `uuidd' ...\", \"Not creating home directory `/run/uuidd'.\", \"Mock systemctl: operation '' recorded but not implemented\", \"invoke-rc.d: policy-rc.d denied execution of start.\", \"Setting up python3-apparmor (3.0.4-2ubuntu2.4) ...\", \"Setting up git-man (1:2.34.1-1ubuntu1.15) ...\", \"Setting up libcgroup1:arm64 (2.0-2) ...\", \"Setting up cgroup-tools (2.0-2) ...\", \"Setting up screen (4.9.0-1) ...\", \"Setting up iptables-persistent (1.0.16) ...\", \"update-alternatives: using /lib/systemd/system/netfilter-persistent.service to provide /lib/systemd/system/iptables.service (iptables.service) in auto mode\", \"Setting up apparmor-utils (3.0.4-2ubuntu2.4) ...\", \"Setting up git (1:2.34.1-1ubuntu1.15) ...\", \"Processing triggers for libc-bin (2.35-0ubuntu3.10) ...\"]}\nalgo-server  |\nalgo-server  | TASK [common : include_tasks] **************************************************\nalgo-server  | included: /algo/roles/common/tasks/iptables.yml for localhost\nalgo-server  |\nalgo-server  | TASK [common : Iptables configured] ********************************************\nalgo-server  | changed: [localhost] => (item={'src': 'rules.v4.j2', 'dest': '/etc/iptables/rules.v4'}) => {\"ansible_loop_var\": \"item\", \"changed\": true, \"checksum\": \"72b2166dab8060974a72ce2be670711869338f5c\", \"dest\": \"/etc/iptables/rules.v4\", \"gid\": 0, \"group\": \"root\", \"item\": {\"dest\": \"/etc/iptables/rules.v4\", \"src\": \"rules.v4.j2\"}, \"md5sum\": \"0ba6bbff320d49c1c5392c5b5ae324e9\", \"mode\": \"0640\", \"owner\": \"root\", \"size\": 3211, \"src\": \"/root/.ansible/tmp/ansible-tmp-1754231795.9110847-1824-197214830210453/source\", \"state\": \"file\", \"uid\": 0}\nalgo-server  |\nalgo-server  | TASK [common : Sysctl tuning] **************************************************\nalgo-server  | changed: [localhost] => (item={'item': 'net.ipv4.ip_forward', 'value': 1}) => {\"ansible_loop_var\": \"item\", \"changed\": true, \"item\": {\"item\": \"net.ipv4.ip_forward\", \"value\": 1}}\nalgo-server  | changed: [localhost] => (item={'item': 'net.ipv4.conf.all.forwarding', 'value': 1}) => {\"ansible_loop_var\": \"item\", \"changed\": true, \"item\": {\"item\": \"net.ipv4.conf.all.forwarding\", \"value\": 1}}\nalgo-server  |\nalgo-server  | RUNNING HANDLER [common : restart iptables] ************************************\nalgo-server  | changed: [localhost] => {\"changed\": true, \"name\": \"netfilter-persistent\", \"status\": {\"enabled\": {\"changed\": false, \"rc\": null, \"stderr\": null, \"stdout\": null}, \"restarted\": {\"changed\": true, \"rc\": 0, \"stderr\": \"run-parts: executing /usr/share/netfilter-persistent/plugins.d/15-ip4tables start\\nrun-parts: executing /usr/share/netfilter-persistent/plugins.d/25-ip6tables start\\n\", \"stdout\": \" * Loading netfilter rules...\\n   ...done.\\n\"}}}\nalgo-server  |\nalgo-server  | TASK [wireguard : Ensure the required directories exist] ***********************\nalgo-server  | changed: [localhost] => (item=configs/10.99.0.10/wireguard//.pki//preshared) => {\"ansible_loop_var\": \"item\", \"changed\": true, \"gid\": 0, \"group\": \"root\", \"item\": \"configs/10.99.0.10/wireguard//.pki//preshared\", \"mode\": \"0755\", \"owner\": \"root\", \"path\": \"configs/10.99.0.10/wireguard//.pki//preshared\", \"size\": 4096, \"state\": \"directory\", \"uid\": 0}\nalgo-server  | changed: [localhost] => (item=configs/10.99.0.10/wireguard//.pki//private) => {\"ansible_loop_var\": \"item\", \"changed\": true, \"gid\": 0, \"group\": \"root\", \"item\": \"configs/10.99.0.10/wireguard//.pki//private\", \"mode\": \"0755\", \"owner\": \"root\", \"path\": \"configs/10.99.0.10/wireguard//.pki//private\", \"size\": 4096, \"state\": \"directory\", \"uid\": 0}\nalgo-server  | changed: [localhost] => (item=configs/10.99.0.10/wireguard//.pki//public) => {\"ansible_loop_var\": \"item\", \"changed\": true, \"gid\": 0, \"group\": \"root\", \"item\": \"configs/10.99.0.10/wireguard//.pki//public\", \"mode\": \"0755\", \"owner\": \"root\", \"path\": \"configs/10.99.0.10/wireguard//.pki//public\", \"size\": 4096, \"state\": \"directory\", \"uid\": 0}\nalgo-server  | changed: [localhost] => (item=configs/10.99.0.10/wireguard//apple/ios) => {\"ansible_loop_var\": \"item\", \"changed\": true, \"gid\": 0, \"group\": \"root\", \"item\": \"configs/10.99.0.10/wireguard//apple/ios\", \"mode\": \"0755\", \"owner\": \"root\", \"path\": \"configs/10.99.0.10/wireguard//apple/ios\", \"size\": 4096, \"state\": \"directory\", \"uid\": 0}\nalgo-server  | changed: [localhost] => (item=configs/10.99.0.10/wireguard//apple/macos) => {\"ansible_loop_var\": \"item\", \"changed\": true, \"gid\": 0, \"group\": \"root\", \"item\": \"configs/10.99.0.10/wireguard//apple/macos\", \"mode\": \"0755\", \"owner\": \"root\", \"path\": \"configs/10.99.0.10/wireguard//apple/macos\", \"size\": 4096, \"state\": \"directory\", \"uid\": 0}\nalgo-server  |\nalgo-server  | TASK [wireguard : Include tasks for Ubuntu] ************************************\nalgo-server  | included: /algo/roles/wireguard/tasks/ubuntu.yml for localhost\nalgo-server  |\nalgo-server  | TASK [wireguard : WireGuard installed] *****************************************\nalgo-server  | fatal: [localhost]: FAILED! => {\"changed\": false, \"msg\": \"Failed to update apt cache: unknown reason\"}\nalgo-server  |\nalgo-server  | TASK [include_tasks] ***********************************************************\nalgo-server  | included: /algo/playbooks/rescue.yml for localhost\nalgo-server  |\nalgo-server  | TASK [debug] *******************************************************************\nalgo-server  | ok: [localhost] => {\nalgo-server  |     \"fail_hint\": \"VARIABLE IS NOT DEFINED!: 'fail_hint' is undefined. 'fail_hint' is undefined\"\nalgo-server  | }\nalgo-server  |\nalgo-server  | TASK [Fail the installation] ***************************************************\nalgo-server  | fatal: [localhost]: FAILED! => {\"changed\": false, \"msg\": \"Failed as requested from task\"}\nalgo-server  |\nalgo-server  | PLAY RECAP *********************************************************************\nalgo-server  | localhost                  : ok=50   changed=17   unreachable=0    failed=1    skipped=41   rescued=1    ignored=1\nalgo-server  |\n"
  },
  {
    "path": "tests/test-aws-credentials.yml",
    "content": "---\n# Test AWS credential reading from files\n# Run with: ansible-playbook tests/test-aws-credentials.yml\n\n- name: Test AWS credential file reading\n  hosts: localhost\n  gather_facts: no\n  vars:\n    # These would normally come from config.cfg\n    cloud_providers:\n      ec2:\n        use_existing_eip: false\n\n  tasks:\n    - name: Test with environment variables\n      block:\n        - include_tasks: ../roles/cloud-ec2/tasks/prompts.yml\n          vars:\n            algo_server_name: test-server\n\n        - assert:\n            that:\n              - access_key == \"test_env_key\"\n              - secret_key == \"test_env_secret\"\n            msg: \"Environment variables should take precedence\"\n      vars:\n        AWS_ACCESS_KEY_ID: \"test_env_key\"\n        AWS_SECRET_ACCESS_KEY: \"test_env_secret\"\n      environment:\n        AWS_ACCESS_KEY_ID: \"test_env_key\"\n        AWS_SECRET_ACCESS_KEY: \"test_env_secret\"\n\n    - name: Test with command line variables\n      block:\n        - include_tasks: ../roles/cloud-ec2/tasks/prompts.yml\n          vars:\n            aws_access_key: \"test_cli_key\"\n            aws_secret_key: \"test_cli_secret\"\n            algo_server_name: test-server\n            region: \"us-east-1\"\n\n        - assert:\n            that:\n              - access_key == \"test_cli_key\"\n              - secret_key == \"test_cli_secret\"\n            msg: \"Command line variables should take precedence over everything\"\n\n    - name: Test reading from credentials file\n      block:\n        - name: Create test credentials directory\n          file:\n            path: /tmp/test-aws\n            state: directory\n            mode: '0700'\n\n        - name: Create test credentials file\n          copy:\n            dest: /tmp/test-aws/credentials\n            mode: '0600'\n            content: |\n              [default]\n              aws_access_key_id = test_file_key\n              aws_secret_access_key = test_file_secret\n\n              [test-profile]\n              aws_access_key_id = test_profile_key\n              aws_secret_access_key = test_profile_secret\n              aws_session_token = test_session_token\n\n        - name: Test default profile\n          include_tasks: ../roles/cloud-ec2/tasks/prompts.yml\n          vars:\n            algo_server_name: test-server\n            region: \"us-east-1\"\n          environment:\n            HOME: /tmp/test-aws\n            AWS_ACCESS_KEY_ID: \"\"\n            AWS_SECRET_ACCESS_KEY: \"\"\n\n        - assert:\n            that:\n              - access_key == \"test_file_key\"\n              - secret_key == \"test_file_secret\"\n            msg: \"Should read from default profile\"\n\n        - name: Test custom profile\n          include_tasks: ../roles/cloud-ec2/tasks/prompts.yml\n          vars:\n            algo_server_name: test-server\n            region: \"us-east-1\"\n          environment:\n            HOME: /tmp/test-aws\n            AWS_PROFILE: \"test-profile\"\n            AWS_ACCESS_KEY_ID: \"\"\n            AWS_SECRET_ACCESS_KEY: \"\"\n\n        - assert:\n            that:\n              - access_key == \"test_profile_key\"\n              - secret_key == \"test_profile_secret\"\n              - session_token == \"test_session_token\"\n            msg: \"Should read from custom profile with session token\"\n\n        - name: Cleanup test directory\n          file:\n            path: /tmp/test-aws\n            state: absent\n"
  },
  {
    "path": "tests/test-local-config.sh",
    "content": "#!/bin/bash\n# Simple test that verifies Algo can generate configurations without errors\n\nset -e\n\necho \"Testing Algo configuration generation...\"\n\n# Generate SSH key if it doesn't exist\nif [ ! -f ~/.ssh/id_rsa ]; then\n    ssh-keygen -f ~/.ssh/id_rsa -t rsa -N ''\nfi\n\n# Create a minimal test configuration\ncat > test-config.cfg << 'EOF'\nusers:\n  - test-user\ncloud_providers:\n  local:\n    server: localhost\n    endpoint: 127.0.0.1\nwireguard_enabled: true\nipsec_enabled: false\ndns_adblocking: false\nssh_tunneling: false\nstore_pki: true\ntests: true\nalgo_provider: local\nalgo_server_name: test-server\nalgo_ondemand_cellular: false\nalgo_ondemand_wifi: false\nalgo_ondemand_wifi_exclude: \"\"\nalgo_dns_adblocking: false\nalgo_ssh_tunneling: false\nwireguard_PersistentKeepalive: 0\nwireguard_network: 10.19.49.0/24\nwireguard_network_ipv6: fd9d:bc11:4020::/48\nwireguard_port: 51820\ndns_encryption: false\nsubjectAltName_type: IP\nsubjectAltName: 127.0.0.1\nIP_subject_alt_name: 127.0.0.1\nalgo_server: localhost\nalgo_user: ubuntu\nansible_ssh_user: ubuntu\nalgo_ssh_port: 22\nendpoint: 127.0.0.1\nserver: localhost\nssh_user: ubuntu\nCA_password: \"test-password-123\"\np12_export_password: \"test-export-password\"\nEOF\n\n# Run Ansible in check mode to verify templates work\necho \"Running Ansible in check mode...\"\nuv run ansible-playbook main.yml \\\n    -i \"localhost,\" \\\n    -c local \\\n    -e @test-config.cfg \\\n    -e \"provider=local\" \\\n    --check \\\n    --diff \\\n    --tags \"configuration\" \\\n    --skip-tags \"restart_services,tests,assert,cloud,facts_install\"\n\necho \"Configuration generation test passed!\"\n\n# Clean up\nrm -f test-config.cfg\n"
  },
  {
    "path": "tests/test-wireguard-async.yml",
    "content": "---\n# Test WireGuard async result structure handling\n# This simulates the async_status result structure to verify our fix\n- name: Test WireGuard async result handling\n  hosts: localhost\n  gather_facts: no\n  vars:\n    # Simulate the actual structure that async_status with with_items returns\n    # This is the key insight: async_status results contain the original item info\n    mock_wg_genkey_results:\n      results:\n        - item: \"user1\"        # This comes from the original wg_genkey.results item\n          stdout: \"mock_private_key_1\"  # This is the command output\n          changed: true\n          rc: 0\n          failed: false\n          finished: true\n        - item: \"10.10.10.1\"   # This comes from the original wg_genkey.results item\n          stdout: \"mock_private_key_2\"  # This is the command output\n          changed: true\n          rc: 0\n          failed: false\n          finished: true\n    wireguard_pki_path: \"/tmp/test-wireguard-pki\"\n\n  tasks:\n    - name: Create test directories\n      file:\n        path: \"{{ item }}\"\n        state: directory\n        mode: '0700'\n      loop:\n        - \"{{ wireguard_pki_path }}/private\"\n        - \"{{ wireguard_pki_path }}/preshared\"\n\n    - name: Test private key saving (using with_items like the actual code)\n      copy:\n        dest: \"{{ wireguard_pki_path }}/private/{{ item.item }}\"\n        content: \"{{ item.stdout }}\"\n        mode: \"0600\"\n      when: item.changed\n      loop: \"{{ mock_wg_genkey_results.results }}\"\n\n    - name: Verify files were created correctly\n      stat:\n        path: \"{{ wireguard_pki_path }}/private/{{ item }}\"\n      register: file_check\n      loop:\n        - \"user1\"\n        - \"10.10.10.1\"\n\n    - name: Assert files exist\n      assert:\n        that:\n          - item.stat.exists\n          - item.stat.mode == \"0600\"\n        msg: \"Private key file should exist with correct permissions\"\n      loop: \"{{ file_check.results }}\"\n\n    - name: Verify file contents\n      slurp:\n        src: \"{{ wireguard_pki_path }}/private/{{ item }}\"\n      register: file_contents\n      loop:\n        - \"user1\"\n        - \"10.10.10.1\"\n\n    - name: Assert file contents are correct\n      assert:\n        that:\n          - (file_contents.results[0].content | b64decode) == \"mock_private_key_1\"\n          - (file_contents.results[1].content | b64decode) == \"mock_private_key_2\"\n        msg: \"File contents should match expected values\"\n\n    # Test the error handling path too\n    - name: Test error condition handling\n      debug:\n        msg: \"Would display error for {{ item.item }}\"\n      when: item.rc is defined and item.rc != 0\n      loop: \"{{ mock_wg_genkey_results.results }}\"\n      # This should not trigger since our mock data has rc: 0\n\n    - name: Cleanup test directory\n      file:\n        path: \"{{ wireguard_pki_path }}\"\n        state: absent\n"
  },
  {
    "path": "tests/test-wireguard-fix.yml",
    "content": "---\n# Test the corrected WireGuard async pattern\n- name: Test corrected WireGuard async pattern\n  hosts: localhost\n  gather_facts: no\n  vars:\n    test_users: [\"testuser1\", \"testuser2\"]\n    IP_subject_alt_name: \"127.0.0.1\"\n    wireguard_pki_path: \"/tmp/test-fixed-wireguard\"\n\n  tasks:\n    - name: Create test directory\n      file:\n        path: \"{{ wireguard_pki_path }}/private\"\n        state: directory\n        mode: '0700'\n\n    - name: Generate keys (parallel) - simulating wg genkey\n      command: echo \"mock_private_key_for_{{ item }}\"\n      register: wg_genkey\n      loop: \"{{ test_users + [IP_subject_alt_name] }}\"\n      async: 10\n      poll: 0\n\n    - name: Wait for completion - simulating async_status\n      async_status:\n        jid: \"{{ item.ansible_job_id }}\"\n      loop: \"{{ wg_genkey.results }}\"\n      register: wg_genkey_results\n      until: wg_genkey_results.finished\n      retries: 15\n      delay: 1\n\n    - name: Save using CORRECTED pattern - item.item.item\n      copy:\n        dest: \"{{ wireguard_pki_path }}/private/{{ item.item.item }}\"\n        content: \"{{ item.stdout }}\"\n        mode: \"0600\"\n      when: item.changed\n      loop: \"{{ wg_genkey_results.results }}\"\n\n    - name: Verify files were created with correct names\n      stat:\n        path: \"{{ wireguard_pki_path }}/private/{{ item }}\"\n      register: file_check\n      loop:\n        - \"testuser1\"\n        - \"testuser2\"\n        - \"127.0.0.1\"\n\n    - name: Assert all files exist\n      assert:\n        that:\n          - item.stat.exists\n        msg: \"File should exist: {{ item.stat.path }}\"\n      loop: \"{{ file_check.results }}\"\n\n    - name: Cleanup\n      file:\n        path: \"{{ wireguard_pki_path }}\"\n        state: absent\n\n    - debug:\n        msg: \"✅ WireGuard async fix test PASSED - item.item.item is the correct pattern!\"\n"
  },
  {
    "path": "tests/test-wireguard-real-async.yml",
    "content": "---\n# CRITICAL TEST: WireGuard Async Structure Debugging\n# ==================================================\n# This test validates the complex triple-nested data structure created by:\n# async + register + loop -> async_status + register + loop\n#\n# DO NOT DELETE: This test prevented production deployment failures by revealing\n# that the access pattern is item.item.item (not item.item as initially assumed).\n#\n# Run with: ansible-playbook tests/test-wireguard-real-async.yml -v\n# Purpose: Debug and validate the async result structure when using with_items\n- name: Test real WireGuard async pattern\n  hosts: localhost\n  gather_facts: no\n  vars:\n    test_users: [\"testuser1\", \"testuser2\"]\n    IP_subject_alt_name: \"127.0.0.1\"\n    wireguard_pki_path: \"/tmp/test-real-wireguard\"\n\n  tasks:\n    - name: Create test directory\n      file:\n        path: \"{{ wireguard_pki_path }}/private\"\n        state: directory\n        mode: '0700'\n\n    - name: Simulate the actual async pattern - Generate keys (parallel)\n      command: echo \"mock_private_key_for_{{ item }}\"\n      register: wg_genkey\n      loop: \"{{ test_users + [IP_subject_alt_name] }}\"\n      async: 10\n      poll: 0\n\n    - name: Debug - Show wg_genkey structure\n      debug:\n        var: wg_genkey\n\n    - name: Simulate the actual async pattern - Wait for completion\n      async_status:\n        jid: \"{{ item.ansible_job_id }}\"\n      loop: \"{{ wg_genkey.results }}\"\n      register: wg_genkey_results\n      until: wg_genkey_results.finished\n      retries: 15\n      delay: 1\n\n    - name: Debug - Show wg_genkey_results structure (the real issue)\n      debug:\n        var: wg_genkey_results\n\n    - name: Try to save using the current failing pattern\n      copy:\n        dest: \"{{ wireguard_pki_path }}/private/{{ item.item }}\"\n        content: \"{{ item.stdout }}\"\n        mode: \"0600\"\n      when: item.changed\n      loop: \"{{ wg_genkey_results.results }}\"\n      failed_when: false\n\n    - name: Cleanup\n      file:\n        path: \"{{ wireguard_pki_path }}\"\n        state: absent\n"
  },
  {
    "path": "tests/test_cloud_init_template.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nCloud-init template validation test.\n\nThis test validates that the cloud-init template for DigitalOcean deployments\nrenders correctly and produces valid YAML that cloud-init can parse.\n\nThis test helps prevent regressions like issue #14800 where YAML formatting\nissues caused cloud-init to fail completely, resulting in SSH timeouts.\n\nUsage:\n    python3 tests/test_cloud_init_template.py\n\nOr from project root:\n    python3 -m pytest tests/test_cloud_init_template.py -v\n\"\"\"\n\nimport sys\nfrom pathlib import Path\n\nimport yaml\n\n# Add project root to path for imports if needed\nPROJECT_ROOT = Path(__file__).parent.parent\nsys.path.insert(0, str(PROJECT_ROOT))\n\n\ndef create_expected_cloud_init():\n    \"\"\"\n    Create the expected cloud-init content that should be generated\n    by our template after the YAML indentation fix.\n    \"\"\"\n    return \"\"\"#cloud-config\n# CRITICAL: The above line MUST be exactly \"#cloud-config\" (no space after #)\n# This is required by cloud-init's YAML parser. Adding a space breaks parsing\n# and causes all cloud-init directives to be skipped, resulting in SSH timeouts.\n# See: https://github.com/trailofbits/algo/issues/14800\noutput: {all: '| tee -a /var/log/cloud-init-output.log'}\n\npackage_update: true\npackage_upgrade: true\n\npackages:\n  - sudo\n\nusers:\n  - default\n  - name: algo\n    homedir: /home/algo\n    sudo: ALL=(ALL) NOPASSWD:ALL\n    groups: adm,netdev\n    shell: /bin/bash\n    lock_passwd: true\n    ssh_authorized_keys:\n      - \"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDTest algo-test\"\n\nwrite_files:\n  - path: /etc/ssh/sshd_config\n    content: |\n      Port 4160\n      AllowGroups algo\n      PermitRootLogin no\n      PasswordAuthentication no\n      ChallengeResponseAuthentication no\n      UsePAM yes\n      X11Forwarding yes\n      PrintMotd no\n      AcceptEnv LANG LC_*\n      Subsystem\tsftp\t/usr/lib/openssh/sftp-server\n\nruncmd:\n  - set -x\n  - ufw --force reset\n  - sudo apt-get remove -y --purge sshguard || true\n  - systemctl restart sshd.service\n\"\"\"\n\n\nclass TestCloudInitTemplate:\n    \"\"\"Test class for cloud-init template validation.\"\"\"\n\n    def test_yaml_validity(self):\n        \"\"\"Test that the expected cloud-init YAML is valid.\"\"\"\n        print(\"🧪 Testing YAML validity...\")\n\n        cloud_init_content = create_expected_cloud_init()\n\n        try:\n            parsed = yaml.safe_load(cloud_init_content)\n            print(\"✅ YAML parsing successful\")\n            assert parsed is not None, \"YAML should parse to a non-None value\"\n            return parsed\n        except yaml.YAMLError as e:\n            print(f\"❌ YAML parsing failed: {e}\")\n            assert False, f\"YAML parsing failed: {e}\"\n\n    def test_required_sections(self):\n        \"\"\"Test that all required cloud-init sections are present.\"\"\"\n        print(\"🧪 Testing required sections...\")\n\n        parsed = self.test_yaml_validity()\n\n        required_sections = [\"package_update\", \"package_upgrade\", \"packages\", \"users\", \"write_files\", \"runcmd\"]\n\n        missing = [section for section in required_sections if section not in parsed]\n        assert not missing, f\"Missing required sections: {missing}\"\n\n        print(\"✅ All required sections present\")\n\n    def test_ssh_configuration(self):\n        \"\"\"Test that SSH configuration is correct.\"\"\"\n        print(\"🧪 Testing SSH configuration...\")\n\n        parsed = self.test_yaml_validity()\n\n        write_files = parsed.get(\"write_files\", [])\n        assert write_files, \"write_files section should be present\"\n\n        # Find sshd_config file\n        sshd_config = None\n        for file_entry in write_files:\n            if file_entry.get(\"path\") == \"/etc/ssh/sshd_config\":\n                sshd_config = file_entry\n                break\n\n        assert sshd_config, \"sshd_config file should be in write_files\"\n\n        content = sshd_config.get(\"content\", \"\")\n        assert content, \"sshd_config should have content\"\n\n        # Check required SSH configurations\n        required_configs = [\"Port 4160\", \"AllowGroups algo\", \"PermitRootLogin no\", \"PasswordAuthentication no\"]\n\n        missing = [config for config in required_configs if config not in content]\n        assert not missing, f\"Missing SSH configurations: {missing}\"\n\n        # Verify proper formatting - first line should be Port directive\n        lines = content.strip().split(\"\\n\")\n        assert lines[0].strip() == \"Port 4160\", f\"First line should be 'Port 4160', got: {lines[0]!r}\"\n\n        print(\"✅ SSH configuration correct\")\n\n    def test_user_creation(self):\n        \"\"\"Test that algo user will be created correctly.\"\"\"\n        print(\"🧪 Testing user creation...\")\n\n        parsed = self.test_yaml_validity()\n\n        users = parsed.get(\"users\", [])\n        assert users, \"users section should be present\"\n\n        # Find algo user\n        algo_user = None\n        for user in users:\n            if isinstance(user, dict) and user.get(\"name\") == \"algo\":\n                algo_user = user\n                break\n\n        assert algo_user, \"algo user should be defined\"\n\n        # Check required user properties\n        required_props = [\"sudo\", \"groups\", \"shell\", \"ssh_authorized_keys\"]\n        missing = [prop for prop in required_props if prop not in algo_user]\n        assert not missing, f\"algo user missing properties: {missing}\"\n\n        # Verify sudo configuration\n        sudo_config = algo_user.get(\"sudo\", \"\")\n        assert \"NOPASSWD:ALL\" in sudo_config, f\"sudo config should allow passwordless access: {sudo_config}\"\n\n        print(\"✅ User creation correct\")\n\n    def test_runcmd_section(self):\n        \"\"\"Test that runcmd section will restart SSH correctly.\"\"\"\n        print(\"🧪 Testing runcmd section...\")\n\n        parsed = self.test_yaml_validity()\n\n        runcmd = parsed.get(\"runcmd\", [])\n        assert runcmd, \"runcmd section should be present\"\n\n        # Check for SSH restart command\n        ssh_restart_found = False\n        for cmd in runcmd:\n            if \"systemctl restart sshd\" in str(cmd):\n                ssh_restart_found = True\n                break\n\n        assert ssh_restart_found, f\"SSH restart command not found in runcmd: {runcmd}\"\n\n        print(\"✅ runcmd section correct\")\n\n    def test_indentation_consistency(self):\n        \"\"\"Test that sshd_config content has consistent indentation.\"\"\"\n        print(\"🧪 Testing indentation consistency...\")\n\n        cloud_init_content = create_expected_cloud_init()\n\n        # Extract the sshd_config content lines\n        lines = cloud_init_content.split(\"\\n\")\n        in_sshd_content = False\n        sshd_lines = []\n\n        for line in lines:\n            if \"content: |\" in line:\n                in_sshd_content = True\n                continue\n            elif in_sshd_content:\n                if line.strip() == \"\" and len(sshd_lines) > 0:\n                    break\n                if line.startswith(\"runcmd:\"):\n                    break\n                sshd_lines.append(line)\n\n        assert sshd_lines, \"Should be able to extract sshd_config content\"\n\n        # Check that all non-empty lines have consistent 6-space indentation\n        non_empty_lines = [line for line in sshd_lines if line.strip()]\n        assert non_empty_lines, \"sshd_config should have content\"\n\n        for line in non_empty_lines:\n            # Each line should start with exactly 6 spaces\n            assert line.startswith(\"      \") and not line.startswith(\"       \"), (\n                f\"Line should have exactly 6 spaces indentation: {line!r}\"\n            )\n\n        print(\"✅ Indentation is consistent\")\n\n\ndef run_tests():\n    \"\"\"Run all tests manually (for non-pytest usage).\"\"\"\n    print(\"🚀 Cloud-init template validation tests\")\n    print(\"=\" * 50)\n\n    test_instance = TestCloudInitTemplate()\n\n    try:\n        test_instance.test_yaml_validity()\n        test_instance.test_required_sections()\n        test_instance.test_ssh_configuration()\n        test_instance.test_user_creation()\n        test_instance.test_runcmd_section()\n        test_instance.test_indentation_consistency()\n\n        print(\"=\" * 50)\n        print(\"🎉 ALL TESTS PASSED!\")\n        print(\"✅ Cloud-init template is working correctly\")\n        print(\"✅ DigitalOcean deployment should succeed\")\n        return True\n\n    except AssertionError as e:\n        print(f\"❌ Test failed: {e}\")\n        return False\n    except Exception as e:\n        print(f\"❌ Unexpected error: {e}\")\n        return False\n\n\nif __name__ == \"__main__\":\n    success = run_tests()\n    sys.exit(0 if success else 1)\n"
  },
  {
    "path": "tests/test_package_preinstall.py",
    "content": "#!/usr/bin/env python3\n\nimport unittest\n\nfrom jinja2 import Template\n\n\nclass TestPackagePreinstall(unittest.TestCase):\n    def setUp(self):\n        \"\"\"Set up test fixtures.\"\"\"\n        # Create a simplified test template with just the packages section\n        self.packages_template = Template(\"\"\"\npackages:\n  - sudo\n{% if performance_preinstall_packages | default(false) %}\n  # Universal tools always needed by Algo (performance optimization)\n  - git\n  - screen\n  - apparmor-utils\n  - uuid-runtime\n  - coreutils\n  - iptables-persistent\n  - cgroup-tools\n{% endif %}\n\"\"\")\n\n    def test_preinstall_disabled_by_default(self):\n        \"\"\"Test that package pre-installation is disabled by default.\"\"\"\n        # Test with default config (performance_preinstall_packages not set)\n        rendered = self.packages_template.render({})\n\n        # Should only have sudo package\n        self.assertIn(\"- sudo\", rendered)\n        self.assertNotIn(\"- git\", rendered)\n        self.assertNotIn(\"- screen\", rendered)\n        self.assertNotIn(\"- apparmor-utils\", rendered)\n\n    def test_preinstall_enabled(self):\n        \"\"\"Test that package pre-installation works when enabled.\"\"\"\n        # Test with pre-installation enabled\n        rendered = self.packages_template.render({\"performance_preinstall_packages\": True})\n\n        # Should have sudo and all universal packages\n        self.assertIn(\"- sudo\", rendered)\n        self.assertIn(\"- git\", rendered)\n        self.assertIn(\"- screen\", rendered)\n        self.assertIn(\"- apparmor-utils\", rendered)\n        self.assertIn(\"- uuid-runtime\", rendered)\n        self.assertIn(\"- coreutils\", rendered)\n        self.assertIn(\"- iptables-persistent\", rendered)\n        self.assertIn(\"- cgroup-tools\", rendered)\n\n    def test_preinstall_disabled_explicitly(self):\n        \"\"\"Test that package pre-installation is disabled when set to false.\"\"\"\n        # Test with pre-installation explicitly disabled\n        rendered = self.packages_template.render({\"performance_preinstall_packages\": False})\n\n        # Should only have sudo package\n        self.assertIn(\"- sudo\", rendered)\n        self.assertNotIn(\"- git\", rendered)\n        self.assertNotIn(\"- screen\", rendered)\n        self.assertNotIn(\"- apparmor-utils\", rendered)\n\n    def test_package_count(self):\n        \"\"\"Test that the correct number of packages are included.\"\"\"\n        # Default: should only have sudo (1 package)\n        rendered_default = self.packages_template.render({})\n        lines_default = [line.strip() for line in rendered_default.split(\"\\n\") if line.strip().startswith(\"- \")]\n        self.assertEqual(len(lines_default), 1)\n\n        # Enabled: should have sudo + 7 universal packages (8 total)\n        rendered_enabled = self.packages_template.render({\"performance_preinstall_packages\": True})\n        lines_enabled = [line.strip() for line in rendered_enabled.split(\"\\n\") if line.strip().startswith(\"- \")]\n        self.assertEqual(len(lines_enabled), 8)\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/unit/test_ansible_12_boolean_fix.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest that verifies the fix for Ansible 12.0.0 boolean type checking.\nThis test reads the actual YAML files to ensure they don't produce string booleans.\n\"\"\"\n\nimport re\nfrom pathlib import Path\n\n\nclass TestAnsible12BooleanFix:\n    \"\"\"Tests to verify Ansible 12.0.0 boolean compatibility.\"\"\"\n\n    def test_ipv6_support_not_string_boolean(self):\n        \"\"\"Verify ipv6_support in facts.yml doesn't produce string 'true'/'false'.\"\"\"\n        facts_file = Path(__file__).parent.parent.parent / \"roles/common/tasks/facts.yml\"\n\n        with open(facts_file) as f:\n            content = f.read()\n\n        # Check that we're NOT using the broken pattern\n        broken_pattern = r'ipv6_support:\\s*\".*\\}true\\{.*\\}false\\{.*\"'\n        assert not re.search(broken_pattern, content), (\n            \"ipv6_support is using string literals 'true'/'false' which breaks Ansible 12\"\n        )\n\n        # Check that we ARE using the correct pattern\n        correct_pattern = r'ipv6_support:\\s*\".*is\\s+defined.*\"'\n        assert re.search(correct_pattern, content), \"ipv6_support should use 'is defined' which returns a boolean\"\n\n    def test_input_yml_algo_variables_not_string_boolean(self):\n        \"\"\"Verify algo_* variables in input.yml don't produce string 'false'.\"\"\"\n        input_file = Path(__file__).parent.parent.parent / \"input.yml\"\n\n        with open(input_file) as f:\n            content = f.read()\n\n        # Variables to check\n        algo_vars = [\n            \"algo_ondemand_cellular\",\n            \"algo_ondemand_wifi\",\n            \"algo_dns_adblocking\",\n            \"algo_ssh_tunneling\",\n            \"algo_store_pki\",\n        ]\n\n        for var in algo_vars:\n            # Find the variable definition\n            var_pattern = rf\"{var}:.*?\\n(.*?)\\n\\s*algo_\"\n            match = re.search(var_pattern, content, re.DOTALL)\n\n            if match:\n                var_content = match.group(1)\n\n                # Check that we're NOT using string literal 'false'\n                # The broken pattern: {%- else %}false{% endif %}\n                assert not re.search(r\"\\{%-?\\s*else\\s*%\\}false\\{%\", var_content), (\n                    f\"{var} is using string literal 'false' which breaks Ansible 12\"\n                )\n\n                # Check that we ARE using {{ false }}\n                # The correct pattern: {%- else %}{{ false }}{% endif %}\n                if \"else\" in var_content:\n                    assert \"{{ false }}\" in var_content or \"{{ true }}\" in var_content or \"| bool\" in var_content, (\n                        f\"{var} should use '{{{{ false }}}}' or '{{{{ true }}}}' for boolean values\"\n                    )\n\n    def test_no_bare_true_false_in_templates(self):\n        \"\"\"Scan for any remaining bare 'true'/'false' in Jinja2 expressions.\"\"\"\n        # Patterns that indicate string boolean literals (bad)\n        bad_patterns = [\n            r\"\\{%[^%]*\\}true\\{%\",  # %}true{%\n            r\"\\{%[^%]*\\}false\\{%\",  # %}false{%\n            r\"%\\}true\\{%\",  # %}true{%\n            r\"%\\}false\\{%\",  # %}false{%\n        ]\n\n        files_to_check = [\n            Path(__file__).parent.parent.parent / \"roles/common/tasks/facts.yml\",\n            Path(__file__).parent.parent.parent / \"input.yml\",\n        ]\n\n        for file_path in files_to_check:\n            with open(file_path) as f:\n                content = f.read()\n\n            for pattern in bad_patterns:\n                matches = re.findall(pattern, content)\n                assert not matches, (\n                    f\"Found string boolean literal in {file_path.name}: {matches}. \"\n                    f\"Use '{{{{ true }}}}' or '{{{{ false }}}}' instead.\"\n                )\n\n    def test_conditional_uses_of_variables(self):\n        \"\"\"Check that when: conditions using these variables will work with booleans.\"\"\"\n        # Files that might have 'when:' conditions\n        files_to_check = [\n            Path(__file__).parent.parent.parent / \"roles/common/tasks/iptables.yml\",\n            Path(__file__).parent.parent.parent / \"server.yml\",\n            Path(__file__).parent.parent.parent / \"users.yml\",\n        ]\n\n        for file_path in files_to_check:\n            if not file_path.exists():\n                continue\n\n            with open(file_path) as f:\n                content = f.read()\n\n            # Find when: conditions\n            when_patterns = re.findall(r\"when:\\s*(\\w+)\\s*$\", content, re.MULTILINE)\n\n            # These variables must be booleans for Ansible 12\n            boolean_vars = [\"ipv6_support\", \"algo_dns_adblocking\", \"algo_ssh_tunneling\"]\n\n            for var in when_patterns:\n                if var in boolean_vars:\n                    # This is good - we're using the variable directly\n                    # which requires it to be a boolean in Ansible 12\n                    pass  # Test passes if we get here\n"
  },
  {
    "path": "tests/unit/test_basic_sanity.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nBasic sanity tests for Algo VPN that don't require deployment\n\"\"\"\n\nimport os\nimport subprocess\nimport sys\n\nimport yaml\n\n\ndef test_python_version():\n    \"\"\"Ensure we're running on Python 3.11+\"\"\"\n    assert sys.version_info >= (3, 11), f\"Python 3.11+ required, got {sys.version}\"\n    print(\"✓ Python version check passed\")\n\n\ndef test_pyproject_file_exists():\n    \"\"\"Check that pyproject.toml exists and has dependencies\"\"\"\n    assert os.path.exists(\"pyproject.toml\"), \"pyproject.toml not found\"\n\n    with open(\"pyproject.toml\") as f:\n        content = f.read()\n        assert \"dependencies\" in content, \"No dependencies section in pyproject.toml\"\n        assert \"ansible\" in content, \"ansible dependency not found\"\n        assert \"jinja2\" in content, \"jinja2 dependency not found\"\n        assert \"netaddr\" in content, \"netaddr dependency not found\"\n\n    print(\"✓ pyproject.toml exists with required dependencies\")\n\n\ndef test_config_file_valid():\n    \"\"\"Check that config.cfg is valid YAML\"\"\"\n    assert os.path.exists(\"config.cfg\"), \"config.cfg not found\"\n\n    with open(\"config.cfg\") as f:\n        try:\n            config = yaml.safe_load(f)\n            assert isinstance(config, dict), \"config.cfg should parse as a dictionary\"\n            print(\"✓ config.cfg is valid YAML\")\n        except yaml.YAMLError as e:\n            raise AssertionError(f\"config.cfg is not valid YAML: {e}\") from e\n\n\ndef test_ansible_syntax():\n    \"\"\"Check that main playbook has valid syntax\"\"\"\n    result = subprocess.run([\"ansible-playbook\", \"main.yml\", \"--syntax-check\"], capture_output=True, text=True)\n\n    assert result.returncode == 0, f\"Ansible syntax check failed:\\n{result.stderr}\"\n    print(\"✓ Ansible playbook syntax is valid\")\n\n\ndef test_shellcheck():\n    \"\"\"Run shellcheck on shell scripts\"\"\"\n    shell_scripts = [\"algo\", \"install.sh\"]\n\n    for script in shell_scripts:\n        if os.path.exists(script):\n            result = subprocess.run([\"shellcheck\", script], capture_output=True, text=True)\n            assert result.returncode == 0, f\"Shellcheck failed for {script}:\\n{result.stdout}\"\n            print(f\"✓ {script} passed shellcheck\")\n\n\ndef test_dockerfile_exists():\n    \"\"\"Check that Dockerfile exists and is not empty\"\"\"\n    assert os.path.exists(\"Dockerfile\"), \"Dockerfile not found\"\n\n    with open(\"Dockerfile\") as f:\n        content = f.read()\n        assert len(content) > 100, \"Dockerfile seems too small\"\n        assert \"FROM\" in content, \"Dockerfile missing FROM statement\"\n\n    print(\"✓ Dockerfile exists and looks valid\")\n\n\ndef test_cloud_init_header_format():\n    \"\"\"Check that cloud-init header is exactly '#cloud-config' without space\"\"\"\n    cloud_init_file = \"files/cloud-init/base.yml\"\n    assert os.path.exists(cloud_init_file), f\"{cloud_init_file} not found\"\n\n    with open(cloud_init_file) as f:\n        first_line = f.readline().rstrip(\"\\n\\r\")\n\n    # The first line MUST be exactly \"#cloud-config\" (no space after #)\n    # This regression was introduced in PR #14775 and broke DigitalOcean deployments\n    # See: https://github.com/trailofbits/algo/issues/14800\n    assert first_line == \"#cloud-config\", (\n        f\"cloud-init header must be exactly '#cloud-config' (no space), \"\n        f\"got '{first_line}'. This breaks cloud-init YAML parsing and causes SSH timeouts.\"\n    )\n\n    print(\"✓ cloud-init header format is correct\")\n\n\nif __name__ == \"__main__\":\n    # Change to repo root\n    os.chdir(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))\n\n    tests = [\n        test_python_version,\n        test_pyproject_file_exists,\n        test_config_file_valid,\n        test_ansible_syntax,\n        test_shellcheck,\n        test_dockerfile_exists,\n        test_cloud_init_header_format,\n    ]\n\n    failed = 0\n    for test in tests:\n        try:\n            test()\n        except AssertionError as e:\n            print(f\"✗ {test.__name__} failed: {e}\")\n            failed += 1\n        except Exception as e:\n            print(f\"✗ {test.__name__} error: {e}\")\n            failed += 1\n\n    if failed > 0:\n        print(f\"\\n{failed} tests failed\")\n        sys.exit(1)\n    else:\n        print(f\"\\nAll {len(tests)} tests passed!\")\n"
  },
  {
    "path": "tests/unit/test_boolean_variables.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest that Ansible variables produce proper boolean types, not strings.\nThis prevents issues with Ansible 12.0.0's strict type checking.\n\"\"\"\n\nimport jinja2\n\n\ndef render_template(template_str, variables=None):\n    \"\"\"Render a Jinja2 template with given variables.\"\"\"\n    env = jinja2.Environment()\n    template = env.from_string(template_str)\n    return template.render(variables or {})\n\n\nclass TestBooleanVariables:\n    \"\"\"Test that critical variables produce actual booleans.\"\"\"\n\n    def test_ipv6_support_produces_boolean(self):\n        \"\"\"Ensure ipv6_support produces boolean, not string 'true'/'false'.\"\"\"\n        # Test with gateway defined (should be boolean True)\n        template = \"{{ ansible_default_ipv6['gateway'] is defined }}\"\n        vars_with_gateway = {\"ansible_default_ipv6\": {\"gateway\": \"fe80::1\"}}\n        result = render_template(template, vars_with_gateway)\n        assert result == \"True\"  # Jinja2 renders boolean True as string \"True\"\n\n        # Test without gateway (should be boolean False)\n        vars_no_gateway = {\"ansible_default_ipv6\": {}}\n        result = render_template(template, vars_no_gateway)\n        assert result == \"False\"  # Jinja2 renders boolean False as string \"False\"\n\n        # The key is that we're NOT producing string literals \"true\" or \"false\"\n        bad_template = \"{% if ansible_default_ipv6['gateway'] is defined %}true{% else %}false{% endif %}\"\n        result_bad = render_template(bad_template, vars_no_gateway)\n        assert result_bad == \"false\"  # This is a string literal, not a boolean\n\n        # Verify our fix doesn't produce string literals\n        assert result != \"false\"  # Our fix produces \"False\" (from boolean), not \"false\" (string literal)\n\n    def test_algo_variables_boolean_fallbacks(self):\n        \"\"\"Ensure algo_* variables produce booleans in their fallback cases.\"\"\"\n        # Test the fixed template (produces boolean)\n        good_template = \"{% if var is defined %}{{ var | bool }}{%- else %}{{ false }}{% endif %}\"\n        result_good = render_template(good_template, {})\n        assert result_good == \"False\"  # Boolean False renders as \"False\"\n\n        # Test the old broken template (produces string)\n        bad_template = \"{% if var is defined %}{{ var | bool }}{%- else %}false{% endif %}\"\n        result_bad = render_template(bad_template, {})\n        assert result_bad == \"false\"  # String literal \"false\"\n\n        # Verify they're different\n        assert result_good != result_bad\n        assert result_good == \"False\" and result_bad == \"false\"\n\n    def test_boolean_filter_on_strings(self):\n        \"\"\"Test that the bool filter correctly converts string values.\"\"\"\n        # Since we can't test Ansible's bool filter directly in Jinja2,\n        # we test the pattern we're using in our templates\n\n        # Test that our templates don't use raw string \"true\"/\"false\"\n        # which would fail in Ansible 12\n        bad_pattern = \"{%- else %}false{% endif %}\"\n        good_pattern = \"{%- else %}{{ false }}{% endif %}\"\n\n        # The bad pattern produces a string literal\n        result_bad = render_template(\"{% if var is defined %}something\" + bad_pattern, {})\n        assert \"false\" in result_bad  # String literal\n\n        # The good pattern produces a boolean value\n        result_good = render_template(\"{% if var is defined %}something\" + good_pattern, {})\n        assert \"False\" in result_good  # Boolean False rendered as \"False\"\n\n    def test_ansible_12_conditional_compatibility(self):\n        \"\"\"\n        Test that our fixes work with Ansible 12's strict type checking.\n        This simulates what Ansible 12 will do with our variables.\n        \"\"\"\n        # Our fixed template - produces actual boolean\n        fixed_ipv6 = \"{{ ansible_default_ipv6['gateway'] is defined }}\"\n        fixed_algo = \"{% if var is defined %}{{ var | bool }}{%- else %}{{ false }}{% endif %}\"\n\n        # Simulate the boolean value in a conditional context\n        # In Ansible 12, this would fail if it's a string \"true\"/\"false\"\n        vars_with_gateway = {\"ansible_default_ipv6\": {\"gateway\": \"fe80::1\"}}\n        ipv6_result = render_template(fixed_ipv6, vars_with_gateway)\n\n        # The result should be \"True\" (boolean rendered), not \"true\" (string literal)\n        assert ipv6_result == \"True\"\n        assert ipv6_result != \"true\"\n\n        # Test algo variable fallback\n        algo_result = render_template(fixed_algo, {})\n        assert algo_result == \"False\"\n        assert algo_result != \"false\"\n\n    def test_regression_no_string_booleans(self):\n        \"\"\"\n        Regression test: ensure we never produce string literals 'true' or 'false'.\n        This is what breaks Ansible 12.0.0.\n        \"\"\"\n        # These patterns should NOT appear in our fixed code\n        bad_patterns = [\n            \"{}true{}\",\n            \"{}false{}\",\n            \"{%- else %}true{% endif %}\",\n            \"{%- else %}false{% endif %}\",\n        ]\n\n        # Test that our fixed templates don't produce string boolean literals\n        fixed_template = \"{{ ansible_default_ipv6['gateway'] is defined }}\"\n        for _pattern in bad_patterns:\n            assert \"true\" not in fixed_template.replace(\" \", \"\")\n            assert \"false\" not in fixed_template.replace(\" \", \"\")\n\n        # Test algo variable fix\n        fixed_algo = \"{% if var is defined %}{{ var | bool }}{%- else %}{{ false }}{% endif %}\"\n        assert \"{}false{}\" not in fixed_algo.replace(\" \", \"\")\n        assert \"{{ false }}\" in fixed_algo\n"
  },
  {
    "path": "tests/unit/test_cloud_provider_configs.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest cloud provider instance type configurations.\n\nValidates config.cfg against known-deprecated instance types that will fail\nat deployment time. Based on issue #14730 (Hetzner cx->cpx migration).\n\nWhy not regex format validation? Providers constantly add new instance families\n(AWS m7.*, g5.*, etc.). Regex patterns would break on legitimate new values.\nInstead, we only check for KNOWN-BAD values that are definitively deprecated.\n\"\"\"\n\nfrom pathlib import Path\n\nimport yaml\n\n# Only values that are KNOWN to fail - update when providers deprecate more\nDEPRECATED_VALUES = {\n    # Intel CX series removed Sept 2024 - AMD CPX series still available\n    # https://docs.hetzner.com/cloud/servers/deprecated-plans/\n    \"hetzner\": {\"server_type\": [\"cx11\", \"cx21\", \"cx31\", \"cx41\", \"cx51\"]},\n    # Old naming scheme deprecated ~2018, use s-*vcpu-* format\n    \"digitalocean\": {\"size\": [\"512mb\", \"1gb\", \"2gb\", \"4gb\", \"8gb\", \"16gb\"]},\n    # Previous gen, unavailable in VPC after EC2-Classic retirement\n    # https://aws.amazon.com/ec2/previous-generation/\n    \"ec2\": {\"size\": [\"t1.micro\", \"m1.small\", \"m1.medium\", \"m1.large\"]},\n}\n\nREQUIRED_FIELDS = {\n    \"ec2\": [\"size\"],\n    \"digitalocean\": [\"size\", \"image\"],\n    \"hetzner\": [\"server_type\", \"image\"],\n    \"vultr\": [\"size\", \"os\"],\n    \"linode\": [\"type\", \"image\"],\n    \"gce\": [\"size\", \"image\"],\n    \"azure\": [\"size\"],\n    \"lightsail\": [\"size\", \"image\"],\n    \"scaleway\": [\"size\", \"image\"],\n}\n\n\ndef load_config():\n    \"\"\"Load config.cfg from the repository root.\"\"\"\n    config_path = Path(__file__).parents[2] / \"config.cfg\"\n    return yaml.safe_load(config_path.read_text())\n\n\ndef test_no_deprecated_instance_types():\n    \"\"\"Catch deprecated instance types before deployment fails.\"\"\"\n    providers = load_config().get(\"cloud_providers\", {})\n\n    for provider, fields in DEPRECATED_VALUES.items():\n        if provider not in providers:\n            continue\n        for field, deprecated in fields.items():\n            value = providers[provider].get(field)\n            assert value not in deprecated, f\"{provider}.{field}='{value}' is deprecated and will fail\"\n\n\ndef test_required_fields_present():\n    \"\"\"Ensure critical fields aren't empty.\"\"\"\n    providers = load_config().get(\"cloud_providers\", {})\n\n    for provider, fields in REQUIRED_FIELDS.items():\n        if provider not in providers:\n            continue\n        for field in fields:\n            value = providers[provider].get(field)\n            # Skip nested dicts (like ec2.image which has subfields)\n            if isinstance(value, dict):\n                continue\n            assert value, f\"{provider}.{field} is empty or missing\"\n\n\ndef test_no_malformed_values():\n    \"\"\"Basic sanity - no control chars, reasonable length.\"\"\"\n    providers = load_config().get(\"cloud_providers\", {})\n\n    for provider, settings in providers.items():\n        if not isinstance(settings, dict):\n            continue\n        for field, value in settings.items():\n            if not isinstance(value, str):\n                continue\n            assert \"\\n\" not in value, f\"{provider}.{field} contains newline\"\n            assert len(value) <= 128, f\"{provider}.{field} too long ({len(value)} chars)\"\n"
  },
  {
    "path": "tests/unit/test_comprehensive_boolean_scan.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest suite to prevent Ansible 12+ boolean type errors in Algo VPN codebase.\n\nBackground:\n-----------\nAnsible 12.0.0 introduced strict boolean type checking that breaks deployments\nwhen string values like \"true\" or \"false\" are used in conditionals. This causes\nerrors like: \"Conditional result (True) was derived from value of type 'str'\"\n\nWhat This Test Protects Against:\n---------------------------------\n1. String literals \"true\"/\"false\" being used instead of actual booleans\n2. Bare true/false in Jinja2 else clauses (should be {{ true }}/{{ false }})\n3. String comparisons in when: conditions (e.g., var == \"true\")\n4. Variables being set to string booleans instead of actual booleans\n\nTest Scope:\n-----------\n- Only tests Algo's own code (roles/, playbooks/, etc.)\n- Excludes external dependencies (.env/, ansible_collections/)\n- Excludes CloudFormation templates which require string booleans\n- Excludes test files which may use different patterns\n\nMutation Testing Verified:\n--------------------------\nAll tests have been verified to catch their target issues through mutation testing:\n- Introducing bare 'false' in else clause → caught by test_no_bare_false_in_jinja_else\n- Using string boolean in facts.yml → caught by test_verify_our_fixes_are_correct\n- Adding string boolean assignments → caught by test_no_other_problematic_patterns\n\nRelated Issues:\n---------------\n- PR #14834: Fixed initial boolean type issues for Ansible 12\n- Issue #14835: Fixed double-templating issues exposed by Ansible 12\n\"\"\"\n\nimport re\nfrom pathlib import Path\n\n\nclass TestComprehensiveBooleanScan:\n    \"\"\"Scan entire codebase for potential string boolean issues.\"\"\"\n\n    def get_yaml_files(self):\n        \"\"\"Get all YAML files in the Algo project, excluding external dependencies.\"\"\"\n        root = Path(__file__).parent.parent.parent\n        yaml_files = []\n\n        # Define directories to scan (Algo's actual code)\n        algo_dirs = [\n            \"roles\",\n            \"playbooks\",\n            \"library\",\n            \"files/cloud-init\",  # Include cloud-init templates but not CloudFormation\n        ]\n\n        # Add root-level YAML files\n        yaml_files.extend(root.glob(\"*.yml\"))\n        yaml_files.extend(root.glob(\"*.yaml\"))\n\n        # Add YAML files from Algo directories\n        for dir_name in algo_dirs:\n            dir_path = root / dir_name\n            if dir_path.exists():\n                yaml_files.extend(dir_path.glob(\"**/*.yml\"))\n                yaml_files.extend(dir_path.glob(\"**/*.yaml\"))\n\n        # Exclude patterns\n        excluded = [\n            \".venv\",  # Virtual environment\n            \".env\",  # Another virtual environment pattern\n            \"venv\",  # Yet another virtual environment\n            \"test\",  # Test files (but keep our own tests)\n            \"molecule\",  # Molecule test files\n            \"site-packages\",  # Python packages\n            \"ansible_collections\",  # External Ansible collections\n            \"stack.yaml\",  # CloudFormation templates (use string booleans by design)\n            \"stack.yml\",  # CloudFormation templates\n            \".git\",  # Git directory\n            \"__pycache__\",  # Python cache\n        ]\n\n        # Filter out excluded paths and CloudFormation templates\n        filtered = []\n        for f in yaml_files:\n            path_str = str(f)\n            # Skip if path contains any excluded pattern\n            if any(exc in path_str for exc in excluded):\n                continue\n            # Skip CloudFormation templates in files/ directories\n            if \"/files/\" in path_str and f.name in [\"stack.yaml\", \"stack.yml\"]:\n                continue\n            filtered.append(f)\n\n        return filtered\n\n    def test_no_string_true_false_in_set_fact(self):\n        \"\"\"Scan all YAML files for set_fact with string 'true'/'false'.\"\"\"\n        issues = []\n        pattern = re.compile(r'set_fact:.*?\\n.*?:\\s*\".*\\}(true|false)\\{.*\"', re.MULTILINE | re.DOTALL)\n\n        for yaml_file in self.get_yaml_files():\n            with open(yaml_file) as f:\n                content = f.read()\n\n            matches = pattern.findall(content)\n            if matches:\n                issues.append(f\"{yaml_file.name}: Found string boolean in set_fact: {matches}\")\n\n        assert not issues, \"Found string booleans in set_fact:\\n\" + \"\\n\".join(issues)\n\n    def test_no_bare_false_in_jinja_else(self):\n        \"\"\"Check for bare 'false' after else in Jinja expressions.\"\"\"\n        issues = []\n        # Pattern for {%- else %}false{% (should be {{ false }})\n        pattern = re.compile(r\"\\{%-?\\s*else\\s*%\\}(true|false)\\{%\")\n\n        for yaml_file in self.get_yaml_files():\n            with open(yaml_file) as f:\n                content = f.read()\n\n            matches = pattern.findall(content)\n            if matches:\n                issues.append(f\"{yaml_file.name}: Found bare '{matches[0]}' after else\")\n\n        assert not issues, \"Found bare true/false in else clauses:\\n\" + \"\\n\".join(issues)\n\n    def test_when_conditions_use_booleans(self):\n        \"\"\"Verify 'when:' conditions that use our variables.\"\"\"\n        boolean_vars = [\n            \"ipv6_support\",\n            \"algo_dns_adblocking\",\n            \"algo_ssh_tunneling\",\n            \"algo_ondemand_cellular\",\n            \"algo_ondemand_wifi\",\n            \"algo_store_pki\",\n        ]\n\n        potential_issues = []\n\n        for yaml_file in self.get_yaml_files():\n            with open(yaml_file) as f:\n                lines = f.readlines()\n\n            for i, line in enumerate(lines):\n                if \"when:\" in line:\n                    for var in boolean_vars:\n                        if var in line:\n                            # Check if it's a simple condition (good) or comparing to string (bad)\n                            if (\n                                f'{var} == \"true\"' in line\n                                or f'{var} == \"false\"' in line\n                                or f'{var} != \"true\"' in line\n                                or f'{var} != \"false\"' in line\n                            ):\n                                potential_issues.append(\n                                    f\"{yaml_file.name}:{i + 1}: Comparing {var} to string in when condition\"\n                                )\n\n        assert not potential_issues, \"Found string comparisons in when conditions:\\n\" + \"\\n\".join(potential_issues)\n\n    def test_template_files_boolean_usage(self):\n        \"\"\"Check Jinja2 template files for boolean usage.\"\"\"\n        root = Path(__file__).parent.parent.parent\n        template_files = list(root.glob(\"**/*.j2\"))\n\n        issues = []\n\n        for template_file in template_files:\n            if \".venv\" in str(template_file):\n                continue\n\n            with open(template_file) as f:\n                content = f.read()\n\n            # Check for conditionals using our boolean variables\n            if \"ipv6_support\" in content:\n                # Look for string comparisons\n                if 'ipv6_support == \"true\"' in content or 'ipv6_support == \"false\"' in content:\n                    issues.append(f\"{template_file.name}: Comparing ipv6_support to string\")\n\n                # Check it's used correctly in if statements\n                if re.search(r'{%\\s*if\\s+ipv6_support\\s*==\\s*[\"\\']true[\"\\']', content):\n                    issues.append(f\"{template_file.name}: String comparison with ipv6_support\")\n\n        assert not issues, \"Found issues in template files:\\n\" + \"\\n\".join(issues)\n\n    def test_all_when_conditions_would_work(self):\n        \"\"\"Test that all when: conditions in the codebase would work with boolean types.\"\"\"\n        root = Path(__file__).parent.parent.parent\n        test_files = [\n            root / \"roles/common/tasks/iptables.yml\",\n            root / \"server.yml\",\n            root / \"users.yml\",\n            root / \"roles/dns/tasks/main.yml\",\n        ]\n\n        for test_file in test_files:\n            if not test_file.exists():\n                continue\n\n            with open(test_file) as f:\n                content = f.read()\n\n            # Find all when: conditions\n            when_lines = re.findall(r\"when:\\s*([^\\n]+)\", content)\n\n            for when_line in when_lines:\n                # Check if it's using one of our boolean variables\n                if any(var in when_line for var in [\"ipv6_support\", \"algo_dns_adblocking\", \"algo_ssh_tunneling\"]):\n                    # Ensure it's not comparing to strings\n                    assert '\"true\"' not in when_line, f\"String comparison in {test_file.name}: {when_line}\"\n                    assert '\"false\"' not in when_line, f\"String comparison in {test_file.name}: {when_line}\"\n                    assert \"'true'\" not in when_line, f\"String comparison in {test_file.name}: {when_line}\"\n                    assert \"'false'\" not in when_line, f\"String comparison in {test_file.name}: {when_line}\"\n\n    def test_no_other_problematic_patterns(self):\n        \"\"\"Look for patterns that would cause Ansible 12 boolean type issues in Algo code.\"\"\"\n        # These patterns would break Ansible 12's strict boolean checking\n        problematic_patterns = [\n            (r':\\s*[\"\\']true[\"\\']$', \"Assigning string 'true' to variable\"),\n            (r':\\s*[\"\\']false[\"\\']$', \"Assigning string 'false' to variable\"),\n            (r'default\\([\"\\']true[\"\\']\\)', \"Using string 'true' as default\"),\n            (r'default\\([\"\\']false[\"\\']\\)', \"Using string 'false' as default\"),\n        ]\n\n        # Known safe exceptions in Algo\n        safe_patterns = [\n            \"booleans_map\",  # This maps string inputs to booleans\n            \"test_\",  # Test files may use different patterns\n            \"molecule\",  # Molecule tests\n            \"ANSIBLE_\",  # Environment variables are strings\n            \"validate_certs\",  # Some modules accept string booleans\n            \"Default:\",  # CloudFormation parameter defaults\n        ]\n\n        issues = []\n\n        for yaml_file in self.get_yaml_files():\n            # Skip files that aren't Ansible playbooks/tasks/vars\n            parts_to_check = [\"tasks\", \"vars\", \"defaults\", \"handlers\", \"meta\", \"playbooks\"]\n            main_files = [\"main.yml\", \"users.yml\", \"server.yml\", \"input.yml\"]\n            if not any(part in str(yaml_file) for part in parts_to_check) and yaml_file.name not in main_files:\n                continue\n\n            with open(yaml_file) as f:\n                lines = f.readlines()\n\n            for i, line in enumerate(lines):\n                # Skip comments and empty lines\n                stripped_line = line.strip()\n                if not stripped_line or stripped_line.startswith(\"#\"):\n                    continue\n\n                for pattern, description in problematic_patterns:\n                    if re.search(pattern, line):\n                        # Check if it's a known safe pattern\n                        if not any(safe in line for safe in safe_patterns):\n                            # This is a real issue that would break Ansible 12\n                            rel_path = yaml_file.relative_to(Path(__file__).parent.parent.parent)\n                            issues.append(f\"{rel_path}:{i + 1}: {description} - {stripped_line}\")\n\n        # All Algo code should be fixed\n        assert not issues, \"Found boolean type issues that would break Ansible 12:\\n\" + \"\\n\".join(issues[:10])\n\n    def test_verify_our_fixes_are_correct(self):\n        \"\"\"Verify our specific fixes are in place and correct.\"\"\"\n        # Check facts.yml\n        facts_file = Path(__file__).parent.parent.parent / \"roles/common/tasks/facts.yml\"\n        with open(facts_file) as f:\n            content = f.read()\n\n        # Should use 'is defined', not string literals\n        assert \"is defined\" in content, \"facts.yml should use 'is defined'\"\n        old_pattern = \"ipv6_support: \\\"{% if ansible_default_ipv6['gateway'] is defined %}\"\n        old_pattern += 'true{% else %}false{% endif %}\"'\n        assert old_pattern not in content, \"facts.yml still has the old string boolean pattern\"\n\n        # Check input.yml\n        input_file = Path(__file__).parent.parent.parent / \"input.yml\"\n        with open(input_file) as f:\n            content = f.read()\n\n        # Count occurrences of the fix\n        assert content.count(\"{{ false }}\") >= 5, \"input.yml should have at least 5 instances of {{ false }}\"\n        assert \"{%- else %}false{% endif %}\" not in content, \"input.yml still has bare 'false'\"\n\n    def test_templates_handle_booleans_correctly(self):\n        \"\"\"Test that template files handle boolean variables correctly.\"\"\"\n        templates_to_check = [\n            (\"roles/wireguard/templates/server.conf.j2\", \"ipv6_support\"),\n            (\"roles/strongswan/templates/ipsec.conf.j2\", \"ipv6_support\"),\n            (\"roles/dns/templates/dnscrypt-proxy.toml.j2\", \"ipv6_support\"),\n        ]\n\n        for template_path, var_name in templates_to_check:\n            template_file = Path(__file__).parent.parent.parent / template_path\n            if not template_file.exists():\n                continue\n\n            with open(template_file) as f:\n                content = f.read()\n\n            if var_name in content:\n                # Verify it's used in conditionals, not compared to strings\n                assert f'{var_name} == \"true\"' not in content, f\"{template_path} compares {var_name} to string 'true'\"\n                assert f'{var_name} == \"false\"' not in content, f\"{template_path} compares {var_name} to string 'false'\"\n\n                # It should be used directly in if statements or with | bool filter\n                if f\"if {var_name}\" in content or f\"{var_name} |\" in content:\n                    pass  # Good - using it as a boolean\n"
  },
  {
    "path": "tests/unit/test_config_validation.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest configuration file validation without deployment\n\"\"\"\n\nimport configparser\nimport os\nimport re\nimport subprocess\nimport sys\nimport tempfile\n\n\ndef test_wireguard_config_format():\n    \"\"\"Test that we can validate WireGuard config format\"\"\"\n    # Sample minimal WireGuard config\n    sample_config = \"\"\"[Interface]\nPrivateKey = aGVsbG8gd29ybGQgdGhpcyBpcyBub3QgYSByZWFsIGtleQo=\nAddress = 10.19.49.2/32\nDNS = 10.19.49.1\n\n[Peer]\nPublicKey = U29tZVB1YmxpY0tleVRoYXRJc05vdFJlYWxseVZhbGlkCg==\nAllowedIPs = 0.0.0.0/0,::/0\nEndpoint = 192.168.1.1:51820\n\"\"\"\n\n    # Validate it has required sections\n    config = configparser.ConfigParser()\n    config.read_string(sample_config)\n\n    assert \"Interface\" in config, \"Missing [Interface] section\"\n    assert \"Peer\" in config, \"Missing [Peer] section\"\n\n    # Validate required fields\n    assert config[\"Interface\"].get(\"PrivateKey\"), \"Missing PrivateKey\"\n    assert config[\"Interface\"].get(\"Address\"), \"Missing Address\"\n    assert config[\"Peer\"].get(\"PublicKey\"), \"Missing PublicKey\"\n    assert config[\"Peer\"].get(\"AllowedIPs\"), \"Missing AllowedIPs\"\n\n    print(\"✓ WireGuard config format validation passed\")\n\n\ndef test_base64_key_format():\n    \"\"\"Test that keys are in valid base64 format\"\"\"\n    # Base64 keys can have variable length, just check format\n    key_pattern = re.compile(r\"^[A-Za-z0-9+/]+=*$\")\n\n    test_keys = [\n        \"aGVsbG8gd29ybGQgdGhpcyBpcyBub3QgYSByZWFsIGtleQo=\",\n        \"U29tZVB1YmxpY0tleVRoYXRJc05vdFJlYWxseVZhbGlkCg==\",\n    ]\n\n    for key in test_keys:\n        assert key_pattern.match(key), f\"Invalid key format: {key}\"\n\n    print(\"✓ Base64 key format validation passed\")\n\n\ndef test_ip_address_format():\n    \"\"\"Test IP address and CIDR notation validation\"\"\"\n    ip_pattern = re.compile(r\"^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}/\\d{1,2}$\")\n    endpoint_pattern = re.compile(r\"^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}:\\d{1,5}$\")\n\n    # Test CIDR notation\n    assert ip_pattern.match(\"10.19.49.2/32\"), \"Invalid CIDR notation\"\n    assert ip_pattern.match(\"192.168.1.0/24\"), \"Invalid CIDR notation\"\n\n    # Test endpoint format\n    assert endpoint_pattern.match(\"192.168.1.1:51820\"), \"Invalid endpoint format\"\n\n    print(\"✓ IP address format validation passed\")\n\n\ndef test_mobile_config_xml():\n    \"\"\"Test that mobile config files would be valid XML\"\"\"\n    # First check if xmllint is available\n    xmllint_check = subprocess.run([\"which\", \"xmllint\"], capture_output=True, text=True)\n\n    if xmllint_check.returncode != 0:\n        print(\"⚠ Skipping XML validation test (xmllint not installed)\")\n        return\n\n    sample_mobileconfig = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n    <key>PayloadDisplayName</key>\n    <string>Algo VPN</string>\n    <key>PayloadIdentifier</key>\n    <string>com.algo-vpn.ios</string>\n    <key>PayloadType</key>\n    <string>Configuration</string>\n    <key>PayloadVersion</key>\n    <integer>1</integer>\n</dict>\n</plist>\"\"\"\n\n    with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".mobileconfig\", delete=False) as f:\n        f.write(sample_mobileconfig)\n        temp_file = f.name\n\n    try:\n        # Use xmllint to validate\n        result = subprocess.run([\"xmllint\", \"--noout\", temp_file], capture_output=True, text=True)\n\n        assert result.returncode == 0, f\"XML validation failed: {result.stderr}\"\n        print(\"✓ Mobile config XML validation passed\")\n    finally:\n        os.unlink(temp_file)\n\n\ndef test_port_ranges():\n    \"\"\"Test that configured ports are in valid ranges\"\"\"\n    valid_ports = [22, 80, 443, 500, 4500, 51820]\n\n    for port in valid_ports:\n        assert 1 <= port <= 65535, f\"Invalid port number: {port}\"\n\n    # Test common VPN ports\n    assert 500 in valid_ports, \"Missing IKE port 500\"\n    assert 4500 in valid_ports, \"Missing IPsec NAT-T port 4500\"\n    assert 51820 in valid_ports, \"Missing WireGuard port 51820\"\n\n    print(\"✓ Port range validation passed\")\n\n\nif __name__ == \"__main__\":\n    tests = [\n        test_wireguard_config_format,\n        test_base64_key_format,\n        test_ip_address_format,\n        test_mobile_config_xml,\n        test_port_ranges,\n    ]\n\n    failed = 0\n    for test in tests:\n        try:\n            test()\n        except AssertionError as e:\n            print(f\"✗ {test.__name__} failed: {e}\")\n            failed += 1\n        except Exception as e:\n            print(f\"✗ {test.__name__} error: {e}\")\n            failed += 1\n\n    if failed > 0:\n        print(f\"\\n{failed} tests failed\")\n        sys.exit(1)\n    else:\n        print(f\"\\nAll {len(tests)} tests passed!\")\n"
  },
  {
    "path": "tests/unit/test_destroy.py",
    "content": "\"\"\"Tests for the destroy playbook and provider destroy task files.\"\"\"\n\nimport os\nimport subprocess\n\nimport yaml  # type: ignore[import-untyped]\n\nPROVIDERS = [\n    \"digitalocean\",\n    \"ec2\",\n    \"lightsail\",\n    \"azure\",\n    \"gce\",\n    \"hetzner\",\n    \"vultr\",\n    \"scaleway\",\n    \"openstack\",\n    \"cloudstack\",\n    \"linode\",\n]\n\nREGION_REQUIRED_PROVIDERS = [\"ec2\", \"lightsail\", \"gce\", \"scaleway\", \"vultr\"]\n\n\ndef test_destroy_playbook_exists():\n    \"\"\"destroy.yml must exist at repo root.\"\"\"\n    assert os.path.exists(\"destroy.yml\"), \"destroy.yml not found\"\n\n\ndef test_destroy_playbook_valid_yaml():\n    \"\"\"destroy.yml must be valid YAML.\"\"\"\n    with open(\"destroy.yml\") as f:\n        data = yaml.safe_load(f)\n    assert isinstance(data, list), \"destroy.yml should be a YAML list\"\n    assert len(data) == 1, \"destroy.yml should have one play\"\n    play = data[0]\n    assert play[\"hosts\"] == \"localhost\"\n    assert play[\"gather_facts\"] is False\n\n\ndef test_destroy_playbook_syntax():\n    \"\"\"destroy.yml must pass ansible-playbook --syntax-check.\"\"\"\n    result = subprocess.run(\n        [\"ansible-playbook\", \"destroy.yml\", \"--syntax-check\"],\n        capture_output=True,\n        text=True,\n    )\n    assert result.returncode == 0, f\"Syntax check failed:\\n{result.stderr}\"\n\n\ndef test_destroy_playbook_has_rescue():\n    \"\"\"destroy.yml must include a rescue block for error handling.\"\"\"\n    with open(\"destroy.yml\") as f:\n        content = f.read()\n    assert \"rescue:\" in content\n    assert \"rescue.yml\" in content\n\n\ndef test_destroy_playbook_has_confirmation():\n    \"\"\"destroy.yml must have a confirmation step.\"\"\"\n    with open(\"destroy.yml\") as f:\n        content = f.read()\n    assert \"confirm\" in content.lower()\n\n\ndef test_all_provider_destroy_files_exist():\n    \"\"\"Every cloud provider must have a destroy.yml task file.\"\"\"\n    for provider in PROVIDERS:\n        path = f\"roles/cloud-{provider}/tasks/destroy.yml\"\n        assert os.path.exists(path), f\"Missing destroy task file: {path}\"\n\n\ndef test_all_provider_destroy_files_valid_yaml():\n    \"\"\"Every provider destroy.yml must be valid YAML.\"\"\"\n    for provider in PROVIDERS:\n        path = f\"roles/cloud-{provider}/tasks/destroy.yml\"\n        with open(path) as f:\n            data = yaml.safe_load(f)\n        assert isinstance(data, list), f\"{path} should be a YAML list\"\n\n\ndef test_provider_destroy_uses_absent_state():\n    \"\"\"Each provider destroy file must use state: absent (or expunged).\"\"\"\n    for provider in PROVIDERS:\n        path = f\"roles/cloud-{provider}/tasks/destroy.yml\"\n        with open(path) as f:\n            content = f.read()\n        assert \"absent\" in content or \"expunged\" in content, f\"{path} missing state: absent/expunged\"\n\n\ndef test_ec2_destroy_uses_cloudformation():\n    \"\"\"EC2 destroy should delete the CloudFormation stack.\"\"\"\n    with open(\"roles/cloud-ec2/tasks/destroy.yml\") as f:\n        content = f.read()\n    assert \"cloudformation\" in content\n    assert \"stack_name\" in content\n\n\ndef test_lightsail_destroy_uses_cloudformation():\n    \"\"\"Lightsail destroy should delete the CloudFormation stack.\"\"\"\n    with open(\"roles/cloud-lightsail/tasks/destroy.yml\") as f:\n        content = f.read()\n    assert \"cloudformation\" in content\n    assert \"stack_name\" in content\n\n\ndef test_gce_destroy_cleans_subsidiary_resources():\n    \"\"\"GCE destroy should clean up firewall, static IP, and network.\"\"\"\n    with open(\"roles/cloud-gce/tasks/destroy.yml\") as f:\n        content = f.read()\n    assert \"gcp_compute_firewall\" in content\n    assert \"gcp_compute_address\" in content\n    assert \"gcp_compute_network\" in content\n\n\ndef test_vultr_destroy_cleans_firewall_group():\n    \"\"\"Vultr destroy should remove the firewall group.\"\"\"\n    with open(\"roles/cloud-vultr/tasks/destroy.yml\") as f:\n        content = f.read()\n    assert \"firewall_group\" in content\n\n\ndef test_openstack_destroy_cleans_security_group():\n    \"\"\"OpenStack destroy should remove the security group.\"\"\"\n    with open(\"roles/cloud-openstack/tasks/destroy.yml\") as f:\n        content = f.read()\n    assert \"security_group\" in content\n\n\ndef test_cloudstack_destroy_cleans_security_group():\n    \"\"\"CloudStack destroy should remove the security group.\"\"\"\n    with open(\"roles/cloud-cloudstack/tasks/destroy.yml\") as f:\n        content = f.read()\n    assert \"security_group\" in content\n\n\ndef test_subsidiary_cleanup_is_best_effort():\n    \"\"\"Subsidiary resource cleanup should use failed_when: false.\"\"\"\n    files_with_subsidiary = {\n        \"gce\": [\"gcp_compute_address\", \"gcp_compute_firewall\", \"gcp_compute_network\"],\n        \"vultr\": [\"firewall_group\"],\n        \"openstack\": [\"security_group\"],\n        \"cloudstack\": [\"security_group\"],\n    }\n    for provider, _resources in files_with_subsidiary.items():\n        path = f\"roles/cloud-{provider}/tasks/destroy.yml\"\n        with open(path) as f:\n            content = f.read()\n        assert \"failed_when: false\" in content, f\"{path} should use failed_when: false for subsidiary cleanup\"\n\n\ndef test_linode_uses_label_not_name():\n    \"\"\"Linode module uses 'label' parameter, not 'name'.\"\"\"\n    with open(\"roles/cloud-linode/tasks/destroy.yml\") as f:\n        content = f.read()\n    assert \"label:\" in content, \"Linode destroy should use 'label' parameter\"\n\n\ndef test_azure_deletes_resource_group():\n    \"\"\"Azure destroy should delete the entire resource group.\"\"\"\n    with open(\"roles/cloud-azure/tasks/destroy.yml\") as f:\n        content = f.read()\n    assert \"azure_rm_resourcegroup\" in content\n    assert \"force_delete_nonempty\" in content\n\n\ndef test_algo_script_has_destroy_command():\n    \"\"\"The algo shell script must include the destroy subcommand.\"\"\"\n    with open(\"algo\") as f:\n        content = f.read()\n    assert \"destroy)\" in content\n    assert \"destroy.yml\" in content\n\n\ndef test_algo_script_destroy_requires_ip():\n    \"\"\"The destroy command should validate that an IP is provided.\"\"\"\n    with open(\"algo\") as f:\n        content = f.read()\n    assert \"server_ip=$2\" in content\n\n\ndef test_server_yml_stores_algo_region():\n    \"\"\"server.yml should store algo_region in .config.yml.\"\"\"\n    with open(\"server.yml\") as f:\n        content = f.read()\n    assert \"algo_region\" in content\n\n\ndef test_destroy_playbook_validates_region_for_required_providers():\n    \"\"\"destroy.yml must check region for ec2/lightsail/gce/scaleway.\"\"\"\n    with open(\"destroy.yml\") as f:\n        content = f.read()\n    for provider in REGION_REQUIRED_PROVIDERS:\n        assert provider in content, f\"destroy.yml should reference {provider} in region validation\"\n\n\ndef test_destroy_playbook_loads_server_config():\n    \"\"\"destroy.yml must load .config.yml from the configs directory.\"\"\"\n    with open(\"destroy.yml\") as f:\n        content = f.read()\n    assert \".config.yml\" in content\n    assert \"include_vars\" in content\n"
  },
  {
    "path": "tests/unit/test_docker_localhost_deployment.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nSimplified Docker-based localhost deployment tests\nVerifies services can start and config files exist in expected locations\n\"\"\"\n\nimport os\nimport subprocess\nimport sys\n\n\ndef check_docker_available():\n    \"\"\"Check if Docker is available\"\"\"\n    try:\n        result = subprocess.run([\"docker\", \"--version\"], capture_output=True, text=True)\n        return result.returncode == 0\n    except FileNotFoundError:\n        return False\n\n\ndef test_wireguard_config_validation():\n    \"\"\"Test that WireGuard configs can be validated\"\"\"\n    # Create a test WireGuard config\n    config = \"\"\"[Interface]\nPrivateKey = EEHcgpEB8JIlUZpYnt3PqJJgfwgRGDQNlGH7gYkMVGo=\nAddress = 10.19.49.1/24,fd9d:bc11:4020::1/64\nListenPort = 51820\n\n[Peer]\nPublicKey = lIiWMxCWtXG5hqZECMXm7mA/4pNKKqtJIBZ5Fc1SeHg=\nAllowedIPs = 10.19.49.2/32,fd9d:bc11:4020::2/128\n\"\"\"\n\n    # Just validate the format\n    required_sections = [\"[Interface]\", \"[Peer]\"]\n    required_fields = [\"PrivateKey\", \"Address\", \"PublicKey\", \"AllowedIPs\"]\n\n    for section in required_sections:\n        if section not in config:\n            print(f\"✗ Missing {section} section\")\n            return False\n\n    for field in required_fields:\n        if field not in config:\n            print(f\"✗ Missing {field} field\")\n            return False\n\n    print(\"✓ WireGuard config format is valid\")\n    return True\n\n\ndef test_strongswan_config_validation():\n    \"\"\"Test that StrongSwan configs can be validated\"\"\"\n    config = \"\"\"config setup\n    charondebug=\"ike 1\"\n    uniqueids=never\n\nconn %default\n    keyexchange=ikev2\n    ike=aes128-sha256-modp2048\n    esp=aes128-sha256-modp2048\n\nconn ikev2-pubkey\n    left=%any\n    leftid=@10.0.0.1\n    leftcert=server.crt\n    right=%any\n    rightauth=pubkey\n\"\"\"\n\n    # Validate format\n    if \"config setup\" not in config:\n        print(\"✗ Missing 'config setup' section\")\n        return False\n\n    if \"conn %default\" not in config:\n        print(\"✗ Missing 'conn %default' section\")\n        return False\n\n    if \"keyexchange=ikev2\" not in config:\n        print(\"✗ Missing IKEv2 configuration\")\n        return False\n\n    print(\"✓ StrongSwan config format is valid\")\n    return True\n\n\ndef test_docker_algo_image():\n    \"\"\"Test that the Algo Docker image can be built\"\"\"\n    # Check if Dockerfile exists\n    if not os.path.exists(\"Dockerfile\"):\n        print(\"✗ Dockerfile not found\")\n        return False\n\n    # Read Dockerfile and validate basic structure\n    with open(\"Dockerfile\") as f:\n        dockerfile_content = f.read()\n\n    required_elements = [\n        \"FROM\",  # Base image\n        \"RUN\",  # Build commands\n        \"COPY\",  # Copy Algo files\n        \"python\",  # Python dependency\n    ]\n\n    missing = []\n    for element in required_elements:\n        if element not in dockerfile_content:\n            missing.append(element)\n\n    if missing:\n        print(f\"✗ Dockerfile missing elements: {', '.join(missing)}\")\n        return False\n\n    print(\"✓ Dockerfile structure is valid\")\n    return True\n\n\ndef test_localhost_deployment_requirements():\n    \"\"\"Test that localhost deployment requirements are met\"\"\"\n    requirements = {\n        \"Python 3.8+\": sys.version_info >= (3, 8),\n        \"Ansible installed\": subprocess.run([\"which\", \"ansible\"], capture_output=True).returncode == 0,\n        \"Main playbook exists\": os.path.exists(\"main.yml\"),\n        \"Project config exists\": os.path.exists(\"pyproject.toml\"),\n        \"Config template exists\": os.path.exists(\"config.cfg.example\") or os.path.exists(\"config.cfg\"),\n    }\n\n    all_met = True\n    for req, met in requirements.items():\n        if met:\n            print(f\"✓ {req}\")\n        else:\n            print(f\"✗ {req}\")\n            all_met = False\n\n    return all_met\n\n\nif __name__ == \"__main__\":\n    print(\"Running Docker localhost deployment tests...\")\n    print(\"=\" * 50)\n\n    # First check if Docker is available\n    docker_available = check_docker_available()\n    if not docker_available:\n        print(\"⚠ Docker not available - some tests will be limited\")\n\n    tests = [\n        test_wireguard_config_validation,\n        test_strongswan_config_validation,\n        test_docker_algo_image,\n        test_localhost_deployment_requirements,\n    ]\n\n    failed = 0\n    for test in tests:\n        print(f\"\\n{test.__name__}:\")\n        try:\n            if not test():\n                failed += 1\n        except Exception as e:\n            print(f\"✗ {test.__name__} error: {e}\")\n            failed += 1\n\n    print(\"\\n\" + \"=\" * 50)\n    if failed > 0:\n        print(f\"❌ {failed} tests failed\")\n        sys.exit(1)\n    else:\n        print(f\"✅ All {len(tests)} tests passed!\")\n"
  },
  {
    "path": "tests/unit/test_double_templating.py",
    "content": "\"\"\"\nTest to detect double Jinja2 templating issues in YAML files.\n\nThis test prevents Ansible 12+ errors from embedded templates in Jinja2 expressions.\nThe pattern `{{ lookup('file', '{{ var }}') }}` is invalid and must be\n`{{ lookup('file', var) }}` instead.\n\nIssue: https://github.com/trailofbits/algo/issues/14835\n\"\"\"\n\nimport re\nfrom pathlib import Path\n\nimport pytest\n\n\ndef find_yaml_files() -> list[Path]:\n    \"\"\"Find all YAML files in the repository.\"\"\"\n    repo_root = Path(__file__).parent.parent.parent\n    yaml_files = []\n\n    # Include all .yml and .yaml files\n    for pattern in [\"**/*.yml\", \"**/*.yaml\"]:\n        yaml_files.extend(repo_root.glob(pattern))\n\n    # Exclude test files and vendor directories\n    excluded_dirs = {\"venv\", \".venv\", \"env\", \".git\", \"__pycache__\", \".pytest_cache\"}\n    yaml_files = [f for f in yaml_files if not any(excluded in f.parts for excluded in excluded_dirs)]\n\n    return sorted(yaml_files)\n\n\ndef detect_double_templating(content: str) -> list[tuple[int, str]]:\n    \"\"\"\n    Detect double templating patterns in file content.\n\n    Returns list of (line_number, problematic_line) tuples.\n    \"\"\"\n    issues = []\n\n    # Pattern 1: lookup() with embedded {{ }}\n    # Matches: lookup('file', '{{ var }}') or lookup(\"file\", \"{{ var }}\")\n    pattern1 = r\"lookup\\s*\\([^)]*['\\\"]{{[^}]*}}['\\\"][^)]*\\)\"\n\n    # Pattern 2: Direct nested {{ {{ }} }}\n    pattern2 = r\"{{\\s*[^}]*{{\\s*[^}]*}}\"\n\n    # Pattern 3: Embedded templates in quoted strings within Jinja2\n    # This catches cases like value: \"{{ '{{ var }}' }}\"\n    pattern3 = r\"{{\\s*['\\\"][^'\\\"]*{{[^}]*}}[^'\\\"]*['\\\"]\"\n\n    lines = content.split(\"\\n\")\n    for i, line in enumerate(lines, 1):\n        # Skip comments\n        stripped = line.split(\"#\")[0]\n        if not stripped.strip():\n            continue\n\n        if re.search(pattern1, stripped) or re.search(pattern2, stripped) or re.search(pattern3, stripped):\n            issues.append((i, line))\n\n    return issues\n\n\ndef test_no_double_templating():\n    \"\"\"Test that no YAML files contain double templating patterns.\"\"\"\n    yaml_files = find_yaml_files()\n    all_issues = {}\n\n    for yaml_file in yaml_files:\n        try:\n            content = yaml_file.read_text()\n            issues = detect_double_templating(content)\n            if issues:\n                # Store relative path for cleaner output\n                rel_path = yaml_file.relative_to(Path(__file__).parent.parent.parent)\n                all_issues[str(rel_path)] = issues\n        except Exception:\n            # Skip binary files or files we can't read\n            continue\n\n    if all_issues:\n        # Format error message for clarity\n        error_msg = \"\\n\\nDouble templating issues found:\\n\"\n        error_msg += \"=\" * 60 + \"\\n\"\n\n        for file_path, issues in all_issues.items():\n            error_msg += f\"\\n{file_path}:\\n\"\n            for line_num, line in issues:\n                error_msg += f\"  Line {line_num}: {line.strip()}\\n\"\n\n        error_msg += \"\\n\" + \"=\" * 60 + \"\\n\"\n        error_msg += \"Fix: Replace '{{ var }}' with var inside lookup() calls\\n\"\n        error_msg += \"Example: lookup('file', '{{ SSH_keys.public }}') → lookup('file', SSH_keys.public)\\n\"\n\n        pytest.fail(error_msg)\n\n\ndef test_specific_known_issues():\n    \"\"\"\n    Test for specific known double-templating issues.\n    This ensures our detection catches the actual bugs from issue #14835.\n    \"\"\"\n    # These are the actual problematic patterns from the codebase\n    known_bad_patterns = [\n        \"{{ lookup('file', '{{ SSH_keys.public }}') }}\",\n        '{{ lookup(\"file\", \"{{ credentials_file_path }}\") }}',\n        \"value: \\\"{{ lookup('file', '{{ SSH_keys.public }}') }}\\\"\",\n        \"PayloadContentCA: \\\"{{ lookup('file' , '{{ ipsec_pki_path }}/cacert.pem')|b64encode }}\\\"\",\n    ]\n\n    for pattern in known_bad_patterns:\n        issues = detect_double_templating(pattern)\n        assert issues, f\"Failed to detect known bad pattern: {pattern}\"\n\n\ndef test_valid_patterns_not_flagged():\n    \"\"\"\n    Test that valid templating patterns are not flagged as errors.\n    \"\"\"\n    valid_patterns = [\n        \"{{ lookup('file', SSH_keys.public) }}\",\n        \"{{ lookup('file', credentials_file_path) }}\",\n        \"value: \\\"{{ lookup('file', SSH_keys.public) }}\\\"\",\n        \"{{ item.1 }}.mobileconfig\",\n        \"{{ loop.index }}. {{ r.server }} ({{ r.IP_subject_alt_name }})\",\n        \"PayloadContentCA: \\\"{{ lookup('file', ipsec_pki_path + '/cacert.pem')|b64encode }}\\\"\",\n        \"ssh_pub_key: \\\"{{ lookup('file', SSH_keys.public) }}\\\"\",\n    ]\n\n    for pattern in valid_patterns:\n        issues = detect_double_templating(pattern)\n        assert not issues, f\"Valid pattern incorrectly flagged: {pattern}\"\n\n\nif __name__ == \"__main__\":\n    # Run the test directly for debugging\n    test_specific_known_issues()\n    test_valid_patterns_not_flagged()\n    test_no_double_templating()\n    print(\"All tests passed!\")\n"
  },
  {
    "path": "tests/unit/test_generated_configs.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest that generated configuration files have valid syntax\nThis validates WireGuard, StrongSwan, SSH, and other configs\n\"\"\"\n\nimport re\nimport subprocess\nimport sys\n\n\ndef check_command_available(cmd):\n    \"\"\"Check if a command is available on the system\"\"\"\n    try:\n        subprocess.run([cmd, \"--version\"], capture_output=True, check=False)\n        return True\n    except FileNotFoundError:\n        return False\n\n\ndef test_wireguard_config_syntax():\n    \"\"\"Test WireGuard configuration file syntax\"\"\"\n    # Sample WireGuard config based on Algo's template\n    sample_config = \"\"\"[Interface]\nAddress = 10.19.49.2/32,fd9d:bc11:4020::2/128\nPrivateKey = SAMPLE_PRIVATE_KEY_BASE64==\nDNS = 1.1.1.1,1.0.0.1\n\n[Peer]\nPublicKey = SAMPLE_PUBLIC_KEY_BASE64==\nPresharedKey = SAMPLE_PRESHARED_KEY_BASE64==\nAllowedIPs = 0.0.0.0/0,::/0\nEndpoint = 10.0.0.1:51820\nPersistentKeepalive = 25\n\"\"\"\n\n    # Validate config structure\n    errors = []\n\n    # Check for required sections\n    if \"[Interface]\" not in sample_config:\n        errors.append(\"Missing [Interface] section\")\n    if \"[Peer]\" not in sample_config:\n        errors.append(\"Missing [Peer] section\")\n\n    # Validate Interface section\n    interface_match = re.search(r\"\\[Interface\\](.*?)\\[Peer\\]\", sample_config, re.DOTALL)\n    if interface_match:\n        interface_section = interface_match.group(1)\n\n        # Check required fields\n        if not re.search(r\"Address\\s*=\", interface_section):\n            errors.append(\"Missing Address in Interface section\")\n        if not re.search(r\"PrivateKey\\s*=\", interface_section):\n            errors.append(\"Missing PrivateKey in Interface section\")\n\n        # Validate IP addresses\n        address_match = re.search(r\"Address\\s*=\\s*([^\\n]+)\", interface_section)\n        if address_match:\n            addresses = address_match.group(1).split(\",\")\n            for addr in addresses:\n                addr = addr.strip()\n                # Basic IP validation\n                if not re.match(r\"^\\d+\\.\\d+\\.\\d+\\.\\d+/\\d+$\", addr) and not re.match(r\"^[0-9a-fA-F:]+/\\d+$\", addr):\n                    errors.append(f\"Invalid IP address format: {addr}\")\n\n    # Validate Peer section\n    peer_match = re.search(r\"\\[Peer\\](.*)\", sample_config, re.DOTALL)\n    if peer_match:\n        peer_section = peer_match.group(1)\n\n        # Check required fields\n        if not re.search(r\"PublicKey\\s*=\", peer_section):\n            errors.append(\"Missing PublicKey in Peer section\")\n        if not re.search(r\"AllowedIPs\\s*=\", peer_section):\n            errors.append(\"Missing AllowedIPs in Peer section\")\n        if not re.search(r\"Endpoint\\s*=\", peer_section):\n            errors.append(\"Missing Endpoint in Peer section\")\n\n        # Validate endpoint format\n        endpoint_match = re.search(r\"Endpoint\\s*=\\s*([^\\n]+)\", peer_section)\n        if endpoint_match:\n            endpoint = endpoint_match.group(1).strip()\n            if not re.match(r\"^[\\d\\.\\:]+:\\d+$\", endpoint):\n                errors.append(f\"Invalid Endpoint format: {endpoint}\")\n\n    if errors:\n        print(\"✗ WireGuard config validation failed:\")\n        for error in errors:\n            print(f\"  - {error}\")\n        assert False, \"WireGuard config validation failed\"\n    else:\n        print(\"✓ WireGuard config syntax validation passed\")\n\n\ndef test_strongswan_ipsec_conf():\n    \"\"\"Test StrongSwan ipsec.conf syntax\"\"\"\n    # Sample ipsec.conf based on Algo's template\n    sample_config = \"\"\"config setup\n    charondebug=\"ike 2, knl 2, cfg 2, net 2, esp 2, dmn 2, mgr 2\"\n    strictcrlpolicy=yes\n    uniqueids=never\n\nconn %default\n    keyexchange=ikev2\n    dpdaction=clear\n    dpddelay=35s\n    dpdtimeout=150s\n    compress=yes\n    ikelifetime=24h\n    lifetime=8h\n    rekey=yes\n    reauth=yes\n    fragmentation=yes\n    ike=aes128gcm16-prfsha512-ecp256,aes128-sha2_256-modp2048\n    esp=aes128gcm16-ecp256,aes128-sha2_256-modp2048\n\nconn ikev2-pubkey\n    auto=add\n    left=%any\n    leftid=@10.0.0.1\n    leftcert=server.crt\n    leftsendcert=always\n    leftsubnet=0.0.0.0/0,::/0\n    right=%any\n    rightid=%any\n    rightauth=pubkey\n    rightsourceip=10.19.49.0/24,fd9d:bc11:4020::/64\n    rightdns=1.1.1.1,1.0.0.1\n\"\"\"\n\n    errors = []\n\n    # Check for required sections\n    if \"config setup\" not in sample_config:\n        errors.append(\"Missing 'config setup' section\")\n    if \"conn %default\" not in sample_config:\n        errors.append(\"Missing 'conn %default' section\")\n\n    # Validate connection settings\n    conn_pattern = re.compile(r\"conn\\s+(\\S+)\")\n    connections = conn_pattern.findall(sample_config)\n\n    if len(connections) < 2:  # Should have at least %default and one other\n        errors.append(\"Not enough connection definitions\")\n\n    # Check for required parameters in connections\n    required_params = [\"keyexchange\", \"left\", \"right\"]\n    for param in required_params:\n        if f\"{param}=\" not in sample_config:\n            errors.append(f\"Missing required parameter: {param}\")\n\n    # Validate IP subnet formats\n    subnet_pattern = re.compile(r\"(left|right)subnet\\s*=\\s*([^\\n]+)\")\n    for match in subnet_pattern.finditer(sample_config):\n        subnets = match.group(2).split(\",\")\n        for subnet in subnets:\n            subnet = subnet.strip()\n            if subnet != \"0.0.0.0/0\" and subnet != \"::/0\":\n                if not re.match(r\"^\\d+\\.\\d+\\.\\d+\\.\\d+/\\d+$\", subnet) and not re.match(r\"^[0-9a-fA-F:]+/\\d+$\", subnet):\n                    errors.append(f\"Invalid subnet format: {subnet}\")\n\n    if errors:\n        print(\"✗ StrongSwan ipsec.conf validation failed:\")\n        for error in errors:\n            print(f\"  - {error}\")\n        assert False, \"ipsec.conf validation failed\"\n    else:\n        print(\"✓ StrongSwan ipsec.conf syntax validation passed\")\n\n\ndef test_ssh_config_syntax():\n    \"\"\"Test SSH tunnel configuration syntax\"\"\"\n    # Sample SSH config for tunneling\n    sample_config = \"\"\"Host algo-tunnel\n    HostName 10.0.0.1\n    User algo\n    Port 4160\n    IdentityFile ~/.ssh/algo.pem\n    StrictHostKeyChecking no\n    UserKnownHostsFile /dev/null\n    ServerAliveInterval 60\n    ServerAliveCountMax 3\n    LocalForward 1080 127.0.0.1:1080\n\"\"\"\n\n    errors = []\n\n    # Parse SSH config format\n    lines = sample_config.strip().split(\"\\n\")\n    current_host = None\n\n    for line in lines:\n        line = line.strip()\n        if not line or line.startswith(\"#\"):\n            continue\n\n        if line.startswith(\"Host \"):\n            current_host = line.split()[1]\n        elif current_host and \" \" in line:\n            key, value = line.split(None, 1)\n\n            # Validate common SSH options\n            if key == \"Port\":\n                try:\n                    port = int(value)\n                    if not 1 <= port <= 65535:\n                        errors.append(f\"Invalid port number: {port}\")\n                except ValueError:\n                    errors.append(f\"Port must be a number: {value}\")\n\n            elif key == \"LocalForward\":\n                # Format: LocalForward [bind_address:]port host:hostport\n                parts = value.split()\n                if len(parts) != 2:\n                    errors.append(f\"Invalid LocalForward format: {value}\")\n\n    if not current_host:\n        errors.append(\"No Host definition found\")\n\n    if errors:\n        print(\"✗ SSH config validation failed:\")\n        for error in errors:\n            print(f\"  - {error}\")\n        assert False, \"SSH config validation failed\"\n    else:\n        print(\"✓ SSH config syntax validation passed\")\n\n\ndef test_iptables_rules_syntax():\n    \"\"\"Test iptables rules syntax\"\"\"\n    # Sample iptables rules based on Algo's rules.v4.j2\n    sample_rules = \"\"\"*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\n-A POSTROUTING -s 10.19.49.0/24 ! -d 10.19.49.0/24 -j MASQUERADE\nCOMMIT\n\n*filter\n:INPUT DROP [0:0]\n:FORWARD DROP [0:0]\n:OUTPUT ACCEPT [0:0]\n-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n-A INPUT -i lo -j ACCEPT\n-A INPUT -p icmp --icmp-type echo-request -j ACCEPT\n-A INPUT -p tcp --dport 4160 -j ACCEPT\n-A INPUT -p udp --dport 51820 -j ACCEPT\n-A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\n-A FORWARD -s 10.19.49.0/24 -j ACCEPT\nCOMMIT\n\"\"\"\n\n    errors = []\n\n    # Check table definitions\n    tables = re.findall(r\"\\*(\\w+)\", sample_rules)\n    if \"filter\" not in tables:\n        errors.append(\"Missing *filter table\")\n    if \"nat\" not in tables:\n        errors.append(\"Missing *nat table\")\n\n    # Check for COMMIT statements\n    commit_count = sample_rules.count(\"COMMIT\")\n    if commit_count != len(tables):\n        errors.append(f\"Number of COMMIT statements ({commit_count}) doesn't match tables ({len(tables)})\")\n\n    # Validate chain policies\n    chain_pattern = re.compile(r\"^:(\\w+)\\s+(ACCEPT|DROP|REJECT)\\s+\\[\\d+:\\d+\\]\", re.MULTILINE)\n    chains = chain_pattern.findall(sample_rules)\n\n    required_chains = [(\"INPUT\", \"DROP\"), (\"FORWARD\", \"DROP\"), (\"OUTPUT\", \"ACCEPT\")]\n    for chain, _policy in required_chains:\n        if not any(c[0] == chain for c in chains):\n            errors.append(f\"Missing required chain: {chain}\")\n\n    # Validate rule syntax\n    rule_pattern = re.compile(r\"^-[AI]\\s+(\\w+)\", re.MULTILINE)\n    rules = rule_pattern.findall(sample_rules)\n\n    if len(rules) < 5:\n        errors.append(\"Insufficient firewall rules\")\n\n    # Check for essential security rules\n    if \"-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT\" not in sample_rules:\n        errors.append(\"Missing stateful connection tracking rule\")\n\n    if errors:\n        print(\"✗ iptables rules validation failed:\")\n        for error in errors:\n            print(f\"  - {error}\")\n        assert False, \"iptables rules validation failed\"\n    else:\n        print(\"✓ iptables rules syntax validation passed\")\n\n\ndef test_dns_config_syntax():\n    \"\"\"Test dnsmasq configuration syntax\"\"\"\n    # Sample dnsmasq config\n    sample_config = \"\"\"user=nobody\ngroup=nogroup\ninterface=eth0\ninterface=wg0\nbind-interfaces\nbogus-priv\nno-resolv\nno-poll\nserver=1.1.1.1\nserver=1.0.0.1\nlocal-ttl=300\ncache-size=10000\nlog-queries\nlog-facility=/var/log/dnsmasq.log\nconf-dir=/etc/dnsmasq.d/,*.conf\naddn-hosts=/var/lib/algo/dns/adblock.hosts\n\"\"\"\n\n    errors = []\n\n    # Parse config\n    for line in sample_config.strip().split(\"\\n\"):\n        line = line.strip()\n        if not line or line.startswith(\"#\"):\n            continue\n\n        # Most dnsmasq options are key=value or just key\n        if \"=\" in line:\n            key, value = line.split(\"=\", 1)\n\n            # Validate specific options\n            if key == \"interface\":\n                if not re.match(r\"^[a-zA-Z0-9\\-_]+$\", value):\n                    errors.append(f\"Invalid interface name: {value}\")\n\n            elif key == \"server\":\n                # Basic IP validation\n                if not re.match(r\"^\\d+\\.\\d+\\.\\d+\\.\\d+$\", value) and not re.match(r\"^[0-9a-fA-F:]+$\", value):\n                    errors.append(f\"Invalid DNS server IP: {value}\")\n\n            elif key == \"cache-size\":\n                try:\n                    size = int(value)\n                    if size < 0:\n                        errors.append(f\"Invalid cache size: {size}\")\n                except ValueError:\n                    errors.append(f\"Cache size must be a number: {value}\")\n\n    # Check for required options\n    required = [\"interface\", \"server\"]\n    for req in required:\n        if f\"{req}=\" not in sample_config:\n            errors.append(f\"Missing required option: {req}\")\n\n    if errors:\n        print(\"✗ dnsmasq config validation failed:\")\n        for error in errors:\n            print(f\"  - {error}\")\n        assert False, \"dnsmasq config validation failed\"\n    else:\n        print(\"✓ dnsmasq config syntax validation passed\")\n\n\nif __name__ == \"__main__\":\n    tests = [\n        test_wireguard_config_syntax,\n        test_strongswan_ipsec_conf,\n        test_ssh_config_syntax,\n        test_iptables_rules_syntax,\n        test_dns_config_syntax,\n    ]\n\n    failed = 0\n    for test in tests:\n        try:\n            test()\n        except AssertionError as e:\n            print(f\"✗ {test.__name__} failed: {e}\")\n            failed += 1\n        except Exception as e:\n            print(f\"✗ {test.__name__} error: {e}\")\n            failed += 1\n\n    if failed > 0:\n        print(f\"\\n{failed} tests failed\")\n        sys.exit(1)\n    else:\n        print(f\"\\nAll {len(tests)} config syntax tests passed!\")\n"
  },
  {
    "path": "tests/unit/test_iptables_rules.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest iptables rules logic for VPN traffic routing.\n\nThese tests verify that the iptables rules templates generate correct\nNAT rules for both WireGuard and IPsec VPN traffic.\n\"\"\"\n\nfrom pathlib import Path\n\nimport pytest\nfrom jinja2 import Environment, FileSystemLoader\n\n\ndef _ansible_bool(value):\n    \"\"\"Simulate the Ansible bool filter for test purposes.\"\"\"\n    if isinstance(value, bool):\n        return value\n    if isinstance(value, str):\n        return value.lower() not in (\"false\", \"no\", \"0\", \"\")\n    return bool(value)\n\n\ndef load_template(template_name):\n    \"\"\"Load a Jinja2 template from the roles/common/templates directory.\"\"\"\n    template_dir = Path(__file__).parent.parent.parent / \"roles\" / \"common\" / \"templates\"\n    env = Environment(loader=FileSystemLoader(str(template_dir)))\n    env.filters[\"bool\"] = _ansible_bool\n    return env.get_template(template_name)\n\n\ndef test_wireguard_nat_rules_ipv4():\n    \"\"\"Test that WireGuard traffic gets proper NAT rules without policy matching.\"\"\"\n    template = load_template(\"rules.v4.j2\")\n\n    # Test with WireGuard enabled\n    result = template.render(\n        ipsec_enabled=False,\n        wireguard_enabled=True,\n        wireguard_network_ipv4=\"10.49.0.0/16\",\n        wireguard_port=51820,\n        wireguard_port_avoid=53,\n        wireguard_port_actual=51820,\n        ansible_default_ipv4={\"interface\": \"eth0\"},\n        snat_aipv4=None,\n        BetweenClients_DROP=True,\n        block_smb=True,\n        block_netbios=True,\n        local_service_ip=\"10.49.0.1\",\n        ansible_ssh_port=22,\n        reduce_mtu=0,\n    )\n\n    # Verify NAT rule exists with output interface and without policy matching\n    assert \"-A POSTROUTING -s 10.49.0.0/16 -o eth0 -j MASQUERADE\" in result\n    # Verify no policy matching in WireGuard NAT rules\n    assert \"-A POSTROUTING -s 10.49.0.0/16 -m policy\" not in result\n\n\ndef test_ipsec_nat_rules_ipv4():\n    \"\"\"Test that IPsec traffic gets proper NAT rules without policy matching.\"\"\"\n    template = load_template(\"rules.v4.j2\")\n\n    # Test with IPsec enabled\n    result = template.render(\n        ipsec_enabled=True,\n        wireguard_enabled=False,\n        strongswan_network=\"10.48.0.0/16\",\n        strongswan_network_ipv6=\"2001:db8::/48\",\n        ansible_default_ipv4={\"interface\": \"eth0\"},\n        snat_aipv4=None,\n        BetweenClients_DROP=True,\n        block_smb=True,\n        block_netbios=True,\n        local_service_ip=\"10.48.0.1\",\n        ansible_ssh_port=22,\n        reduce_mtu=0,\n    )\n\n    # Verify NAT rule exists with output interface and without policy matching\n    assert \"-A POSTROUTING -s 10.48.0.0/16 -o eth0 -j MASQUERADE\" in result\n    # Verify no policy matching in IPsec NAT rules (this was the bug)\n    assert \"-A POSTROUTING -s 10.48.0.0/16 -m policy --pol none\" not in result\n\n\ndef test_both_vpns_nat_rules_ipv4():\n    \"\"\"Test NAT rules when both VPN types are enabled.\"\"\"\n    template = load_template(\"rules.v4.j2\")\n\n    result = template.render(\n        ipsec_enabled=True,\n        wireguard_enabled=True,\n        strongswan_network=\"10.48.0.0/16\",\n        wireguard_network_ipv4=\"10.49.0.0/16\",\n        strongswan_network_ipv6=\"2001:db8::/48\",\n        wireguard_network_ipv6=\"2001:db8:a160::/48\",\n        wireguard_port=51820,\n        wireguard_port_avoid=53,\n        wireguard_port_actual=51820,\n        ansible_default_ipv4={\"interface\": \"eth0\"},\n        snat_aipv4=None,\n        BetweenClients_DROP=True,\n        block_smb=True,\n        block_netbios=True,\n        local_service_ip=\"10.49.0.1\",\n        ansible_ssh_port=22,\n        reduce_mtu=0,\n    )\n\n    # Both should have NAT rules with output interface\n    assert \"-A POSTROUTING -s 10.48.0.0/16 -o eth0 -j MASQUERADE\" in result\n    assert \"-A POSTROUTING -s 10.49.0.0/16 -o eth0 -j MASQUERADE\" in result\n\n    # Neither should have policy matching\n    assert \"-m policy --pol none\" not in result\n\n\ndef test_alternative_ingress_snat():\n    \"\"\"Test that alternative ingress IP uses SNAT instead of MASQUERADE.\"\"\"\n    template = load_template(\"rules.v4.j2\")\n\n    result = template.render(\n        ipsec_enabled=True,\n        wireguard_enabled=True,\n        strongswan_network=\"10.48.0.0/16\",\n        wireguard_network_ipv4=\"10.49.0.0/16\",\n        strongswan_network_ipv6=\"2001:db8::/48\",\n        wireguard_network_ipv6=\"2001:db8:a160::/48\",\n        wireguard_port=51820,\n        wireguard_port_avoid=53,\n        wireguard_port_actual=51820,\n        ansible_default_ipv4={\"interface\": \"eth0\"},\n        snat_aipv4=\"192.168.1.100\",  # Alternative ingress IP\n        BetweenClients_DROP=True,\n        block_smb=True,\n        block_netbios=True,\n        local_service_ip=\"10.49.0.1\",\n        ansible_ssh_port=22,\n        reduce_mtu=0,\n    )\n\n    # Should use SNAT with specific IP and output interface instead of MASQUERADE\n    assert \"-A POSTROUTING -s 10.48.0.0/16 -o eth0 -j SNAT --to 192.168.1.100\" in result\n    assert \"-A POSTROUTING -s 10.49.0.0/16 -o eth0 -j SNAT --to 192.168.1.100\" in result\n    assert \"MASQUERADE\" not in result\n\n\ndef test_ipsec_forward_rule_has_policy_match():\n    \"\"\"Test that IPsec FORWARD rules still use policy matching (this is correct).\"\"\"\n    template = load_template(\"rules.v4.j2\")\n\n    result = template.render(\n        ipsec_enabled=True,\n        wireguard_enabled=False,\n        strongswan_network=\"10.48.0.0/16\",\n        strongswan_network_ipv6=\"2001:db8::/48\",\n        ansible_default_ipv4={\"interface\": \"eth0\"},\n        snat_aipv4=None,\n        BetweenClients_DROP=True,\n        block_smb=True,\n        block_netbios=True,\n        local_service_ip=\"10.48.0.1\",\n        ansible_ssh_port=22,\n        reduce_mtu=0,\n    )\n\n    # FORWARD rule should have policy match (this is correct and should stay)\n    assert \"-A FORWARD -m conntrack --ctstate NEW -s 10.48.0.0/16 -m policy --pol ipsec --dir in -j ACCEPT\" in result\n\n\ndef test_wireguard_forward_rule_no_policy_match():\n    \"\"\"Test that WireGuard FORWARD rules don't use policy matching.\"\"\"\n    template = load_template(\"rules.v4.j2\")\n\n    result = template.render(\n        ipsec_enabled=False,\n        wireguard_enabled=True,\n        wireguard_network_ipv4=\"10.49.0.0/16\",\n        wireguard_port=51820,\n        wireguard_port_avoid=53,\n        wireguard_port_actual=51820,\n        ansible_default_ipv4={\"interface\": \"eth0\"},\n        snat_aipv4=None,\n        BetweenClients_DROP=True,\n        block_smb=True,\n        block_netbios=True,\n        local_service_ip=\"10.49.0.1\",\n        ansible_ssh_port=22,\n        reduce_mtu=0,\n    )\n\n    # WireGuard FORWARD rule should NOT have any policy match\n    assert \"-A FORWARD -m conntrack --ctstate NEW -s 10.49.0.0/16 -j ACCEPT\" in result\n    assert \"-A FORWARD -m conntrack --ctstate NEW -s 10.49.0.0/16 -m policy\" not in result\n\n\ndef test_output_interface_in_nat_rules():\n    \"\"\"Test that output interface is specified in NAT rules.\"\"\"\n    template = load_template(\"rules.v4.j2\")\n\n    result = template.render(\n        snat_aipv4=False,\n        wireguard_enabled=True,\n        ipsec_enabled=True,\n        wireguard_network_ipv4=\"10.49.0.0/16\",\n        strongswan_network=\"10.48.0.0/16\",\n        ansible_default_ipv4={\"interface\": \"eth0\", \"address\": \"10.0.0.1\"},\n        ansible_default_ipv6={\"interface\": \"eth0\", \"address\": \"fd9d:bc11:4020::1\"},\n        wireguard_port_actual=51820,\n        wireguard_port_avoid=53,\n        wireguard_port=51820,\n        ansible_ssh_port=22,\n        reduce_mtu=0,\n    )\n\n    # Check that output interface is specified for both VPNs\n    assert \"-A POSTROUTING -s 10.49.0.0/16 -o eth0 -j MASQUERADE\" in result\n    assert \"-A POSTROUTING -s 10.48.0.0/16 -o eth0 -j MASQUERADE\" in result\n\n    # Ensure we don't have rules without output interface\n    assert \"-A POSTROUTING -s 10.49.0.0/16 -j MASQUERADE\" not in result\n    assert \"-A POSTROUTING -s 10.48.0.0/16 -j MASQUERADE\" not in result\n\n\ndef test_dns_firewall_restricted_to_vpn():\n    \"\"\"Test that DNS access is restricted to VPN clients only.\"\"\"\n    template = load_template(\"rules.v4.j2\")\n\n    result = template.render(\n        ipsec_enabled=True,\n        wireguard_enabled=True,\n        strongswan_network=\"10.48.0.0/16\",\n        wireguard_network_ipv4=\"10.49.0.0/16\",\n        strongswan_network_ipv6=\"2001:db8::/48\",\n        wireguard_network_ipv6=\"2001:db8:a160::/48\",\n        wireguard_port=51820,\n        wireguard_port_avoid=53,\n        wireguard_port_actual=51820,\n        ansible_default_ipv4={\"interface\": \"eth0\"},\n        snat_aipv4=None,\n        BetweenClients_DROP=True,\n        block_smb=True,\n        block_netbios=True,\n        local_service_ip=\"172.23.198.242\",\n        ansible_ssh_port=22,\n        reduce_mtu=0,\n    )\n\n    # DNS should only be accessible from VPN subnets\n    assert \"-A INPUT -s 10.48.0.0/16,10.49.0.0/16 -d 172.23.198.242 -p udp --dport 53 -j ACCEPT\" in result\n    # Should NOT have unrestricted DNS access\n    assert \"-A INPUT -d 172.23.198.242 -p udp --dport 53 -j ACCEPT\" not in result\n\n\nif __name__ == \"__main__\":\n    pytest.main([__file__, \"-v\"])\n"
  },
  {
    "path": "tests/unit/test_lightsail_boto3_fix.py",
    "content": "#!/usr/bin/env python\n\"\"\"\nTest for AWS Lightsail boto3 parameter fix.\nVerifies that get_aws_connection_info() works without the deprecated boto3 parameter.\nAddresses issue #14822.\n\"\"\"\n\nimport importlib.util\nimport os\nimport sys\nimport unittest\nfrom unittest.mock import MagicMock, patch\n\n# Add the library directory to the path\nsys.path.insert(0, os.path.join(os.path.dirname(__file__), \"../../library\"))\n\n\nclass TestLightsailBoto3Fix(unittest.TestCase):\n    \"\"\"Test that lightsail_region_facts.py works without boto3 parameter.\"\"\"\n\n    def setUp(self):\n        \"\"\"Set up test fixtures.\"\"\"\n        # Mock the ansible module_utils since we're testing outside of Ansible\n        self.mock_modules = {\n            \"ansible.module_utils.basic\": MagicMock(),\n            \"ansible.module_utils.ec2\": MagicMock(),\n            \"ansible.module_utils.aws.core\": MagicMock(),\n        }\n\n        # Apply mocks\n        self.patches = []\n        for module_name, mock_module in self.mock_modules.items():\n            patcher = patch.dict(\"sys.modules\", {module_name: mock_module})\n            patcher.start()\n            self.patches.append(patcher)\n\n    def tearDown(self):\n        \"\"\"Clean up patches.\"\"\"\n        for patcher in self.patches:\n            patcher.stop()\n\n    def test_lightsail_region_facts_imports(self):\n        \"\"\"Test that lightsail_region_facts can be imported.\"\"\"\n        try:\n            # Import the module\n            spec = importlib.util.spec_from_file_location(\n                \"lightsail_region_facts\",\n                os.path.join(os.path.dirname(__file__), \"../../library/lightsail_region_facts.py\"),\n            )\n            assert spec is not None, \"Failed to create module spec\"\n            assert spec.loader is not None, \"Module spec has no loader\"\n            module = importlib.util.module_from_spec(spec)\n\n            # This should not raise an error\n            spec.loader.exec_module(module)\n\n            # Verify the module loaded\n            self.assertIsNotNone(module)\n            self.assertTrue(hasattr(module, \"main\"))\n\n        except Exception as e:\n            self.fail(f\"Failed to import lightsail_region_facts: {e}\")\n\n    def test_get_aws_connection_info_called_without_boto3(self):\n        \"\"\"Test that get_aws_connection_info is called without boto3 parameter.\"\"\"\n        # Mock get_aws_connection_info to track calls\n        mock_get_aws_connection_info = MagicMock(return_value=(\"us-west-2\", None, {}))\n\n        with patch(\"ansible.module_utils.ec2.get_aws_connection_info\", mock_get_aws_connection_info):\n            # Import the module\n            spec = importlib.util.spec_from_file_location(\n                \"lightsail_region_facts\",\n                os.path.join(os.path.dirname(__file__), \"../../library/lightsail_region_facts.py\"),\n            )\n            assert spec is not None, \"Failed to create module spec\"\n            assert spec.loader is not None, \"Module spec has no loader\"\n            module = importlib.util.module_from_spec(spec)\n\n            # Mock AnsibleModule\n            mock_ansible_module = MagicMock()\n            mock_ansible_module.params = {}\n            mock_ansible_module.check_mode = False\n\n            with patch(\"ansible.module_utils.basic.AnsibleModule\", return_value=mock_ansible_module):\n                # Execute the module\n                try:\n                    spec.loader.exec_module(module)\n                    module.main()\n                except SystemExit:\n                    # Module calls exit_json or fail_json which raises SystemExit\n                    pass\n                except Exception:\n                    # We expect some exceptions since we're mocking, but we want to check the call\n                    pass\n\n            # Verify get_aws_connection_info was called\n            if mock_get_aws_connection_info.called:\n                # Get the call arguments\n                call_args = mock_get_aws_connection_info.call_args\n\n                # Ensure boto3=True is NOT in the arguments\n                if call_args:\n                    # Check positional arguments\n                    if call_args[0]:  # args\n                        self.assertTrue(\n                            len(call_args[0]) <= 1,\n                            \"get_aws_connection_info should be called with at most 1 positional arg (module)\",\n                        )\n\n                    # Check keyword arguments\n                    if call_args[1]:  # kwargs\n                        self.assertNotIn(\n                            \"boto3\", call_args[1], \"get_aws_connection_info should not be called with boto3 parameter\"\n                        )\n\n    def test_no_boto3_parameter_in_source(self):\n        \"\"\"Verify that boto3 parameter is not present in the source code.\"\"\"\n        lightsail_path = os.path.join(os.path.dirname(__file__), \"../../library/lightsail_region_facts.py\")\n\n        with open(lightsail_path) as f:\n            content = f.read()\n\n        # Check that boto3=True is not in the file\n        self.assertNotIn(\n            \"boto3=True\", content, \"boto3=True parameter should not be present in lightsail_region_facts.py\"\n        )\n\n        # Check that boto3 parameter is not used with get_aws_connection_info\n        self.assertNotIn(\n            \"get_aws_connection_info(module, boto3\",\n            content,\n            \"get_aws_connection_info should not be called with boto3 parameter\",\n        )\n\n    def test_regression_issue_14822(self):\n        \"\"\"\n        Regression test for issue #14822.\n        Ensures that the deprecated boto3 parameter is not used.\n        \"\"\"\n        # This test documents the specific issue that was fixed\n        # The boto3 parameter was deprecated and removed in amazon.aws collection\n        # that comes with Ansible 11.x\n\n        lightsail_path = os.path.join(os.path.dirname(__file__), \"../../library/lightsail_region_facts.py\")\n\n        with open(lightsail_path) as f:\n            lines = f.readlines()\n\n        # Find the line that calls get_aws_connection_info\n        for line_num, line in enumerate(lines, 1):\n            if \"get_aws_connection_info\" in line and \"region\" in line:\n                # This should be around line 85\n                # Verify it doesn't have boto3=True\n                self.assertNotIn(\"boto3\", line, f\"Line {line_num} should not contain boto3 parameter\")\n\n                # Verify the correct format\n                self.assertIn(\n                    \"get_aws_connection_info(module)\",\n                    line,\n                    f\"Line {line_num} should call get_aws_connection_info(module) without boto3\",\n                )\n                break\n        else:\n            self.fail(\"Could not find get_aws_connection_info call in lightsail_region_facts.py\")\n\n\nif __name__ == \"__main__\":\n    unittest.main()\n"
  },
  {
    "path": "tests/unit/test_list_servers.py",
    "content": "\"\"\"Tests for scripts/list_servers.py.\"\"\"\n\nimport importlib.util\nimport json\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nimport pytest\n\n# Load list_servers module from scripts/ (not a Python package)\n_script = Path(__file__).resolve().parents[2] / \"scripts\" / \"list_servers.py\"\n_spec = importlib.util.spec_from_file_location(\"list_servers\", str(_script))\n_mod = importlib.util.module_from_spec(_spec)\n_spec.loader.exec_module(_mod)\nlist_servers = _mod.list_servers\n\n\n@pytest.fixture()\ndef configs_dir(tmp_path):\n    \"\"\"Create a temporary configs directory with sample configs.\"\"\"\n    server1 = tmp_path / \"10.0.0.1\"\n    server1.mkdir()\n    (server1 / \".config.yml\").write_text(\"server: 10.0.0.1\\nalgo_provider: digitalocean\\nalgo_server_name: algo\\n\")\n\n    server2 = tmp_path / \"10.0.0.2\"\n    server2.mkdir()\n    (server2 / \".config.yml\").write_text(\"server: 10.0.0.2\\nalgo_provider: ec2\\nalgo_server_name: prod\\n\")\n    return tmp_path\n\n\ndef test_empty_directory(tmp_path):\n    \"\"\"Empty configs directory returns empty list.\"\"\"\n    assert list_servers(tmp_path) == []\n\n\ndef test_missing_directory(tmp_path):\n    \"\"\"Non-existent path returns empty list via glob.\"\"\"\n    assert list_servers(tmp_path / \"nonexistent\") == []\n\n\ndef test_lists_servers(configs_dir):\n    \"\"\"Parses .config.yml files and returns server metadata.\"\"\"\n    servers = list_servers(configs_dir)\n    assert len(servers) == 2\n    names = {s[\"algo_server_name\"] for s in servers}\n    assert names == {\"algo\", \"prod\"}\n\n\ndef test_sorted_output(configs_dir):\n    \"\"\"Servers are returned in sorted directory order.\"\"\"\n    servers = list_servers(configs_dir)\n    ips = [s[\"server\"] for s in servers]\n    assert ips == [\"10.0.0.1\", \"10.0.0.2\"]\n\n\ndef test_skips_empty_yaml(tmp_path):\n    \"\"\"Empty YAML files (parsing to None) are skipped.\"\"\"\n    server = tmp_path / \"10.0.0.5\"\n    server.mkdir()\n    (server / \".config.yml\").write_text(\"\")\n\n    assert list_servers(tmp_path) == []\n\n\ndef test_cli_output(tmp_path):\n    \"\"\"CLI outputs valid JSON to stdout.\"\"\"\n    server = tmp_path / \"10.0.0.1\"\n    server.mkdir()\n    (server / \".config.yml\").write_text(\"server: 10.0.0.1\\nalgo_server_name: test\\n\")\n\n    result = subprocess.run(\n        [sys.executable, \"scripts/list_servers.py\", str(tmp_path)],\n        capture_output=True,\n        text=True,\n        check=True,\n    )\n    data = json.loads(result.stdout)\n    assert len(data) == 1\n    assert data[0][\"server\"] == \"10.0.0.1\"\n\n\ndef test_cli_missing_dir():\n    \"\"\"CLI outputs empty JSON array for missing directory.\"\"\"\n    result = subprocess.run(\n        [\n            sys.executable,\n            \"scripts/list_servers.py\",\n            \"/nonexistent/path\",\n        ],\n        capture_output=True,\n        text=True,\n        check=True,\n    )\n    assert json.loads(result.stdout) == []\n"
  },
  {
    "path": "tests/unit/test_openssl_compatibility.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest PKI certificate validation for Ansible-generated certificates\nHybrid approach: validates actual certificates when available, else tests templates/config\nBased on issues #14755, #14718 - Apple device compatibility\nIssues #75, #153 - Security enhancements (name constraints, EKU restrictions)\n\"\"\"\n\nimport glob\nimport os\nimport re\nimport subprocess\nimport sys\nfrom datetime import UTC\n\nfrom cryptography import x509\nfrom cryptography.x509.oid import ExtensionOID, NameOID\n\n\ndef find_generated_certificates():\n    \"\"\"Find Ansible-generated certificate files in configs directory\"\"\"\n    # Look for configs directory structure created by Ansible\n    config_patterns = [\n        \"configs/*/ipsec/.pki/cacert.pem\",\n        \"../configs/*/ipsec/.pki/cacert.pem\",  # From tests/unit directory\n        \"../../configs/*/ipsec/.pki/cacert.pem\",  # Alternative path\n    ]\n\n    for pattern in config_patterns:\n        ca_certs = glob.glob(pattern)\n        if ca_certs:\n            base_path = os.path.dirname(ca_certs[0])\n            return {\n                \"ca_cert\": ca_certs[0],\n                \"base_path\": base_path,\n                \"server_certs\": glob.glob(f\"{base_path}/certs/*.crt\"),\n                \"p12_files\": glob.glob(f\"{base_path.replace('/.pki', '')}/manual/*.p12\"),\n            }\n\n    return None\n\n\ndef test_openssl_version_detection():\n    \"\"\"Test that we can detect OpenSSL version for compatibility checks\"\"\"\n    result = subprocess.run([\"openssl\", \"version\"], capture_output=True, text=True)\n\n    assert result.returncode == 0, \"Failed to get OpenSSL version\"\n\n    # Parse version - e.g., \"OpenSSL 3.0.2 15 Mar 2022\"\n    version_match = re.search(r\"OpenSSL\\s+(\\d+)\\.(\\d+)\\.(\\d+)\", result.stdout)\n    assert version_match, f\"Can't parse OpenSSL version: {result.stdout}\"\n\n    major = int(version_match.group(1))\n    minor = int(version_match.group(2))\n\n    print(f\"✓ OpenSSL version detected: {major}.{minor}\")\n    return (major, minor)\n\n\ndef validate_ca_certificate_real(cert_files):\n    \"\"\"Validate actual Ansible-generated CA certificate\"\"\"\n    # Read the actual CA certificate generated by Ansible\n    with open(cert_files[\"ca_cert\"], \"rb\") as f:\n        cert_data = f.read()\n\n    certificate = x509.load_pem_x509_certificate(cert_data)\n\n    # Check Basic Constraints\n    basic_constraints = certificate.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value\n    assert basic_constraints.ca is True, \"CA certificate should have CA:TRUE\"\n    assert basic_constraints.path_length == 0, \"CA should have pathlen:0 constraint\"\n\n    # Check Key Usage\n    key_usage = certificate.extensions.get_extension_for_oid(ExtensionOID.KEY_USAGE).value\n    assert key_usage.key_cert_sign is True, \"CA should have keyCertSign usage\"\n    assert key_usage.crl_sign is True, \"CA should have cRLSign usage\"\n\n    # Check Extended Key Usage (Issue #75)\n    eku = certificate.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value\n    assert x509.oid.ExtendedKeyUsageOID.SERVER_AUTH in eku, \"CA should allow signing server certificates\"\n    assert x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH in eku, \"CA should allow signing client certificates\"\n    assert x509.ObjectIdentifier(\"1.3.6.1.5.5.7.3.17\") in eku, \"CA should have IPsec End Entity EKU\"\n\n    # Check Name Constraints (Issue #75) - defense against certificate misuse\n    name_constraints = certificate.extensions.get_extension_for_oid(ExtensionOID.NAME_CONSTRAINTS).value\n    assert name_constraints.permitted_subtrees is not None, \"CA should have permitted name constraints\"\n    assert name_constraints.excluded_subtrees is not None, \"CA should have excluded name constraints\"\n\n    # Verify public domains are excluded\n    excluded_dns = [\n        constraint.value for constraint in name_constraints.excluded_subtrees if isinstance(constraint, x509.DNSName)\n    ]\n    public_domains = [\".com\", \".org\", \".net\", \".gov\", \".edu\", \".mil\", \".int\"]\n    for domain in public_domains:\n        assert domain in excluded_dns, f\"CA should exclude public domain {domain}\"\n\n    # Verify private IP ranges are excluded (Issue #75)\n    excluded_ips = [\n        constraint.value for constraint in name_constraints.excluded_subtrees if isinstance(constraint, x509.IPAddress)\n    ]\n    assert len(excluded_ips) > 0, \"CA should exclude private IP ranges\"\n\n    # Verify email domains are also excluded (Issue #153)\n    excluded_emails = [\n        constraint.value for constraint in name_constraints.excluded_subtrees if isinstance(constraint, x509.RFC822Name)\n    ]\n    email_domains = [\".com\", \".org\", \".net\", \".gov\", \".edu\", \".mil\", \".int\"]\n    for domain in email_domains:\n        assert domain in excluded_emails, f\"CA should exclude email domain {domain}\"\n\n    print(f\"✓ Real CA certificate has proper security constraints: {cert_files['ca_cert']}\")\n\n\ndef validate_ca_certificate_config():\n    \"\"\"Validate CA certificate configuration in Ansible files (CI mode)\"\"\"\n    # Check that the Ansible task file has proper CA certificate configuration\n    openssl_task_file = find_ansible_file(\"roles/strongswan/tasks/openssl.yml\")\n    if not openssl_task_file:\n        print(\"⚠ Could not find openssl.yml task file\")\n        return\n\n    with open(openssl_task_file) as f:\n        content = f.read()\n\n    # Verify key security configurations are present\n    security_checks = [\n        (\"name_constraints_permitted\", \"Name constraints should be configured\"),\n        (\"name_constraints_excluded\", \"Excluded name constraints should be configured\"),\n        (\"extended_key_usage\", \"Extended Key Usage should be configured\"),\n        (\"1.3.6.1.5.5.7.3.17\", \"IPsec End Entity OID should be present\"),\n        (\"serverAuth\", \"Server authentication EKU should be present\"),\n        (\"clientAuth\", \"Client authentication EKU should be present\"),\n        (\"basic_constraints\", \"Basic constraints should be configured\"),\n        (\"CA:TRUE\", \"CA certificate should be marked as CA\"),\n        (\"pathlen:0\", \"Path length constraint should be set\"),\n    ]\n\n    for check, message in security_checks:\n        assert check in content, f\"Missing security configuration: {message}\"\n\n    # Verify public domains are excluded\n    public_domains = [\".com\", \".org\", \".net\", \".gov\", \".edu\", \".mil\", \".int\"]\n    for domain in public_domains:\n        # Handle both double quotes and single quotes in YAML\n        assert f'\"DNS:{domain}\"' in content or f\"'DNS:{domain}'\" in content, (\n            f\"Public domain {domain} should be excluded\"\n        )\n\n    # Verify private IP ranges are excluded\n    private_ranges = [\"10.0.0.0\", \"172.16.0.0\", \"192.168.0.0\"]\n    for ip_range in private_ranges:\n        assert ip_range in content, f\"Private IP range {ip_range} should be excluded\"\n\n    # Verify email domains are excluded (Issue #153)\n    email_domains = [\".com\", \".org\", \".net\", \".gov\", \".edu\", \".mil\", \".int\"]\n    for domain in email_domains:\n        # Handle both double quotes and single quotes in YAML\n        assert f'\"email:{domain}\"' in content or f\"'email:{domain}'\" in content, (\n            f\"Email domain {domain} should be excluded\"\n        )\n\n    # Verify IPv6 constraints are present (Issue #153)\n    assert \"IP:::/0\" in content, \"IPv6 all addresses should be excluded\"\n\n    print(\"✓ CA certificate configuration has proper security constraints\")\n\n\ndef test_ca_certificate():\n    \"\"\"Test CA certificate - uses real certs if available, else validates config (Issue #75, #153)\"\"\"\n    cert_files = find_generated_certificates()\n    if cert_files:\n        validate_ca_certificate_real(cert_files)\n    else:\n        validate_ca_certificate_config()\n\n\ndef validate_server_certificates_real(cert_files):\n    \"\"\"Validate actual Ansible-generated server certificates\"\"\"\n    # Filter to only actual server certificates (not client certs)\n    # Server certificates contain IP addresses in the filename\n    import re\n\n    server_certs = [\n        f\n        for f in cert_files[\"server_certs\"]\n        if not f.endswith(\"/cacert.pem\") and re.search(r\"\\d+\\.\\d+\\.\\d+\\.\\d+\\.crt$\", f)\n    ]\n    if not server_certs:\n        print(\"⚠ No server certificates found\")\n        return\n\n    for server_cert_path in server_certs:\n        with open(server_cert_path, \"rb\") as f:\n            cert_data = f.read()\n\n        certificate = x509.load_pem_x509_certificate(cert_data)\n\n        # Check it's not a CA certificate\n        basic_constraints = certificate.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value\n        assert basic_constraints.ca is False, \"Server certificate should not be a CA\"\n\n        # Check Extended Key Usage (Issue #75)\n        eku = certificate.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value\n        assert x509.oid.ExtendedKeyUsageOID.SERVER_AUTH in eku, \"Server cert must have serverAuth EKU\"\n        assert x509.ObjectIdentifier(\"1.3.6.1.5.5.7.3.17\") in eku, \"Server cert should have IPsec End Entity EKU\"\n        # Security check: Server certificates should NOT have clientAuth to prevent role confusion (Issue #153)\n        assert x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH not in eku, (\n            \"Server cert should NOT have clientAuth EKU for role separation\"\n        )\n\n        # Check SAN extension exists (required for Apple devices)\n        try:\n            san = certificate.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value\n            assert len(san) > 0, \"Server certificate must have SAN extension for Apple device compatibility\"\n        except x509.ExtensionNotFound:\n            assert False, \"Server certificate missing SAN extension - required for modern clients\"\n\n        print(f\"✓ Real server certificate valid: {os.path.basename(server_cert_path)}\")\n\n\ndef validate_server_certificates_config():\n    \"\"\"Validate server certificate configuration in Ansible files (CI mode)\"\"\"\n    openssl_task_file = find_ansible_file(\"roles/strongswan/tasks/openssl.yml\")\n    if not openssl_task_file:\n        print(\"⚠ Could not find openssl.yml task file\")\n        return\n\n    with open(openssl_task_file) as f:\n        content = f.read()\n\n    # Look for server certificate CSR section\n    server_csr_section = re.search(r\"Create CSRs for server certificate.*?register: server_csr\", content, re.DOTALL)\n    if not server_csr_section:\n        print(\"⚠ Could not find server certificate CSR section\")\n        return\n\n    server_section = server_csr_section.group(0)\n\n    # Check server certificate CSR configuration\n    server_checks = [\n        (\"subject_alt_name\", \"Server certificates should have SAN extension\"),\n        (\"serverAuth\", \"Server certificates should have serverAuth EKU\"),\n        (\"1.3.6.1.5.5.7.3.17\", \"Server certificates should have IPsec End Entity EKU\"),\n        (\"digitalSignature\", \"Server certificates should have digital signature usage\"),\n        (\"keyEncipherment\", \"Server certificates should have key encipherment usage\"),\n    ]\n\n    for check, message in server_checks:\n        assert check in server_section, f\"Missing server certificate configuration: {message}\"\n\n    # Security check: Server certificates should NOT have clientAuth (Issue #153)\n    # Look for clientAuth in extended_key_usage section, not in comments\n    eku_lines = [\n        line\n        for line in server_section.split(\"\\n\")\n        if \"extended_key_usage:\" in line or (line.strip().startswith(\"- \") and \"clientAuth\" in line)\n    ]\n    has_client_auth = any(\"clientAuth\" in line for line in eku_lines if line.strip().startswith(\"- \"))\n    assert not has_client_auth, \"Server certificates should NOT have clientAuth EKU for role separation\"\n\n    # Verify SAN extension is configured for Apple compatibility\n    assert \"subjectAltName\" in server_section, \"Server certificates missing SAN configuration for Apple compatibility\"\n\n    print(\"✓ Server certificate configuration has proper EKU and SAN settings\")\n\n\ndef test_server_certificates():\n    \"\"\"Test server certificates - uses real certs if available, else validates config\"\"\"\n    cert_files = find_generated_certificates()\n    if cert_files:\n        validate_server_certificates_real(cert_files)\n    else:\n        validate_server_certificates_config()\n\n\ndef validate_client_certificates_real(cert_files):\n    \"\"\"Validate actual Ansible-generated client certificates\"\"\"\n    # Find client certificates (not CA cert, not server cert with IP/DNS name)\n    client_certs = []\n    for cert_path in cert_files[\"server_certs\"]:\n        if \"cacert.pem\" in cert_path:\n            continue\n\n        with open(cert_path, \"rb\") as f:\n            cert_data = f.read()\n        certificate = x509.load_pem_x509_certificate(cert_data)\n\n        # Check if this looks like a client cert vs server cert\n        cn = certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value\n        # Server certs typically have IP addresses or domain names as CN\n        if not (cn.replace(\".\", \"\").isdigit() or (\".\" in cn and len(cn.split(\".\")) == 4)):\n            client_certs.append((cert_path, certificate))\n\n    if not client_certs:\n        print(\"⚠ No client certificates found\")\n        return\n\n    for cert_path, certificate in client_certs:\n        # Check it's not a CA certificate\n        basic_constraints = certificate.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value\n        assert basic_constraints.ca is False, \"Client certificate should not be a CA\"\n\n        # Check Extended Key Usage restrictions (Issue #75)\n        eku = certificate.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value\n        assert x509.oid.ExtendedKeyUsageOID.CLIENT_AUTH in eku, \"Client cert must have clientAuth EKU\"\n        assert x509.ObjectIdentifier(\"1.3.6.1.5.5.7.3.17\") in eku, \"Client cert should have IPsec End Entity EKU\"\n\n        # Security check: Client certificates should NOT have serverAuth (prevents impersonation) (Issue #153)\n        assert x509.oid.ExtendedKeyUsageOID.SERVER_AUTH not in eku, (\n            \"Client cert must NOT have serverAuth EKU to prevent server impersonation\"\n        )\n\n        # Check SAN extension for email\n        try:\n            san = certificate.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME).value\n            email_sans = [name.value for name in san if isinstance(name, x509.RFC822Name)]\n            assert len(email_sans) > 0, \"Client certificate should have email SAN\"\n        except x509.ExtensionNotFound:\n            print(f\"⚠ Client certificate missing SAN extension: {os.path.basename(cert_path)}\")\n\n        print(f\"✓ Real client certificate valid: {os.path.basename(cert_path)}\")\n\n\ndef validate_client_certificates_config():\n    \"\"\"Validate client certificate configuration in Ansible files (CI mode)\"\"\"\n    openssl_task_file = find_ansible_file(\"roles/strongswan/tasks/openssl.yml\")\n    if not openssl_task_file:\n        print(\"⚠ Could not find openssl.yml task file\")\n        return\n\n    with open(openssl_task_file) as f:\n        content = f.read()\n\n    # Look for client certificate CSR section\n    client_csr_section = re.search(\n        r\"Create CSRs for client certificates.*?register: client_csr_jobs\", content, re.DOTALL\n    )\n    if not client_csr_section:\n        print(\"⚠ Could not find client certificate CSR section\")\n        return\n\n    client_section = client_csr_section.group(0)\n\n    # Check client certificate configuration\n    client_checks = [\n        (\"clientAuth\", \"Client certificates should have clientAuth EKU\"),\n        (\"1.3.6.1.5.5.7.3.17\", \"Client certificates should have IPsec End Entity EKU\"),\n        (\"digitalSignature\", \"Client certificates should have digital signature usage\"),\n        (\"keyEncipherment\", \"Client certificates should have key encipherment usage\"),\n        (\"email:\", \"Client certificates should have email SAN\"),\n    ]\n\n    for check, message in client_checks:\n        assert check in client_section, f\"Missing client certificate configuration: {message}\"\n\n    # Security check: Client certificates should NOT have serverAuth (Issue #153)\n    # Look for serverAuth in extended_key_usage section, not in comments\n    eku_lines = [\n        line\n        for line in client_section.split(\"\\n\")\n        if \"extended_key_usage:\" in line or (line.strip().startswith(\"- \") and \"serverAuth\" in line)\n    ]\n    has_server_auth = any(\"serverAuth\" in line for line in eku_lines if line.strip().startswith(\"- \"))\n    assert not has_server_auth, \"Client certificates must NOT have serverAuth EKU to prevent server impersonation\"\n\n    # Verify client certificates use unique email domains (Issue #153)\n    assert \"openssl_constraint_random_id\" in client_section, (\n        \"Client certificates should use unique email domain per deployment\"\n    )\n\n    print(\"✓ Client certificate configuration has proper EKU restrictions (no serverAuth)\")\n\n\ndef test_client_certificates():\n    \"\"\"Test client certificates - uses real certs if available, else validates config (Issue #75, #153)\"\"\"\n    cert_files = find_generated_certificates()\n    if cert_files:\n        validate_client_certificates_real(cert_files)\n    else:\n        validate_client_certificates_config()\n\n\ndef validate_pkcs12_files_real(cert_files):\n    \"\"\"Validate actual Ansible-generated PKCS#12 files\"\"\"\n    if not cert_files.get(\"p12_files\"):\n        print(\"⚠ No PKCS#12 files found\")\n        return\n\n    major, _minor = test_openssl_version_detection()\n\n    for p12_file in cert_files[\"p12_files\"]:\n        assert os.path.exists(p12_file), f\"PKCS#12 file should exist: {p12_file}\"\n\n        # Test that PKCS#12 file can be read (validates format)\n        legacy_flag = [\"-legacy\"] if major >= 3 else []\n\n        result = subprocess.run(\n            [\n                \"openssl\",\n                \"pkcs12\",\n                \"-info\",\n                \"-in\",\n                p12_file,\n                \"-passin\",\n                \"pass:\",  # Try empty password first\n                \"-noout\",\n            ]\n            + legacy_flag,\n            capture_output=True,\n            text=True,\n        )\n\n        # PKCS#12 files should be readable (even if password-protected)\n        # We're just testing format validity, not trying to extract contents\n        if result.returncode != 0:\n            # Try with common password patterns if empty password fails\n            print(f\"⚠ PKCS#12 file may require password: {os.path.basename(p12_file)}\")\n\n        print(f\"✓ Real PKCS#12 file exists: {os.path.basename(p12_file)}\")\n\n\ndef validate_pkcs12_files_config():\n    \"\"\"Validate PKCS#12 file configuration in Ansible files (CI mode)\"\"\"\n    openssl_task_file = find_ansible_file(\"roles/strongswan/tasks/openssl.yml\")\n    if not openssl_task_file:\n        print(\"⚠ Could not find openssl.yml task file\")\n        return\n\n    with open(openssl_task_file) as f:\n        content = f.read()\n\n    # Check PKCS#12 generation configuration\n    p12_checks = [\n        (\"openssl_pkcs12\", \"PKCS#12 generation should be configured\"),\n        (\"encryption_level\", \"PKCS#12 encryption level should be configured\"),\n        (\"compatibility2022\", \"PKCS#12 should use Apple-compatible encryption\"),\n        (\"friendly_name\", \"PKCS#12 should have friendly names\"),\n        (\"other_certificates\", \"PKCS#12 should include CA certificate for full chain\"),\n        (\"passphrase\", \"PKCS#12 files should be password protected\"),\n        ('mode: \"0600\"', \"PKCS#12 files should have secure permissions\"),\n    ]\n\n    for check, message in p12_checks:\n        assert check in content, f\"Missing PKCS#12 configuration: {message}\"\n\n    print(\"✓ PKCS#12 configuration has proper Apple device compatibility settings\")\n\n\ndef test_pkcs12_files():\n    \"\"\"Test PKCS#12 files - uses real files if available, else validates config (Issue #14755, #14718)\"\"\"\n    cert_files = find_generated_certificates()\n    if cert_files:\n        validate_pkcs12_files_real(cert_files)\n    else:\n        validate_pkcs12_files_config()\n\n\ndef validate_certificate_chain_real(cert_files):\n    \"\"\"Validate actual Ansible-generated certificate chain\"\"\"\n    # Load CA certificate\n    with open(cert_files[\"ca_cert\"], \"rb\") as f:\n        ca_cert_data = f.read()\n    ca_certificate = x509.load_pem_x509_certificate(ca_cert_data)\n\n    # Test that all other certificates are signed by the CA\n    other_certs = [f for f in cert_files[\"server_certs\"] if f != cert_files[\"ca_cert\"]]\n\n    if not other_certs:\n        print(\"⚠ No client/server certificates found to validate\")\n        return\n\n    for cert_path in other_certs:\n        with open(cert_path, \"rb\") as f:\n            cert_data = f.read()\n        certificate = x509.load_pem_x509_certificate(cert_data)\n\n        # Verify the certificate was signed by our CA\n        assert certificate.issuer == ca_certificate.subject, f\"Certificate {cert_path} not signed by CA\"\n\n        # Verify certificate is currently valid (not expired)\n        from datetime import datetime\n\n        now = datetime.now(UTC)\n        assert certificate.not_valid_before_utc <= now, f\"Certificate {cert_path} not yet valid\"\n        assert certificate.not_valid_after_utc >= now, f\"Certificate {cert_path} has expired\"\n\n        print(f\"✓ Real certificate chain valid: {os.path.basename(cert_path)}\")\n\n    print(\"✓ All real certificates properly signed by CA\")\n\n\ndef validate_certificate_chain_config():\n    \"\"\"Validate certificate chain configuration in Ansible files (CI mode)\"\"\"\n    openssl_task_file = find_ansible_file(\"roles/strongswan/tasks/openssl.yml\")\n    if not openssl_task_file:\n        print(\"⚠ Could not find openssl.yml task file\")\n        return\n\n    with open(openssl_task_file) as f:\n        content = f.read()\n\n    # Check certificate signing configuration\n    chain_checks = [\n        (\"provider: ownca\", \"Certificates should be signed by own CA\"),\n        (\"ownca_path\", \"CA certificate path should be specified\"),\n        (\"ownca_privatekey_path\", \"CA private key path should be specified\"),\n        (\"ownca_privatekey_passphrase\", \"CA private key should be password protected\"),\n        (\"certificate_validity_days: 3650\", \"Certificate validity should be configurable (default 10 years)\"),\n        (\n            'ownca_not_after: \"+{{ certificate_validity_days }}d\"',\n            \"Certificates should use configurable validity period\",\n        ),\n        ('ownca_not_before: \"-1d\"', \"Certificates should have backdated start time\"),\n        (\"curve: secp384r1\", \"Should use strong elliptic curve cryptography\"),\n        (\"type: ECC\", \"Should use elliptic curve keys for better security\"),\n    ]\n\n    for check, message in chain_checks:\n        assert check in content, f\"Missing certificate chain configuration: {message}\"\n\n    print(\"✓ Certificate chain configuration properly set up for CA signing\")\n\n\ndef test_certificate_chain():\n    \"\"\"Test certificate chain - uses real certs if available, else validates config\"\"\"\n    cert_files = find_generated_certificates()\n    if cert_files:\n        validate_certificate_chain_real(cert_files)\n    else:\n        validate_certificate_chain_config()\n\n\ndef find_ansible_file(relative_path):\n    \"\"\"Find Ansible file from various possible locations\"\"\"\n    # Try different base paths\n    possible_bases = [\n        \".\",  # Current directory\n        \"..\",  # Parent directory (from tests/unit)\n        \"../..\",  # Grandparent (from tests/unit to project root)\n        \"../../..\",  # Alternative deep path\n    ]\n\n    for base in possible_bases:\n        full_path = os.path.join(base, relative_path)\n        if os.path.exists(full_path):\n            return full_path\n\n    return None\n\n\nif __name__ == \"__main__\":\n    tests = [\n        test_openssl_version_detection,\n        test_ca_certificate,\n        test_server_certificates,\n        test_client_certificates,\n        test_pkcs12_files,\n        test_certificate_chain,\n    ]\n\n    failed = 0\n    for test in tests:\n        try:\n            test()\n        except AssertionError as e:\n            print(f\"✗ {test.__name__} failed: {e}\")\n            failed += 1\n        except Exception as e:\n            print(f\"✗ {test.__name__} error: {e}\")\n            failed += 1\n\n    if failed > 0:\n        print(f\"\\n{failed} tests failed\")\n        sys.exit(1)\n    else:\n        print(f\"\\nAll {len(tests)} tests passed!\")\n"
  },
  {
    "path": "tests/unit/test_scaleway_fix.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest Scaleway role fixes for issue #14846\n\nThis test validates that:\n1. The Scaleway role uses the modern 'project' parameter instead of deprecated 'organization'\n2. The Marketplace API is used for image lookup instead of the broken scaleway_image_info module\n3. The prompts include organization/project ID collection\n\"\"\"\n\nimport sys\nfrom pathlib import Path\n\nimport yaml\n\n\ndef load_yaml_file(file_path):\n    \"\"\"Load and parse a YAML file\"\"\"\n    with open(file_path) as f:\n        return yaml.safe_load(f)\n\n\ndef test_scaleway_main_uses_project_parameter():\n    \"\"\"Test that main.yml uses 'project' instead of deprecated 'organization' parameter\"\"\"\n    main_yml = Path(\"roles/cloud-scaleway/tasks/main.yml\")\n    assert main_yml.exists(), \"Scaleway main.yml not found\"\n\n    with open(main_yml) as f:\n        content = f.read()\n\n    # Should NOT use the broken scaleway_organization_info module\n    assert \"scaleway_organization_info\" not in content, (\n        \"Still using broken scaleway_organization_info module (issue #14846)\"\n    )\n\n    # Should NOT use the broken scaleway_image_info module\n    assert \"scaleway_image_info\" not in content, \"Still using broken scaleway_image_info module\"\n\n    # Should use project parameter (modern approach)\n    assert \"project:\" in content, \"Missing 'project:' parameter in scaleway_compute calls\"\n    assert \"algo_scaleway_org_id\" in content, \"Missing algo_scaleway_org_id variable reference\"\n\n    # Should NOT use deprecated organization parameter\n    assert 'organization: \"{{' not in content, \"Still using deprecated 'organization' parameter\"\n\n    # Should use Marketplace API for image lookup\n    assert \"api-marketplace.scaleway.com\" in content, \"Not using Scaleway Marketplace API for image lookup\"\n\n    print(\"✓ Scaleway main.yml uses modern 'project' parameter\")\n\n\ndef test_scaleway_prompts_collect_org_id():\n    \"\"\"Test that prompts.yml collects organization/project ID from user\"\"\"\n    prompts_yml = Path(\"roles/cloud-scaleway/tasks/prompts.yml\")\n    assert prompts_yml.exists(), \"Scaleway prompts.yml not found\"\n\n    with open(prompts_yml) as f:\n        content = f.read()\n\n    # Should prompt for organization ID\n    assert \"Organization ID\" in content, \"Missing prompt for Scaleway Organization ID\"\n\n    # Should set algo_scaleway_org_id fact\n    assert \"algo_scaleway_org_id:\" in content, \"Missing algo_scaleway_org_id fact definition\"\n\n    # Should support SCW_DEFAULT_ORGANIZATION_ID env var\n    assert \"SCW_DEFAULT_ORGANIZATION_ID\" in content, (\n        \"Missing support for SCW_DEFAULT_ORGANIZATION_ID environment variable\"\n    )\n\n    # Should mention console.scaleway.com for finding the ID\n    assert \"console.scaleway.com\" in content, \"Missing instructions on where to find Organization ID\"\n\n    print(\"✓ Scaleway prompts.yml collects organization/project ID\")\n\n\ndef test_scaleway_config_has_valid_settings():\n    \"\"\"Test that config.cfg has valid Scaleway settings\"\"\"\n    config_file = Path(\"config.cfg\")\n    assert config_file.exists(), \"config.cfg not found\"\n\n    with open(config_file) as f:\n        content = f.read()\n\n    # Should have scaleway section\n    assert \"scaleway:\" in content, \"Missing Scaleway configuration section\"\n\n    # Should specify Ubuntu 22.04\n    assert \"Ubuntu 22.04\" in content or \"ubuntu\" in content.lower(), \"Missing Ubuntu image specification\"\n\n    print(\"✓ config.cfg has valid Scaleway settings\")\n\n\ndef test_scaleway_marketplace_api_usage():\n    \"\"\"Test that the role correctly uses Scaleway Marketplace API\"\"\"\n    main_yml = Path(\"roles/cloud-scaleway/tasks/main.yml\")\n\n    with open(main_yml) as f:\n        content = f.read()\n\n    # Should use uri module to fetch from Marketplace API\n    assert \"uri:\" in content, \"Not using uri module for API calls\"\n\n    # Should filter for Ubuntu 22.04 Jammy\n    assert \"Ubuntu\" in content and \"22\" in content, \"Not filtering for Ubuntu 22.04 image\"\n\n    # Should set scaleway_image_id variable\n    assert \"scaleway_image_id\" in content, \"Missing scaleway_image_id variable for image UUID\"\n\n    print(\"✓ Scaleway role uses Marketplace API correctly\")\n\n\nif __name__ == \"__main__\":\n    tests = [\n        test_scaleway_main_uses_project_parameter,\n        test_scaleway_prompts_collect_org_id,\n        test_scaleway_config_has_valid_settings,\n        test_scaleway_marketplace_api_usage,\n    ]\n\n    failed = 0\n    for test in tests:\n        try:\n            test()\n        except AssertionError as e:\n            print(f\"✗ {test.__name__} failed: {e}\")\n            failed += 1\n        except Exception as e:\n            print(f\"✗ {test.__name__} error: {e}\")\n            failed += 1\n\n    if failed > 0:\n        print(f\"\\n{failed} tests failed\")\n        sys.exit(1)\n    else:\n        print(f\"\\nAll {len(tests)} tests passed!\")\n"
  },
  {
    "path": "tests/unit/test_strongswan_templates.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nEnhanced tests for StrongSwan templates.\nTests all strongswan role templates with various configurations.\n\"\"\"\n\nimport os\nimport sys\nimport uuid\n\nfrom jinja2 import Environment, FileSystemLoader, StrictUndefined\n\n# Add parent directory to path for fixtures\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom fixtures import load_test_variables\n\n\ndef mock_to_uuid(value):\n    \"\"\"Mock the to_uuid filter\"\"\"\n    return str(uuid.uuid5(uuid.NAMESPACE_DNS, str(value)))\n\n\ndef mock_bool(value):\n    \"\"\"Mock the bool filter\"\"\"\n    return str(value).lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef mock_version(version_string, comparison):\n    \"\"\"Mock the version comparison filter\"\"\"\n    # Simple mock - just return True for now\n    return True\n\n\ndef mock_b64encode(value):\n    \"\"\"Mock base64 encoding\"\"\"\n    import base64\n\n    if isinstance(value, str):\n        value = value.encode(\"utf-8\")\n    return base64.b64encode(value).decode(\"ascii\")\n\n\ndef mock_b64decode(value):\n    \"\"\"Mock base64 decoding\"\"\"\n    import base64\n\n    return base64.b64decode(value).decode(\"utf-8\")\n\n\ndef get_strongswan_test_variables(scenario=\"default\"):\n    \"\"\"Get test variables for StrongSwan templates with different scenarios.\"\"\"\n    base_vars = load_test_variables()\n\n    # Add StrongSwan specific variables\n    strongswan_vars = {\n        \"ipsec_config_path\": \"/etc/ipsec.d\",\n        \"ipsec_pki_path\": \"/etc/ipsec.d\",\n        \"strongswan_enabled\": True,\n        \"strongswan_network\": \"10.19.48.0/24\",\n        \"strongswan_network_ipv6\": \"fd9d:bc11:4021::/64\",\n        \"strongswan_log_level\": \"2\",\n        \"openssl_constraint_random_id\": \"test-\" + str(uuid.uuid4()),\n        \"subjectAltName\": \"IP:10.0.0.1,IP:2600:3c01::f03c:91ff:fedf:3b2a\",\n        \"subjectAltName_type\": \"IP\",\n        \"subjectAltName_client\": \"IP:10.0.0.1\",\n        \"ansible_default_ipv6\": {\"address\": \"2600:3c01::f03c:91ff:fedf:3b2a\"},\n        \"openssl_version\": \"3.0.0\",\n        \"p12_export_password\": \"test-password\",\n        \"ike_lifetime\": \"24h\",\n        \"ipsec_lifetime\": \"8h\",\n        \"ike_dpd\": \"30s\",\n        \"ipsec_dead_peer_detection\": True,\n        \"rekey_margin\": \"3m\",\n        \"rekeymargin\": \"3m\",\n        \"dpddelay\": \"35s\",\n        \"keyexchange\": \"ikev2\",\n        \"ike_cipher\": \"aes128gcm16-prfsha512-ecp256\",\n        \"esp_cipher\": \"aes128gcm16-ecp256\",\n        \"leftsourceip\": \"10.19.48.1\",\n        \"leftsubnet\": \"0.0.0.0/0,::/0\",\n        \"rightsourceip\": \"10.19.48.2/24,fd9d:bc11:4021::2/64\",\n    }\n\n    # Merge with base variables\n    test_vars = {**base_vars, **strongswan_vars}\n\n    # Apply scenario-specific overrides\n    if scenario == \"ipv4_only\":\n        test_vars[\"ipv6_support\"] = False\n        test_vars[\"subjectAltName\"] = \"IP:10.0.0.1\"\n        test_vars[\"ansible_default_ipv6\"] = None\n    elif scenario == \"dns_hostname\":\n        test_vars[\"IP_subject_alt_name\"] = \"vpn.example.com\"\n        test_vars[\"subjectAltName\"] = \"DNS:vpn.example.com\"\n        test_vars[\"subjectAltName_type\"] = \"DNS\"\n    elif scenario == \"openssl_legacy\":\n        test_vars[\"openssl_version\"] = \"1.1.1\"\n\n    return test_vars\n\n\ndef test_strongswan_templates():\n    \"\"\"Test all StrongSwan templates with various configurations.\"\"\"\n    templates = [\n        \"roles/strongswan/templates/ipsec.conf.j2\",\n        \"roles/strongswan/templates/ipsec.secrets.j2\",\n        \"roles/strongswan/templates/strongswan.conf.j2\",\n        \"roles/strongswan/templates/charon.conf.j2\",\n        \"roles/strongswan/templates/client_ipsec.conf.j2\",\n        \"roles/strongswan/templates/client_ipsec.secrets.j2\",\n        \"roles/strongswan/templates/100-CustomLimitations.conf.j2\",\n    ]\n\n    scenarios = [\"default\", \"ipv4_only\", \"dns_hostname\", \"openssl_legacy\"]\n    errors = []\n    tested = 0\n\n    for template_path in templates:\n        if not os.path.exists(template_path):\n            print(f\"  ⚠️  Skipping {template_path} (not found)\")\n            continue\n\n        template_dir = os.path.dirname(template_path)\n        template_name = os.path.basename(template_path)\n\n        for scenario in scenarios:\n            tested += 1\n            test_vars = get_strongswan_test_variables(scenario)\n\n            try:\n                env = Environment(loader=FileSystemLoader(template_dir), undefined=StrictUndefined)\n\n                # Add mock filters\n                env.filters[\"to_uuid\"] = mock_to_uuid\n                env.filters[\"bool\"] = mock_bool\n                env.filters[\"b64encode\"] = mock_b64encode\n                env.filters[\"b64decode\"] = mock_b64decode\n                env.tests[\"version\"] = mock_version\n\n                # For client templates, add item context\n                if \"client\" in template_name:\n                    test_vars[\"item\"] = \"testuser\"\n\n                template = env.get_template(template_name)\n                output = template.render(**test_vars)\n\n                # Basic validation\n                assert len(output) > 0, f\"Empty output from {template_path} ({scenario})\"\n\n                # Specific validations based on template\n                if \"ipsec.conf\" in template_name and \"client\" not in template_name:\n                    assert \"conn\" in output, \"Missing connection definition\"\n                    if scenario != \"ipv4_only\" and test_vars.get(\"ipv6_support\"):\n                        assert \"::/0\" in output or \"fd9d:bc11\" in output, \"Missing IPv6 configuration\"\n\n                if \"ipsec.secrets\" in template_name:\n                    assert \"PSK\" in output or \"ECDSA\" in output, \"Missing authentication method\"\n\n                if \"strongswan.conf\" in template_name:\n                    assert \"charon\" in output, \"Missing charon configuration\"\n\n                print(f\"  ✅ {template_name} ({scenario})\")\n\n            except Exception as e:\n                errors.append(f\"{template_path} ({scenario}): {e!s}\")\n                print(f\"  ❌ {template_name} ({scenario}): {e!s}\")\n\n    if errors:\n        print(f\"\\n❌ StrongSwan template tests failed with {len(errors)} errors\")\n        for error in errors[:5]:\n            print(f\"    {error}\")\n        return False\n    else:\n        print(f\"\\n✅ All StrongSwan template tests passed ({tested} tests)\")\n        return True\n\n\ndef test_openssl_template_constraints():\n    \"\"\"Test the OpenSSL task template that had the inline comment issue.\"\"\"\n    # This tests the actual openssl.yml task file to ensure our fix works\n    import yaml\n\n    openssl_path = \"roles/strongswan/tasks/openssl.yml\"\n    if not os.path.exists(openssl_path):\n        print(\"⚠️  OpenSSL tasks file not found\")\n        return True\n\n    try:\n        with open(openssl_path) as f:\n            content = yaml.safe_load(f)\n\n        # Find the CA CSR task\n        ca_csr_task = None\n        for task in content:\n            if isinstance(task, dict) and task.get(\"name\", \"\").startswith(\"Create certificate signing request\"):\n                ca_csr_task = task\n                break\n\n        if ca_csr_task:\n            # Check that name_constraints_permitted is properly formatted\n            csr_module = ca_csr_task.get(\"community.crypto.openssl_csr_pipe\", {})\n            constraints = csr_module.get(\"name_constraints_permitted\", \"\")\n\n            # The constraints should be a Jinja2 template without inline comments\n            if \"#\" in str(constraints):\n                # Check if the # is within {{ }}\n                import re\n\n                jinja_blocks = re.findall(r\"\\{\\{.*?\\}\\}\", str(constraints), re.DOTALL)\n                for block in jinja_blocks:\n                    if \"#\" in block:\n                        print(\"❌ Found inline comment in Jinja2 expression\")\n                        return False\n\n        print(\"✅ OpenSSL template constraints validated\")\n        return True\n\n    except Exception as e:\n        print(f\"⚠️  Error checking OpenSSL tasks: {e}\")\n        return True  # Don't fail the test for this\n\n\ndef test_mobileconfig_template():\n    \"\"\"Test the mobileconfig template with various scenarios.\"\"\"\n    template_path = \"roles/strongswan/templates/mobileconfig.j2\"\n\n    if not os.path.exists(template_path):\n        print(\"⚠️  Mobileconfig template not found\")\n        return True\n\n    # Skip this test - mobileconfig.j2 is too tightly coupled to Ansible runtime\n    # It requires complex mock objects (item.1.stdout) and many dynamic variables\n    # that are generated during playbook execution\n    print(\"⚠️  Skipping mobileconfig template test (requires Ansible runtime context)\")\n    return True\n\n    test_cases = [\n        {\n            \"name\": \"iPhone with cellular on-demand\",\n            \"algo_ondemand_cellular\": \"true\",\n            \"algo_ondemand_wifi\": \"false\",\n        },\n        {\n            \"name\": \"iPad with WiFi on-demand\",\n            \"algo_ondemand_cellular\": \"false\",\n            \"algo_ondemand_wifi\": \"true\",\n            \"algo_ondemand_wifi_exclude\": \"MyHomeNetwork,OfficeWiFi\",\n        },\n        {\n            \"name\": \"Mac without on-demand\",\n            \"algo_ondemand_cellular\": \"false\",\n            \"algo_ondemand_wifi\": \"false\",\n        },\n    ]\n\n    errors = []\n    for test_case in test_cases:\n        test_vars = get_strongswan_test_variables()\n        test_vars.update(test_case)\n\n        # Mock Ansible task result format for item\n        class MockTaskResult:\n            def __init__(self, content):\n                self.stdout = content\n\n        test_vars[\"item\"] = (\"testuser\", MockTaskResult(\"TU9DS19QS0NTMTJfQ09OVEVOVA==\"))  # Tuple with mock result\n        test_vars[\"PayloadContentCA_base64\"] = \"TU9DS19DQV9DRVJUX0JBU0U2NA==\"  # Valid base64\n        test_vars[\"PayloadContentUser_base64\"] = \"TU9DS19VU0VSX0NFUlRfQkFTRTY0\"  # Valid base64\n        test_vars[\"pkcs12_PayloadCertificateUUID\"] = str(uuid.uuid4())\n        test_vars[\"PayloadContent\"] = \"TU9DS19QS0NTMTJfQ09OVEVOVA==\"  # Valid base64 for PKCS12\n        test_vars[\"algo_server_name\"] = \"test-algo-vpn\"\n        test_vars[\"VPN_PayloadIdentifier\"] = str(uuid.uuid4())\n        test_vars[\"CA_PayloadIdentifier\"] = str(uuid.uuid4())\n        test_vars[\"PayloadContentCA\"] = \"TU9DS19DQV9DRVJUX0NPTlRFTlQ=\"  # Valid base64\n\n        try:\n            env = Environment(loader=FileSystemLoader(\"roles/strongswan/templates\"), undefined=StrictUndefined)\n\n            # Add mock filters\n            env.filters[\"to_uuid\"] = mock_to_uuid\n            env.filters[\"b64encode\"] = mock_b64encode\n            env.filters[\"b64decode\"] = mock_b64decode\n\n            template = env.get_template(\"mobileconfig.j2\")\n            output = template.render(**test_vars)\n\n            # Validate output\n            assert \"<?xml\" in output, \"Missing XML declaration\"\n            assert \"<plist\" in output, \"Missing plist element\"\n            assert \"PayloadType\" in output, \"Missing PayloadType\"\n\n            # Check on-demand configuration\n            if test_case.get(\"algo_ondemand_cellular\") == \"true\" or test_case.get(\"algo_ondemand_wifi\") == \"true\":\n                assert \"OnDemandEnabled\" in output, f\"Missing OnDemand config for {test_case['name']}\"\n\n            print(f\"  ✅ Mobileconfig: {test_case['name']}\")\n\n        except Exception as e:\n            errors.append(f\"Mobileconfig ({test_case['name']}): {e!s}\")\n            print(f\"  ❌ Mobileconfig ({test_case['name']}): {e!s}\")\n\n    if errors:\n        return False\n\n    print(\"✅ All mobileconfig tests passed\")\n    return True\n\n\nif __name__ == \"__main__\":\n    print(\"🔍 Testing StrongSwan templates...\\n\")\n\n    all_passed = True\n\n    # Run tests\n    tests = [\n        test_strongswan_templates,\n        test_openssl_template_constraints,\n        test_mobileconfig_template,\n    ]\n\n    for test in tests:\n        if not test():\n            all_passed = False\n\n    if all_passed:\n        print(\"\\n✅ All StrongSwan template tests passed!\")\n        sys.exit(0)\n    else:\n        print(\"\\n❌ Some tests failed\")\n        sys.exit(1)\n"
  },
  {
    "path": "tests/unit/test_template_rendering.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest that Ansible templates render correctly\nThis catches undefined variables, syntax errors, and logic bugs\n\"\"\"\n\nimport os\nimport sys\nfrom pathlib import Path\n\nfrom jinja2 import Environment, FileSystemLoader, StrictUndefined, TemplateSyntaxError, UndefinedError\n\n# Add parent directory to path for fixtures\nsys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))\nfrom fixtures import load_test_variables\n\n\n# Mock Ansible filters that don't exist in plain Jinja2\ndef mock_to_uuid(value):\n    \"\"\"Mock the to_uuid filter\"\"\"\n    return \"12345678-1234-5678-1234-567812345678\"\n\n\ndef mock_bool(value):\n    \"\"\"Mock the bool filter\"\"\"\n    return str(value).lower() in (\"true\", \"1\", \"yes\", \"on\")\n\n\ndef mock_lookup(type, path):\n    \"\"\"Mock the lookup function\"\"\"\n    # Return fake data for file lookups\n    if type == \"file\":\n        if \"private\" in path:\n            return \"MOCK_PRIVATE_KEY_BASE64==\"\n        elif \"public\" in path:\n            return \"MOCK_PUBLIC_KEY_BASE64==\"\n        elif \"preshared\" in path:\n            return \"MOCK_PRESHARED_KEY_BASE64==\"\n    return \"MOCK_LOOKUP_DATA\"\n\n\ndef get_test_variables():\n    \"\"\"Get a comprehensive set of test variables for template rendering\"\"\"\n    # Load from fixtures for consistency\n    return load_test_variables()\n\n\ndef find_templates():\n    \"\"\"Find all Jinja2 template files in the repo\"\"\"\n    templates = []\n    for pattern in [\"**/*.j2\", \"**/*.jinja2\", \"**/*.yml.j2\"]:\n        templates.extend(Path(\".\").glob(pattern))\n    return templates\n\n\ndef test_template_syntax():\n    \"\"\"Test that all templates have valid Jinja2 syntax\"\"\"\n    templates = find_templates()\n\n    # Skip some paths that aren't real templates\n    skip_paths = [\".git/\", \"venv/\", \".venv/\", \".env/\", \"configs/\"]\n\n    # Skip templates that use Ansible-specific filters\n    skip_templates = [\"vpn-dict.j2\", \"mobileconfig.j2\", \"dnscrypt-proxy.toml.j2\", \"dnscrypt-proxy/\"]\n\n    errors = []\n    skipped = 0\n    for template_path in templates:\n        # Skip unwanted paths\n        if any(skip in str(template_path) for skip in skip_paths):\n            continue\n\n        # Skip templates with Ansible-specific features\n        if any(skip in str(template_path) for skip in skip_templates):\n            skipped += 1\n            continue\n\n        try:\n            template_dir = template_path.parent\n            env = Environment(loader=FileSystemLoader(template_dir), undefined=StrictUndefined)\n\n            # Just try to load the template - this checks syntax\n            env.get_template(template_path.name)\n\n        except TemplateSyntaxError as e:\n            errors.append(f\"{template_path}: Syntax error - {e}\")\n        except Exception as e:\n            errors.append(f\"{template_path}: Error loading - {e}\")\n\n    if errors:\n        print(f\"✗ Template syntax check failed with {len(errors)} errors:\")\n        for error in errors[:10]:  # Show first 10 errors\n            print(f\"  - {error}\")\n        if len(errors) > 10:\n            print(f\"  ... and {len(errors) - 10} more\")\n        assert False, \"Template syntax errors found\"\n    else:\n        print(f\"✓ Template syntax check passed ({len(templates) - skipped} templates, {skipped} skipped)\")\n\n\ndef test_critical_templates():\n    \"\"\"Test that critical templates render with test data\"\"\"\n    critical_templates = [\n        \"roles/wireguard/templates/client.conf.j2\",\n        \"roles/strongswan/templates/ipsec.conf.j2\",\n        \"roles/strongswan/templates/ipsec.secrets.j2\",\n        \"roles/dns/templates/adblock.sh.j2\",\n        \"roles/dns/templates/dnsmasq.conf.j2\",\n        \"roles/common/templates/rules.v4.j2\",\n        \"roles/common/templates/rules.v6.j2\",\n    ]\n\n    test_vars = get_test_variables()\n    errors = []\n\n    for template_path in critical_templates:\n        if not os.path.exists(template_path):\n            continue  # Skip if template doesn't exist\n\n        try:\n            template_dir = os.path.dirname(template_path)\n            template_name = os.path.basename(template_path)\n\n            env = Environment(loader=FileSystemLoader(template_dir), undefined=StrictUndefined)\n\n            # Add mock functions\n            env.globals[\"lookup\"] = mock_lookup\n            env.filters[\"to_uuid\"] = mock_to_uuid\n            env.filters[\"bool\"] = mock_bool\n\n            template = env.get_template(template_name)\n\n            # Add item context for templates that use loops\n            # With modern loop syntax, item is the username string directly\n            if \"client\" in template_name:\n                test_vars[\"item\"] = \"test-user\"\n\n            # Try to render\n            output = template.render(**test_vars)\n\n            # Basic validation - should produce some output\n            assert len(output) > 0, f\"Empty output from {template_path}\"\n\n        except UndefinedError as e:\n            errors.append(f\"{template_path}: Missing variable - {e}\")\n        except Exception as e:\n            errors.append(f\"{template_path}: Render error - {e}\")\n\n    if errors:\n        print(\"✗ Critical template rendering failed:\")\n        for error in errors:\n            print(f\"  - {error}\")\n        assert False, \"Critical template rendering errors\"\n    else:\n        print(\"✓ Critical template rendering test passed\")\n\n\ndef test_variable_consistency():\n    \"\"\"Check that commonly used variables are defined consistently\"\"\"\n    # Variables that should be used consistently across templates\n    common_vars = [\n        \"server_name\",\n        \"IP_subject_alt_name\",\n        \"wireguard_port\",\n        \"wireguard_network\",\n        \"dns_servers\",\n        \"users\",\n    ]\n\n    # Check if main.yml defines these\n    if os.path.exists(\"main.yml\"):\n        with open(\"main.yml\") as f:\n            content = f.read()\n\n        missing = []\n        for var in common_vars:\n            # Simple check - could be improved\n            if var not in content:\n                missing.append(var)\n\n        if missing:\n            print(f\"⚠ Variables possibly not defined in main.yml: {missing}\")\n\n    print(\"✓ Variable consistency check completed\")\n\n\ndef test_wireguard_ipv6_endpoints():\n    \"\"\"Test that WireGuard client configs properly format IPv6 endpoints\"\"\"\n    test_cases = [\n        # IPv4 address - should not be bracketed\n        {\"IP_subject_alt_name\": \"192.168.1.100\", \"expected_endpoint\": \"Endpoint = 192.168.1.100:51820\"},\n        # IPv6 address - should be bracketed\n        {\n            \"IP_subject_alt_name\": \"2600:3c01::f03c:91ff:fedf:3b2a\",\n            \"expected_endpoint\": \"Endpoint = [2600:3c01::f03c:91ff:fedf:3b2a]:51820\",\n        },\n        # Hostname - should not be bracketed\n        {\"IP_subject_alt_name\": \"vpn.example.com\", \"expected_endpoint\": \"Endpoint = vpn.example.com:51820\"},\n        # IPv6 with zone ID - should be bracketed\n        {\"IP_subject_alt_name\": \"fe80::1%eth0\", \"expected_endpoint\": \"Endpoint = [fe80::1%eth0]:51820\"},\n    ]\n\n    template_path = \"roles/wireguard/templates/client.conf.j2\"\n    if not os.path.exists(template_path):\n        print(f\"⚠ Skipping IPv6 endpoint test - {template_path} not found\")\n        return\n\n    base_vars = get_test_variables()\n    errors = []\n\n    for test_case in test_cases:\n        try:\n            # Set up test variables\n            test_vars = {**base_vars, **test_case}\n            # With modern loop syntax, item is the username string directly\n            test_vars[\"item\"] = \"test-user\"\n\n            # Render template\n            env = Environment(loader=FileSystemLoader(\"roles/wireguard/templates\"), undefined=StrictUndefined)\n            env.globals[\"lookup\"] = mock_lookup\n\n            template = env.get_template(\"client.conf.j2\")\n            output = template.render(**test_vars)\n\n            # Check if the expected endpoint format is in the output\n            if test_case[\"expected_endpoint\"] not in output:\n                errors.append(\n                    f\"Expected '{test_case['expected_endpoint']}' for IP '{test_case['IP_subject_alt_name']}' but not found in output\"\n                )\n                # Print relevant part of output for debugging\n                for line in output.split(\"\\n\"):\n                    if \"Endpoint\" in line:\n                        errors.append(f\"  Found: {line.strip()}\")\n\n        except Exception as e:\n            errors.append(f\"Error testing {test_case['IP_subject_alt_name']}: {e}\")\n\n    if errors:\n        print(\"✗ WireGuard IPv6 endpoint test failed:\")\n        for error in errors:\n            print(f\"  - {error}\")\n        assert False, \"IPv6 endpoint formatting errors\"\n    else:\n        print(\"✓ WireGuard IPv6 endpoint test passed (4 test cases)\")\n\n\ndef test_template_conditionals():\n    \"\"\"Test templates with different conditional states\"\"\"\n    test_cases = [\n        # WireGuard enabled, IPsec disabled\n        {\n            \"wireguard_enabled\": True,\n            \"ipsec_enabled\": False,\n            \"dns_encryption\": True,\n            \"dns_adblocking\": True,\n            \"algo_ssh_tunneling\": False,\n        },\n        # IPsec enabled, WireGuard disabled\n        {\n            \"wireguard_enabled\": False,\n            \"ipsec_enabled\": True,\n            \"dns_encryption\": False,\n            \"dns_adblocking\": False,\n            \"algo_ssh_tunneling\": True,\n        },\n        # Both enabled\n        {\n            \"wireguard_enabled\": True,\n            \"ipsec_enabled\": True,\n            \"dns_encryption\": True,\n            \"dns_adblocking\": True,\n            \"algo_ssh_tunneling\": True,\n        },\n    ]\n\n    base_vars = get_test_variables()\n\n    for i, test_case in enumerate(test_cases):\n        # Merge test case with base vars\n        test_vars = {**base_vars, **test_case}\n\n        # Test a few templates that have conditionals\n        conditional_templates = [\n            \"roles/common/templates/rules.v4.j2\",\n        ]\n\n        for template_path in conditional_templates:\n            if not os.path.exists(template_path):\n                continue\n\n            try:\n                template_dir = os.path.dirname(template_path)\n                template_name = os.path.basename(template_path)\n\n                env = Environment(loader=FileSystemLoader(template_dir), undefined=StrictUndefined)\n\n                # Add mock functions\n                env.globals[\"lookup\"] = mock_lookup\n                env.filters[\"to_uuid\"] = mock_to_uuid\n                env.filters[\"bool\"] = mock_bool\n\n                template = env.get_template(template_name)\n                output = template.render(**test_vars)\n\n                # Verify conditionals work\n                if test_case.get(\"wireguard_enabled\"):\n                    assert str(test_vars[\"wireguard_port\"]) in output, f\"WireGuard port missing when enabled (case {i})\"\n\n            except Exception as e:\n                print(f\"✗ Conditional test failed for {template_path} case {i}: {e}\")\n                raise\n\n    print(\"✓ Template conditional tests passed\")\n\n\nif __name__ == \"__main__\":\n    # Check if we have Jinja2 available\n    try:\n        import jinja2  # noqa: F401\n    except ImportError:\n        print(\"⚠ Skipping template tests - jinja2 not installed\")\n        print(\"  Run: pip install jinja2\")\n        sys.exit(0)\n\n    tests = [\n        test_template_syntax,\n        test_critical_templates,\n        test_variable_consistency,\n        test_wireguard_ipv6_endpoints,\n        test_template_conditionals,\n    ]\n\n    failed = 0\n    for test in tests:\n        try:\n            test()\n        except AssertionError as e:\n            print(f\"✗ {test.__name__} failed: {e}\")\n            failed += 1\n        except Exception as e:\n            print(f\"✗ {test.__name__} error: {e}\")\n            failed += 1\n\n    if failed > 0:\n        print(f\"\\n{failed} tests failed\")\n        sys.exit(1)\n    else:\n        print(f\"\\nAll {len(tests)} template tests passed!\")\n"
  },
  {
    "path": "tests/unit/test_user_management.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest user management functionality without deployment\nBased on issues #14745, #14746, #14738, #14726\n\"\"\"\n\nimport os\nimport re\nimport sys\nimport tempfile\n\nimport yaml\n\n\ndef test_user_list_parsing():\n    \"\"\"Test that user lists in config.cfg are parsed correctly\"\"\"\n    test_config = \"\"\"\nusers:\n  - alice\n  - bob\n  - charlie\n  - user-with-dash\n  - user_with_underscore\n\"\"\"\n\n    config = yaml.safe_load(test_config)\n    users = config.get(\"users\", [])\n\n    assert len(users) == 5, f\"Expected 5 users, got {len(users)}\"\n    assert \"alice\" in users, \"Missing user 'alice'\"\n    assert \"user-with-dash\" in users, \"Dash in username not handled\"\n    assert \"user_with_underscore\" in users, \"Underscore in username not handled\"\n\n    # Test that usernames are valid\n    username_pattern = re.compile(r\"^[a-zA-Z0-9_-]+$\")\n    for user in users:\n        assert username_pattern.match(user), f\"Invalid username format: {user}\"\n\n    print(\"✓ User list parsing test passed\")\n\n\ndef test_server_selection_format():\n    \"\"\"Test server selection string parsing (issue #14727)\"\"\"\n    # Test various server display formats\n    test_cases = [\n        {\"display\": \"1. 192.168.1.100 (algo-server)\", \"expected_ip\": \"192.168.1.100\", \"expected_name\": \"algo-server\"},\n        {\"display\": \"2. 10.0.0.1 (production-vpn)\", \"expected_ip\": \"10.0.0.1\", \"expected_name\": \"production-vpn\"},\n        {\n            \"display\": \"3. vpn.example.com (example-server)\",\n            \"expected_ip\": \"vpn.example.com\",\n            \"expected_name\": \"example-server\",\n        },\n    ]\n\n    # Pattern to extract IP and name from display string\n    pattern = re.compile(r\"^\\d+\\.\\s+([^\\s]+)\\s+\\(([^)]+)\\)$\")\n\n    for case in test_cases:\n        match = pattern.match(case[\"display\"])\n        assert match, f\"Failed to parse: {case['display']}\"\n\n        ip_or_host = match.group(1)\n        name = match.group(2)\n\n        assert ip_or_host == case[\"expected_ip\"], f\"Wrong IP extracted: {ip_or_host}\"\n        assert name == case[\"expected_name\"], f\"Wrong name extracted: {name}\"\n\n    print(\"✓ Server selection format test passed\")\n\n\ndef test_ssh_key_preservation():\n    \"\"\"Test that SSH keys aren't regenerated unnecessarily\"\"\"\n    with tempfile.TemporaryDirectory() as tmpdir:\n        ssh_key_path = os.path.join(tmpdir, \"test_key\")\n\n        # Simulate existing SSH key\n        with open(ssh_key_path, \"w\") as f:\n            f.write(\"EXISTING_SSH_KEY_CONTENT\")\n        with open(f\"{ssh_key_path}.pub\", \"w\") as f:\n            f.write(\"ssh-rsa EXISTING_PUBLIC_KEY\")\n\n        # Record original content\n        with open(ssh_key_path) as f:\n            original_content = f.read()\n\n        # Test that key is preserved when it already exists\n        assert os.path.exists(ssh_key_path), \"SSH key should exist\"\n        assert os.path.exists(f\"{ssh_key_path}.pub\"), \"SSH public key should exist\"\n\n        # Verify content hasn't changed\n        with open(ssh_key_path) as f:\n            current_content = f.read()\n        assert current_content == original_content, \"SSH key was modified\"\n\n    print(\"✓ SSH key preservation test passed\")\n\n\ndef test_ca_password_handling():\n    \"\"\"Test CA password validation and handling\"\"\"\n    # Test password requirements\n    valid_passwords = [\"SecurePassword123!\", \"Algo-VPN-2024\", \"Complex#Pass@Word999\"]\n\n    invalid_passwords = [\n        \"\",  # Empty\n        \"short\",  # Too short\n        \"password with spaces\",  # Spaces not allowed in some contexts\n    ]\n\n    # Basic password validation\n    for pwd in valid_passwords:\n        assert len(pwd) >= 12, f\"Password too short: {pwd}\"\n        assert \" \" not in pwd, f\"Password contains spaces: {pwd}\"\n\n    for pwd in invalid_passwords:\n        issues = []\n        if len(pwd) < 12:\n            issues.append(\"too short\")\n        if \" \" in pwd:\n            issues.append(\"contains spaces\")\n        if not pwd:\n            issues.append(\"empty\")\n        assert issues, f\"Expected validation issues for: {pwd}\"\n\n    print(\"✓ CA password handling test passed\")\n\n\ndef test_user_config_generation():\n    \"\"\"Test that user configs would be generated correctly\"\"\"\n    users = [\"alice\", \"bob\", \"charlie\"]\n    server_name = \"test-server\"\n\n    # Simulate config file structure\n    for user in users:\n        # Test WireGuard config path\n        wg_path = f\"configs/{server_name}/wireguard/{user}.conf\"\n        assert user in wg_path, \"Username not in WireGuard config path\"\n\n        # Test IPsec config path\n        ipsec_path = f\"configs/{server_name}/ipsec/{user}.p12\"\n        assert user in ipsec_path, \"Username not in IPsec config path\"\n\n        # Test SSH tunnel config path\n        ssh_path = f\"configs/{server_name}/ssh-tunnel/{user}.pem\"\n        assert user in ssh_path, \"Username not in SSH config path\"\n\n    print(\"✓ User config generation test passed\")\n\n\ndef test_duplicate_user_handling():\n    \"\"\"Test handling of duplicate usernames\"\"\"\n    test_config = \"\"\"\nusers:\n  - alice\n  - bob\n  - alice\n  - charlie\n\"\"\"\n\n    config = yaml.safe_load(test_config)\n    users = config.get(\"users\", [])\n\n    # Check for duplicates\n    unique_users = list(set(users))\n    assert len(unique_users) < len(users), \"Duplicates should be detected\"\n\n    # Test that duplicates can be identified\n    seen = set()\n    duplicates = []\n    for user in users:\n        if user in seen:\n            duplicates.append(user)\n        seen.add(user)\n\n    assert \"alice\" in duplicates, \"Duplicate 'alice' not detected\"\n\n    print(\"✓ Duplicate user handling test passed\")\n\n\nif __name__ == \"__main__\":\n    tests = [\n        test_user_list_parsing,\n        test_server_selection_format,\n        test_ssh_key_preservation,\n        test_ca_password_handling,\n        test_user_config_generation,\n        test_duplicate_user_handling,\n    ]\n\n    failed = 0\n    for test in tests:\n        try:\n            test()\n        except AssertionError as e:\n            print(f\"✗ {test.__name__} failed: {e}\")\n            failed += 1\n        except Exception as e:\n            print(f\"✗ {test.__name__} error: {e}\")\n            failed += 1\n\n    if failed > 0:\n        print(f\"\\n{failed} tests failed\")\n        sys.exit(1)\n    else:\n        print(f\"\\nAll {len(tests)} tests passed!\")\n"
  },
  {
    "path": "tests/unit/test_wireguard_key_generation.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest WireGuard key generation - focused on x25519_pubkey module integration\nAddresses test gap identified in tests/README.md line 63-67: WireGuard private/public key generation\n\"\"\"\n\nimport base64\nimport os\nimport subprocess\nimport sys\nimport tempfile\n\n# Add library directory to path to import our custom module\nsys.path.insert(0, os.path.join(os.path.dirname(__file__), \"..\", \"..\", \"library\"))\n\n\ndef test_wireguard_tools_available():\n    \"\"\"Test that WireGuard tools are available for validation\"\"\"\n    try:\n        result = subprocess.run([\"wg\", \"--version\"], capture_output=True, text=True)\n        assert result.returncode == 0, \"WireGuard tools not available\"\n        print(f\"✓ WireGuard tools available: {result.stdout.strip()}\")\n        return True\n    except FileNotFoundError:\n        print(\"⚠ WireGuard tools not available - skipping validation tests\")\n        return False\n\n\ndef test_x25519_module_import():\n    \"\"\"Test that our custom x25519_pubkey module can be imported and used\"\"\"\n    try:\n        import x25519_pubkey  # noqa: F401\n\n        print(\"✓ x25519_pubkey module imports successfully\")\n        return True\n    except ImportError as e:\n        assert False, f\"Cannot import x25519_pubkey module: {e}\"\n\n\ndef generate_test_private_key():\n    \"\"\"Generate a test private key using the same method as Algo\"\"\"\n    with tempfile.NamedTemporaryFile(suffix=\".raw\", delete=False) as temp_file:\n        raw_key_path = temp_file.name\n\n    try:\n        # Generate 32 random bytes for X25519 private key (same as community.crypto does)\n        import secrets\n\n        raw_data = secrets.token_bytes(32)\n\n        # Write raw key to file (like community.crypto openssl_privatekey with format: raw)\n        with open(raw_key_path, \"wb\") as f:\n            f.write(raw_data)\n\n        assert len(raw_data) == 32, f\"Private key should be 32 bytes, got {len(raw_data)}\"\n\n        b64_key = base64.b64encode(raw_data).decode()\n\n        print(f\"✓ Generated private key (base64): {b64_key[:12]}...\")\n\n        return raw_key_path, b64_key\n\n    except Exception:\n        # Clean up on error\n        if os.path.exists(raw_key_path):\n            os.unlink(raw_key_path)\n        raise\n\n\ndef test_x25519_pubkey_from_raw_file():\n    \"\"\"Test our x25519_pubkey module with raw private key file\"\"\"\n    raw_key_path, _b64_key = generate_test_private_key()\n\n    try:\n        # Import here so we can mock the module_utils if needed\n\n        # Mock the AnsibleModule for testing\n        class MockModule:\n            def __init__(self, params):\n                self.params = params\n                self.result = {}\n\n            def fail_json(self, **kwargs):\n                raise Exception(f\"Module failed: {kwargs}\")\n\n            def exit_json(self, **kwargs):\n                self.result = kwargs\n\n        with tempfile.NamedTemporaryFile(suffix=\".pub\", delete=False) as temp_pub:\n            public_key_path = temp_pub.name\n\n        try:\n            # Test the module logic directly\n            import x25519_pubkey\n            from x25519_pubkey import run_module\n\n            original_AnsibleModule = x25519_pubkey.AnsibleModule\n\n            try:\n                # Mock the module call\n                mock_module = MockModule(\n                    {\"private_key_path\": raw_key_path, \"public_key_path\": public_key_path, \"private_key_b64\": None}\n                )\n\n                x25519_pubkey.AnsibleModule = lambda **kwargs: mock_module\n\n                # Run the module\n                run_module()\n\n                # Check the result\n                assert \"public_key\" in mock_module.result\n                assert mock_module.result[\"changed\"]\n                assert os.path.exists(public_key_path)\n\n                with open(public_key_path) as f:\n                    derived_pubkey = f.read().strip()\n\n                # Validate base64 format\n                try:\n                    decoded = base64.b64decode(derived_pubkey, validate=True)\n                    assert len(decoded) == 32, f\"Public key should be 32 bytes, got {len(decoded)}\"\n                except Exception as e:\n                    assert False, f\"Invalid base64 public key: {e}\"\n\n                print(f\"✓ Derived public key from raw file: {derived_pubkey[:12]}...\")\n\n                return derived_pubkey\n\n            finally:\n                x25519_pubkey.AnsibleModule = original_AnsibleModule\n\n        finally:\n            if os.path.exists(public_key_path):\n                os.unlink(public_key_path)\n\n    finally:\n        if os.path.exists(raw_key_path):\n            os.unlink(raw_key_path)\n\n\ndef test_x25519_pubkey_from_b64_string():\n    \"\"\"Test our x25519_pubkey module with base64 private key string\"\"\"\n    raw_key_path, b64_key = generate_test_private_key()\n\n    try:\n\n        class MockModule:\n            def __init__(self, params):\n                self.params = params\n                self.result = {}\n\n            def fail_json(self, **kwargs):\n                raise Exception(f\"Module failed: {kwargs}\")\n\n            def exit_json(self, **kwargs):\n                self.result = kwargs\n\n        import x25519_pubkey\n        from x25519_pubkey import run_module\n\n        original_AnsibleModule = x25519_pubkey.AnsibleModule\n\n        try:\n            mock_module = MockModule({\"private_key_b64\": b64_key, \"private_key_path\": None, \"public_key_path\": None})\n\n            x25519_pubkey.AnsibleModule = lambda **kwargs: mock_module\n\n            # Run the module\n            run_module()\n\n            # Check the result\n            assert \"public_key\" in mock_module.result\n            derived_pubkey = mock_module.result[\"public_key\"]\n\n            # Validate base64 format\n            try:\n                decoded = base64.b64decode(derived_pubkey, validate=True)\n                assert len(decoded) == 32, f\"Public key should be 32 bytes, got {len(decoded)}\"\n            except Exception as e:\n                assert False, f\"Invalid base64 public key: {e}\"\n\n            print(f\"✓ Derived public key from base64 string: {derived_pubkey[:12]}...\")\n\n            return derived_pubkey\n\n        finally:\n            x25519_pubkey.AnsibleModule = original_AnsibleModule\n\n    finally:\n        if os.path.exists(raw_key_path):\n            os.unlink(raw_key_path)\n\n\ndef test_wireguard_validation():\n    \"\"\"Test that our derived keys work with actual WireGuard tools\"\"\"\n    if not test_wireguard_tools_available():\n        return\n\n    # Generate keys using our method\n    raw_key_path, b64_key = generate_test_private_key()\n\n    try:\n        # Derive public key using our module\n\n        class MockModule:\n            def __init__(self, params):\n                self.params = params\n                self.result = {}\n\n            def fail_json(self, **kwargs):\n                raise Exception(f\"Module failed: {kwargs}\")\n\n            def exit_json(self, **kwargs):\n                self.result = kwargs\n\n        import x25519_pubkey\n        from x25519_pubkey import run_module\n\n        original_AnsibleModule = x25519_pubkey.AnsibleModule\n\n        try:\n            mock_module = MockModule({\"private_key_b64\": b64_key, \"private_key_path\": None, \"public_key_path\": None})\n\n            x25519_pubkey.AnsibleModule = lambda **kwargs: mock_module\n            run_module()\n\n            derived_pubkey = mock_module.result[\"public_key\"]\n\n        finally:\n            x25519_pubkey.AnsibleModule = original_AnsibleModule\n\n        with tempfile.NamedTemporaryFile(mode=\"w\", suffix=\".conf\", delete=False) as temp_config:\n            # Create a WireGuard config using our keys\n            wg_config = f\"\"\"[Interface]\nPrivateKey = {b64_key}\nAddress = 10.19.49.1/24\n\n[Peer]\nPublicKey = {derived_pubkey}\nAllowedIPs = 10.19.49.2/32\n\"\"\"\n            temp_config.write(wg_config)\n            config_path = temp_config.name\n\n        try:\n            # Test that WireGuard can parse our config\n            result = subprocess.run([\"wg-quick\", \"strip\", config_path], capture_output=True, text=True)\n\n            assert result.returncode == 0, f\"WireGuard rejected our config: {result.stderr}\"\n\n            # Test key derivation with wg pubkey command\n            wg_result = subprocess.run([\"wg\", \"pubkey\"], input=b64_key, capture_output=True, text=True)\n\n            if wg_result.returncode == 0:\n                wg_derived = wg_result.stdout.strip()\n                assert wg_derived == derived_pubkey, f\"Key mismatch: wg={wg_derived} vs ours={derived_pubkey}\"\n                print(\"✓ WireGuard validation: keys match wg pubkey output\")\n            else:\n                print(f\"⚠ Could not validate with wg pubkey: {wg_result.stderr}\")\n\n            print(\"✓ WireGuard accepts our generated configuration\")\n\n        finally:\n            if os.path.exists(config_path):\n                os.unlink(config_path)\n\n    finally:\n        if os.path.exists(raw_key_path):\n            os.unlink(raw_key_path)\n\n\ndef test_key_consistency():\n    \"\"\"Test that the same private key always produces the same public key\"\"\"\n    # Generate one private key to reuse\n    raw_key_path, b64_key = generate_test_private_key()\n\n    try:\n\n        def derive_pubkey_from_same_key():\n            class MockModule:\n                def __init__(self, params):\n                    self.params = params\n                    self.result = {}\n\n                def fail_json(self, **kwargs):\n                    raise Exception(f\"Module failed: {kwargs}\")\n\n                def exit_json(self, **kwargs):\n                    self.result = kwargs\n\n            import x25519_pubkey\n            from x25519_pubkey import run_module\n\n            original_AnsibleModule = x25519_pubkey.AnsibleModule\n\n            try:\n                mock_module = MockModule(\n                    {\n                        \"private_key_b64\": b64_key,  # SAME key each time\n                        \"private_key_path\": None,\n                        \"public_key_path\": None,\n                    }\n                )\n\n                x25519_pubkey.AnsibleModule = lambda **kwargs: mock_module\n                run_module()\n\n                return mock_module.result[\"public_key\"]\n\n            finally:\n                x25519_pubkey.AnsibleModule = original_AnsibleModule\n\n        # Derive public key multiple times from same private key\n        pubkey1 = derive_pubkey_from_same_key()\n        pubkey2 = derive_pubkey_from_same_key()\n\n        assert pubkey1 == pubkey2, f\"Key derivation not consistent: {pubkey1} vs {pubkey2}\"\n        print(\"✓ Key derivation is consistent\")\n\n    finally:\n        if os.path.exists(raw_key_path):\n            os.unlink(raw_key_path)\n\n\nif __name__ == \"__main__\":\n    tests = [\n        test_x25519_module_import,\n        test_x25519_pubkey_from_raw_file,\n        test_x25519_pubkey_from_b64_string,\n        test_key_consistency,\n        test_wireguard_validation,\n    ]\n\n    failed = 0\n    for test in tests:\n        try:\n            test()\n        except AssertionError as e:\n            print(f\"✗ {test.__name__} failed: {e}\")\n            failed += 1\n        except Exception as e:\n            print(f\"✗ {test.__name__} error: {e}\")\n            failed += 1\n\n    if failed > 0:\n        print(f\"\\n{failed} tests failed\")\n        sys.exit(1)\n    else:\n        print(f\"\\nAll {len(tests)} tests passed!\")\n"
  },
  {
    "path": "tests/unit/test_yaml_jinja2_expressions.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nTest that Jinja2 expressions within YAML files are valid.\nThis catches issues like inline comments in Jinja2 expressions within YAML task files.\n\"\"\"\n\nimport re\nfrom pathlib import Path\n\nimport pytest\nimport yaml\nfrom jinja2 import Environment, StrictUndefined, TemplateSyntaxError\n\n\ndef find_yaml_files_with_jinja2():\n    \"\"\"Find all YAML files that might contain Jinja2 expressions.\"\"\"\n    yaml_files = []\n\n    # Look for YAML files in roles that are likely to have Jinja2\n    patterns = [\"roles/**/tasks/*.yml\", \"roles/**/defaults/*.yml\", \"roles/**/vars/*.yml\", \"playbooks/*.yml\", \"*.yml\"]\n\n    skip_dirs = {\".git\", \".venv\", \"venv\", \".env\", \"configs\"}\n\n    for pattern in patterns:\n        for path in Path(\".\").glob(pattern):\n            if not any(skip_dir in path.parts for skip_dir in skip_dirs):\n                yaml_files.append(path)\n\n    return sorted(yaml_files)\n\n\ndef extract_jinja2_expressions(content):\n    \"\"\"Extract all Jinja2 expressions from text content.\"\"\"\n    expressions = []\n\n    # Find {{ ... }} expressions (variable interpolations)\n    for match in re.finditer(r\"\\{\\{(.+?)\\}\\}\", content, re.DOTALL):\n        expressions.append(\n            {\n                \"type\": \"variable\",\n                \"content\": match.group(1),\n                \"full\": match.group(0),\n                \"start\": match.start(),\n                \"end\": match.end(),\n            }\n        )\n\n    # Find {% ... %} expressions (control structures)\n    for match in re.finditer(r\"\\{%(.+?)%\\}\", content, re.DOTALL):\n        expressions.append(\n            {\n                \"type\": \"control\",\n                \"content\": match.group(1),\n                \"full\": match.group(0),\n                \"start\": match.start(),\n                \"end\": match.end(),\n            }\n        )\n\n    return expressions\n\n\ndef find_line_number(content, position):\n    \"\"\"Find the line number for a given position in content.\"\"\"\n    return content[:position].count(\"\\n\") + 1\n\n\ndef validate_jinja2_expression(expression, context_vars=None):\n    \"\"\"\n    Validate a single Jinja2 expression.\n    Returns (is_valid, error_message)\n    \"\"\"\n    if context_vars is None:\n        context_vars = get_test_variables()\n\n    # First check for inline comments - this is the main issue we want to catch\n    if \"#\" in expression[\"content\"]:\n        # Check if the # is within a list or dict literal\n        content = expression[\"content\"]\n        # Remove strings to avoid false positives\n        cleaned = re.sub(r'\"[^\"]*\"', '\"\"', content)\n        cleaned = re.sub(r\"'[^']*'\", \"''\", cleaned)\n\n        # Look for # that appears to be a comment\n        # The # should have something before it (not at start) and something after (the comment text)\n        # Also check for # at the start of a line within the expression\n        if \"#\" in cleaned:\n            # Check each line in the cleaned expression\n            for line in cleaned.split(\"\\n\"):\n                line = line.strip()\n                if \"#\" in line:\n                    # If # appears and it's not escaped (\\#)\n                    hash_idx = line.find(\"#\")\n                    if hash_idx >= 0:\n                        # Check if it's escaped\n                        if hash_idx == 0 or line[hash_idx - 1] != \"\\\\\":\n                            # This looks like an inline comment\n                            return (\n                                False,\n                                \"Inline comment (#) found in Jinja2 expression - comments must be outside expressions\",\n                            )\n\n    try:\n        env = Environment(undefined=StrictUndefined)\n\n        # Add common Ansible filters (expanded list)\n        env.filters[\"bool\"] = lambda x: bool(x)\n        env.filters[\"default\"] = lambda x, d=\"\": x if x else d\n        env.filters[\"to_uuid\"] = lambda x: \"mock-uuid\"\n        env.filters[\"b64encode\"] = lambda x: \"mock-base64\"\n        env.filters[\"b64decode\"] = lambda x: \"mock-decoded\"\n        env.filters[\"version\"] = lambda x, op: True\n        env.filters[\"ternary\"] = lambda x, y, z=None: y if x else (z if z is not None else \"\")\n        env.filters[\"regex_replace\"] = lambda x, p, r: x\n        env.filters[\"difference\"] = lambda x, y: list(set(x) - set(y))\n        env.filters[\"strftime\"] = lambda fmt, ts: \"mock-timestamp\"\n        env.filters[\"int\"] = lambda x: int(x) if x else 0\n        env.filters[\"list\"] = lambda x: list(x)\n        env.filters[\"map\"] = lambda x, *args: x\n        env.tests[\"version\"] = lambda x, op: True\n\n        # Wrap the expression in appropriate delimiters for parsing\n        if expression[\"type\"] == \"variable\":\n            template_str = \"{{\" + expression[\"content\"] + \"}}\"\n        else:\n            template_str = \"{%\" + expression[\"content\"] + \"%}\"\n\n        # Try to compile the template\n        template = env.from_string(template_str)\n\n        # Try to render it with test variables\n        # This will catch undefined variables and runtime errors\n        template.render(**context_vars)\n\n        return True, None\n\n    except TemplateSyntaxError as e:\n        # Check for the specific inline comment issue\n        if \"#\" in expression[\"content\"]:\n            # Check if the # is within a list or dict literal\n            content = expression[\"content\"]\n            # Remove strings to avoid false positives\n            cleaned = re.sub(r'\"[^\"]*\"', '\"\"', content)\n            cleaned = re.sub(r\"'[^']*'\", \"''\", cleaned)\n\n            # Look for # that appears to be a comment (not in string, not escaped)\n            if re.search(r\"[^\\\\\\n]#[^\\}]\", cleaned):\n                return False, \"Inline comment (#) found in Jinja2 expression - comments must be outside expressions\"\n\n        return False, f\"Syntax error: {e.message}\"\n\n    except Exception as e:\n        # Be lenient - we mainly care about inline comments and basic syntax\n        # Ignore runtime errors (undefined vars, missing attributes, etc.)\n        error_str = str(e).lower()\n        if any(ignore in error_str for ignore in [\"undefined\", \"has no attribute\", \"no filter\"]):\n            return True, None  # These are runtime issues, not syntax issues\n        return False, f\"Error: {e!s}\"\n\n\ndef get_test_variables():\n    \"\"\"Get a comprehensive set of test variables for expression validation.\"\"\"\n    return {\n        # Network configuration\n        \"IP_subject_alt_name\": \"10.0.0.1\",\n        \"server_name\": \"algo-vpn\",\n        \"wireguard_port\": 51820,\n        \"wireguard_network\": \"10.19.49.0/24\",\n        \"wireguard_network_ipv6\": \"fd9d:bc11:4021::/64\",\n        \"strongswan_network\": \"10.19.48.0/24\",\n        \"strongswan_network_ipv6\": \"fd9d:bc11:4020::/64\",\n        # Feature flags\n        \"ipv6_support\": True,\n        \"dns_encryption\": True,\n        \"dns_adblocking\": True,\n        \"wireguard_enabled\": True,\n        \"ipsec_enabled\": True,\n        # OpenSSL/PKI\n        \"openssl_constraint_random_id\": \"test-uuid-12345\",\n        \"CA_password\": \"test-password\",\n        \"p12_export_password\": \"test-p12-password\",\n        \"ipsec_pki_path\": \"/etc/ipsec.d\",\n        \"ipsec_config_path\": \"/etc/ipsec.d\",\n        \"subjectAltName\": \"IP:10.0.0.1,DNS:vpn.example.com\",\n        \"subjectAltName_type\": \"IP\",\n        # Ansible variables\n        \"ansible_default_ipv4\": {\"address\": \"10.0.0.1\"},\n        \"ansible_default_ipv6\": {\"address\": \"2600:3c01::f03c:91ff:fedf:3b2a\"},\n        \"ansible_distribution\": \"Ubuntu\",\n        \"ansible_distribution_version\": \"22.04\",\n        \"ansible_date_time\": {\"epoch\": \"1234567890\"},\n        # User management\n        \"users\": [\"alice\", \"bob\", \"charlie\"],\n        \"all_users\": [\"alice\", \"bob\", \"charlie\", \"david\"],\n        # Common variables\n        \"item\": \"test-item\",\n        \"algo_provider\": \"local\",\n        \"algo_server_name\": \"algo-vpn\",\n        \"dns_servers\": [\"1.1.1.1\", \"1.0.0.1\"],\n        # OpenSSL version for conditionals\n        \"openssl_version\": \"3.0.0\",\n        # IPsec configuration\n        \"certificate_validity_days\": 3650,\n        \"ike_cipher\": \"aes128gcm16-prfsha512-ecp256\",\n        \"esp_cipher\": \"aes128gcm16-ecp256\",\n    }\n\n\ndef validate_yaml_file(yaml_path, check_inline_comments_only=False):\n    \"\"\"\n    Validate all Jinja2 expressions in a YAML file.\n    Returns (has_inline_comments, list_of_inline_comment_errors, list_of_other_errors)\n    \"\"\"\n    inline_comment_errors = []\n    other_errors = []\n\n    try:\n        with open(yaml_path) as f:\n            content = f.read()\n\n        # First, check if it's valid YAML\n        try:\n            yaml.safe_load(content)\n        except yaml.YAMLError:\n            # YAML syntax error, not our concern here\n            return False, [], []\n\n        # Extract all Jinja2 expressions\n        expressions = extract_jinja2_expressions(content)\n\n        if not expressions:\n            return False, [], []  # No Jinja2 expressions to validate\n\n        # Validate each expression\n        for expr in expressions:\n            is_valid, error = validate_jinja2_expression(expr)\n\n            if not is_valid:\n                line_num = find_line_number(content, expr[\"start\"])\n                error_msg = f\"{yaml_path}:{line_num}: {error}\"\n\n                # Separate inline comment errors from other errors\n                if error and \"inline comment\" in error.lower():\n                    inline_comment_errors.append(error_msg)\n                    # Show context for inline comment errors\n                    if len(expr[\"full\"]) < 200:\n                        inline_comment_errors.append(f\"  Expression: {expr['full'][:100]}...\")\n                elif not check_inline_comments_only:\n                    other_errors.append(error_msg)\n\n    except Exception as e:\n        if not check_inline_comments_only:\n            other_errors.append(f\"{yaml_path}: Error reading file: {e}\")\n\n    return len(inline_comment_errors) > 0, inline_comment_errors, other_errors\n\n\ndef test_regression_openssl_inline_comments():\n    \"\"\"\n    Regression test for the specific OpenSSL inline comment bug that was reported.\n    Tests that we correctly detect inline comments in the exact pattern that caused the issue.\n    \"\"\"\n    # The problematic expression that was reported\n    problematic_expr = \"\"\"{{ [\n  subjectAltName_type + ':' + IP_subject_alt_name + ('/255.255.255.255' if subjectAltName_type == 'IP' else ''),\n  'DNS:' + openssl_constraint_random_id,        # Per-deployment UUID prevents cross-deployment reuse\n  'email:' + openssl_constraint_random_id       # Unique email domain isolates certificate scope\n] + (\n  ['IP:' + ansible_default_ipv6['address'] + '/128'] if ipv6_support else []\n) }}\"\"\"\n\n    # The fixed expression (without inline comments)\n    fixed_expr = \"\"\"{{ [\n  subjectAltName_type + ':' + IP_subject_alt_name + ('/255.255.255.255' if subjectAltName_type == 'IP' else ''),\n  'DNS:' + openssl_constraint_random_id,\n  'email:' + openssl_constraint_random_id\n] + (\n  ['IP:' + ansible_default_ipv6['address'] + '/128'] if ipv6_support else []\n) }}\"\"\"\n\n    # Test the problematic expression - should fail\n    expr_with_comments = {\n        \"type\": \"variable\",\n        \"content\": problematic_expr[2:-2],  # Remove {{ }}\n        \"full\": problematic_expr,\n    }\n    is_valid, error = validate_jinja2_expression(expr_with_comments)\n    assert not is_valid, \"Should have detected inline comments in problematic expression\"\n    assert \"inline comment\" in error.lower(), f\"Expected inline comment error, got: {error}\"\n\n    # Test the fixed expression - should pass\n    expr_fixed = {\n        \"type\": \"variable\",\n        \"content\": fixed_expr[2:-2],  # Remove {{ }}\n        \"full\": fixed_expr,\n    }\n    is_valid, error = validate_jinja2_expression(expr_fixed)\n    assert is_valid, f\"Fixed expression should pass but got error: {error}\"\n\n\ndef test_edge_cases_inline_comments():\n    \"\"\"\n    Test various edge cases for inline comment detection.\n    Ensures we correctly handle hashes in strings, escaped hashes, and various comment patterns.\n    \"\"\"\n    test_cases = [\n        # (expression, should_pass, description)\n        (\"{{ 'string with # hash' }}\", True, \"Hash in string should pass\"),\n        ('{{ \"another # in string\" }}', True, \"Hash in double-quoted string should pass\"),\n        (\"{{ var # comment }}\", False, \"Simple inline comment should fail\"),\n        (\"{{ var1 + var2  # This is an inline comment }}\", False, \"Inline comment with text should fail\"),\n        (r\"{{ '\\#' + 'escaped hash' }}\", True, \"Escaped hash should pass\"),\n        (\"{% if true # comment %}\", False, \"Comment in control block should fail\"),\n        (\"{% for item in list # loop comment %}\", False, \"Comment in for loop should fail\"),\n        (\"{{ {'key': 'value # not a comment'} }}\", True, \"Hash in dict string value should pass\"),\n        (\"{{ url + '/#anchor' }}\", True, \"URL fragment should pass\"),\n        (\"{{ '#FF0000' }}\", True, \"Hex color code should pass\"),\n        (\"{{ var }}  # comment outside\", True, \"Comment outside expression should pass\"),\n        (\n            \"\"\"{{ [\n            'item1',  # comment here\n            'item2'\n        ] }}\"\"\",\n            False,\n            \"Multi-line with inline comment should fail\",\n        ),\n    ]\n\n    for expr_str, should_pass, description in test_cases:\n        # For the \"comment outside\" case, extract just the Jinja2 expression\n        if \"{{\" in expr_str and \"#\" in expr_str and expr_str.index(\"#\") > expr_str.index(\"}}\"):\n            # Comment is outside the expression - extract just the expression part\n            match = re.search(r\"(\\{\\{.+?\\}\\})\", expr_str)\n            if match:\n                actual_expr = match.group(1)\n                expr_type = \"variable\"\n                content = actual_expr[2:-2].strip()\n            else:\n                continue\n        elif expr_str.strip().startswith(\"{{\"):\n            expr_type = \"variable\"\n            content = expr_str.strip()[2:-2]\n            actual_expr = expr_str.strip()\n        elif expr_str.strip().startswith(\"{%\"):\n            expr_type = \"control\"\n            content = expr_str.strip()[2:-2]\n            actual_expr = expr_str.strip()\n        else:\n            continue\n\n        expr = {\"type\": expr_type, \"content\": content, \"full\": actual_expr}\n\n        is_valid, error = validate_jinja2_expression(expr)\n\n        if should_pass:\n            assert is_valid, f\"{description}: {error}\"\n        else:\n            assert not is_valid, f\"{description}: Should have failed but passed\"\n            assert \"inline comment\" in (error or \"\").lower(), (\n                f\"{description}: Expected inline comment error, got: {error}\"\n            )\n\n\ndef test_yaml_files_no_inline_comments():\n    \"\"\"\n    Test that all YAML files in the project don't contain inline comments in Jinja2 expressions.\n    \"\"\"\n    yaml_files = find_yaml_files_with_jinja2()\n\n    all_inline_comment_errors = []\n    files_with_inline_comments = []\n\n    for yaml_file in yaml_files:\n        has_inline_comments, inline_errors, _ = validate_yaml_file(yaml_file, check_inline_comments_only=True)\n\n        if has_inline_comments:\n            files_with_inline_comments.append(str(yaml_file))\n            all_inline_comment_errors.extend(inline_errors)\n\n    # Assert no inline comments found\n    assert not all_inline_comment_errors, (\n        f\"Found inline comments in {len(files_with_inline_comments)} files:\\n\"\n        + \"\\n\".join(all_inline_comment_errors[:10])  # Show first 10 errors\n    )\n\n\ndef test_openssl_file_specifically():\n    \"\"\"\n    Specifically test the OpenSSL file that had the original bug.\n    \"\"\"\n    openssl_file = Path(\"roles/strongswan/tasks/openssl.yml\")\n\n    if not openssl_file.exists():\n        pytest.skip(f\"{openssl_file} not found\")\n\n    has_inline_comments, inline_errors, _ = validate_yaml_file(openssl_file)\n\n    assert not has_inline_comments, f\"Found inline comments in {openssl_file}:\\n\" + \"\\n\".join(inline_errors)\n"
  },
  {
    "path": "tests/validate_jinja2_templates.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nValidate all Jinja2 templates in the Algo codebase.\nThis script checks for:\n1. Syntax errors (including inline comments in expressions)\n2. Undefined variables\n3. Common anti-patterns\n\"\"\"\n\nimport re\nimport sys\nfrom pathlib import Path\n\nfrom jinja2 import Environment, FileSystemLoader, StrictUndefined, TemplateSyntaxError, meta\n\n\ndef find_jinja2_templates(root_dir: str = \".\") -> list[Path]:\n    \"\"\"Find all Jinja2 template files in the project.\"\"\"\n    templates = []\n    patterns = [\"**/*.j2\", \"**/*.jinja2\", \"**/*.yml.j2\", \"**/*.conf.j2\"]\n\n    # Skip these directories\n    skip_dirs = {\".git\", \".venv\", \"venv\", \".env\", \"configs\", \"__pycache__\", \".cache\"}\n\n    for pattern in patterns:\n        for path in Path(root_dir).glob(pattern):\n            # Skip if in a directory we want to ignore\n            if not any(skip_dir in path.parts for skip_dir in skip_dirs):\n                templates.append(path)\n\n    return sorted(templates)\n\n\ndef check_inline_comments_in_expressions(template_content: str, template_path: Path) -> list[str]:\n    \"\"\"\n    Check for inline comments (#) within Jinja2 expressions.\n    This is the error we just fixed in openssl.yml.\n    \"\"\"\n    errors = []\n\n    # Pattern to find Jinja2 expressions\n    jinja_pattern = re.compile(r\"\\{\\{.*?\\}\\}|\\{%.*?%\\}\", re.DOTALL)\n\n    for match in jinja_pattern.finditer(template_content):\n        expression = match.group()\n        lines = expression.split(\"\\n\")\n\n        for i, line in enumerate(lines):\n            # Check for # that's not in a string\n            # Simple heuristic: if # appears after non-whitespace and not in quotes\n            if \"#\" in line:\n                # Remove quoted strings to avoid false positives\n                cleaned = re.sub(r'\"[^\"]*\"', \"\", line)\n                cleaned = re.sub(r\"'[^']*'\", \"\", cleaned)\n\n                if \"#\" in cleaned:\n                    # Check if it's likely a comment (has text after it)\n                    hash_pos = cleaned.index(\"#\")\n                    if hash_pos > 0 and cleaned[hash_pos - 1 : hash_pos] != \"\\\\\":\n                        line_num = template_content[: match.start()].count(\"\\n\") + i + 1\n                        errors.append(\n                            f\"{template_path}:{line_num}: Inline comment (#) found in Jinja2 expression. \"\n                            f\"Move comments outside the expression.\"\n                        )\n\n    return errors\n\n\ndef check_undefined_variables(template_path: Path) -> list[str]:\n    \"\"\"\n    Parse template and extract all undefined variables.\n    This helps identify what variables need to be provided.\n    \"\"\"\n    errors = []\n\n    try:\n        with open(template_path) as f:\n            template_content = f.read()\n\n        env = Environment(undefined=StrictUndefined)\n        ast = env.parse(template_content)\n        undefined_vars = meta.find_undeclared_variables(ast)\n\n        # Common Ansible variables that are always available\n        ansible_builtins = {\n            \"ansible_default_ipv4\",\n            \"ansible_default_ipv6\",\n            \"ansible_hostname\",\n            \"ansible_distribution\",\n            \"ansible_distribution_version\",\n            \"ansible_facts\",\n            \"inventory_hostname\",\n            \"hostvars\",\n            \"groups\",\n            \"group_names\",\n            \"play_hosts\",\n            \"ansible_version\",\n            \"ansible_user\",\n            \"ansible_host\",\n            \"item\",\n            \"ansible_loop\",\n            \"ansible_index\",\n            \"lookup\",\n        }\n\n        # Filter out known Ansible variables\n        unknown_vars = undefined_vars - ansible_builtins\n\n        # Only report if there are truly unknown variables\n        if unknown_vars and len(unknown_vars) < 20:  # Avoid noise from templates with many vars\n            errors.append(f\"{template_path}: Uses undefined variables: {', '.join(sorted(unknown_vars))}\")\n\n    except Exception:\n        # Don't report parse errors here, they're handled elsewhere\n        pass\n\n    return errors\n\n\ndef validate_template_syntax(template_path: Path) -> tuple[bool, list[str]]:\n    \"\"\"\n    Validate a single template for syntax errors.\n    Returns (is_valid, list_of_errors)\n    \"\"\"\n    errors = []\n\n    # Skip full parsing for templates that use Ansible-specific features heavily\n    # We still check for inline comments but skip full template parsing\n    ansible_specific_templates = {\n        \"dnscrypt-proxy.toml.j2\",  # Uses |bool filter\n        \"mobileconfig.j2\",  # Uses |to_uuid filter and complex item structures\n        \"vpn-dict.j2\",  # Uses |to_uuid filter\n    }\n\n    if template_path.name in ansible_specific_templates:\n        # Still check for inline comments but skip full parsing\n        try:\n            with open(template_path) as f:\n                template_content = f.read()\n            errors.extend(check_inline_comments_in_expressions(template_content, template_path))\n        except Exception:\n            pass\n        return len(errors) == 0, errors\n\n    try:\n        with open(template_path) as f:\n            template_content = f.read()\n\n        # Check for inline comments first (our custom check)\n        errors.extend(check_inline_comments_in_expressions(template_content, template_path))\n\n        # Try to parse the template\n        env = Environment(loader=FileSystemLoader(template_path.parent), undefined=StrictUndefined)\n\n        # Add mock Ansible filters to avoid syntax errors\n        env.filters[\"bool\"] = lambda x: x\n        env.filters[\"to_uuid\"] = lambda x: x\n        env.filters[\"b64encode\"] = lambda x: x\n        env.filters[\"b64decode\"] = lambda x: x\n        env.filters[\"regex_replace\"] = lambda x, y, z: x\n        env.filters[\"default\"] = lambda x, d: x if x else d\n\n        # This will raise TemplateSyntaxError if there's a syntax problem\n        env.get_template(template_path.name)\n\n        # Also check for undefined variables (informational)\n        # Commenting out for now as it's too noisy, but useful for debugging\n        # errors.extend(check_undefined_variables(template_path))\n\n    except TemplateSyntaxError as e:\n        errors.append(f\"{template_path}:{e.lineno}: Syntax error: {e.message}\")\n    except UnicodeDecodeError:\n        errors.append(f\"{template_path}: Unable to decode file (not UTF-8)\")\n    except Exception as e:\n        errors.append(f\"{template_path}: Error: {e!s}\")\n\n    return len(errors) == 0, errors\n\n\ndef check_common_antipatterns(template_path: Path) -> list[str]:\n    \"\"\"Check for common Jinja2 anti-patterns.\"\"\"\n    warnings = []\n\n    try:\n        with open(template_path) as f:\n            content = f.read()\n\n        # Check for missing spaces around filters\n        if re.search(r\"\\{\\{[^}]+\\|[^ ]\", content):\n            warnings.append(f\"{template_path}: Missing space after filter pipe (|)\")\n\n        # Check for deprecated 'when' in Jinja2 (should use if)\n        if re.search(r\"\\{%\\s*when\\s+\", content):\n            warnings.append(f\"{template_path}: Use 'if' instead of 'when' in Jinja2 templates\")\n\n        # Check for extremely long expressions (harder to debug)\n        for match in re.finditer(r\"\\{\\{(.+?)\\}\\}\", content, re.DOTALL):\n            if len(match.group(1)) > 200:\n                line_num = content[: match.start()].count(\"\\n\") + 1\n                warnings.append(\n                    f\"{template_path}:{line_num}: Very long expression (>200 chars), consider breaking it up\"\n                )\n\n    except Exception:\n        pass  # Ignore errors in anti-pattern checking\n\n    return warnings\n\n\ndef main():\n    \"\"\"Main validation function.\"\"\"\n    print(\"🔍 Validating Jinja2 templates in Algo...\\n\")\n\n    # Find all templates\n    templates = find_jinja2_templates()\n    print(f\"Found {len(templates)} Jinja2 templates\\n\")\n\n    all_errors = []\n    all_warnings = []\n    valid_count = 0\n\n    # Validate each template\n    for template in templates:\n        is_valid, errors = validate_template_syntax(template)\n        warnings = check_common_antipatterns(template)\n\n        if is_valid:\n            valid_count += 1\n        else:\n            all_errors.extend(errors)\n\n        all_warnings.extend(warnings)\n\n    # Report results\n    print(f\"✅ {valid_count}/{len(templates)} templates have valid syntax\")\n\n    if all_errors:\n        print(f\"\\n❌ Found {len(all_errors)} errors:\\n\")\n        for error in all_errors:\n            print(f\"  ERROR: {error}\")\n\n    if all_warnings:\n        print(f\"\\n⚠️  Found {len(all_warnings)} warnings:\\n\")\n        for warning in all_warnings:\n            print(f\"  WARN: {warning}\")\n\n    if all_errors:\n        print(\"\\n❌ Template validation FAILED\")\n        return 1\n    else:\n        print(\"\\n✅ All templates validated successfully!\")\n        return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
  },
  {
    "path": "users.yml",
    "content": "---\n- name: Manage VPN Users\n  hosts: localhost\n  gather_facts: false\n  tags: always\n  vars_files:\n    - config.cfg\n\n  tasks:\n    - when: server is undefined\n      block:\n        - name: Get list of installed config files\n          find:\n            paths: configs/\n            depth: 2\n            recurse: true\n            hidden: true\n            patterns: .config.yml\n          register: _configs_list\n\n        - name: Verify servers\n          assert:\n            that: _configs_list.matched > 0\n            msg: No servers found, nothing to update.\n\n        - name: Build list of installed servers\n          set_fact:\n            server_list: \"{{ server_list | default([]) + [{'server': config.server, 'IP_subject_alt_name': config.IP_subject_alt_name}] }}\"\n          loop: \"{{ _configs_list.files }}\"\n          loop_control:\n            label: \"{{ item.path }}\"\n          vars:\n            config: \"{{ lookup('file', item.path) | from_yaml }}\"\n\n        - name: Server address prompt\n          pause:\n            prompt: |\n              Select the server to update user list below:\n                {% for r in server_list %}\n                  {{ loop.index }}. {{ r.server }} ({{ r.IP_subject_alt_name }})\n              {% endfor %}\n          register: _server\n\n    - block:\n        - name: Set facts based on the input\n          set_fact:\n            algo_server: >-\n              {%- if server is defined -%}{{ server }}{%-\n              elif _server.user_input -%}{{ server_list[_server.user_input | int - 1].server }}{%-\n              else -%}omit{%-\n              endif -%}\n\n        - name: Import host specific variables\n          include_vars:\n            file: configs/{{ algo_server }}/.config.yml\n\n        - name: Validate users list is not empty\n          fail:\n            msg: |\n              NO USERS DEFINED\n\n              The 'users' list in config.cfg is empty. At least one user is required.\n              Add users to config.cfg before running update-users.\n          when: users | default([]) | length == 0\n\n        - name: Local deployment permission validation\n          when: algo_server == 'localhost' or algo_provider | default('') == 'local'\n          block:\n            - name: Get config directory owner\n              stat:\n                path: configs/{{ algo_server }}\n              register: config_dir_stat\n\n            - name: Fail on permission mismatch\n              fail:\n                msg: |\n                  PERMISSION MISMATCH DETECTED\n\n                  Config directory owner: {{ config_dir_stat.stat.pw_name }}\n                  Current user: {{ ansible_user_id }}\n\n                  Running update-users with mismatched permissions will create\n                  files with inconsistent ownership, breaking future operations.\n\n                  TO FIX: Run this command, then retry update-users:\n                    sudo chown -R {{ ansible_user_id }} configs/{{ algo_server }}/\n\n                  PREVENT: Always run update-users the same way as initial deployment\n                  (both with sudo, or both without sudo).\n              when: config_dir_stat.stat.pw_name != ansible_user_id\n\n        - name: Test SSH connectivity to server\n          wait_for:\n            host: \"{{ algo_server }}\"\n            port: \"{{ ansible_ssh_port | default(ssh_port) | int }}\"\n            timeout: 10\n          register: ssh_check\n          failed_when: false\n          when: algo_server != 'localhost'\n\n        - name: Fail with helpful message if server unreachable\n          fail:\n            msg: |\n              Cannot connect to {{ algo_server }} on port {{ ansible_ssh_port | default(ssh_port) }}.\n\n              Possible causes:\n              - Server is not running (check your cloud provider console)\n              - IP address changed (common after EC2 restart without Elastic IP)\n              - Firewall/security group blocking port {{ ansible_ssh_port | default(ssh_port) }}\n\n              To diagnose:\n                nc -zv {{ algo_server }} {{ ansible_ssh_port | default(ssh_port) }}\n                ssh -vvv -p {{ ansible_ssh_port | default(ssh_port) }} -i configs/algo.pem {{ server_user | default('algo') }}@{{ algo_server }}\n          when:\n            - algo_server != 'localhost'\n            - ssh_check is failed\n\n        - when: ipsec_enabled | bool\n          block:\n            - name: CA password prompt\n              pause:\n                prompt: Enter the password for the private CA key\n                echo: false\n              register: _ca_password\n              when: ca_password is undefined\n\n            - name: Set facts based on the input\n              set_fact:\n                CA_password: >-\n                  {%- if ca_password is defined -%}{{ ca_password }}{%-\n                  elif _ca_password.user_input -%}{{ _ca_password.user_input }}{%-\n                  else -%}omit{%-\n                  endif -%}\n\n        - name: Local pre-tasks\n          import_tasks: playbooks/cloud-pre.yml\n          become: false\n\n        - name: Add the server to the vpn-host group\n          add_host:\n            name: \"{{ algo_server }}\"\n            groups: vpn-host\n            ansible_ssh_user: \"{{ server_user | default('root') }}\"\n            ansible_connection: \"{% if algo_server == 'localhost' %}local{% else %}ssh{% endif %}\"\n            ansible_python_interpreter: \"{% if algo_server == 'localhost' %}{{ ansible_playbook_python }}{% else %}/usr/bin/python3{% endif %}\"\n            CA_password: \"{{ CA_password | default(omit) }}\"\n      rescue:\n        - include_tasks: playbooks/rescue.yml\n\n- name: User management\n  hosts: vpn-host\n  gather_facts: true\n  become: true\n  vars_files:\n    - config.cfg\n    - configs/{{ inventory_hostname }}/.config.yml\n\n  tasks:\n    - block:\n        - import_role:\n            name: common\n\n        - import_role:\n            name: wireguard\n          when: wireguard_enabled | bool\n\n        - import_role:\n            name: strongswan\n          when: ipsec_enabled | bool\n          tags: ipsec\n\n        - import_role:\n            name: ssh_tunneling\n          when: algo_ssh_tunneling | bool\n\n        - debug:\n            msg:\n              - \"{{ congrats.common.split('\\n') }}\"\n              - \"    {{ congrats.p12_pass if algo_ssh_tunneling or ipsec_enabled else '' }}\"\n              - \"    {{ congrats.ca_key_pass if algo_store_pki and ipsec_enabled else '' }}\"\n              - \"    {{ congrats.ssh_access if algo_provider != 'local' else '' }}\"\n          tags: always\n      rescue:\n        - include_tasks: playbooks/rescue.yml\n"
  }
]