Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .ansible-lint
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,6 @@ skip_list:
- 'no-handler' # Handler usage - some legitimate non-handler use cases
- 'name[missing]' # All tasks should be named - 113 issues to fix (temporary)

warn_list:
- yaml[line-length] # Line length - informational only
- key-order[task] # Task key ordering - many existing violations, fix gradually

# Enable additional rules
enable_list:
- no-log-password
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ jobs:
- name: Run yamllint
run: uv run --with yamllint yamllint -c .yamllint .

jinja2-lint:
name: Jinja2 template linting
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 # v5.0.1
with:
persist-credentials: false

- name: Setup uv environment
uses: ./.github/actions/setup-uv

- name: Run j2lint
run: |
# Lint Jinja2 templates for syntax and style issues
# Ignored rules (incompatible with Ansible config-file templates):
# S3: indentation (dictated by output format, not Jinja style)
# S5: tabs (some config formats require them)
# S6: whitespace-control delimiters ({%- -%} are standard Ansible)
# S7: single-statement-per-line (inline Jinja in config output)
# V1: lowercase variables (existing names like IP_subject_alt_name)
uv run --with j2lint j2lint roles/ --ignore S3 S5 S6 S7 V1

python-lint:
name: Python linting
runs-on: ubuntu-22.04
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/smart-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ jobs:
- '**/*.yml'
- '**/*.yaml'
- '**/*.sh'
- '**/*.j2'
- '.ansible-lint'
- '.yamllint'
- 'pyproject.toml'
Expand Down Expand Up @@ -254,6 +255,11 @@ jobs:
# Run Ansible linter
uv run --with ansible-lint ansible-lint

# Check Jinja2 templates
if git diff --name-only "${BASE_SHA}" "${HEAD_SHA}" | grep -q '\.j2$'; then
uv run --with j2lint j2lint roles/ --ignore S3 S5 S6 S7 V1
fi

# Check shell scripts if any changed
if git diff --name-only "${BASE_SHA}" "${HEAD_SHA}" | grep -q '\.sh$'; then
find . -name "*.sh" -type f -not -path "./.git/*" -exec shellcheck {} +
Expand Down
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ repos:
types: [python]
pass_filenames: false

- id: j2lint
name: Jinja2 template lint
entry: bash -c 'uv run j2lint roles/ --ignore S3 S5 S6 S7 V1'
language: system
files: '\.j2$'
pass_filenames: false

- id: ansible-lint
name: Ansible-lint
entry: bash -c 'uv run ansible-lint --force-color || echo "Ansible-lint had issues - check output"'
Expand Down
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ Common lint issues to fix before submitting:
- Jinja2 spacing errors (`{{foo}}` should be `{{ foo }}`)
- Missing `mode:` on file/directory tasks

### Zero-Tolerance Warning Policy

**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.).

Why this matters for Algo:
- **Security tool** - VPN misconfigurations silently break privacy guarantees. A "cosmetic" warning today hides a real bug tomorrow.
- **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.
- **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.

Resolution order of preference:
1. **Fix it** - Preferred. Most findings have straightforward fixes.
2. **Allowlist in config** - If the rule is wrong for this project, add to `skip_list` with a comment explaining why.
3. **Inline suppress** - Last resort. Use `# noqa: rule-name` with a comment justifying the exception.

Never use `warn_list` in `.ansible-lint` — it exists as a migration tool, not a permanent home. Rules either pass or are explicitly skipped.

### Design Requirements

When adding or modifying features, verify these before requesting review:
Expand Down
24 changes: 24 additions & 0 deletions algo
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,30 @@ fi

# Run the appropriate playbook
case "$1" in
help|-h|--help)
echo "Usage: ./algo [COMMAND] [ANSIBLE_OPTIONS]"
echo ""
echo "Set up a personal VPN in the cloud."
echo ""
echo "Commands:"
echo " (default) Deploy a new VPN server"
echo " update-users Add or remove users on an existing server"
echo ""
echo "Configuration:"
echo " Edit config.cfg to set users, DNS, and VPN options before deploying."
echo ""
echo "Non-interactive deployment:"
echo " ./algo -e 'provider=digitalocean server_name=algo region=nyc3 do_token=TOKEN'"
echo ""
echo "Common Ansible options (passed through):"
echo " -e KEY=VALUE Set variable (bypass interactive prompts)"
echo " -v, -vvv Increase output verbosity"
echo " --skip-tags TAG Skip specific components"
echo " -t, --tags TAG Run only specific components"
echo ""
echo "Docs: https://trailofbits.github.io/algo/"
exit 0
;;
update-users)
uv run ansible-playbook users.yml "${@:2}" -t update-users ;;
*)
Expand Down
42 changes: 32 additions & 10 deletions input.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,22 +109,44 @@
- name: Set facts based on the input
set_fact:
algo_server_name: >-
{% if server_name is defined %}{% set _server = server_name %}{%- elif _algo_server_name.user_input is defined and _algo_server_name.user_input | length > 0 -%}
{%- set _server = _algo_server_name.user_input -%}
{%- else %}{% set _server = defaults['server_name'] %}{% endif -%}
{%- if server_name is defined -%}{% set _server = server_name %}{%-
elif _algo_server_name.user_input is defined and _algo_server_name.user_input | length > 0 -%}{%-
set _server = _algo_server_name.user_input -%}{%-
else -%}{% set _server = defaults['server_name'] %}{%-
endif -%}
{{ _server | regex_replace('(?!\.)(\W|_)', '-') }}
algo_ondemand_cellular: >-
{% if ondemand_cellular is defined %}{{ ondemand_cellular | bool }}{%- elif _ondemand_cellular.user_input is defined %}{{ booleans_map[_ondemand_cellular.user_input] | default(defaults['ondemand_cellular']) }}{%- else %}{{ false }}{% endif %}
{%- if ondemand_cellular is defined -%}{{ ondemand_cellular | bool }}{%-
elif _ondemand_cellular.user_input is defined -%}{{ booleans_map[_ondemand_cellular.user_input] | default(defaults['ondemand_cellular']) }}{%-
else -%}{{ false }}{%-
endif -%}
algo_ondemand_wifi: >-
{% if ondemand_wifi is defined %}{{ ondemand_wifi | bool }}{%- elif _ondemand_wifi.user_input is defined %}{{ booleans_map[_ondemand_wifi.user_input] | default(defaults['ondemand_wifi']) }}{%- else %}{{ false }}{% endif %}
{%- if ondemand_wifi is defined -%}{{ ondemand_wifi | bool }}{%-
elif _ondemand_wifi.user_input is defined -%}{{ booleans_map[_ondemand_wifi.user_input] | default(defaults['ondemand_wifi']) }}{%-
else -%}{{ false }}{%-
endif -%}
algo_ondemand_wifi_exclude: >-
{% if ondemand_wifi_exclude is defined %}{{ ondemand_wifi_exclude | b64encode }}{%- elif _ondemand_wifi_exclude.user_input is defined and _ondemand_wifi_exclude.user_input | length > 0 -%}
{{ _ondemand_wifi_exclude.user_input | b64encode }}{%- else %}{{ '_null' | b64encode }}{% endif %}
{%- if ondemand_wifi_exclude is defined -%}{{ ondemand_wifi_exclude | b64encode }}{%-
elif _ondemand_wifi_exclude.user_input is defined and _ondemand_wifi_exclude.user_input | length > 0 -%}
{{ _ondemand_wifi_exclude.user_input | b64encode }}{%-
else -%}{{ '_null' | b64encode }}{%-
endif -%}
algo_dns_adblocking: >-
{% if dns_adblocking is defined %}{{ dns_adblocking | bool }}{%- elif _dns_adblocking.user_input is defined %}{{ booleans_map[_dns_adblocking.user_input] | default(defaults['dns_adblocking']) }}{%- else %}{{ false }}{% endif %}
{%- if dns_adblocking is defined -%}{{ dns_adblocking | bool }}{%-
elif _dns_adblocking.user_input is defined -%}{{ booleans_map[_dns_adblocking.user_input] | default(defaults['dns_adblocking']) }}{%-
else -%}{{ false }}{%-
endif -%}
algo_ssh_tunneling: >-
{% if ssh_tunneling is defined %}{{ ssh_tunneling | bool }}{%- elif _ssh_tunneling.user_input is defined %}{{ booleans_map[_ssh_tunneling.user_input] | default(defaults['ssh_tunneling']) }}{%- else %}{{ false }}{% endif %}
{%- if ssh_tunneling is defined -%}{{ ssh_tunneling | bool }}{%-
elif _ssh_tunneling.user_input is defined -%}{{ booleans_map[_ssh_tunneling.user_input] | default(defaults['ssh_tunneling']) }}{%-
else -%}{{ false }}{%-
endif -%}
algo_store_pki: >-
{% if ipsec_enabled %}{%- if store_pki is defined %}{{ store_pki | bool }}{%- elif _store_pki.user_input is defined %}{{ booleans_map[_store_pki.user_input] | default(defaults['store_pki']) }}{%- else %}{{ false }}{% endif %}{% endif %}
{%- if ipsec_enabled -%}
{%- if store_pki is defined -%}{{ store_pki | bool }}{%-
elif _store_pki.user_input is defined -%}{{ booleans_map[_store_pki.user_input] | default(defaults['store_pki']) }}{%-
else -%}{{ false }}{%-
endif -%}
{%- endif -%}
rescue:
- include_tasks: playbooks/rescue.yml
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ dev-dependencies = [
"ruff>=0.8.0", # Python linter and formatter
"yamllint>=1.35.0", # YAML linter
"ansible-lint>=24.0.0", # Ansible linter
"j2lint>=1.2.0", # Jinja2 template linter
]

[tool.pytest.ini_options]
Expand Down
5 changes: 4 additions & 1 deletion roles/cloud-azure/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

- set_fact:
algo_region: >-
{% if region is defined %}{{ region }}{%- elif _algo_region.user_input %}{{ azure_regions[_algo_region.user_input | int - 1]['name'] }}{%- else %}{{ azure_regions[default_region | int - 1]['name'] }}{% endif %}
{%- if region is defined -%}{{ region }}{%-
elif _algo_region.user_input -%}{{ azure_regions[_algo_region.user_input | int - 1]['name'] }}{%-
else -%}{{ azure_regions[default_region | int - 1]['name'] }}{%-
endif -%}

- name: Create AlgoVPN Server
azure.azcollection.azure_rm_deployment:
Expand Down
4 changes: 2 additions & 2 deletions roles/cloud-azure/tasks/prompts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
subscription_id: "{{ azure_subscription_id | default(lookup('env', 'AZURE_SUBSCRIPTION_ID'), true) }}"
no_log: true

- block:
- when: region is undefined
block:
- name: Set the default region
set_fact:
default_region: >-
Expand All @@ -22,4 +23,3 @@
Enter the number of your desired region
[{{ default_region }}]
register: _algo_region
when: region is undefined
17 changes: 10 additions & 7 deletions roles/cloud-cloudstack/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@
- name: Include prompts
import_tasks: prompts.yml

- block:
- environment:
CLOUDSTACK_KEY: "{{ algo_cs_key }}"
CLOUDSTACK_SECRET: "{{ algo_cs_token }}"
CLOUDSTACK_ENDPOINT: "{{ algo_cs_url }}"
no_log: true
block:
- set_fact:
algo_region: >-
{%- if region is defined -%}{{ region }}{%- elif _algo_region.user_input is defined and _algo_region.user_input | length > 0 -%}{{ cs_zones[_algo_region.user_input | int - 1]['name'] }}{%- else -%}{{ cs_zones[default_zone | int - 1]['name'] }}{%- endif -%}
{%- if region is defined -%}{{ region }}{%-
elif _algo_region.user_input is defined and _algo_region.user_input | length > 0 -%}{{ cs_zones[_algo_region.user_input | int - 1]['name'] }}{%-
else -%}{{ cs_zones[default_zone | int - 1]['name'] }}{%-
endif -%}

- name: Security group created
cs_securitygroup:
Expand Down Expand Up @@ -48,8 +56,3 @@
ansible_ssh_user: algo
ansible_ssh_port: "{{ ssh_port }}"
cloudinit: true
environment:
CLOUDSTACK_KEY: "{{ algo_cs_key }}"
CLOUDSTACK_SECRET: "{{ algo_cs_token }}"
CLOUDSTACK_ENDPOINT: "{{ algo_cs_url }}"
no_log: true
4 changes: 2 additions & 2 deletions roles/cloud-digitalocean/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
- set_fact:
droplet: "{{ digital_ocean_droplet.data.droplet | default(digital_ocean_droplet.data) }}"

- block:
- when: alternative_ingress_ip | bool
block:
- name: Create a Floating IP
community.digitalocean.digital_ocean_floating_ip:
state: present
Expand All @@ -41,7 +42,6 @@
- name: Set the static ip as a fact
set_fact:
cloud_alternative_ingress_ip: "{{ digital_ocean_floating_ip.data.floating_ip.ip }}"
when: alternative_ingress_ip

- set_fact:
cloud_instance_ip: "{{ (droplet.networks.v4 | selectattr('type', '==', 'public')).0.ip_address }}"
Expand Down
5 changes: 4 additions & 1 deletion roles/cloud-digitalocean/tasks/prompts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,7 @@
- name: Set additional facts
set_fact:
algo_do_region: >-
{% if region is defined %}{{ region }}{%- elif _algo_region.user_input %}{{ do_regions[_algo_region.user_input | int - 1]['slug'] }}{%- else %}{{ do_regions[default_region | int - 1]['slug'] }}{% endif %}
{%- if region is defined -%}{{ region }}{%-
elif _algo_region.user_input -%}{{ do_regions[_algo_region.user_input | int - 1]['slug'] }}{%-
else -%}{{ do_regions[default_region | int - 1]['slug'] }}{%-
endif -%}
21 changes: 12 additions & 9 deletions roles/cloud-ec2/tasks/prompts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
aws_profile: "{{ lookup('env', 'AWS_PROFILE') | default('default', true) }}"

# Try to read credentials from file if not already provided
- block:
- when:
- aws_access_key is undefined
- lookup('env', 'AWS_ACCESS_KEY_ID')|length <= 0
block:
- name: Check if AWS credentials file exists
stat:
path: "{{ aws_credentials_path }}"
Expand All @@ -20,9 +23,6 @@
_file_session_token: "{{ lookup('ini', 'aws_session_token', section=aws_profile, file=aws_credentials_path, errors='ignore') | default('', true) }}"
when: aws_creds_file.stat.exists
no_log: true
when:
- aws_access_key is undefined
- lookup('env', 'AWS_ACCESS_KEY_ID')|length <= 0

# Prompt for credentials if still not available
- pause:
Expand Down Expand Up @@ -69,7 +69,8 @@
| default('') }}
no_log: true

- block:
- when: region is undefined
block:
- name: Get regions
aws_region_info:
aws_access_key: "{{ access_key }}"
Expand Down Expand Up @@ -101,15 +102,18 @@
Enter the number of your desired region
[{{ default_region }}]
register: _algo_region
when: region is undefined

- name: Set algo_region and stack_name facts
set_fact:
algo_region: >-
{%- if region is defined -%}{{ region }}{%- elif _algo_region.user_input -%}{{ aws_regions[_algo_region.user_input | int - 1]['region_name'] }}{%- else -%}{{ aws_regions[default_region | int - 1]['region_name'] }}{%- endif -%}
{%- if region is defined -%}{{ region }}{%-
elif _algo_region.user_input -%}{{ aws_regions[_algo_region.user_input | int - 1]['region_name'] }}{%-
else -%}{{ aws_regions[default_region | int - 1]['region_name'] }}{%-
endif -%}
stack_name: "{{ algo_server_name | replace('.', '-') }}"

- block:
- when: cloud_providers.ec2.use_existing_eip
block:
- name: Get existing available Elastic IPs
ec2_eip_info:
aws_access_key: "{{ access_key }}"
Expand All @@ -134,4 +138,3 @@

- set_fact:
existing_eip: "{{ available_eip_addresses[_use_existing_eip.user_input | int - 1]['allocation_id'] }}"
when: cloud_providers.ec2.use_existing_eip
4 changes: 2 additions & 2 deletions roles/cloud-gce/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
- "{{ ssh_port }}"
- ip_protocol: icmp

- block:
- when: cloud_providers.gce.external_static_ip
block:
- name: External IP allocated
gcp_compute_address:
auth_kind: serviceaccount
Expand All @@ -45,7 +46,6 @@
- name: Set External IP as a fact
set_fact:
external_ip: "{{ gcp_compute_address.address }}"
when: cloud_providers.gce.external_static_ip

- name: Instance created
gcp_compute_instance:
Expand Down
8 changes: 4 additions & 4 deletions roles/cloud-gce/tasks/prompts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@
no_log: true

- set_fact:
service_account_email: "{{ credentials_file_lookup.client_email | default(lookup('env', 'GCE_EMAIL')) }}"
project_id: "{{ credentials_file_lookup.project_id | default(lookup('env', 'GCE_PROJECT')) }}"
service_account_email: "{{ credentials_file_lookup.client_email | default(lookup('env', 'GCE_EMAIL'), true) }}"
project_id: "{{ credentials_file_lookup.project_id | default(lookup('env', 'GCE_PROJECT'), true) }}"
no_log: true

- block:
- when: region is undefined
block:
- name: Get regions
gcp_compute_location_info:
auth_kind: serviceaccount
Expand Down Expand Up @@ -57,7 +58,6 @@
Enter the number of your desired region
[{{ default_region }}]
register: _gce_region
when: region is undefined

- name: Set region as a fact
set_fact:
Expand Down
5 changes: 4 additions & 1 deletion roles/cloud-hetzner/tasks/prompts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,7 @@
- name: Set additional facts
set_fact:
algo_hcloud_region: >-
{%- if region is defined -%}{{ region }}{%- elif _algo_region.user_input -%}{{ hcloud_regions[_algo_region.user_input | int - 1]['location'] }}{%- else -%}{{ hcloud_regions[default_region | int - 1]['location'] }}{%- endif -%}
{%- if region is defined -%}{{ region }}{%-
elif _algo_region.user_input -%}{{ hcloud_regions[_algo_region.user_input | int - 1]['location'] }}{%-
else -%}{{ hcloud_regions[default_region | int - 1]['location'] }}{%-
endif -%}
Loading