{"owner":"trailofbits","repo":"algo","hasSkills":true,"hasMcp":false,"mcpConfig":null,"found":["CLAUDE.md"],"skills":{"CLAUDE.md":"# 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"},"files":{"CLAUDE.md":"# 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"},"items":[{"name":"CLAUDE.md","path":"CLAUDE.md","title":"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","category":"root","tokens":4383}]}