├── configs └── .gitinit ├── CODEOWNERS ├── roles ├── common │ ├── tasks │ │ ├── aip │ │ │ ├── placeholder.yml │ │ │ ├── main.yml │ │ │ └── digitalocean.yml │ │ ├── unattended-upgrades.yml │ │ ├── iptables.yml │ │ ├── main.yml │ │ ├── facts.yml │ │ └── packages.yml │ ├── templates │ │ ├── 10-algo-lo100.network.j2 │ │ ├── 10periodic.j2 │ │ └── 99-algo-ipv6-egress.yaml.j2 │ ├── defaults │ │ └── main.yml │ └── handlers │ │ └── main.yml ├── strongswan │ ├── meta │ │ └── main.yml │ ├── templates │ │ ├── ipsec.secrets.j2 │ │ ├── client_ipsec.secrets.j2 │ │ ├── strongswan.conf.j2 │ │ ├── client_ipsec.conf.j2 │ │ ├── 100-CustomLimitations.conf.j2 │ │ └── ipsec.conf.j2 │ ├── tasks │ │ ├── distribute_keys.yml │ │ ├── main.yml │ │ ├── client_configs.yml │ │ ├── ubuntu.yml │ │ └── ipsec_configuration.yml │ └── handlers │ │ └── main.yml ├── local │ └── tasks │ │ └── main.yml ├── cloud-linode │ ├── defaults │ │ └── main.yml │ └── tasks │ │ ├── prompts.yml │ │ └── main.yml ├── cloud-scaleway │ ├── defaults │ │ └── main.yml │ └── tasks │ │ └── prompts.yml ├── ssh_tunneling │ ├── defaults │ │ └── main.yml │ ├── handlers │ │ └── main.yml │ └── templates │ │ └── ssh_config.j2 ├── client │ ├── files │ │ └── libstrongswan-relax-constraints.conf │ ├── handlers │ │ └── main.yml │ └── tasks │ │ ├── systems │ │ ├── CentOS.yml │ │ ├── Fedora.yml │ │ ├── Debian.yml │ │ ├── Ubuntu.yml │ │ └── main.yml │ │ └── main.yml ├── cloud-ec2 │ ├── defaults │ │ └── main.yml │ └── tasks │ │ ├── main.yml │ │ └── cloudformation.yml ├── wireguard │ ├── handlers │ │ └── main.yml │ ├── tasks │ │ ├── mobileconfig.yml │ │ ├── ubuntu.yml │ │ └── keys.yml │ ├── templates │ │ ├── client.conf.j2 │ │ ├── server.conf.j2 │ │ └── mobileconfig.j2 │ ├── defaults │ │ └── main.yml │ └── files │ │ └── wireguard.sh ├── dns │ ├── files │ │ ├── 50-dnscrypt-proxy-unattended-upgrades │ │ └── apparmor.profile.dnscrypt-proxy │ ├── defaults │ │ └── main.yml │ ├── handlers │ │ └── main.yml │ ├── templates │ │ ├── dnscrypt-proxy │ │ │ ├── cache.toml.j2 │ │ │ ├── sources.toml.j2 │ │ │ └── filters.toml.j2 │ │ ├── dnscrypt-proxy.toml.j2 │ │ ├── ip-blacklist.txt.j2 │ │ └── adblock.sh.j2 │ └── tasks │ │ ├── dns_adblocking.yml │ │ └── main.yml ├── cloud-lightsail │ ├── tasks │ │ ├── main.yml │ │ ├── cloudformation.yml │ │ └── prompts.yml │ └── files │ │ └── stack.yaml ├── privacy │ ├── templates │ │ ├── 46-privacy-ssh-filter.conf.j2 │ │ ├── auth-logrotate.j2 │ │ ├── 47-privacy-auth-filter.conf.j2 │ │ ├── 48-privacy-kernel-filter.conf.j2 │ │ ├── clear-history-on-logout.sh.j2 │ │ ├── privacy-log-cleanup.sh.j2 │ │ ├── 49-privacy-vpn-filter.conf.j2 │ │ ├── privacy-rsyslog.conf.j2 │ │ ├── kern-logrotate.j2 │ │ ├── privacy-shutdown-cleanup.service.j2 │ │ └── privacy-logrotate.j2 │ ├── handlers │ │ └── main.yml │ ├── tasks │ │ ├── main.yml │ │ ├── clear_history.yml │ │ ├── log_rotation.yml │ │ ├── log_filtering.yml │ │ └── auto_cleanup.yml │ └── defaults │ │ └── main.yml ├── cloud-azure │ └── tasks │ │ ├── prompts.yml │ │ └── main.yml ├── cloud-hetzner │ └── tasks │ │ ├── main.yml │ │ └── prompts.yml ├── cloud-digitalocean │ └── tasks │ │ └── main.yml ├── cloud-vultr │ └── tasks │ │ ├── prompts.yml │ │ └── main.yml ├── cloud-cloudstack │ └── tasks │ │ └── main.yml └── cloud-gce │ └── tasks │ └── main.yml ├── tests ├── integration │ ├── test-configs │ │ ├── .provisioned │ │ ├── localhost │ │ └── 10.99.0.10 │ │ │ ├── ipsec │ │ │ ├── .pki │ │ │ │ ├── private │ │ │ │ │ ├── .rnd │ │ │ │ │ ├── testuser1.p12 │ │ │ │ │ ├── testuser2.p12 │ │ │ │ │ ├── testuser1_ca.p12 │ │ │ │ │ ├── testuser2_ca.p12 │ │ │ │ │ ├── 10.99.0.10.key │ │ │ │ │ ├── testuser1.key │ │ │ │ │ ├── testuser2.key │ │ │ │ │ └── cakey.pem │ │ │ │ ├── serial │ │ │ │ ├── serial.old │ │ │ │ ├── serial_generated │ │ │ │ ├── 10.99.0.10_ca_generated │ │ │ │ ├── certs │ │ │ │ │ ├── 10.99.0.10_crt_generated │ │ │ │ │ ├── testuser1_crt_generated │ │ │ │ │ └── testuser2_crt_generated │ │ │ │ ├── index.txt.attr │ │ │ │ ├── index.txt.attr.old │ │ │ │ ├── ecparams │ │ │ │ │ └── secp384r1.pem │ │ │ │ ├── index.txt.old │ │ │ │ ├── .rnd │ │ │ │ ├── index.txt │ │ │ │ ├── public │ │ │ │ │ ├── testuser1.pub │ │ │ │ │ └── testuser2.pub │ │ │ │ ├── reqs │ │ │ │ │ ├── testuser1.req │ │ │ │ │ ├── testuser2.req │ │ │ │ │ └── 10.99.0.10.req │ │ │ │ └── cacert.pem │ │ │ └── manual │ │ │ │ ├── testuser1.secrets │ │ │ │ ├── testuser2.secrets │ │ │ │ ├── testuser1.p12 │ │ │ │ ├── testuser2.p12 │ │ │ │ ├── testuser1.conf │ │ │ │ ├── testuser2.conf │ │ │ │ └── cacert.pem │ │ │ ├── wireguard │ │ │ ├── .pki │ │ │ │ ├── index.txt │ │ │ │ ├── private │ │ │ │ │ ├── testuser1 │ │ │ │ │ ├── testuser2 │ │ │ │ │ └── 10.99.0.10 │ │ │ │ ├── public │ │ │ │ │ ├── 10.99.0.10 │ │ │ │ │ ├── testuser1 │ │ │ │ │ └── testuser2 │ │ │ │ └── preshared │ │ │ │ │ ├── 10.99.0.10 │ │ │ │ │ ├── testuser1 │ │ │ │ │ └── testuser2 │ │ │ ├── testuser1.png │ │ │ ├── testuser2.png │ │ │ ├── testuser1.conf │ │ │ ├── testuser2.conf │ │ │ └── apple │ │ │ │ └── ios │ │ │ │ └── testuser1.mobileconfig │ │ │ └── .config.yml │ ├── mock-apparmor_status.sh │ ├── ansible.cfg │ ├── ansible-service-wrapper.py │ └── mock_modules │ │ └── shell.py ├── fixtures │ └── __init__.py ├── test-local-config.sh ├── test-wireguard-fix.yml └── test-wireguard-real-async.yml ├── logo.png ├── docs ├── images │ ├── do-api.png │ ├── firewalls.png │ ├── do-firewall.png │ ├── do-new-token.png │ ├── do-view-token.png │ ├── aws-ec2-new-user.png │ ├── aws-ec2-new-policy.png │ ├── aws-ec2-attach-policy.png │ ├── aws-ec2-new-user-csv.png │ ├── aws-ec2-new-user-name.png │ ├── aws-ec2-new-user-confirm.png │ ├── aws-ec2-new-policy-review.png │ └── cloud-alternative-ingress-ip.png ├── client-android.md ├── cloud-hetzner.md ├── cloud-linode.md ├── cloud-cloudstack.md ├── cloud-scaleway.md ├── deploy-from-macos.md ├── cloud-alternative-ingress-ip.md ├── cloud-vultr.md ├── deploy-from-cloudshell.md ├── client-macos-wireguard.md ├── index.md ├── client-apple-ipsec.md ├── cloud-gce.md ├── aws-credentials.md ├── client-linux-ipsec.md ├── deploy-to-ubuntu.md ├── client-linux-wireguard.md ├── firewalls.md └── client-linux.md ├── inventory ├── playbooks ├── rescue.yml ├── tmpfs │ ├── linux.yml │ ├── macos.yml │ ├── main.yml │ └── umount.yml ├── cloud-pre.yml └── cloud-post.yml ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── bug_report.md │ └── feature_request.md ├── actions │ ├── setup-uv │ │ └── action.yml │ └── setup-algo │ │ └── action.yml ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── docker-image.yaml │ ├── claude.yml │ └── test-effectiveness.yml ├── pytest.ini ├── files └── cloud-init │ ├── sshd_config │ ├── base.sh │ ├── base.yml │ └── README.md ├── requirements.yml ├── ansible.cfg ├── cloud.yml ├── .yamllint ├── .dockerignore ├── SECURITY.md ├── deploy_client.yml ├── scripts ├── lint.sh └── annotate-test-failure.sh ├── CONTRIBUTING.md ├── algo-docker.sh ├── PULL_REQUEST_TEMPLATE.md ├── Dockerfile ├── .pre-commit-config.yaml └── .ansible-lint /configs/.gitinit: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jackivanov 2 | -------------------------------------------------------------------------------- /roles/common/tasks/aip/placeholder.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /roles/strongswan/meta/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/.provisioned: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/test-configs/localhost: -------------------------------------------------------------------------------- 1 | 10.99.0.10 -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/logo.png -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/.rnd: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/serial: -------------------------------------------------------------------------------- 1 | 04 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/serial.old: -------------------------------------------------------------------------------- 1 | 03 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/serial_generated: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/10.99.0.10_ca_generated: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/10.99.0.10_crt_generated: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/testuser1_crt_generated: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/certs/testuser2_crt_generated: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /roles/strongswan/templates/ipsec.secrets.j2: -------------------------------------------------------------------------------- 1 | : ECDSA {{ IP_subject_alt_name }}.key 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/index.txt.attr: -------------------------------------------------------------------------------- 1 | unique_subject = yes 2 | -------------------------------------------------------------------------------- /docs/images/do-api.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/do-api.png -------------------------------------------------------------------------------- /inventory: -------------------------------------------------------------------------------- 1 | [local] 2 | localhost ansible_connection=local ansible_python_interpreter=python3 3 | -------------------------------------------------------------------------------- /roles/local/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include prompts 3 | import_tasks: prompts.yml 4 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/index.txt.attr.old: -------------------------------------------------------------------------------- 1 | unique_subject = yes 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/.pki/index.txt: -------------------------------------------------------------------------------- 1 | testuser1 2 | testuser2 3 | -------------------------------------------------------------------------------- /docs/images/firewalls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/firewalls.png -------------------------------------------------------------------------------- /roles/strongswan/templates/client_ipsec.secrets.j2: -------------------------------------------------------------------------------- 1 | {{ IP_subject_alt_name }} : ECDSA {{ item }}.key 2 | -------------------------------------------------------------------------------- /docs/images/do-firewall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/do-firewall.png -------------------------------------------------------------------------------- /docs/images/do-new-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/do-new-token.png -------------------------------------------------------------------------------- /roles/cloud-linode/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | linode_venv: "{{ playbook_dir }}/configs/.venvs/linode" 3 | -------------------------------------------------------------------------------- /roles/cloud-scaleway/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | scaleway_regions: 3 | - alias: par1 4 | - alias: ams1 5 | -------------------------------------------------------------------------------- /docs/images/do-view-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/do-view-token.png -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser1.secrets: -------------------------------------------------------------------------------- 1 | 10.99.0.10 : ECDSA testuser1.key 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser2.secrets: -------------------------------------------------------------------------------- 1 | 10.99.0.10 : ECDSA testuser2.key 2 | -------------------------------------------------------------------------------- /docs/images/aws-ec2-new-user.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/aws-ec2-new-user.png -------------------------------------------------------------------------------- /playbooks/rescue.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - debug: 3 | var: fail_hint 4 | 5 | - name: Fail the installation 6 | fail: 7 | -------------------------------------------------------------------------------- /docs/images/aws-ec2-new-policy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/aws-ec2-new-policy.png -------------------------------------------------------------------------------- /roles/ssh_tunneling/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | ssh_tunnels_config_path: configs/{{ IP_subject_alt_name }}/ssh-tunnel/ 3 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/.pki/private/testuser1: -------------------------------------------------------------------------------- 1 | OC3EYbHLzszqmYqFlC22bnPDoTHp4ORULg9vCphbcEY= 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/.pki/private/testuser2: -------------------------------------------------------------------------------- 1 | yKU40Lrt5xutKuXHcJipej0wdqPVExuGmjoPzBar/GI= 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/.pki/public/10.99.0.10: -------------------------------------------------------------------------------- 1 | IJFSpegTMGKoK5EtJaX2uH/hBWxq8ZpNOJIBMZnE4w0= 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/.pki/public/testuser1: -------------------------------------------------------------------------------- 1 | yoUuE/xoUE4bbR4enH9lmOc+lLB0mecK6ifMwiajDz4= 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/.pki/public/testuser2: -------------------------------------------------------------------------------- 1 | zJ76JrM4mYQk8QIGMIZy9V9lORvw75lh3ByhgXbH1kA= 2 | -------------------------------------------------------------------------------- /docs/images/aws-ec2-attach-policy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/aws-ec2-attach-policy.png -------------------------------------------------------------------------------- /docs/images/aws-ec2-new-user-csv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/aws-ec2-new-user-csv.png -------------------------------------------------------------------------------- /docs/images/aws-ec2-new-user-name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/aws-ec2-new-user-name.png -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/.pki/preshared/10.99.0.10: -------------------------------------------------------------------------------- 1 | Ggpzqj5CnamCMBaKQCC+xih3lfj+I1tOfImOizyDLkA= 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/.pki/preshared/testuser1: -------------------------------------------------------------------------------- 1 | CSLeU66thNW52DkY6MVJ2A5VodnxbsC7EklcrHPCKco= 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/.pki/preshared/testuser2: -------------------------------------------------------------------------------- 1 | WUuCbhaOJfPtCrwU4EnlpqVmmPuaJJYYyzc2sy+afVQ= 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/.pki/private/10.99.0.10: -------------------------------------------------------------------------------- 1 | EPokMfsIC6Heg4/tm9gaMt2rRwXjACwvmdJAXO/byH8= 2 | -------------------------------------------------------------------------------- /docs/images/aws-ec2-new-user-confirm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/aws-ec2-new-user-confirm.png -------------------------------------------------------------------------------- /roles/client/files/libstrongswan-relax-constraints.conf: -------------------------------------------------------------------------------- 1 | libstrongswan { 2 | x509 { 3 | enforce_critical = no 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /roles/client/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart strongswan 3 | service: name={{ strongswan_service }} state=restarted 4 | -------------------------------------------------------------------------------- /docs/images/aws-ec2-new-policy-review.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/aws-ec2-new-policy-review.png -------------------------------------------------------------------------------- /docs/images/cloud-alternative-ingress-ip.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/docs/images/cloud-alternative-ingress-ip.png -------------------------------------------------------------------------------- /roles/ssh_tunneling/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart ssh 3 | service: name="{{ ssh_service_name | default('ssh') }}" state=restarted 4 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/ecparams/secp384r1.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PARAMETERS----- 2 | BgUrgQQAIg== 3 | -----END EC PARAMETERS----- 4 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/index.txt.old: -------------------------------------------------------------------------------- 1 | V 350801125927Z 01 unknown /CN=10.99.0.10 2 | V 350801125927Z 02 unknown /CN=testuser1 3 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/.rnd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/tests/integration/test-configs/10.99.0.10/ipsec/.pki/.rnd -------------------------------------------------------------------------------- /tests/integration/mock-apparmor_status.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Mock apparmor_status for Docker testing 3 | # Return error code to indicate AppArmor is not available 4 | exit 1 5 | -------------------------------------------------------------------------------- /roles/client/tasks/systems/CentOS.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Set OS specific facts 3 | set_fact: 4 | prerequisites: 5 | - epel-release 6 | configs_prefix: /etc/strongswan 7 | -------------------------------------------------------------------------------- /tests/integration/ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | library = /algo/tests/integration/mock_modules 3 | roles_path = /algo/roles 4 | host_key_checking = False 5 | stdout_callback = debug 6 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/testuser1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/tests/integration/test-configs/10.99.0.10/wireguard/testuser1.png -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/testuser2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/tests/integration/test-configs/10.99.0.10/wireguard/testuser2.png -------------------------------------------------------------------------------- /playbooks/tmpfs/linux.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Linux | set OS specific facts 3 | set_fact: 4 | tmpfs_volume_name: AlgoVPN-{{ IP_subject_alt_name }} 5 | tmpfs_volume_path: /dev/shm 6 | -------------------------------------------------------------------------------- /roles/client/tasks/systems/Fedora.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Set OS specific facts 3 | set_fact: 4 | prerequisites: 5 | - libselinux-python 6 | configs_prefix: /etc/strongswan 7 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser1.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser1.p12 -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser2.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser2.p12 -------------------------------------------------------------------------------- /roles/client/tasks/systems/Debian.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Set OS specific facts 3 | set_fact: 4 | prerequisites: 5 | - libstrongswan-standard-plugins 6 | configs_prefix: /etc 7 | -------------------------------------------------------------------------------- /roles/client/tasks/systems/Ubuntu.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Set OS specific facts 3 | set_fact: 4 | prerequisites: 5 | - libstrongswan-standard-plugins 6 | configs_prefix: /etc 7 | -------------------------------------------------------------------------------- /roles/common/templates/10-algo-lo100.network.j2: -------------------------------------------------------------------------------- 1 | [Match] 2 | Name=lo 3 | 4 | [Network] 5 | Description=lo:100 6 | Address={{ local_service_ip }}/32 7 | Address={{ local_service_ipv6 }}/128 8 | -------------------------------------------------------------------------------- /roles/cloud-ec2/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | encrypted: "{{ cloud_providers.ec2.encrypted }}" 3 | ec2_vpc_nets: 4 | cidr_block: 172.16.0.0/16 5 | subnet_cidr: 172.16.254.0/23 6 | existing_eip: "" 7 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/index.txt: -------------------------------------------------------------------------------- 1 | V 350801125927Z 01 unknown /CN=10.99.0.10 2 | V 350801125927Z 02 unknown /CN=testuser1 3 | V 350801125927Z 03 unknown /CN=testuser2 4 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser1.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser1.p12 -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser2.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser2.p12 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.retry 2 | .idea/ 3 | configs/* 4 | inventory_users 5 | *.kate-swp 6 | .env/ 7 | .venv/ 8 | .DS_Store 9 | .vagrant 10 | .ansible/ 11 | __pycache__/ 12 | *.pyc 13 | algo.egg-info/ 14 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser1_ca.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser1_ca.p12 -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser2_ca.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/trailofbits/algo/HEAD/tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser2_ca.p12 -------------------------------------------------------------------------------- /roles/common/templates/10periodic.j2: -------------------------------------------------------------------------------- 1 | APT::Periodic::Update-Package-Lists "1"; 2 | APT::Periodic::Download-Upgradeable-Packages "1"; 3 | APT::Periodic::AutocleanInterval "7"; 4 | APT::Periodic::Unattended-Upgrade "1"; 5 | -------------------------------------------------------------------------------- /roles/common/templates/99-algo-ipv6-egress.yaml.j2: -------------------------------------------------------------------------------- 1 | network: 2 | version: 2 3 | ethernets: 4 | {{ ansible_default_ipv6.interface }}: 5 | addresses: 6 | - {{ ipv6_egress_ip }} 7 | -------------------------------------------------------------------------------- /roles/wireguard/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: daemon-reload 3 | systemd: 4 | daemon_reload: true 5 | 6 | - name: restart wireguard 7 | service: 8 | name: "{{ service_name }}" 9 | state: restarted 10 | -------------------------------------------------------------------------------- /roles/dns/files/50-dnscrypt-proxy-unattended-upgrades: -------------------------------------------------------------------------------- 1 | // Automatically upgrade packages from these (origin:archive) pairs 2 | Unattended-Upgrade::Allowed-Origins { 3 | "LP-PPA-shevchuk-dnscrypt-proxy:${distro_codename}"; 4 | }; 5 | -------------------------------------------------------------------------------- /roles/dns/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | algo_dns_adblocking: false 3 | apparmor_enabled: true 4 | dns_encryption: true 5 | ipv6_support: false 6 | dnscrypt_servers: 7 | ipv4: 8 | - cloudflare 9 | ipv6: 10 | - cloudflare-ipv6 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | blank_issues_enabled: true 3 | contact_links: 4 | - name: Troubleshooting Guide 5 | url: https://trailofbits.github.io/algo/troubleshooting.html 6 | about: Check common issues and solutions before filing 7 | -------------------------------------------------------------------------------- /roles/ssh_tunneling/templates/ssh_config.j2: -------------------------------------------------------------------------------- 1 | Host algo 2 | DynamicForward 127.0.0.1:1080 3 | LogLevel quiet 4 | Compression yes 5 | IdentitiesOnly yes 6 | IdentityFile {{ item }}.ssh.pem 7 | User {{ item }} 8 | Hostname {{ IP_subject_alt_name }} 9 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests/unit 3 | python_files = test_*.py 4 | python_classes = Test* 5 | python_functions = test_* 6 | addopts = -v --tb=short 7 | filterwarnings = 8 | ignore::DeprecationWarning 9 | ignore::PendingDeprecationWarning 10 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/public/testuser1.pub: -------------------------------------------------------------------------------- 1 | ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBIFNInItxfjPUuPg79iGqcbLfMlkBBidzjHDmZhOGo4ZXCVWeJ+0h5vCUeyBER6AbftH1rFJJXKY2scuSCPWYMzYqaWpPxMzgQIRrXAX9u5B7Qu+1TklHwyBoqAlp0rl5w== 2 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/public/testuser2.pub: -------------------------------------------------------------------------------- 1 | ecdsa-sha2-nistp384 AAAAE2VjZHNhLXNoYTItbmlzdHAzODQAAAAIbmlzdHAzODQAAABhBIjt/G1EDl/0cxNRbFjPP5fGsz/FEv5ACs//RtpzmjS9wbjmfyHVrTk3ew/AzAAXXCo+Os9CfXJ+K4KCnRmiJecOPLdnZoQViY5mTcfVvgDpdfNDxpTJyDqx0efAGdSn4Q== 2 | -------------------------------------------------------------------------------- /files/cloud-init/sshd_config: -------------------------------------------------------------------------------- 1 | Port {{ ssh_port }} 2 | AllowGroups algo 3 | PermitRootLogin no 4 | PasswordAuthentication no 5 | ChallengeResponseAuthentication no 6 | UsePAM yes 7 | X11Forwarding yes 8 | PrintMotd no 9 | AcceptEnv LANG LC_* 10 | Subsystem sftp /usr/lib/openssh/sftp-server 11 | -------------------------------------------------------------------------------- /docs/client-android.md: -------------------------------------------------------------------------------- 1 | # Android client setup 2 | 3 | ## Installation via profiles 4 | 5 | 1. [Install the WireGuard VPN Client](https://play.google.com/store/apps/details?id=com.wireguard.android). 6 | 2. Open QR code `configs//wireguard/.png` and scan it in the WireGuard app 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a problem with Algo 4 | --- 5 | 6 | **What happened?** 7 | 8 | 9 | **Environment** (cloud provider, OS, WireGuard or IPsec) 10 | 11 | 12 | **Output** 13 | ``` 14 | Paste any error messages or relevant output here 15 | ``` 16 | -------------------------------------------------------------------------------- /roles/wireguard/tasks/mobileconfig.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: WireGuard apple mobileconfig generated 3 | template: 4 | src: mobileconfig.j2 5 | dest: "{{ wireguard_config_path }}/apple/{{ system }}/{{ item }}.mobileconfig" 6 | mode: "0600" 7 | loop: "{{ wireguard_users }}" 8 | loop_control: 9 | index_var: index 10 | when: item in users 11 | -------------------------------------------------------------------------------- /roles/cloud-lightsail/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include prompts 3 | import_tasks: prompts.yml 4 | 5 | - name: Deploy the stack 6 | import_tasks: cloudformation.yml 7 | 8 | - set_fact: 9 | cloud_instance_ip: "{{ stack.stack_outputs.IpAddress }}" 10 | ansible_ssh_user: algo 11 | ansible_ssh_port: "{{ ssh_port }}" 12 | cloudinit: true 13 | -------------------------------------------------------------------------------- /roles/client/tasks/systems/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - include_tasks: Debian.yml 3 | when: ansible_distribution == 'Debian' 4 | 5 | - include_tasks: Ubuntu.yml 6 | when: ansible_distribution == 'Ubuntu' 7 | 8 | - include_tasks: CentOS.yml 9 | when: ansible_distribution == 'CentOS' 10 | 11 | - include_tasks: Fedora.yml 12 | when: ansible_distribution == 'Fedora' 13 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/10.99.0.10.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCzl0q5oCboLdR2z2+f 3 | 8vva98ZlmXOoJUoQ2PolcmYzsXLsrN9IJ5FA0dxwGSPSkGShZANiAARhGbPWo1Ja 4 | /zN9pnvuvGfA0bGAu8ByBvtDhi0rdmq53gL4LTAh0Ru219fjaZLj2ZFlR4IkaeFK 5 | zNErxUkwXTV/H2O/Uq6FUtpf6V4nRbnczeOZG9H2JHI1KL/kUQ1xZC4= 6 | -----END PRIVATE KEY----- 7 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser1.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDqYZekavWtJL939gPZ 3 | UGvuag2088jWmu3Iic5hp1QfgdFqSiuk69Xmgc3nzin8ulGhZANiAASBTSJyLcX4 4 | z1Lj4O/YhqnGy3zJZAQYnc4xw5mYThqOGVwlVniftIebwlHsgREegG37R9axSSVy 5 | mNrHLkgj1mDM2KmlqT8TM4ECEa1wF/buQe0LvtU5JR8MgaKgJadK5ec= 6 | -----END PRIVATE KEY----- 7 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/testuser2.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCNgvEKQSidzP9DtA5q 3 | bj3qD3sWPeNTZhJ319E9NTgGvU6GPSxssiPZglgDziO0ALqhZANiAASI7fxtRA5f 4 | 9HMTUWxYzz+XxrM/xRL+QArP/0bac5o0vcG45n8h1a05N3sPwMwAF1wqPjrPQn1y 5 | fiuCgp0ZoiXnDjy3Z2aEFYmOZk3H1b4A6XXzQ8aUycg6sdHnwBnUp+E= 6 | -----END PRIVATE KEY----- 7 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/testuser1.conf: -------------------------------------------------------------------------------- 1 | [Interface] 2 | PrivateKey = OC3EYbHLzszqmYqFlC22bnPDoTHp4ORULg9vCphbcEY= 3 | Address = 10.19.49.2 4 | DNS = 8.8.8.8,8.8.4.4 5 | 6 | [Peer] 7 | PublicKey = IJFSpegTMGKoK5EtJaX2uH/hBWxq8ZpNOJIBMZnE4w0= 8 | PresharedKey = CSLeU66thNW52DkY6MVJ2A5VodnxbsC7EklcrHPCKco= 9 | AllowedIPs = 0.0.0.0/0,::/0 10 | Endpoint = 10.99.0.10:51820 11 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/testuser2.conf: -------------------------------------------------------------------------------- 1 | [Interface] 2 | PrivateKey = yKU40Lrt5xutKuXHcJipej0wdqPVExuGmjoPzBar/GI= 3 | Address = 10.19.49.3 4 | DNS = 8.8.8.8,8.8.4.4 5 | 6 | [Peer] 7 | PublicKey = IJFSpegTMGKoK5EtJaX2uH/hBWxq8ZpNOJIBMZnE4w0= 8 | PresharedKey = WUuCbhaOJfPtCrwU4EnlpqVmmPuaJJYYyzc2sy+afVQ= 9 | AllowedIPs = 0.0.0.0/0,::/0 10 | Endpoint = 10.99.0.10:51820 11 | -------------------------------------------------------------------------------- /docs/cloud-hetzner.md: -------------------------------------------------------------------------------- 1 | ## API Token 2 | 3 | Sign 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. 4 | -------------------------------------------------------------------------------- /docs/cloud-linode.md: -------------------------------------------------------------------------------- 1 | ## API Token 2 | 3 | Sign in to the Linode Manager and go to the 4 | [tokens management page](https://cloud.linode.com/profile/tokens). 5 | 6 | Click `Add a Personal Access Token`. Label your new token and select *at least* the 7 | `Linodes` read/write permission and `StackScripts` read/write permission. 8 | Press `Submit` and make sure to copy the displayed token 9 | as it won't be shown again. 10 | -------------------------------------------------------------------------------- /roles/strongswan/templates/strongswan.conf.j2: -------------------------------------------------------------------------------- 1 | # strongswan.conf - strongSwan configuration file 2 | # 3 | # Refer to the strongswan.conf(5) manpage for details 4 | # 5 | # Configuration changes should be made in the included files 6 | 7 | charon { 8 | load_modular = yes 9 | plugins { 10 | include strongswan.d/charon/*.conf 11 | } 12 | user = strongswan 13 | group = nogroup 14 | } 15 | 16 | include strongswan.d/*.conf 17 | -------------------------------------------------------------------------------- /roles/dns/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: daemon-reload 3 | systemd: 4 | daemon_reload: true 5 | 6 | - name: restart dnscrypt-proxy.socket 7 | systemd: 8 | name: dnscrypt-proxy.socket 9 | state: restarted 10 | daemon_reload: true 11 | when: uses_systemd_socket 12 | 13 | - name: restart dnscrypt-proxy 14 | systemd: 15 | name: dnscrypt-proxy 16 | state: restarted 17 | daemon_reload: true 18 | when: uses_systemd_socket 19 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/.config.yml: -------------------------------------------------------------------------------- 1 | server: localhost 2 | server_user: root 3 | ansible_ssh_port: "22" 4 | algo_provider: local 5 | algo_server_name: algo-test-server 6 | algo_ondemand_cellular: False 7 | algo_ondemand_wifi: False 8 | algo_ondemand_wifi_exclude: X251bGw= 9 | algo_dns_adblocking: False 10 | algo_ssh_tunneling: False 11 | algo_store_pki: True 12 | IP_subject_alt_name: 10.99.0.10 13 | ipsec_enabled: True 14 | wireguard_enabled: True 15 | -------------------------------------------------------------------------------- /roles/common/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | install_headers: false 3 | aip_supported_providers: 4 | - digitalocean 5 | snat_aipv4: false 6 | ipv6_default: "{{ ansible_default_ipv6.address + '/' + ansible_default_ipv6.prefix }}" 7 | ipv6_subnet_size: "{{ ipv6_default | ansible.utils.ipaddr('size') }}" 8 | ipv6_egress_ip: >- 9 | {{ (ipv6_default | ansible.utils.next_nth_usable(15 | random(seed=algo_server_name + ansible_fqdn))) + '/124' if ipv6_subnet_size | int > 1 else ipv6_default }} 10 | -------------------------------------------------------------------------------- /requirements.yml: -------------------------------------------------------------------------------- 1 | --- 2 | collections: 3 | - name: ansible.posix 4 | version: "==2.1.0" 5 | - name: ansible.utils 6 | version: ">=4.0.0" 7 | - name: community.general 8 | version: "==11.1.0" 9 | - name: community.crypto 10 | version: "==3.0.3" 11 | - name: openstack.cloud 12 | version: "==2.4.1" 13 | - name: linode.cloud 14 | version: ">=0.41.0" 15 | - name: community.digitalocean 16 | version: ">=1.26.0" 17 | - name: azure.azcollection 18 | version: ">=3.0.0" 19 | -------------------------------------------------------------------------------- /playbooks/tmpfs/macos.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: MacOS | set OS specific facts 3 | set_fact: 4 | tmpfs_volume_name: AlgoVPN-{{ IP_subject_alt_name }} 5 | tmpfs_volume_path: /Volumes 6 | 7 | - name: MacOS | mount a ram disk 8 | shell: > 9 | /usr/sbin/diskutil info "/{{ tmpfs_volume_path }}/{{ tmpfs_volume_name }}/" || 10 | /usr/sbin/diskutil erasevolume HFS+ "{{ tmpfs_volume_name }}" $(hdiutil attach -nomount ram://64000) 11 | args: 12 | creates: /{{ tmpfs_volume_path }}/{{ tmpfs_volume_name }} 13 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/reqs/testuser1.req: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIIBDDCBkwIBADAUMRIwEAYDVQQDDAl0ZXN0dXNlcjEwdjAQBgcqhkjOPQIBBgUr 3 | gQQAIgNiAASBTSJyLcX4z1Lj4O/YhqnGy3zJZAQYnc4xw5mYThqOGVwlVniftIeb 4 | wlHsgREegG37R9axSSVymNrHLkgj1mDM2KmlqT8TM4ECEa1wF/buQe0LvtU5JR8M 5 | gaKgJadK5eegADAKBggqhkjOPQQDAgNoADBlAjEA6fukMpfRV9EguhFUu2ArTEUi 6 | y3wjuRlz0oOX1Al4bDdl0fI8fdGPhfWMkCFV99h1AjASgmIyTUBBShipXCq1zXYG 7 | yneN1AXvkW4sbdFZ55GC++fGyZo9uOiTj/NEpn52a1E= 8 | -----END CERTIFICATE REQUEST----- 9 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/reqs/testuser2.req: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIIBDTCBkwIBADAUMRIwEAYDVQQDDAl0ZXN0dXNlcjIwdjAQBgcqhkjOPQIBBgUr 3 | gQQAIgNiAASI7fxtRA5f9HMTUWxYzz+XxrM/xRL+QArP/0bac5o0vcG45n8h1a05 4 | N3sPwMwAF1wqPjrPQn1yfiuCgp0ZoiXnDjy3Z2aEFYmOZk3H1b4A6XXzQ8aUycg6 5 | sdHnwBnUp+GgADAKBggqhkjOPQQDAgNpADBmAjEA/pC5b5Ei8Hmfgsl5WHfOhV/r 6 | iReLin1RESK29Lcsxi6z2pvEGNkOFCq8tPJHr1L6AjEAuq9eBom5P0D8d+9MJcKt 7 | 3Zjtfb6Liyyupd2euSytyFKuY6NnbjMvAR4kZ3jhdw30 8 | -----END CERTIFICATE REQUEST----- 9 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/reqs/10.99.0.10.req: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIIBDTCBlAIBADAVMRMwEQYDVQQDDAoxMC45OS4wLjEwMHYwEAYHKoZIzj0CAQYF 3 | K4EEACIDYgAEYRmz1qNSWv8zfaZ77rxnwNGxgLvAcgb7Q4YtK3Zqud4C+C0wIdEb 4 | ttfX42mS49mRZUeCJGnhSszRK8VJMF01fx9jv1KuhVLaX+leJ0W53M3jmRvR9iRy 5 | NSi/5FENcWQuoAAwCgYIKoZIzj0EAwIDaAAwZQIxANzofzNNOzBP5IxqtGOs9l53 6 | aNpmDf638Ho6lXdXRtGynUyZ9ORoeIANVN4Kb/HbTQIwQndvZ4PIPvCp1QW1LmP5 7 | kPd+OFyoyiJavLa9zRJsuAsYaj5NQucZJHKxLqWdGB94 8 | -----END CERTIFICATE REQUEST----- 9 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/private/cakey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN ENCRYPTED PRIVATE KEY----- 2 | MIIBEzBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIfcz2CPzqvHQCAggA 3 | MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECGnSWGAxVxG5BIHAug0MQFAsaf6G 4 | usnpuTDOgIq5RGgeHhakkknU/RQ2zsPlxOpM3y3c7fURahWqC6Po21M3Az37pRHs 5 | bf35e8/8Gxp7eRSyVoPF88MmxGVxFIDeP/YuzoGILLjIWDZ2E89SSP7GnzO1a4UV 6 | poHWMV4hZvpT/Ey+1LK2cu7zLbQ5chBZ4aeButXxDHLl5ylPe+yBCoforpLAr3iA 7 | zI0DNoOe25EoIBWPycT+c3tExVLGE0MN9RusBlaB6f0go2kSWhQU 8 | -----END ENCRYPTED PRIVATE KEY----- 9 | -------------------------------------------------------------------------------- /ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | inventory = inventory 3 | pipelining = True 4 | retry_files_enabled = False 5 | host_key_checking = False 6 | timeout = 60 7 | stdout_callback = default 8 | display_skipped_hosts = no 9 | force_valid_group_names = ignore 10 | remote_tmp = /tmp/.ansible/tmp 11 | 12 | [paramiko_connection] 13 | record_host_keys = False 14 | 15 | [ssh_connection] 16 | ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o UserKnownHostsFile=/dev/null -o ConnectTimeout=6 -o ConnectionAttempts=30 -o IdentitiesOnly=yes 17 | scp_if_ssh = True 18 | retries = 30 19 | -------------------------------------------------------------------------------- /roles/common/tasks/unattended-upgrades.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Install unattended-upgrades 3 | apt: 4 | name: unattended-upgrades 5 | state: present 6 | 7 | - name: Configure unattended-upgrades 8 | template: 9 | src: 50unattended-upgrades.j2 10 | dest: /etc/apt/apt.conf.d/50unattended-upgrades 11 | owner: root 12 | group: root 13 | mode: '0644' 14 | 15 | - name: Periodic upgrades configured 16 | template: 17 | src: 10periodic.j2 18 | dest: /etc/apt/apt.conf.d/10periodic 19 | owner: root 20 | group: root 21 | mode: '0644' 22 | -------------------------------------------------------------------------------- /playbooks/tmpfs/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include tasks for MacOS 3 | import_tasks: macos.yml 4 | when: ansible_system == "Darwin" 5 | 6 | - name: Include tasks for Linux 7 | import_tasks: linux.yml 8 | when: ansible_system == "Linux" 9 | 10 | - name: Set config paths as facts 11 | set_fact: 12 | ipsec_pki_path: /{{ tmpfs_volume_path }}/{{ tmpfs_volume_name }}/IPsec/ 13 | 14 | - name: Update config paths 15 | add_host: 16 | name: "{{ 'localhost' if cloud_instance_ip == 'localhost' else cloud_instance_ip }}" 17 | ipsec_pki_path: "{{ ipsec_pki_path }}" 18 | -------------------------------------------------------------------------------- /roles/dns/templates/dnscrypt-proxy/cache.toml.j2: -------------------------------------------------------------------------------- 1 | ########################### 2 | # DNS cache # 3 | ########################### 4 | 5 | ## Enable a DNS cache to reduce latency and outgoing traffic 6 | cache = true 7 | 8 | ## Cache size 9 | cache_size = 4096 10 | 11 | ## Minimum TTL for cached entries 12 | cache_min_ttl = 2400 13 | 14 | ## Maximum TTL for cached entries 15 | cache_max_ttl = 86400 16 | 17 | ## Minimum TTL for negatively cached entries 18 | cache_neg_min_ttl = 60 19 | 20 | ## Maximum TTL for negatively cached entries 21 | cache_neg_max_ttl = 600 22 | -------------------------------------------------------------------------------- /.github/actions/setup-uv/action.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Setup uv Environment' 3 | description: 'Install uv and sync dependencies for Algo VPN project' 4 | outputs: 5 | uv-version: 6 | description: 'The version of uv that was installed' 7 | value: ${{ steps.setup.outputs.uv-version }} 8 | runs: 9 | using: composite 10 | steps: 11 | - name: Install uv 12 | id: setup 13 | uses: astral-sh/setup-uv@1ddb97e5078301c0bec13b38151f8664ed04edc8 # v6 14 | with: 15 | enable-cache: true 16 | - name: Sync dependencies 17 | run: uv sync 18 | shell: bash 19 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser1.conf: -------------------------------------------------------------------------------- 1 | conn algovpn-10.99.0.10 2 | fragmentation=yes 3 | rekey=no 4 | dpdaction=clear 5 | keyexchange=ikev2 6 | compress=no 7 | dpddelay=35s 8 | 9 | ike=aes256gcm16-prfsha512-ecp384! 10 | esp=aes256gcm16-ecp384! 11 | 12 | right=10.99.0.10 13 | rightid=10.99.0.10 14 | rightsubnet=0.0.0.0/0 15 | rightauth=pubkey 16 | 17 | leftsourceip=%config 18 | leftauth=pubkey 19 | leftcert=testuser1.crt 20 | leftfirewall=yes 21 | left=%defaultroute 22 | 23 | auto=add 24 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/manual/testuser2.conf: -------------------------------------------------------------------------------- 1 | conn algovpn-10.99.0.10 2 | fragmentation=yes 3 | rekey=no 4 | dpdaction=clear 5 | keyexchange=ikev2 6 | compress=no 7 | dpddelay=35s 8 | 9 | ike=aes256gcm16-prfsha512-ecp384! 10 | esp=aes256gcm16-ecp384! 11 | 12 | right=10.99.0.10 13 | rightid=10.99.0.10 14 | rightsubnet=0.0.0.0/0 15 | rightauth=pubkey 16 | 17 | leftsourceip=%config 18 | leftauth=pubkey 19 | leftcert=testuser2.crt 20 | leftfirewall=yes 21 | left=%defaultroute 22 | 23 | auto=add 24 | -------------------------------------------------------------------------------- /roles/dns/templates/dnscrypt-proxy.toml.j2: -------------------------------------------------------------------------------- 1 | ############################################## 2 | # # 3 | # dnscrypt-proxy configuration # 4 | # # 5 | ############################################## 6 | 7 | ## Online documentation: https://dnscrypt.info/doc 8 | 9 | 10 | {% include 'dnscrypt-proxy/global.toml.j2' %} 11 | 12 | 13 | {% include 'dnscrypt-proxy/cache.toml.j2' %} 14 | 15 | 16 | {% include 'dnscrypt-proxy/filters.toml.j2' %} 17 | 18 | 19 | {% include 'dnscrypt-proxy/sources.toml.j2' %} 20 | -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | """Test fixtures for Algo unit tests""" 2 | 3 | from pathlib import Path 4 | 5 | import yaml 6 | 7 | 8 | def load_test_variables(): 9 | """Load test variables from YAML fixture""" 10 | fixture_path = Path(__file__).parent / "test_variables.yml" 11 | with open(fixture_path) as f: 12 | return yaml.safe_load(f) 13 | 14 | 15 | def get_test_config(overrides=None): 16 | """Get test configuration with optional overrides""" 17 | config = load_test_variables() 18 | if overrides: 19 | config.update(overrides) 20 | return config 21 | -------------------------------------------------------------------------------- /roles/dns/tasks/dns_adblocking.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Adblock script created 3 | template: 4 | src: adblock.sh.j2 5 | dest: /usr/local/sbin/adblock.sh 6 | owner: root 7 | group: "{{ root_group | default('root') }}" 8 | mode: '0755' 9 | 10 | - name: Adblock script added to cron 11 | cron: 12 | name: Adblock hosts update 13 | minute: "{{ range(0, 60) | random }}" 14 | hour: "{{ range(0, 24) | random }}" 15 | job: /usr/local/sbin/adblock.sh 16 | user: root 17 | 18 | - name: Update adblock hosts 19 | command: /usr/local/sbin/adblock.sh 20 | changed_when: false 21 | -------------------------------------------------------------------------------- /cloud.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Provision the server 3 | hosts: localhost 4 | tags: always 5 | become: false 6 | vars_files: 7 | - config.cfg 8 | 9 | tasks: 10 | - block: 11 | - name: Local pre-tasks 12 | import_tasks: playbooks/cloud-pre.yml 13 | 14 | - name: Include a provisioning role 15 | include_role: 16 | name: "{{ 'local' if algo_provider == 'local' else 'cloud-' + algo_provider }}" 17 | 18 | - name: Local post-tasks 19 | import_tasks: playbooks/cloud-post.yml 20 | rescue: 21 | - include_tasks: playbooks/rescue.yml 22 | -------------------------------------------------------------------------------- /roles/common/tasks/iptables.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Iptables configured 3 | template: 4 | src: "{{ item.src }}" 5 | dest: "{{ item.dest }}" 6 | owner: root 7 | group: root 8 | mode: '0640' 9 | loop: 10 | - { src: rules.v4.j2, dest: /etc/iptables/rules.v4 } 11 | notify: 12 | - restart iptables 13 | 14 | - name: Iptables configured 15 | template: 16 | src: "{{ item.src }}" 17 | dest: "{{ item.dest }}" 18 | owner: root 19 | group: root 20 | mode: '0640' 21 | when: ipv6_support 22 | loop: 23 | - { src: rules.v6.j2, dest: /etc/iptables/rules.v6 } 24 | notify: 25 | - restart iptables 26 | -------------------------------------------------------------------------------- /roles/strongswan/templates/client_ipsec.conf.j2: -------------------------------------------------------------------------------- 1 | conn algovpn-{{ IP_subject_alt_name }} 2 | fragmentation=yes 3 | rekey=no 4 | dpdaction=clear 5 | keyexchange=ikev2 6 | compress=no 7 | dpddelay=35s 8 | 9 | ike={{ ciphers.defaults.ike }} 10 | esp={{ ciphers.defaults.esp }} 11 | 12 | right={{ IP_subject_alt_name }} 13 | rightid={{ IP_subject_alt_name }} 14 | rightsubnet={{ rightsubnet | default('0.0.0.0/0') }} 15 | rightauth=pubkey 16 | 17 | leftsourceip=%config 18 | leftauth=pubkey 19 | leftcert={{ item }}.crt 20 | leftfirewall=yes 21 | left=%defaultroute 22 | 23 | auto=add 24 | -------------------------------------------------------------------------------- /roles/common/tasks/aip/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Verify the provider 3 | assert: 4 | that: algo_provider in aip_supported_providers 5 | msg: Algo does not support Alternative Ingress IP for {{ algo_provider }} 6 | 7 | - name: Include alternative ingress ip configuration 8 | include_tasks: 9 | file: "{{ algo_provider if algo_provider in aip_supported_providers else 'placeholder' }}.yml" 10 | when: algo_provider in aip_supported_providers 11 | 12 | - name: Verify SNAT IPv4 found 13 | assert: 14 | that: snat_aipv4 | ansible.utils.ipv4 15 | msg: The SNAT IPv4 address not found. Cannot proceed with the alternative ingress ip. 16 | -------------------------------------------------------------------------------- /roles/common/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart rsyslog 3 | service: name=rsyslog state=restarted 4 | 5 | - name: flush routing cache 6 | shell: echo 1 > /proc/sys/net/ipv4/route/flush 7 | changed_when: false 8 | 9 | - name: restart systemd-networkd 10 | systemd: 11 | name: systemd-networkd 12 | state: restarted 13 | daemon_reload: true 14 | 15 | - name: restart systemd-resolved 16 | systemd: 17 | name: systemd-resolved 18 | state: restarted 19 | 20 | - name: restart iptables 21 | service: name=netfilter-persistent state=restarted 22 | 23 | - name: netplan apply 24 | command: netplan apply 25 | changed_when: false 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /roles/privacy/templates/46-privacy-ssh-filter.conf.j2: -------------------------------------------------------------------------------- 1 | # Privacy-enhanced SSH log filtering 2 | # Filters successful SSH connections while keeping failures for security 3 | # Generated by Algo VPN privacy role 4 | 5 | {% if privacy_advanced.disable_ssh_success_logs %} 6 | # Filter successful SSH connections (keep failures for security monitoring) 7 | :msg, contains, "sshd.*Accepted" stop 8 | :msg, contains, "sshd.*session opened" stop 9 | :msg, contains, "sshd.*session closed" stop 10 | 11 | # Filter SSH key-based authentication success 12 | :msg, contains, "sshd.*publickey" stop 13 | {% endif %} 14 | 15 | # Continue processing SSH failure messages and other logs 16 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | # Cloud-init files must be excluded from normal YAML rules 5 | # The #cloud-config header cannot have a space and cannot have --- document start 6 | ignore: | 7 | files/cloud-init/ 8 | .env/ 9 | .venv/ 10 | .ansible/ 11 | configs/ 12 | tests/integration/test-configs/ 13 | 14 | rules: 15 | line-length: 16 | max: 160 17 | level: warning 18 | comments: 19 | min-spaces-from-content: 1 20 | comments-indentation: false 21 | octal-values: 22 | forbid-implicit-octal: true 23 | forbid-explicit-octal: true 24 | braces: 25 | max-spaces-inside: 1 26 | truthy: 27 | allowed-values: ['true', 'false', 'yes', 'no'] 28 | -------------------------------------------------------------------------------- /roles/common/tasks/aip/digitalocean.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Get the anchor IP 3 | uri: 4 | url: http://169.254.169.254/metadata/v1/interfaces/public/0/anchor_ipv4/address 5 | return_content: true 6 | register: anchor_ipv4 7 | until: anchor_ipv4 is succeeded 8 | retries: 30 9 | delay: 10 10 | 11 | - name: Set SNAT IP as a fact 12 | set_fact: 13 | snat_aipv4: "{{ anchor_ipv4.content }}" 14 | 15 | - name: IPv6 egress alias configured 16 | template: 17 | src: 99-algo-ipv6-egress.yaml.j2 18 | dest: /etc/netplan/99-algo-ipv6-egress.yaml 19 | mode: '0644' 20 | when: 21 | - ipv6_support 22 | - ipv6_subnet_size|int > 1 23 | notify: 24 | - netplan apply 25 | -------------------------------------------------------------------------------- /roles/privacy/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Privacy role handlers 3 | # These handlers are triggered by privacy configuration changes 4 | 5 | - name: restart rsyslog 6 | systemd: 7 | name: rsyslog 8 | state: restarted 9 | daemon_reload: yes 10 | become: yes 11 | 12 | - name: restart systemd-journald 13 | systemd: 14 | name: systemd-journald 15 | state: restarted 16 | daemon_reload: yes 17 | become: yes 18 | 19 | - name: reload systemd 20 | systemd: 21 | daemon_reload: yes 22 | become: yes 23 | 24 | - name: enable privacy shutdown cleanup 25 | systemd: 26 | name: privacy-shutdown-cleanup.service 27 | enabled: yes 28 | daemon_reload: yes 29 | become: yes 30 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Version control and CI 2 | .git/ 3 | .github/ 4 | .gitignore 5 | 6 | # Development environment 7 | .env 8 | .venv/ 9 | .ruff_cache/ 10 | __pycache__/ 11 | *.pyc 12 | *.pyo 13 | *.pyd 14 | 15 | # Documentation and metadata 16 | docs/ 17 | tests/ 18 | README.md 19 | CHANGELOG.md 20 | CONTRIBUTING.md 21 | PULL_REQUEST_TEMPLATE.md 22 | SECURITY.md 23 | logo.png 24 | .travis.yml 25 | 26 | # Build artifacts and configs 27 | configs/ 28 | Dockerfile 29 | .dockerignore 30 | Vagrantfile 31 | 32 | # User configuration (should be bind-mounted) 33 | config.cfg 34 | 35 | # IDE and editor files 36 | .vscode/ 37 | .idea/ 38 | *.swp 39 | *.swo 40 | *~ 41 | 42 | # OS generated files 43 | .DS_Store 44 | Thumbs.db 45 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # These are supported funding model platforms 3 | 4 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 5 | patreon: algovpn 6 | open_collective: # Replace with a single Open Collective username 7 | ko_fi: # Replace with a single Ko-fi username 8 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 9 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 10 | liberapay: # Replace with a single Liberapay username 11 | issuehunt: # Replace with a single IssueHunt username 12 | otechie: # Replace with a single Otechie username 13 | custom: # Replace with a single custom sponsorship URL 14 | -------------------------------------------------------------------------------- /roles/privacy/templates/auth-logrotate.j2: -------------------------------------------------------------------------------- 1 | # Privacy-enhanced auth log rotation 2 | # Reduces retention time for authentication logs 3 | # Generated by Algo VPN privacy role 4 | 5 | /var/log/auth.log 6 | { 7 | # Shorter retention for auth logs (privacy) 8 | rotate 2 9 | maxage {{ privacy_log_rotation.max_age | int // 2 }} 10 | size {{ privacy_log_rotation.max_size // 2 }}M 11 | 12 | daily 13 | missingok 14 | notifempty 15 | compress 16 | delaycompress 17 | 18 | create 0640 syslog adm 19 | copytruncate 20 | 21 | postrotate 22 | if [ -f /var/run/rsyslogd.pid ]; then 23 | kill -HUP `cat /var/run/rsyslogd.pid` 24 | fi 25 | endscript 26 | } 27 | -------------------------------------------------------------------------------- /docs/cloud-cloudstack.md: -------------------------------------------------------------------------------- 1 | ### Configuration file 2 | 3 | > **⚠️ 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. 4 | 5 | Algo 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. 6 | 7 | For CloudStack providers, you'll need to set: 8 | 9 | ```bash 10 | export CLOUDSTACK_KEY="" 11 | export CLOUDSTACK_SECRET="" 12 | export CLOUDSTACK_ENDPOINT="" 13 | ``` 14 | 15 | Make sure your provider supports the CloudStack API. Contact your provider for the correct API endpoint URL. 16 | -------------------------------------------------------------------------------- /roles/common/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Check the system 3 | raw: uname -a 4 | register: OS 5 | changed_when: false 6 | tags: 7 | - update-users 8 | 9 | - fail: 10 | when: cloud_test|default(false)|bool 11 | 12 | - include_tasks: ubuntu.yml 13 | when: '"Ubuntu" in OS.stdout or "Linux" in OS.stdout' 14 | 15 | # Include facts separately - always runs to ensure OS detection facts are available 16 | - include_tasks: facts.yml 17 | tags: 18 | - always 19 | - update-users 20 | 21 | - name: Sysctl tuning 22 | sysctl: name="{{ item.item }}" value="{{ item.value }}" 23 | when: item.item is defined and item.item != none 24 | loop: "{{ sysctl | default([]) }}" 25 | tags: 26 | - always 27 | 28 | - meta: flush_handlers 29 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Issues 2 | 3 | The 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. 4 | 5 | To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/trailofbits/algo/security/) tab. 6 | 7 | The 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. 8 | 9 | Report security bugs in third-party modules to the person or team maintaining the module. 10 | -------------------------------------------------------------------------------- /roles/dns/templates/ip-blacklist.txt.j2: -------------------------------------------------------------------------------- 1 | 0.0.0.0 2 | 10.* 3 | 127.* 4 | 169.254.* 5 | 172.16.* 6 | 172.17.* 7 | 172.18.* 8 | 172.19.* 9 | 172.20.* 10 | 172.21.* 11 | 172.22.* 12 | 172.23.* 13 | 172.24.* 14 | 172.25.* 15 | 172.26.* 16 | 172.27.* 17 | 172.28.* 18 | 172.29.* 19 | 172.30.* 20 | 172.31.* 21 | 192.168.* 22 | ::ffff:0.0.0.0 23 | ::ffff:10.* 24 | ::ffff:127.* 25 | ::ffff:169.254.* 26 | ::ffff:172.16.* 27 | ::ffff:172.17.* 28 | ::ffff:172.18.* 29 | ::ffff:172.19.* 30 | ::ffff:172.20.* 31 | ::ffff:172.21.* 32 | ::ffff:172.22.* 33 | ::ffff:172.23.* 34 | ::ffff:172.24.* 35 | ::ffff:172.25.* 36 | ::ffff:172.26.* 37 | ::ffff:172.27.* 38 | ::ffff:172.28.* 39 | ::ffff:172.29.* 40 | ::ffff:172.30.* 41 | ::ffff:172.31.* 42 | ::ffff:192.168.* 43 | fd00::* 44 | fe80::* 45 | -------------------------------------------------------------------------------- /roles/privacy/templates/47-privacy-auth-filter.conf.j2: -------------------------------------------------------------------------------- 1 | # Privacy-enhanced authentication log filtering 2 | # WARNING: Use with caution - this reduces security logging 3 | # Only enable if you understand the security implications 4 | # Generated by Algo VPN privacy role 5 | 6 | {% if privacy_log_filtering.exclude_auth_logs %} 7 | # Filter successful authentication messages (reduces audit trail) 8 | :msg, contains, "authentication success" stop 9 | :msg, contains, "session opened" stop 10 | :msg, contains, "session closed" stop 11 | 12 | # Filter sudo success messages (keep failures for security) 13 | :msg, regex, "sudo.*COMMAND" stop 14 | 15 | # Filter cron messages 16 | :msg, contains, "CRON" stop 17 | {% endif %} 18 | 19 | # Continue processing other auth messages 20 | -------------------------------------------------------------------------------- /roles/privacy/templates/48-privacy-kernel-filter.conf.j2: -------------------------------------------------------------------------------- 1 | # Privacy-enhanced kernel log filtering 2 | # Filters kernel messages that may reveal VPN usage patterns 3 | # Generated by Algo VPN privacy role 4 | 5 | {% if privacy_log_filtering.filter_kernel_vpn_logs %} 6 | # Filter iptables/netfilter messages related to VPN 7 | :msg, contains, "iptables" stop 8 | :msg, contains, "netfilter" stop 9 | 10 | # Filter connection tracking messages 11 | :msg, contains, "nf_conntrack" stop 12 | 13 | # Filter network interface up/down messages for VPN interfaces 14 | :msg, regex, "wg[0-9]+.*link" stop 15 | :msg, regex, "ipsec[0-9]+.*link" stop 16 | 17 | # Filter routing table changes 18 | :msg, contains, "rtnetlink" stop 19 | {% endif %} 20 | 21 | # Continue processing non-filtered messages 22 | -------------------------------------------------------------------------------- /deploy_client.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Configure the client 3 | hosts: localhost 4 | become: false 5 | vars_files: 6 | - config.cfg 7 | 8 | tasks: 9 | - name: Add the droplet to an inventory group 10 | add_host: 11 | name: "{{ client_ip }}" 12 | groups: client-host 13 | ansible_ssh_user: "{{ 'root' if client_ip == 'localhost' else ssh_user }}" 14 | vpn_user: "{{ vpn_user }}" 15 | IP_subject_alt_name: "{{ server_ip }}" 16 | ansible_python_interpreter: /usr/bin/python3 17 | 18 | - name: Configure the client and install required software 19 | hosts: client-host 20 | gather_facts: false 21 | become: true 22 | vars_files: 23 | - config.cfg 24 | - roles/strongswan/defaults/main.yml 25 | roles: 26 | - role: client 27 | -------------------------------------------------------------------------------- /roles/wireguard/templates/client.conf.j2: -------------------------------------------------------------------------------- 1 | [Interface] 2 | PrivateKey = {{ lookup('file', wireguard_pki_path + '/private/' + item) }} 3 | Address = {{ wireguard_client_ip }} 4 | DNS = {{ wireguard_dns_servers }} 5 | {% if reduce_mtu | int > 0 %}MTU = {{ 1420 - reduce_mtu | int }} 6 | {% endif %} 7 | 8 | [Peer] 9 | PublicKey = {{ lookup('file', wireguard_pki_path + '/public/' + IP_subject_alt_name) }} 10 | PresharedKey = {{ lookup('file', wireguard_pki_path + '/preshared/' + item) }} 11 | AllowedIPs = 0.0.0.0/0,::/0 12 | Endpoint = {% if ':' in IP_subject_alt_name %}[{{ IP_subject_alt_name }}]:{{ wireguard_port }}{% else %}{{ IP_subject_alt_name }}:{{ wireguard_port }}{% endif %} 13 | {{ 'PersistentKeepalive = ' + wireguard_PersistentKeepalive | string if wireguard_PersistentKeepalive > 0 else '' }} 14 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # Run the same linting as CI 5 | echo "Running ansible-lint..." 6 | ansible-lint . 7 | 8 | echo "Running playbook dry-run check..." 9 | # Test main playbook logic without making changes - catches runtime issues 10 | ansible-playbook main.yml --check --connection=local \ 11 | -e "server_ip=test" \ 12 | -e "server_name=ci-test" \ 13 | -e "IP_subject_alt_name=192.168.1.1" \ 14 | || echo "Dry-run completed with issues - check output above" 15 | 16 | echo "Running yamllint..." 17 | yamllint -c .yamllint . 18 | 19 | echo "Running ruff..." 20 | ruff check . || true # Start with warnings only 21 | 22 | echo "Running shellcheck..." 23 | find . -type f -name "*.sh" -not -path "./.git/*" -exec shellcheck {} \; 24 | 25 | echo "All linting completed!" 26 | -------------------------------------------------------------------------------- /scripts/annotate-test-failure.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Annotate test failures with metadata for tracking 3 | 4 | # This script should be called when a test fails in CI 5 | # Usage: ./annotate-test-failure.sh 6 | 7 | TEST_NAME="$1" 8 | CONTEXT="$2" 9 | TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ") 10 | 11 | # Create failures log if it doesn't exist 12 | mkdir -p .metrics 13 | FAILURE_LOG=".metrics/test-failures.jsonl" 14 | 15 | # Add failure record 16 | cat >> "$FAILURE_LOG" << EOF 17 | {"test": "$TEST_NAME", "context": "$CONTEXT", "timestamp": "$TIMESTAMP", "commit": "$GITHUB_SHA", "pr": "$GITHUB_PR_NUMBER", "branch": "$GITHUB_REF_NAME"} 18 | EOF 19 | 20 | # Also add as GitHub annotation if in CI 21 | if [ -n "$GITHUB_ACTIONS" ]; then 22 | echo "::warning title=Test Failure::$TEST_NAME failed in $CONTEXT" 23 | fi 24 | -------------------------------------------------------------------------------- /docs/cloud-scaleway.md: -------------------------------------------------------------------------------- 1 | ### Configuration file 2 | 3 | Algo requires an API key from your Scaleway account to create a server. 4 | The 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". 5 | You'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". 6 | 7 | Enter the "Secret key" when Algo prompts you for the `auth token`. You won't need the "Access key". 8 | This information will be pass as the `algo_scaleway_token` variable when asked for in the Algo prompt. 9 | 10 | Your organization ID is also on this page: https://console.scaleway.com/account/credentials 11 | -------------------------------------------------------------------------------- /roles/wireguard/templates/server.conf.j2: -------------------------------------------------------------------------------- 1 | [Interface] 2 | Address = {{ wireguard_server_ip }} 3 | ListenPort = {{ wireguard_port_actual if wireguard_port | int == wireguard_port_avoid | int else wireguard_port }} 4 | PrivateKey = {{ lookup('file', wireguard_pki_path + '/private/' + IP_subject_alt_name) }} 5 | SaveConfig = false 6 | 7 | {% for u in wireguard_users %} 8 | {% if u in users %} 9 | {% set index = loop.index %} 10 | 11 | [Peer] 12 | # {{ u }} 13 | PublicKey = {{ lookup('file', wireguard_pki_path + '/public/' + u) }} 14 | PresharedKey = {{ lookup('file', wireguard_pki_path + '/preshared/' + u) }} 15 | AllowedIPs = {{ 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 else '' }} 16 | {% endif %} 17 | {% endfor %} 18 | -------------------------------------------------------------------------------- /roles/dns/files/apparmor.profile.dnscrypt-proxy: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | /usr/{s,}bin/dnscrypt-proxy flags=(attach_disconnected) { 4 | #include 5 | #include 6 | #include 7 | 8 | capability chown, 9 | capability dac_override, 10 | capability net_bind_service, 11 | capability setgid, 12 | capability setuid, 13 | capability sys_resource, 14 | 15 | /etc/dnscrypt-proxy/** r, 16 | /usr/bin/dnscrypt-proxy mr, 17 | /var/cache/{private/,}dnscrypt-proxy/** rw, 18 | 19 | /tmp/*.tmp w, 20 | owner /tmp/*.tmp r, 21 | 22 | /run/systemd/notify rw, 23 | /lib/x86_64-linux-gnu/ld-*.so mr, 24 | @{PROC}/sys/kernel/hostname r, 25 | @{PROC}/sys/net/core/somaxconn r, 26 | /etc/ld.so.cache r, 27 | /usr/local/lib/{@{multiarch}/,}libldns.so* mr, 28 | /usr/local/lib/{@{multiarch}/,}libsodium.so* mr, 29 | } 30 | -------------------------------------------------------------------------------- /roles/dns/templates/dnscrypt-proxy/sources.toml.j2: -------------------------------------------------------------------------------- 1 | ######################### 2 | # Servers # 3 | ######################### 4 | 5 | ## Remote lists of available servers 6 | [sources] 7 | 8 | ## Public resolvers from https://github.com/DNSCrypt/dnscrypt-resolvers 9 | [sources.'public-resolvers'] 10 | urls = ['https://raw.githubusercontent.com/DNSCrypt/dnscrypt-resolvers/master/v2/public-resolvers.md', 'https://download.dnscrypt.info/resolvers-list/v2/public-resolvers.md'] 11 | cache_file = '/var/cache/dnscrypt-proxy/public-resolvers.md' 12 | minisign_key = 'RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3' 13 | prefix = '' 14 | 15 | 16 | 17 | ## Optional, local, static list of additional servers 18 | [static] 19 | 20 | {% if custom_server_stamps %}{% for name, stamp in custom_server_stamps.items() %} 21 | [static.'{{ name }}'] 22 | stamp = '{{ stamp }}' 23 | {%- endfor %}{% endif %} 24 | -------------------------------------------------------------------------------- /roles/wireguard/templates/mobileconfig.j2: -------------------------------------------------------------------------------- 1 | #jinja2:lstrip_blocks: True 2 | 3 | 4 | 5 | 6 | PayloadContent 7 | 8 | {% include 'vpn-dict.j2' %} 9 | 10 | PayloadDisplayName 11 | AlgoVPN {{ algo_server_name }} WireGuard 12 | PayloadIdentifier 13 | donut.local.{{ 500000 | random | to_uuid | upper }} 14 | PayloadOrganization 15 | AlgoVPN 16 | PayloadRemovalDisallowed 17 | 18 | PayloadType 19 | Configuration 20 | PayloadUUID 21 | {{ 400000 | random | to_uuid | upper }} 22 | PayloadVersion 23 | 1 24 | 25 | 26 | -------------------------------------------------------------------------------- /files/cloud-init/base.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -eux 3 | 4 | # shellcheck disable=SC2230 5 | which sudo || until \ 6 | apt-get update -y && \ 7 | apt-get install sudo -yf --install-suggests; do 8 | sleep 3 9 | done 10 | 11 | getent passwd algo || useradd -m -d /home/algo -s /bin/bash -G adm -p '!' algo 12 | 13 | (umask 337 && echo "algo ALL=(ALL) NOPASSWD:ALL" >/etc/sudoers.d/10-algo-user) 14 | 15 | cat </etc/ssh/sshd_config 16 | {{ lookup('template', 'files/cloud-init/sshd_config') }} 17 | EOF 18 | 19 | test -d /home/algo/.ssh || sudo -u algo mkdir -m 0700 /home/algo/.ssh 20 | echo "{{ lookup('file', SSH_keys.public) }}" | (sudo -u algo tee /home/algo/.ssh/authorized_keys && chmod 0600 /home/algo/.ssh/authorized_keys) 21 | 22 | ufw --force reset 23 | 24 | # shellcheck disable=SC2015 25 | dpkg -l sshguard && until apt-get remove -y --purge sshguard; do 26 | sleep 3 27 | done || true 28 | 29 | systemctl restart sshd.service 30 | -------------------------------------------------------------------------------- /playbooks/tmpfs/umount.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Linux | Delete the PKI directory 3 | file: 4 | path: /{{ facts.tmpfs_volume_path }}/{{ facts.tmpfs_volume_name }}/ 5 | state: absent 6 | when: facts.ansible_system == "Linux" 7 | 8 | - block: 9 | - name: MacOS | check fs the ramdisk exists 10 | command: /usr/sbin/diskutil info "{{ facts.tmpfs_volume_name }}" 11 | failed_when: false 12 | changed_when: false 13 | register: diskutil_info 14 | 15 | - name: MacOS | unmount and eject the ram disk 16 | shell: > 17 | /usr/sbin/diskutil umount force "/{{ facts.tmpfs_volume_path }}/{{ facts.tmpfs_volume_name }}/" && 18 | /usr/sbin/diskutil eject "{{ facts.tmpfs_volume_name }}" 19 | changed_when: false 20 | when: diskutil_info.rc == 0 21 | register: result 22 | until: result.rc == 0 23 | retries: 5 24 | delay: 3 25 | when: 26 | - facts.ansible_system == "Darwin" 27 | -------------------------------------------------------------------------------- /roles/cloud-ec2/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include prompts 3 | import_tasks: prompts.yml 4 | 5 | - name: Locate official AMI for region 6 | ec2_ami_info: 7 | aws_access_key: "{{ access_key }}" 8 | aws_secret_key: "{{ secret_key }}" 9 | owners: "{{ cloud_providers.ec2.image.owner }}" 10 | region: "{{ algo_region }}" 11 | filters: 12 | architecture: "{{ cloud_providers.ec2.image.arch }}" 13 | name: ubuntu/images/hvm-ssd/{{ cloud_providers.ec2.image.name }}-*64-server-* 14 | register: ami_search 15 | no_log: true 16 | 17 | - name: Set the ami id as a fact 18 | set_fact: 19 | ami_image: "{{ (ami_search.images | sort(attribute='creation_date') | last)['image_id'] }}" 20 | 21 | - name: Deploy the stack 22 | import_tasks: cloudformation.yml 23 | 24 | - set_fact: 25 | cloud_instance_ip: "{{ stack.stack_outputs.ElasticIP }}" 26 | ansible_ssh_user: algo 27 | ansible_ssh_port: "{{ ssh_port }}" 28 | cloudinit: true 29 | -------------------------------------------------------------------------------- /roles/cloud-lightsail/tasks/cloudformation.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Note: Using template_body instead of deprecated 'template' parameter. 3 | # The 'template' parameter is deprecated and will be removed after 2026-05-01. 4 | - name: Deploy the template 5 | cloudformation: 6 | aws_access_key: "{{ access_key }}" 7 | aws_secret_key: "{{ secret_key }}" 8 | stack_name: "{{ stack_name }}" 9 | state: present 10 | region: "{{ algo_region }}" 11 | template_body: "{{ lookup('file', 'roles/cloud-lightsail/files/stack.yaml') }}" 12 | template_parameters: 13 | InstanceTypeParameter: "{{ cloud_providers.lightsail.size }}" 14 | ImageIdParameter: "{{ cloud_providers.lightsail.image }}" 15 | WireGuardPort: "{{ wireguard_port }}" 16 | SshPort: "{{ ssh_port }}" 17 | UserData: "{{ lookup('template', 'files/cloud-init/base.sh') }}" 18 | tags: 19 | Environment: Algo 20 | Lightsail: true 21 | register: stack 22 | no_log: true 23 | -------------------------------------------------------------------------------- /roles/privacy/templates/clear-history-on-logout.sh.j2: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Privacy-enhanced history clearing on logout 3 | # This script clears command history when users log out 4 | # Generated by Algo VPN privacy role 5 | 6 | {% if privacy_history_clearing.clear_bash_history %} 7 | # Clear bash history 8 | if [ -f ~/.bash_history ]; then 9 | > ~/.bash_history 10 | fi 11 | 12 | # Clear zsh history 13 | if [ -f ~/.zsh_history ]; then 14 | > ~/.zsh_history 15 | fi 16 | 17 | # Clear current session history 18 | history -c 19 | history -w 20 | 21 | # Clear less history 22 | if [ -f ~/.lesshst ]; then 23 | rm -f ~/.lesshst 24 | fi 25 | 26 | # Clear vim history 27 | if [ -f ~/.viminfo ]; then 28 | rm -f ~/.viminfo 29 | fi 30 | {% endif %} 31 | 32 | {% if privacy_history_clearing.clear_system_history %} 33 | # Clear temporary files in user directory 34 | find ~/tmp -type f -delete 2>/dev/null || true 35 | find ~/.cache -type f -delete 2>/dev/null || true 36 | {% endif %} 37 | -------------------------------------------------------------------------------- /roles/strongswan/tasks/distribute_keys.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Copy the keys to the strongswan directory 3 | copy: 4 | src: "{{ ipsec_pki_path }}/{{ item.src }}" 5 | dest: "{{ config_prefix | default('/') }}etc/ipsec.d/{{ item.dest }}" 6 | owner: "{{ item.owner }}" 7 | group: "{{ item.group }}" 8 | mode: "{{ item.mode }}" 9 | loop: 10 | - src: cacert.pem 11 | dest: cacerts/ca.crt 12 | owner: strongswan 13 | group: "{{ root_group | default('root') }}" 14 | mode: "0600" 15 | - src: certs/{{ IP_subject_alt_name }}.crt 16 | dest: certs/{{ IP_subject_alt_name }}.crt 17 | owner: strongswan 18 | group: "{{ root_group | default('root') }}" 19 | mode: "0600" 20 | - src: private/{{ IP_subject_alt_name }}.key 21 | dest: private/{{ IP_subject_alt_name }}.key 22 | owner: strongswan 23 | group: "{{ root_group | default('root') }}" 24 | mode: "0600" 25 | notify: 26 | - restart strongswan 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | # Maintain dependencies for GitHub Actions 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | cooldown: 10 | default-days: 7 11 | groups: 12 | github-actions: 13 | patterns: 14 | - "*" 15 | 16 | # Maintain dependencies for Python using uv 17 | # Using "uv" ecosystem ensures both pyproject.toml AND uv.lock are updated together 18 | # This prevents Docker build failures from lockfile mismatches 19 | - package-ecosystem: "uv" 20 | directory: "/" 21 | schedule: 22 | interval: "weekly" 23 | cooldown: 24 | default-days: 7 25 | groups: 26 | python: 27 | patterns: 28 | - "*" 29 | 30 | # Maintain Docker base image (python:3.12-alpine) 31 | - package-ecosystem: "docker" 32 | directory: "/" 33 | schedule: 34 | interval: "weekly" 35 | cooldown: 36 | default-days: 7 37 | -------------------------------------------------------------------------------- /docs/deploy-from-macos.md: -------------------------------------------------------------------------------- 1 | # Deploy from macOS 2 | 3 | You can install the Algo scripts on a macOS system and use them to deploy your AlgoVPN to a cloud provider. 4 | 5 | ## Installation 6 | 7 | Algo handles all Python setup automatically. Simply: 8 | 9 | 1. Get Algo: `git clone https://github.com/trailofbits/algo.git && cd algo` 10 | 2. Run Algo: `./algo` 11 | 12 | The 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. 13 | 14 | ## What happens automatically 15 | 16 | When you run `./algo` for the first time: 17 | - uv is installed automatically using curl 18 | - Python 3.11+ is installed and managed by uv 19 | - All required dependencies (Ansible, etc.) are installed 20 | - Your VPN deployment begins 21 | 22 | No manual Python installation, virtual environments, or dependency management required! 23 | -------------------------------------------------------------------------------- /docs/cloud-alternative-ingress-ip.md: -------------------------------------------------------------------------------- 1 | # Alternative Ingress IP 2 | 3 | This 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. 4 | 5 | ![cloud-alternative-ingress-ip](/docs/images/cloud-alternative-ingress-ip.png) 6 | 7 | Additional info might be found in [this issue](https://github.com/trailofbits/algo/issues/1047) 8 | 9 | 10 | 11 | 12 | #### Caveats 13 | 14 | ##### Extra charges 15 | 16 | - 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. 17 | 18 | ##### IPv6 19 | 20 | Some cloud providers provision a VM with an `/128` address block size. This is the only IPv6 address provided and for outbound and incoming traffic. 21 | 22 | If 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. 23 | -------------------------------------------------------------------------------- /roles/privacy/templates/privacy-log-cleanup.sh.j2: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Privacy log cleanup script 3 | # Immediately cleans up existing logs and applies privacy settings 4 | # Generated by Algo VPN privacy role 5 | 6 | set -euo pipefail 7 | 8 | echo "Starting privacy log cleanup..." 9 | 10 | # Truncate existing log files to apply new rotation settings immediately 11 | find /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 12 | 13 | # Remove old rotated logs that exceed our retention policy 14 | find /var/log -type f \( -name "*.log.*" -o -name "*.gz" \) -mtime +{{ privacy_log_rotation.max_age }} -delete 2>/dev/null || true 15 | 16 | # Clean up systemd journal to respect new settings 17 | if [ -d /var/log/journal ]; then 18 | journalctl --vacuum-time={{ privacy_log_rotation.max_age }}d 2>/dev/null || true 19 | journalctl --vacuum-size={{ privacy_log_rotation.max_size * 10 }}M 2>/dev/null || true 20 | fi 21 | 22 | echo "Privacy log cleanup completed" 23 | -------------------------------------------------------------------------------- /roles/cloud-azure/tasks/prompts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - set_fact: 3 | secret: "{{ azure_secret | default(lookup('env', 'AZURE_SECRET'), true) }}" 4 | tenant: "{{ azure_tenant | default(lookup('env', 'AZURE_TENANT'), true) }}" 5 | client_id: "{{ azure_client_id | default(lookup('env', 'AZURE_CLIENT_ID'), true) }}" 6 | subscription_id: "{{ azure_subscription_id | default(lookup('env', 'AZURE_SUBSCRIPTION_ID'), true) }}" 7 | no_log: true 8 | 9 | - block: 10 | - name: Set the default region 11 | set_fact: 12 | default_region: >- 13 | {% for r in azure_regions %}{%- if r['name'] == "eastus" %}{{ loop.index }}{% endif %}{%- endfor %} 14 | 15 | - pause: 16 | prompt: | 17 | What region should the server be located in? 18 | {% for r in azure_regions %} 19 | {{ loop.index }}. {{ r['regionalDisplayName'] }} 20 | {% endfor %} 21 | 22 | Enter the number of your desired region 23 | [{{ default_region }}] 24 | register: _algo_region 25 | when: region is undefined 26 | -------------------------------------------------------------------------------- /roles/strongswan/templates/100-CustomLimitations.conf.j2: -------------------------------------------------------------------------------- 1 | # Algo VPN systemd security hardening for StrongSwan 2 | # Enhanced hardening on top of existing AppArmor 3 | [Service] 4 | # Privilege restrictions 5 | NoNewPrivileges=yes 6 | 7 | # Filesystem isolation (complements AppArmor) 8 | ProtectHome=yes 9 | PrivateTmp=yes 10 | ProtectKernelTunables=yes 11 | ProtectControlGroups=yes 12 | 13 | # Network restrictions - include IPsec kernel communication requirements 14 | # AF_UNIX required for VICI socket communication (swanctl/modern StrongSwan interface) 15 | RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK AF_PACKET AF_UNIX 16 | 17 | # Allow access to IPsec configuration, state, and kernel interfaces 18 | ReadWritePaths=/etc/ipsec.d /var/lib/strongswan 19 | ReadOnlyPaths=/proc/net/pfkey 20 | 21 | # System call filtering (complements AppArmor restrictions) 22 | # Allow crypto operations, remove cpu-emulation restriction for crypto algorithms 23 | SystemCallFilter=@system-service @network-io 24 | SystemCallFilter=~@debug @mount @swap @reboot 25 | SystemCallErrorNumber=EPERM 26 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/.pki/cacert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICoTCCAiagAwIBAgIUYQ99YGsE7jDrDq93WTTWMkaM7IUwCgYIKoZIzj0EAwIw 3 | FTETMBEGA1UEAwwKMTAuOTkuMC4xMDAeFw0yNTA4MDMxMjU5MjdaFw0zNTA4MDEx 4 | MjU5MjdaMBUxEzARBgNVBAMMCjEwLjk5LjAuMTAwdjAQBgcqhkjOPQIBBgUrgQQA 5 | IgNiAASA2JYIRHTHqMnrGCoIFg8RVz3v2QdjGJnkF3f2Ia4s/V5LaP+WP0PhDEF3 6 | pVHRzHKd2ntk0DBRNOih+/BiQ+lQhfET8tWH+mfAk0HemsgRzRIGadxPVxi1piqJ 7 | sL8uWU6jggE1MIIBMTAdBgNVHQ4EFgQUv/5pOGOAGenWXTgdI+dhjK9K6K0wUAYD 8 | VR0jBEkwR4AUv/5pOGOAGenWXTgdI+dhjK9K6K2hGaQXMBUxEzARBgNVBAMMCjEw 9 | Ljk5LjAuMTCCFGEPfWBrBO4w6w6vd1k01jJGjOyFMBIGA1UdEwEB/wQIMAYBAf8C 10 | AQAwgZwGA1UdHgEB/wSBkTCBjqBmMAqHCApjAAr/////MCuCKTk1NDZkNTc0LTNm 11 | ZDYtNThmNC05YWM2LTJjNjljYjc1NWRiNy5hbGdvMCuBKTk1NDZkNTc0LTNmZDYt 12 | NThmNC05YWM2LTJjNjljYjc1NWRiNy5hbGdvoSQwIocgAAAAAAAAAAAAAAAAAAAA 13 | AAAAAAAAAAAAAAAAAAAAAAAwCwYDVR0PBAQDAgEGMAoGCCqGSM49BAMCA2kAMGYC 14 | MQCsWQOinhhs4yZSOvupPIQKw7hMpKkEiKS6RtRfrvZohGQK92OKXsETLd7YPh3N 15 | RBACMQC8WAe35PXcg+JY8padri4d/u2ITreCXARuhUjypm+Ucy1qQ5A18wjj6/KV 16 | JJYlbfk= 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/ipsec/manual/cacert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICoTCCAiagAwIBAgIUYQ99YGsE7jDrDq93WTTWMkaM7IUwCgYIKoZIzj0EAwIw 3 | FTETMBEGA1UEAwwKMTAuOTkuMC4xMDAeFw0yNTA4MDMxMjU5MjdaFw0zNTA4MDEx 4 | MjU5MjdaMBUxEzARBgNVBAMMCjEwLjk5LjAuMTAwdjAQBgcqhkjOPQIBBgUrgQQA 5 | IgNiAASA2JYIRHTHqMnrGCoIFg8RVz3v2QdjGJnkF3f2Ia4s/V5LaP+WP0PhDEF3 6 | pVHRzHKd2ntk0DBRNOih+/BiQ+lQhfET8tWH+mfAk0HemsgRzRIGadxPVxi1piqJ 7 | sL8uWU6jggE1MIIBMTAdBgNVHQ4EFgQUv/5pOGOAGenWXTgdI+dhjK9K6K0wUAYD 8 | VR0jBEkwR4AUv/5pOGOAGenWXTgdI+dhjK9K6K2hGaQXMBUxEzARBgNVBAMMCjEw 9 | Ljk5LjAuMTCCFGEPfWBrBO4w6w6vd1k01jJGjOyFMBIGA1UdEwEB/wQIMAYBAf8C 10 | AQAwgZwGA1UdHgEB/wSBkTCBjqBmMAqHCApjAAr/////MCuCKTk1NDZkNTc0LTNm 11 | ZDYtNThmNC05YWM2LTJjNjljYjc1NWRiNy5hbGdvMCuBKTk1NDZkNTc0LTNmZDYt 12 | NThmNC05YWM2LTJjNjljYjc1NWRiNy5hbGdvoSQwIocgAAAAAAAAAAAAAAAAAAAA 13 | AAAAAAAAAAAAAAAAAAAAAAAwCwYDVR0PBAQDAgEGMAoGCCqGSM49BAMCA2kAMGYC 14 | MQCsWQOinhhs4yZSOvupPIQKw7hMpKkEiKS6RtRfrvZohGQK92OKXsETLd7YPh3N 15 | RBACMQC8WAe35PXcg+JY8padri4d/u2ITreCXARuhUjypm+Ucy1qQ5A18wjj6/KV 16 | JJYlbfk= 17 | -----END CERTIFICATE----- 18 | -------------------------------------------------------------------------------- /roles/strongswan/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include tasks for Debian/Ubuntu 3 | include_tasks: ubuntu.yml 4 | when: is_debian_based 5 | 6 | - name: Ensure that the strongswan user exists 7 | user: 8 | name: strongswan 9 | group: nogroup 10 | shell: "{{ strongswan_shell }}" 11 | home: "{{ strongswan_home }}" 12 | state: present 13 | 14 | - name: Install strongSwan 15 | package: name=strongswan state=present 16 | 17 | - import_tasks: ipsec_configuration.yml 18 | - import_tasks: openssl.yml 19 | tags: update-users 20 | - import_tasks: distribute_keys.yml 21 | - import_tasks: client_configs.yml 22 | delegate_to: localhost 23 | become: false 24 | tags: update-users 25 | 26 | - name: strongSwan started 27 | service: 28 | name: "{{ strongswan_service }}" 29 | state: started 30 | enabled: true 31 | 32 | - meta: flush_handlers 33 | 34 | - name: Delete the PKI directory 35 | file: 36 | path: "{{ ipsec_pki_path }}" 37 | state: absent 38 | become: false 39 | delegate_to: localhost 40 | when: 41 | - not algo_store_pki 42 | - not pki_in_tmpfs 43 | -------------------------------------------------------------------------------- /docs/cloud-vultr.md: -------------------------------------------------------------------------------- 1 | ### Configuration file 2 | 3 | Algo 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". 4 | 5 | Algo 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: 6 | 7 | ``` 8 | [default] 9 | key = 10 | ``` 11 | where you've cut-and-pasted the API key from above into the `` field (no brackets). 12 | 13 | When Algo asks `Enter the local path to your configuration INI file 14 | (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. 15 | -------------------------------------------------------------------------------- /roles/cloud-hetzner/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include prompts 3 | import_tasks: prompts.yml 4 | 5 | - name: Create an ssh key 6 | hetzner.hcloud.ssh_key: 7 | name: algo-{{ 999999 | random(seed=lookup('file', SSH_keys.public)) }} 8 | public_key: "{{ lookup('file', SSH_keys.public) }}" 9 | state: present 10 | api_token: "{{ algo_hcloud_token }}" 11 | register: hcloud_ssh_key 12 | 13 | - name: Create a server... 14 | hetzner.hcloud.server: 15 | name: "{{ algo_server_name }}" 16 | location: "{{ algo_hcloud_region }}" 17 | server_type: "{{ cloud_providers.hetzner.server_type }}" 18 | image: "{{ cloud_providers.hetzner.image }}" 19 | state: present 20 | api_token: "{{ algo_hcloud_token }}" 21 | ssh_keys: "{{ hcloud_ssh_key.hcloud_ssh_key.name }}" 22 | user_data: "{{ lookup('template', 'files/cloud-init/base.yml') }}" 23 | labels: 24 | Environment: algo 25 | register: hcloud_server 26 | 27 | - set_fact: 28 | cloud_instance_ip: "{{ hcloud_server.hcloud_server.ipv4_address }}" 29 | ansible_ssh_user: algo 30 | ansible_ssh_port: "{{ ssh_port }}" 31 | cloudinit: true 32 | -------------------------------------------------------------------------------- /roles/wireguard/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | wireguard_PersistentKeepalive: 0 3 | wireguard_config_path: configs/{{ IP_subject_alt_name }}/wireguard 4 | wireguard_pki_path: "{{ wireguard_config_path }}/.pki" 5 | wireguard_interface: wg0 6 | wireguard_port_avoid: 53 7 | wireguard_port_actual: 51820 8 | keys_clean_all: false 9 | wireguard_dns_servers: >- 10 | {% if algo_dns_adblocking | default(false) | bool or dns_encryption | default(false) | bool %}{{ local_service_ip }}{{ ', ' + local_service_ipv6 if ipv6_support else '' }}{% else %}{% for host in dns_servers.ipv4 %}{{ host }}{% if not loop.last %},{% endif %}{% endfor %}{%- if ipv6_support %},{% for host in dns_servers.ipv6 %}{{ host }}{% if not loop.last %},{% endif %}{% endfor %}{% endif %}{% endif %} 11 | wireguard_client_ip: >- 12 | {{ wireguard_network_ipv4 | ansible.utils.ipmath(index | int + 2) }} 13 | {{ ',' + wireguard_network_ipv6 | ansible.utils.ipmath(index | int + 2) if ipv6_support else '' }} 14 | wireguard_server_ip: >- 15 | {{ wireguard_network_ipv4 | ansible.utils.ipaddr('1') }} 16 | {{ ',' + wireguard_network_ipv6 | ansible.utils.ipaddr('1') if ipv6_support else '' }} 17 | -------------------------------------------------------------------------------- /roles/wireguard/files/wireguard.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # PROVIDE: wireguard 4 | # REQUIRE: LOGIN 5 | # BEFORE: securelevel 6 | # KEYWORD: shutdown 7 | 8 | # shellcheck source=/dev/null 9 | . /etc/rc.subr 10 | 11 | name="wg" 12 | # shellcheck disable=SC2034 13 | rcvar=wg_enable 14 | 15 | command="/usr/local/bin/wg-quick" 16 | # shellcheck disable=SC2034 17 | start_cmd=wg_up 18 | # shellcheck disable=SC2034 19 | stop_cmd=wg_down 20 | # shellcheck disable=SC2034 21 | status_cmd=wg_status 22 | pidfile="/var/run/$name.pid" 23 | load_rc_config "$name" 24 | 25 | : "${wg_enable=NO}" 26 | : "${wg_interface=wg0}" 27 | 28 | wg_up() { 29 | echo "Starting WireGuard..." 30 | /usr/sbin/daemon -cS -p "${pidfile}" "${command}" up "${wg_interface}" 31 | } 32 | 33 | wg_down() { 34 | echo "Stopping WireGuard..." 35 | "${command}" down "${wg_interface}" 36 | } 37 | 38 | wg_status () { 39 | not_running () { 40 | echo "WireGuard is not running on $wg_interface" && exit 1 41 | } 42 | if /usr/local/bin/wg show wg0; then 43 | echo "WireGuard is running on $wg_interface" 44 | else 45 | not_running 46 | fi 47 | } 48 | 49 | run_rc_command "$1" 50 | -------------------------------------------------------------------------------- /roles/dns/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include tasks for Debian/Ubuntu 3 | include_tasks: ubuntu.yml 4 | when: is_debian_based 5 | 6 | - name: dnscrypt-proxy ip-blacklist configured 7 | template: 8 | src: ip-blacklist.txt.j2 9 | dest: "{{ config_prefix | default('/') }}etc/dnscrypt-proxy/ip-blacklist.txt" 10 | mode: '0644' 11 | notify: 12 | - restart dnscrypt-proxy 13 | 14 | - name: dnscrypt-proxy configured 15 | template: 16 | src: dnscrypt-proxy.toml.j2 17 | dest: "{{ config_prefix | default('/') }}etc/dnscrypt-proxy/dnscrypt-proxy.toml" 18 | mode: '0644' 19 | notify: 20 | - restart dnscrypt-proxy 21 | 22 | - name: Include DNS adblocking tasks 23 | import_tasks: dns_adblocking.yml 24 | when: algo_dns_adblocking 25 | 26 | - meta: flush_handlers 27 | 28 | - name: Ensure dnscrypt-proxy socket is enabled and started 29 | systemd: 30 | name: dnscrypt-proxy.socket 31 | enabled: true 32 | state: started 33 | daemon_reload: true 34 | when: uses_systemd_socket 35 | 36 | - name: dnscrypt-proxy enabled and started 37 | service: 38 | name: dnscrypt-proxy 39 | state: started 40 | enabled: true 41 | -------------------------------------------------------------------------------- /roles/cloud-ec2/tasks/cloudformation.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Note: Using template_body instead of deprecated 'template' parameter. 3 | # The 'template' parameter is deprecated and will be removed after 2026-05-01. 4 | - name: Deploy the template 5 | cloudformation: 6 | aws_access_key: "{{ access_key }}" 7 | aws_secret_key: "{{ secret_key }}" 8 | aws_session_token: "{{ session_token if session_token else omit }}" 9 | stack_name: "{{ stack_name }}" 10 | state: present 11 | region: "{{ algo_region }}" 12 | template_body: "{{ lookup('file', 'roles/cloud-ec2/files/stack.yaml') }}" 13 | template_parameters: 14 | InstanceTypeParameter: "{{ cloud_providers.ec2.size }}" 15 | ImageIdParameter: "{{ ami_image }}" 16 | WireGuardPort: "{{ wireguard_port }}" 17 | UseThisElasticIP: "{{ existing_eip }}" 18 | EbsEncrypted: "{{ encrypted }}" 19 | UserData: "{{ lookup('template', 'files/cloud-init/base.yml') | b64encode }}" 20 | SshPort: "{{ ssh_port }}" 21 | InstanceMarketTypeParameter: "{{ cloud_providers.ec2.instance_market_type }}" 22 | tags: 23 | Environment: Algo 24 | register: stack 25 | no_log: true 26 | -------------------------------------------------------------------------------- /roles/privacy/templates/49-privacy-vpn-filter.conf.j2: -------------------------------------------------------------------------------- 1 | # Privacy-enhanced rsyslog configuration 2 | # Filters VPN-related log entries for enhanced privacy 3 | # Generated by Algo VPN privacy role 4 | 5 | # Stop processing VPN-related messages to prevent them from being logged 6 | # This helps maintain user privacy by not storing VPN connection details 7 | 8 | {% if privacy_log_filtering.exclude_vpn_logs %} 9 | # Filter WireGuard messages 10 | :msg, contains, "wireguard" stop 11 | 12 | # Filter StrongSwan/IPsec messages 13 | :msg, contains, "strongswan" stop 14 | :msg, contains, "ipsec" stop 15 | :msg, contains, "charon" stop 16 | :msg, contains, "xl2tpd" stop 17 | 18 | # Filter VPN interface messages 19 | :msg, contains, "wg0" stop 20 | :msg, contains, "ipsec0" stop 21 | 22 | # Filter VPN-related kernel messages 23 | :msg, regex, "IN=wg[0-9]+" stop 24 | :msg, regex, "OUT=wg[0-9]+" stop 25 | {% endif %} 26 | 27 | {% if privacy_log_filtering.filter_kernel_vpn_logs %} 28 | # Filter kernel messages related to VPN traffic 29 | :msg, contains, "netfilter" stop 30 | :msg, regex, "FORWARD.*DPT:(51820|500|4500)" stop 31 | {% endif %} 32 | 33 | # Continue processing other messages 34 | & stop 35 | -------------------------------------------------------------------------------- /roles/privacy/templates/privacy-rsyslog.conf.j2: -------------------------------------------------------------------------------- 1 | # Privacy-enhanced rsyslog configuration 2 | # Minimal logging configuration for enhanced privacy 3 | # Generated by Algo VPN privacy role 4 | 5 | # Global settings for privacy 6 | $ModLoad imuxsock # provides support for local system logging 7 | $ModLoad imklog # provides kernel logging support 8 | 9 | # Reduce logging verbosity 10 | $KLogPermitNonKernelFacility on 11 | $SystemLogSocketName /run/systemd/journal/syslog 12 | 13 | # Privacy-enhanced rules 14 | {% if privacy_advanced.reduce_kernel_verbosity %} 15 | # Reduce kernel message verbosity 16 | kern.info;kern.!debug /var/log/kern.log 17 | {% else %} 18 | kern.* /var/log/kern.log 19 | {% endif %} 20 | 21 | # Essential system messages only 22 | *.emerg :omusrmsg:* 23 | *.alert /var/log/alert.log 24 | *.crit /var/log/critical.log 25 | *.err /var/log/error.log 26 | 27 | # Compress and limit emergency logs 28 | $template PrivacyTemplate,"%timegenerated% %hostname% %syslogtag%%msg%\n" 29 | $ActionFileDefaultTemplate PrivacyTemplate 30 | 31 | # Stop processing after essential logs to prevent detailed logging 32 | & stop 33 | -------------------------------------------------------------------------------- /docs/deploy-from-cloudshell.md: -------------------------------------------------------------------------------- 1 | # Deploy from Google Cloud Shell 2 | 3 | If 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. 4 | 5 | 1. See the [Cloud Shell documentation](https://cloud.google.com/shell/docs/) to start an instance of Cloud Shell in your browser. 6 | 7 | 2. Get Algo and run it: 8 | ```bash 9 | git clone https://github.com/trailofbits/algo.git 10 | cd algo 11 | ./algo 12 | ``` 13 | 14 | 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. 15 | 16 | 3. 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: 17 | ``` 18 | zip -r configs configs 19 | dl configs.zip 20 | ``` 21 | 22 | 4. Unzip `configs.zip` on your local system and use the files to configure your VPN clients. 23 | -------------------------------------------------------------------------------- /roles/privacy/templates/kern-logrotate.j2: -------------------------------------------------------------------------------- 1 | # Privacy-enhanced kernel log rotation 2 | # Reduces retention time for kernel logs that may contain VPN traces 3 | # Generated by Algo VPN privacy role 4 | 5 | /var/log/kern.log 6 | { 7 | # Aggressive rotation for kernel logs 8 | rotate {{ privacy_log_rotation.rotate_count }} 9 | maxage {{ privacy_log_rotation.max_age }} 10 | size {{ privacy_log_rotation.max_size }}M 11 | 12 | daily 13 | missingok 14 | notifempty 15 | compress 16 | delaycompress 17 | 18 | create 0640 syslog adm 19 | copytruncate 20 | 21 | # Pre-rotation script to filter VPN-related entries 22 | prerotate 23 | # Create filtered version excluding VPN traces 24 | if [ -f /var/log/kern.log ]; then 25 | grep -v -E "(wireguard|ipsec|strongswan|xl2tpd)" /var/log/kern.log > /tmp/kern.log.filtered || true 26 | if [ -s /tmp/kern.log.filtered ]; then 27 | mv /tmp/kern.log.filtered /var/log/kern.log 28 | fi 29 | fi 30 | endscript 31 | 32 | postrotate 33 | if [ -f /var/run/rsyslogd.pid ]; then 34 | kill -HUP `cat /var/run/rsyslogd.pid` 35 | fi 36 | endscript 37 | } 38 | -------------------------------------------------------------------------------- /roles/common/tasks/facts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Set OS platform facts 3 | set_fact: 4 | is_debian_based: "{{ ansible_distribution in ['Debian', 'Ubuntu'] }}" 5 | uses_systemd_socket: "{{ ansible_distribution in ['Debian', 'Ubuntu'] }}" 6 | is_ubuntu_22_plus: "{{ ansible_distribution == 'Ubuntu' and ansible_distribution_version is version('22.04', '>=') }}" 7 | tags: always 8 | 9 | - name: Define facts 10 | set_fact: 11 | p12_export_password: "{{ p12_password | default(lookup('password', '/dev/null length=9 chars=ascii_letters,digits,_,@')) }}" 12 | tags: update-users 13 | no_log: true 14 | 15 | - name: Set facts 16 | set_fact: 17 | CA_password: "{{ ca_password | default(lookup('password', '/dev/null length=16 chars=ascii_letters,digits,_,@')) }}" 18 | IP_subject_alt_name: "{{ IP_subject_alt_name }}" 19 | no_log: true 20 | 21 | - name: Set IPv6 support as a fact 22 | set_fact: 23 | ipv6_support: "{{ ansible_default_ipv6['gateway'] is defined }}" 24 | tags: always 25 | 26 | - name: Check size of MTU 27 | set_fact: 28 | reduce_mtu: "{{ 1500 - ansible_default_ipv4['mtu'] | int if reduce_mtu | int == 0 and ansible_default_ipv4['mtu'] | int < 1500 else reduce_mtu | int }}" 29 | tags: always 30 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Filing New Issues 2 | 3 | * 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 4 | * Algo automatically installs dependencies with uv - no manual setup required 5 | * We support modern clients: macOS 12+, iOS 15+, Windows 11+, Ubuntu 22.04+, etc. 6 | * Supported cloud providers: DigitalOcean, AWS, Azure, GCP, Vultr, Hetzner, Linode, OpenStack, CloudStack 7 | * If you need to file a new issue, fill out any relevant fields in the Issue Template 8 | 9 | ### Pull Requests 10 | 11 | * Run the full linter suite: `./scripts/lint.sh` 12 | * Test your changes on multiple platforms when possible 13 | * Use conventional commit messages that clearly describe your changes 14 | * Pin dependency versions rather than using ranges (e.g., `==1.2.3` not `>=1.2.0`) 15 | 16 | ### Development Setup 17 | 18 | * Clone the repository: `git clone https://github.com/trailofbits/algo.git` 19 | * Run Algo: `./algo` (dependencies installed automatically via uv) 20 | * Install pre-commit hooks: `uv run pre-commit install` (optional, for contributors) 21 | * For local testing, consider using Docker or a cloud provider test instance 22 | 23 | Thanks! 24 | -------------------------------------------------------------------------------- /roles/privacy/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Privacy enhancements for Algo VPN 3 | # This role implements additional privacy measures to reduce log retention 4 | # and minimize traces of VPN usage on the server 5 | 6 | - name: Display privacy enhancements status 7 | debug: 8 | msg: "Privacy enhancements are {{ 'enabled' if privacy_enhancements_enabled else 'disabled' }}" 9 | 10 | - name: Privacy enhancements block 11 | block: 12 | - name: Include log rotation tasks 13 | include_tasks: log_rotation.yml 14 | tags: privacy-logs 15 | 16 | - name: Include history clearing tasks 17 | include_tasks: clear_history.yml 18 | tags: privacy-history 19 | 20 | - name: Include log filtering tasks 21 | include_tasks: log_filtering.yml 22 | tags: privacy-filtering 23 | 24 | - name: Include automatic cleanup tasks 25 | include_tasks: auto_cleanup.yml 26 | tags: privacy-cleanup 27 | 28 | - name: Include advanced privacy tasks 29 | include_tasks: advanced_privacy.yml 30 | tags: privacy-advanced 31 | 32 | - name: Display privacy enhancements completion 33 | debug: 34 | msg: "Privacy enhancements have been successfully applied" 35 | 36 | when: privacy_enhancements_enabled | bool 37 | -------------------------------------------------------------------------------- /roles/strongswan/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: restart strongswan 3 | service: name={{ strongswan_service }} state=restarted 4 | 5 | - name: daemon-reload 6 | systemd: daemon_reload=true 7 | 8 | - name: restart apparmor 9 | service: name=apparmor state=restarted 10 | 11 | - name: rereadcrls 12 | shell: | 13 | # Check if StrongSwan is actually running 14 | if ! systemctl is-active --quiet strongswan-starter 2>/dev/null && \ 15 | ! systemctl is-active --quiet strongswan 2>/dev/null && \ 16 | ! service strongswan status >/dev/null 2>&1; then 17 | echo "StrongSwan is not running, skipping CRL reload" 18 | exit 0 19 | fi 20 | 21 | # StrongSwan is running, wait a moment for it to stabilize 22 | sleep 2 23 | 24 | # Try to reload CRLs with retries 25 | for attempt in 1 2 3; do 26 | if ipsec rereadcrls 2>/dev/null && ipsec purgecrls 2>/dev/null; then 27 | echo "Successfully reloaded CRLs" 28 | exit 0 29 | fi 30 | echo "Attempt $attempt failed, retrying..." 31 | sleep 2 32 | done 33 | 34 | # If StrongSwan is running but we can't reload CRLs, that's a real problem 35 | echo "Failed to reload CRLs after 3 attempts" 36 | exit 1 37 | changed_when: false 38 | -------------------------------------------------------------------------------- /.github/actions/setup-algo/action.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 'Setup Algo Environment' 3 | description: 'Setup Python, uv, and dependencies for Algo VPN CI' 4 | inputs: 5 | python-version: 6 | description: 'Python version to use' 7 | required: false 8 | default: '3.11' 9 | install-shellcheck: 10 | description: 'Install shellcheck for shell script linting' 11 | required: false 12 | default: 'false' 13 | install-ansible-collections: 14 | description: 'Install Ansible Galaxy collections' 15 | required: false 16 | default: 'false' 17 | runs: 18 | using: composite 19 | steps: 20 | - name: Setup Python 21 | uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 22 | with: 23 | python-version: ${{ inputs.python-version }} 24 | 25 | - name: Setup uv environment 26 | uses: ./.github/actions/setup-uv 27 | 28 | - name: Install shellcheck 29 | if: inputs.install-shellcheck == 'true' 30 | run: sudo apt-get update && sudo apt-get install -y shellcheck 31 | shell: bash 32 | 33 | - name: Install Ansible collections 34 | if: inputs.install-ansible-collections == 'true' 35 | run: uv run ansible-galaxy collection install -r requirements.yml 36 | shell: bash 37 | -------------------------------------------------------------------------------- /roles/privacy/templates/privacy-shutdown-cleanup.service.j2: -------------------------------------------------------------------------------- 1 | # Privacy shutdown cleanup systemd service 2 | # Clears logs and sensitive data on system shutdown 3 | # Generated by Algo VPN privacy role 4 | 5 | [Unit] 6 | Description=Privacy Cleanup on Shutdown 7 | DefaultDependencies=false 8 | Before=shutdown.target reboot.target halt.target 9 | Requires=-.mount 10 | 11 | [Service] 12 | Type=oneshot 13 | RemainAfterExit=true 14 | ExecStart=/bin/true 15 | ExecStop=/bin/bash -c ' 16 | # Clear all logs 17 | find /var/log -type f -name "*.log" -exec truncate -s 0 {} \; 2>/dev/null || true; 18 | 19 | # Clear rotated logs 20 | find /var/log -type f \( -name "*.log.*" -o -name "*.gz" \) -delete 2>/dev/null || true; 21 | 22 | # Clear systemd journal 23 | if [ -d /var/log/journal ]; then 24 | rm -rf /var/log/journal/* 2>/dev/null || true; 25 | fi; 26 | 27 | # Clear bash history 28 | for user_home in /home/* /root; do 29 | if [ -d "$user_home" ]; then 30 | rm -f "$user_home"/.bash_history 2>/dev/null || true; 31 | rm -f "$user_home"/.zsh_history 2>/dev/null || true; 32 | fi; 33 | done; 34 | 35 | # Clear temporary files 36 | rm -rf /tmp/* /var/tmp/* 2>/dev/null || true; 37 | 38 | # Sync to ensure changes are written 39 | sync; 40 | ' 41 | TimeoutStopSec=30 42 | 43 | [Install] 44 | WantedBy=shutdown.target 45 | -------------------------------------------------------------------------------- /files/cloud-init/base.yml: -------------------------------------------------------------------------------- 1 | #cloud-config 2 | # CRITICAL: The above line MUST be exactly "#cloud-config" (no space after #) 3 | # This is required by cloud-init's YAML parser. Adding a space breaks parsing 4 | # and causes all cloud-init directives to be skipped, resulting in SSH timeouts. 5 | # See: https://github.com/trailofbits/algo/issues/14800 6 | output: {all: '| tee -a /var/log/cloud-init-output.log'} 7 | 8 | package_update: true 9 | package_upgrade: true 10 | 11 | packages: 12 | - sudo 13 | {% if performance_preinstall_packages | default(false) %} 14 | # Universal tools always needed by Algo (performance optimization) 15 | - git 16 | - screen 17 | - apparmor-utils 18 | - uuid-runtime 19 | - coreutils 20 | - iptables-persistent 21 | - cgroup-tools 22 | {% endif %} 23 | 24 | users: 25 | - default 26 | - name: algo 27 | homedir: /home/algo 28 | sudo: ALL=(ALL) NOPASSWD:ALL 29 | groups: adm,netdev 30 | shell: /bin/bash 31 | lock_passwd: true 32 | ssh_authorized_keys: 33 | - "{{ lookup('file', SSH_keys.public) | string }}" 34 | 35 | write_files: 36 | - path: /etc/ssh/sshd_config 37 | content: | 38 | {{ lookup('template', 'files/cloud-init/sshd_config') | string | indent(width=6, first=True) }} 39 | 40 | runcmd: 41 | - set -x 42 | - ufw --force reset 43 | - sudo apt-get remove -y --purge sshguard || true 44 | - systemctl restart sshd.service 45 | -------------------------------------------------------------------------------- /algo-docker.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eEo pipefail 4 | 5 | ALGO_DIR="/algo" 6 | DATA_DIR="/data" 7 | 8 | umask 0077 9 | 10 | usage() { 11 | retcode="${1:-0}" 12 | echo "To run algo from Docker:" 13 | echo "" 14 | echo "docker run --cap-drop=all -it -v :${DATA_DIR} ghcr.io/trailofbits/algo:latest" 15 | echo "" 16 | exit "${retcode}" 17 | } 18 | 19 | if [ ! -f "${DATA_DIR}"/config.cfg ] ; then 20 | echo "Looks like you're not bind-mounting your config.cfg into this container." 21 | echo "algo needs a configuration file to run." 22 | echo "" 23 | usage -1 24 | fi 25 | 26 | if [ ! -e /dev/console ] ; then 27 | echo "Looks like you're trying to run this container without a TTY." 28 | echo "If you don't pass -t, you can't interact with the algo script." 29 | echo "" 30 | usage -1 31 | fi 32 | 33 | # To work around problems with bind-mounting Windows volumes, we need to 34 | # copy files out of ${DATA_DIR}, ensure appropriate line endings and permissions, 35 | # then copy the algo-generated files into ${DATA_DIR}. 36 | 37 | tr -d '\r' < "${DATA_DIR}"/config.cfg > "${ALGO_DIR}"/config.cfg 38 | test -d "${DATA_DIR}"/configs && rsync -qLktr --delete "${DATA_DIR}"/configs "${ALGO_DIR}"/ 39 | 40 | "${ALGO_DIR}"/algo "${ALGO_ARGS[@]}" 41 | retcode=${?} 42 | 43 | rsync -qLktr --delete "${ALGO_DIR}"/configs "${DATA_DIR}"/ 44 | exit "${retcode}" 45 | -------------------------------------------------------------------------------- /roles/strongswan/tasks/client_configs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Register p12 PayloadContent 3 | shell: | 4 | set -o pipefail 5 | cat private/{{ item }}.p12 | 6 | base64 7 | register: PayloadContent 8 | changed_when: false 9 | args: 10 | executable: bash 11 | chdir: "{{ ipsec_pki_path }}" 12 | loop: "{{ users }}" 13 | 14 | - name: Set facts for mobileconfigs 15 | set_fact: 16 | PayloadContentCA: "{{ lookup('file', ipsec_pki_path + '/cacert.pem') | b64encode }}" 17 | 18 | - name: Build the mobileconfigs 19 | template: 20 | src: mobileconfig.j2 21 | dest: "{{ ipsec_config_path }}/apple/{{ item.0 }}.mobileconfig" 22 | mode: '0600' 23 | with_together: 24 | - "{{ users }}" 25 | - "{{ PayloadContent.results }}" 26 | no_log: "{{ algo_no_log | bool }}" 27 | 28 | - name: Build the client ipsec config file 29 | template: 30 | src: client_ipsec.conf.j2 31 | dest: "{{ ipsec_config_path }}/manual/{{ item }}.conf" 32 | mode: '0600' 33 | loop: "{{ users }}" 34 | 35 | 36 | - name: Build the client ipsec secret file 37 | template: 38 | src: client_ipsec.secrets.j2 39 | dest: "{{ ipsec_config_path }}/manual/{{ item }}.secrets" 40 | mode: '0600' 41 | loop: "{{ users }}" 42 | 43 | - name: Restrict permissions for the local private directories 44 | file: 45 | path: "{{ ipsec_config_path }}" 46 | state: directory 47 | mode: '0700' 48 | -------------------------------------------------------------------------------- /roles/strongswan/templates/ipsec.conf.j2: -------------------------------------------------------------------------------- 1 | config setup 2 | uniqueids=never # allow multiple connections per user 3 | 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 }}" 4 | 5 | conn %default 6 | fragmentation=yes 7 | rekey=no 8 | dpdaction=clear 9 | keyexchange=ikev2 10 | compress=yes 11 | dpddelay=35s 12 | lifetime=3h 13 | ikelifetime=12h 14 | 15 | ike={{ ciphers.defaults.ike }} 16 | esp={{ ciphers.defaults.esp }} 17 | 18 | left=%any 19 | leftauth=pubkey 20 | leftid={{ IP_subject_alt_name }} 21 | leftcert={{ IP_subject_alt_name }}.crt 22 | leftsendcert=always 23 | leftsubnet=0.0.0.0/0,::/0 24 | 25 | right=%any 26 | rightauth=pubkey 27 | rightsourceip={{ strongswan_network }},{{ strongswan_network_ipv6 }} 28 | {% if algo_dns_adblocking or dns_encryption %} 29 | rightdns={{ local_service_ip }}{{ ',' + local_service_ipv6 if ipv6_support else '' }} 30 | {% else %} 31 | rightdns={% for host in dns_servers.ipv4 %}{{ host }}{% if not loop.last %},{% endif %}{% endfor %}{% if ipv6_support %},{% for host in dns_servers.ipv6 %}{{ host }}{% if not loop.last %},{% endif %}{% endfor %}{% endif %} 32 | {% endif %} 33 | 34 | conn ikev2-pubkey 35 | auto=add 36 | -------------------------------------------------------------------------------- /docs/client-macos-wireguard.md: -------------------------------------------------------------------------------- 1 | # MacOS WireGuard Client Setup 2 | 3 | The 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. 4 | 5 | ## Install WireGuard 6 | 7 | Install the wireguard-go userspace driver: 8 | 9 | ``` 10 | brew install wireguard-tools 11 | ``` 12 | 13 | ## Locate the Config File 14 | 15 | Algo generates a WireGuard configuration file, `wireguard/.conf`, and a QR code, `wireguard/.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. 16 | 17 | Note that each client you use to connect to Algo VPN must have a unique WireGuard config. 18 | 19 | ## Configure WireGuard 20 | 21 | You'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. 22 | 23 | ``` 24 | # Copy the config file to the WireGuard configuration directory on your macOS device 25 | mkdir /usr/local/etc/wireguard/ 26 | cp .conf /usr/local/etc/wireguard/wg0.conf 27 | 28 | # Start the WireGuard VPN 29 | sudo wg-quick up wg0 30 | 31 | # Verify the connection to the Algo VPN 32 | wg 33 | 34 | # See that your client is using the IP address of your Algo VPN: 35 | curl ipv4.icanhazip.com 36 | ``` 37 | -------------------------------------------------------------------------------- /roles/privacy/templates/privacy-logrotate.j2: -------------------------------------------------------------------------------- 1 | # Privacy-enhanced logrotate configuration 2 | # This configuration enforces aggressive log rotation for privacy 3 | # Generated by Algo VPN privacy role 4 | # Replaces the default rsyslog logrotate configuration 5 | 6 | # Main system logs (may not all exist on every system) 7 | /var/log/syslog 8 | /var/log/messages 9 | /var/log/daemon.log 10 | /var/log/debug 11 | /var/log/user.log 12 | /var/log/mail.log 13 | /var/log/mail.err 14 | /var/log/mail.warn 15 | { 16 | # Rotate {{ privacy_log_rotation.rotate_count }} times before deletion 17 | rotate {{ privacy_log_rotation.rotate_count }} 18 | 19 | # Maximum age in days 20 | maxage {{ privacy_log_rotation.max_age }} 21 | 22 | # Maximum size per file 23 | size {{ privacy_log_rotation.max_size }}M 24 | 25 | {% if privacy_log_rotation.daily_rotation %} 26 | # Force daily rotation 27 | daily 28 | {% endif %} 29 | 30 | {% if privacy_log_rotation.compress %} 31 | # Compress rotated files 32 | compress 33 | delaycompress 34 | {% endif %} 35 | 36 | # Missing files are ok (not all systems have all logs) 37 | missingok 38 | 39 | # Don't rotate if empty 40 | notifempty 41 | 42 | # Create new files with specific permissions 43 | create 0640 syslog adm 44 | 45 | # Truncate original file after rotation 46 | copytruncate 47 | 48 | # Execute after rotation 49 | postrotate 50 | # Send SIGHUP to rsyslog 51 | /usr/bin/killall -HUP rsyslogd 2>/dev/null || true 52 | endscript 53 | } 54 | -------------------------------------------------------------------------------- /roles/dns/templates/adblock.sh.j2: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Block ads, malware, etc.. 3 | 4 | TEMP="$(mktemp)" 5 | TEMP_SORTED="$(mktemp)" 6 | WHITELIST="/etc/dnscrypt-proxy/white.list" 7 | BLACKLIST="/etc/dnscrypt-proxy/black.list" 8 | BLOCKHOSTS="{{ config_prefix | default('/') }}etc/dnscrypt-proxy/blacklist.txt" 9 | BLOCKLIST_URLS="{% for url in adblock_lists %}{{ url }} {% endfor %}" 10 | 11 | #Delete the old block.hosts to make room for the updates 12 | rm -f $BLOCKHOSTS 13 | 14 | echo 'Downloading hosts lists...' 15 | #Download and process the files needed to make the lists (enable/add more, if you want) 16 | for url in $BLOCKLIST_URLS; do 17 | 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" 18 | done 19 | 20 | #Add black list, if non-empty 21 | if [ -s "$BLACKLIST" ] 22 | then 23 | echo 'Adding blacklist...' 24 | cat $BLACKLIST >> "$TEMP" 25 | fi 26 | 27 | #Sort the download/black lists 28 | awk '/^[^#]/ { print $1 }' "$TEMP" | sort -u > "$TEMP_SORTED" 29 | 30 | #Filter (if applicable) 31 | if [ -s "$WHITELIST" ] 32 | then 33 | #Filter the blacklist, suppressing whitelist matches 34 | # This is relatively slow =-( 35 | echo 'Filtering white list...' 36 | grep -v -E "^[[:space:]]*$" $WHITELIST | awk '/^[^#]/ {sub(/\r$/,"");print $1}' | grep -vf - "$TEMP_SORTED" > $BLOCKHOSTS 37 | else 38 | cat "$TEMP_SORTED" > $BLOCKHOSTS 39 | fi 40 | 41 | echo 'Restarting dns service...' 42 | #Restart the dns service 43 | systemctl restart dnscrypt-proxy.service 44 | 45 | exit 0 46 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Types of changes 16 | 17 | - Bug fix (non-breaking change which fixes an issue) 18 | - New feature (non-breaking change which adds functionality) 19 | - Breaking change (fix or feature that would cause existing functionality to not work as expected) 20 | 21 | ## Checklist: 22 | 23 | 24 | - [ ] I have read the **CONTRIBUTING** document. 25 | - [ ] My code passes all linters (`./scripts/lint.sh`) 26 | - [ ] My code follows the code style of this project. 27 | - [ ] My change requires a change to the documentation. 28 | - [ ] I have updated the documentation accordingly. 29 | - [ ] I have added tests to cover my changes. 30 | - [ ] All new and existing tests passed. 31 | - [ ] Dependencies use exact versions (e.g., `==1.2.3` not `>=1.2.0`). 32 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Algo VPN documentation 2 | 3 | * Deployment instructions 4 | - Deploy from [RedHat/CentOS 6.x](deploy-from-redhat-centos6.md) 5 | - Deploy from [Windows](deploy-from-windows.md) 6 | - Deploy from a [Docker container](deploy-from-docker.md) 7 | - Deploy from [Ansible](deploy-from-ansible.md) non-interactively 8 | - Deploy onto a [cloud server at time of creation with shell script or cloud-init](deploy-from-script-or-cloud-init-to-localhost.md) 9 | - Deploy from [macOS](deploy-from-macos.md) 10 | - Deploy from [Google Cloud Shell](deploy-from-cloudshell.md) 11 | * Client setup 12 | - Setup [Android](client-android.md) clients 13 | - Setup [Generic/Linux](client-linux.md) clients with Ansible 14 | - Setup Ubuntu clients to use [WireGuard](client-linux-wireguard.md) 15 | - Setup Linux clients to use [IPsec](client-linux-ipsec.md) 16 | - Setup Apple devices to use [IPsec](client-apple-ipsec.md) 17 | - Setup Macs running macOS 10.13 or older to use [WireGuard](client-macos-wireguard.md) 18 | * Cloud provider setup 19 | - Configure [Amazon EC2](cloud-amazon-ec2.md) 20 | - Configure [Azure](cloud-azure.md) 21 | - Configure [DigitalOcean](cloud-do.md) 22 | - Configure [Google Cloud Platform](cloud-gce.md) 23 | - Configure [Vultr](cloud-vultr.md) 24 | - Configure [CloudStack](cloud-cloudstack.md) 25 | - Configure [Hetzner Cloud](cloud-hetzner.md) 26 | * Advanced Deployment 27 | - Deploy to your own [Ubuntu](deploy-to-ubuntu.md) server, and road warrior setup 28 | - Deploy to an [unsupported cloud provider](deploy-to-unsupported-cloud.md) 29 | * [FAQ](faq.md) 30 | * [Firewalls](firewalls.md) 31 | * [Troubleshooting](troubleshooting.md) 32 | -------------------------------------------------------------------------------- /docs/client-apple-ipsec.md: -------------------------------------------------------------------------------- 1 | # Using the built-in IPSEC VPN on Apple Devices 2 | 3 | ## Configure IPsec 4 | 5 | Find 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. 6 | 7 | ## Enable the VPN 8 | 9 | On 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. 10 | 11 | ## Managing "Connect On Demand" 12 | 13 | If 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". 14 | 15 | On 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. 16 | -------------------------------------------------------------------------------- /tests/integration/ansible-service-wrapper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Wrapper for Ansible's service module that always succeeds for known services 4 | """ 5 | 6 | import json 7 | import sys 8 | 9 | # Parse module arguments 10 | args = json.loads(sys.stdin.read()) 11 | module_args = args.get("ANSIBLE_MODULE_ARGS", {}) 12 | 13 | service_name = module_args.get("name", "") 14 | state = module_args.get("state", "started") 15 | 16 | # Known services that should always succeed 17 | known_services = [ 18 | "netfilter-persistent", 19 | "iptables", 20 | "wg-quick@wg0", 21 | "strongswan-starter", 22 | "ipsec", 23 | "apparmor", 24 | "unattended-upgrades", 25 | "systemd-networkd", 26 | "systemd-resolved", 27 | "rsyslog", 28 | "ipfw", 29 | "cron", 30 | ] 31 | 32 | # Check if it's a known service 33 | service_found = False 34 | for svc in known_services: 35 | if service_name == svc or service_name.startswith(svc + "."): 36 | service_found = True 37 | break 38 | 39 | if service_found: 40 | # Return success 41 | result = { 42 | "changed": True if state in ["started", "stopped", "restarted", "reloaded"] else False, 43 | "name": service_name, 44 | "state": state, 45 | "status": { 46 | "LoadState": "loaded", 47 | "ActiveState": "active" if state != "stopped" else "inactive", 48 | "SubState": "running" if state != "stopped" else "dead", 49 | }, 50 | } 51 | print(json.dumps(result)) 52 | sys.exit(0) 53 | else: 54 | # Service not found 55 | error = {"failed": True, "msg": f"Could not find the requested service {service_name}: "} 56 | print(json.dumps(error)) 57 | sys.exit(1) 58 | -------------------------------------------------------------------------------- /roles/cloud-hetzner/tasks/prompts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - pause: 3 | prompt: | 4 | Enter your API token (https://trailofbits.github.io/algo/cloud-hetzner.html#api-token): 5 | echo: false 6 | register: _hcloud_token 7 | when: 8 | - hcloud_token is undefined 9 | - lookup('env', 'HCLOUD_TOKEN')|length <= 0 10 | no_log: true 11 | 12 | - name: Set the token as a fact 13 | set_fact: 14 | algo_hcloud_token: "{{ hcloud_token | default(_hcloud_token.user_input | default(None)) | default(lookup('env', 'HCLOUD_TOKEN'), true) }}" 15 | no_log: true 16 | 17 | - name: Get regions 18 | hetzner.hcloud.datacenter_info: 19 | api_token: "{{ algo_hcloud_token }}" 20 | register: _hcloud_regions 21 | 22 | - name: Set facts about the regions 23 | set_fact: 24 | hcloud_regions: "{{ _hcloud_regions.hcloud_datacenter_info | sort(attribute='location') }}" 25 | 26 | - name: Set default region 27 | set_fact: 28 | default_region: >- 29 | {%- for r in hcloud_regions -%} 30 | {%- if r['location'] == "nbg1" %}{{ loop.index }}{% endif -%} 31 | {%- endfor %} 32 | 33 | - pause: 34 | prompt: | 35 | What region should the server be located in? 36 | {% for r in hcloud_regions %} 37 | {{ loop.index }}. {{ r['location'] }} {{ r['description'] }} 38 | {% endfor %} 39 | 40 | Enter the number of your desired region 41 | [{{ default_region }}] 42 | register: _algo_region 43 | when: region is undefined 44 | 45 | - name: Set additional facts 46 | set_fact: 47 | algo_hcloud_region: >- 48 | {%- 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 -%} 49 | -------------------------------------------------------------------------------- /roles/dns/templates/dnscrypt-proxy/filters.toml.j2: -------------------------------------------------------------------------------- 1 | ######################### 2 | # Filters # 3 | ######################### 4 | 5 | ## Immediately respond to IPv6-related queries with an empty response 6 | block_ipv6 = false 7 | 8 | 9 | 10 | ############################### 11 | # Query logging # 12 | ############################### 13 | 14 | ## Log client queries to a file 15 | ## Privacy warning: Only enable for debugging purposes 16 | [query_log] 17 | format = 'tsv' 18 | 19 | 20 | 21 | ############################################ 22 | # Suspicious queries logging # 23 | ############################################ 24 | 25 | ## Log queries for nonexistent zones 26 | [nx_log] 27 | format = 'tsv' 28 | 29 | 30 | 31 | ###################################################### 32 | # Pattern-based blocking (blacklists) # 33 | ###################################################### 34 | 35 | [blacklist] 36 | {{ "blacklist_file = 'blacklist.txt'" if algo_dns_adblocking else "" }} 37 | 38 | 39 | 40 | ########################################################### 41 | # Pattern-based IP blocking (IP blacklists) # 42 | ########################################################### 43 | 44 | [ip_blacklist] 45 | blacklist_file = 'ip-blacklist.txt' 46 | 47 | 48 | 49 | ###################################################### 50 | # Pattern-based whitelisting (blacklists bypass) # 51 | ###################################################### 52 | 53 | [whitelist] 54 | # whitelist_file = 'whitelist.txt' 55 | 56 | 57 | 58 | ########################################## 59 | # Time access restrictions # 60 | ########################################## 61 | 62 | [schedules] 63 | # Time-based access rules can be defined here 64 | -------------------------------------------------------------------------------- /roles/cloud-digitalocean/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include prompts 3 | import_tasks: prompts.yml 4 | 5 | - name: Upload the SSH key 6 | digital_ocean_sshkey: 7 | oauth_token: "{{ algo_do_token }}" 8 | name: "{{ SSH_keys.comment }}" 9 | ssh_pub_key: "{{ lookup('file', SSH_keys.public) }}" 10 | register: do_ssh_key 11 | 12 | - name: Creating a droplet... 13 | digital_ocean_droplet: 14 | state: present 15 | name: "{{ algo_server_name }}" 16 | oauth_token: "{{ algo_do_token }}" 17 | size: "{{ cloud_providers.digitalocean.size }}" 18 | region: "{{ algo_do_region }}" 19 | image: "{{ cloud_providers.digitalocean.image }}" 20 | wait_timeout: 300 21 | unique_name: true 22 | ipv6: true 23 | ssh_keys: "{{ do_ssh_key.data.ssh_key.id }}" 24 | user_data: "{{ lookup('template', 'files/cloud-init/base.yml') | string }}" 25 | tags: 26 | - Environment:Algo 27 | register: digital_ocean_droplet 28 | 29 | # Return data is not idempotent 30 | - set_fact: 31 | droplet: "{{ digital_ocean_droplet.data.droplet | default(digital_ocean_droplet.data) }}" 32 | 33 | - block: 34 | - name: Create a Floating IP 35 | community.digitalocean.digital_ocean_floating_ip: 36 | state: present 37 | oauth_token: "{{ algo_do_token }}" 38 | droplet_id: "{{ droplet.id }}" 39 | register: digital_ocean_floating_ip 40 | 41 | - name: Set the static ip as a fact 42 | set_fact: 43 | cloud_alternative_ingress_ip: "{{ digital_ocean_floating_ip.data.floating_ip.ip }}" 44 | when: alternative_ingress_ip 45 | 46 | - set_fact: 47 | cloud_instance_ip: "{{ (droplet.networks.v4 | selectattr('type', '==', 'public')).0.ip_address }}" 48 | ansible_ssh_user: algo 49 | ansible_ssh_port: "{{ ssh_port }}" 50 | cloudinit: true 51 | -------------------------------------------------------------------------------- /roles/cloud-linode/tasks/prompts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - pause: 3 | prompt: | 4 | Enter your ACCESS token. (https://developers.linode.com/api/v4/#access-and-authentication): 5 | echo: false 6 | register: _linode_token 7 | when: 8 | - linode_token is undefined 9 | - lookup('env', 'LINODE_API_TOKEN')|length <= 0 10 | no_log: true 11 | 12 | - name: Set the token as a fact 13 | set_fact: 14 | algo_linode_token: "{{ linode_token | default(_linode_token.user_input | default(None)) | default(lookup('env', 'LINODE_API_TOKEN'), true) }}" 15 | no_log: true 16 | 17 | - name: Get regions 18 | uri: 19 | url: https://api.linode.com/v4/regions 20 | method: GET 21 | status_code: 200 22 | register: _linode_regions 23 | 24 | - name: Set facts about the regions 25 | set_fact: 26 | linode_regions: "{{ _linode_regions.json.data | sort(attribute='id') }}" 27 | 28 | - name: Set default region 29 | set_fact: 30 | default_region: >- 31 | {%- for r in linode_regions -%} 32 | {%- if r['id'] == "us-east" %}{{ loop.index }}{% endif -%} 33 | {%- endfor %} 34 | 35 | - pause: 36 | prompt: | 37 | What region should the server be located in? 38 | {% for r in linode_regions %} 39 | {{ loop.index }}. {{ r['id'] }} 40 | {% endfor %} 41 | 42 | Enter the number of your desired region 43 | [{{ default_region }}] 44 | register: _algo_region 45 | when: region is undefined 46 | 47 | - name: Set additional facts 48 | set_fact: 49 | algo_linode_region: >- 50 | {%- if region is defined -%}{{ region }}{%- elif _algo_region.user_input -%}{{ linode_regions[_algo_region.user_input | int - 1]['id'] }}{%- else -%}{{ linode_regions[default_region | int - 1]['id'] }}{%- endif -%} 51 | public_key: "{{ lookup('file', SSH_keys.public) }}" 52 | -------------------------------------------------------------------------------- /roles/wireguard/tasks/ubuntu.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: WireGuard installed (individual) 3 | apt: 4 | name: wireguard 5 | state: present 6 | update_cache: true 7 | when: not performance_parallel_packages | default(true) 8 | 9 | - name: Set OS specific facts 10 | set_fact: 11 | service_name: wg-quick@{{ wireguard_interface }} 12 | tags: always 13 | 14 | - name: Ubuntu | Ensure that the WireGuard service directory exists 15 | file: 16 | path: /etc/systemd/system/wg-quick@{{ wireguard_interface }}.service.d/ 17 | state: directory 18 | mode: '0755' 19 | owner: root 20 | group: root 21 | 22 | - name: Ubuntu | Apply systemd security hardening for WireGuard 23 | copy: 24 | dest: /etc/systemd/system/wg-quick@{{ wireguard_interface }}.service.d/90-security-hardening.conf 25 | content: | 26 | # Algo VPN systemd security hardening for WireGuard 27 | [Service] 28 | # Privilege restrictions 29 | NoNewPrivileges=yes 30 | 31 | # Filesystem isolation 32 | ProtectSystem=strict 33 | ProtectHome=yes 34 | PrivateTmp=yes 35 | ProtectKernelTunables=yes 36 | ProtectControlGroups=yes 37 | 38 | # Network restrictions - WireGuard needs NETLINK for interface management 39 | RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK 40 | 41 | # Allow access to WireGuard configuration 42 | ReadWritePaths=/etc/wireguard 43 | ReadOnlyPaths=/etc/resolv.conf 44 | 45 | # System call filtering - allow network and system service calls 46 | SystemCallFilter=@system-service @network-io 47 | SystemCallFilter=~@debug @mount @swap @reboot @raw-io 48 | SystemCallErrorNumber=EPERM 49 | owner: root 50 | group: root 51 | mode: '0644' 52 | notify: 53 | - daemon-reload 54 | - restart wireguard 55 | -------------------------------------------------------------------------------- /roles/cloud-linode/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include prompts 3 | import_tasks: prompts.yml 4 | 5 | - name: Set facts 6 | set_fact: 7 | stackscript: | 8 | {{ lookup('template', 'files/cloud-init/base.sh') }} 9 | mkdir -p /var/lib/cloud/data/ || true 10 | touch /var/lib/cloud/data/result.json 11 | 12 | - name: Create a stackscript 13 | linode.cloud.stackscript: 14 | api_token: "{{ algo_linode_token }}" 15 | label: "{{ algo_server_name }}" 16 | state: present 17 | description: Environment:Algo 18 | images: 19 | - "{{ cloud_providers.linode.image }}" 20 | script: | 21 | {{ stackscript }} 22 | register: _linode_stackscript 23 | no_log: true 24 | 25 | - name: Update the stackscript 26 | uri: 27 | url: https://api.linode.com/v4/linode/stackscripts/{{ _linode_stackscript.stackscript.id }} 28 | method: PUT 29 | body_format: json 30 | body: 31 | script: | 32 | {{ stackscript }} 33 | headers: 34 | Content-Type: application/json 35 | Authorization: Bearer {{ algo_linode_token }} 36 | when: (_linode_stackscript.stackscript.script | hash('md5')) != (stackscript | hash('md5')) 37 | no_log: true 38 | 39 | - name: Creating an instance... 40 | linode.cloud.instance: 41 | api_token: "{{ algo_linode_token }}" 42 | label: "{{ algo_server_name }}" 43 | state: present 44 | region: "{{ algo_linode_region }}" 45 | image: "{{ cloud_providers.linode.image }}" 46 | type: "{{ cloud_providers.linode.type }}" 47 | authorized_keys: "{{ public_key }}" 48 | stackscript_id: "{{ _linode_stackscript.stackscript.id }}" 49 | register: _linode 50 | no_log: true 51 | 52 | - set_fact: 53 | cloud_instance_ip: "{{ _linode.instance.ipv4[0] }}" 54 | ansible_ssh_user: algo 55 | ansible_ssh_port: "{{ ssh_port }}" 56 | cloudinit: true 57 | -------------------------------------------------------------------------------- /roles/privacy/tasks/clear_history.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Clear command history and disable persistent history for privacy 3 | 4 | - name: Clear bash history for all users 5 | shell: | 6 | for user_home in /home/* /root; do 7 | if [ -d "$user_home" ]; then 8 | rm -f "$user_home/.bash_history" 9 | rm -f "$user_home/.zsh_history" 10 | rm -f "$user_home/.sh_history" 11 | fi 12 | done 13 | when: privacy_history_clearing.clear_bash_history | bool 14 | changed_when: false 15 | 16 | - name: Clear system command history logs 17 | file: 18 | path: "{{ item }}" 19 | state: absent 20 | loop: 21 | - /var/log/lastlog 22 | - /var/log/wtmp.1 23 | - /var/log/btmp.1 24 | - /tmp/.X* 25 | - /tmp/.font-unix 26 | - /tmp/.ICE-unix 27 | when: privacy_history_clearing.clear_system_history | bool 28 | failed_when: false 29 | 30 | - name: Configure bash to not save history for service users 31 | lineinfile: 32 | path: "{{ item }}/.bashrc" 33 | line: "{{ history_disable_line }}" 34 | create: true 35 | mode: '0644' 36 | loop: 37 | - /root 38 | - /home/ubuntu 39 | vars: 40 | history_disable_line: | 41 | # Privacy enhancement: disable bash history 42 | export HISTFILE=/dev/null 43 | export HISTSIZE=0 44 | export HISTFILESIZE=0 45 | unset HISTFILE 46 | when: privacy_history_clearing.disable_service_history | bool 47 | failed_when: false 48 | 49 | - name: Create history clearing script for logout 50 | template: 51 | src: clear-history-on-logout.sh.j2 52 | dest: /etc/bash.bash_logout 53 | mode: '0644' 54 | owner: root 55 | group: root 56 | when: privacy_history_clearing.clear_bash_history | bool 57 | 58 | # Note: We don't clear current session history as each Ansible task 59 | # runs in its own shell session, making this operation ineffective 60 | -------------------------------------------------------------------------------- /docs/cloud-gce.md: -------------------------------------------------------------------------------- 1 | # Google Cloud Platform setup 2 | 3 | * Follow the [`gcloud` installation instructions](https://cloud.google.com/sdk/) 4 | 5 | * Log into your account using `gcloud init` 6 | 7 | ### Creating a project 8 | 9 | The 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. 10 | 11 | ```bash 12 | ## Create the project to group the resources 13 | ### You might need to change it to have a global unique project id 14 | PROJECT_ID=${USER}-algo-vpn 15 | BILLING_ID="$(gcloud beta billing accounts list --format="value(ACCOUNT_ID)")" 16 | 17 | gcloud projects create ${PROJECT_ID} --name algo-vpn --set-as-default 18 | gcloud beta billing projects link ${PROJECT_ID} --billing-account ${BILLING_ID} 19 | 20 | ## Create an account that have access to the VPN 21 | gcloud iam service-accounts create algo-vpn --display-name "Algo VPN" 22 | gcloud iam service-accounts keys create configs/gce.json \ 23 | --iam-account algo-vpn@${PROJECT_ID}.iam.gserviceaccount.com 24 | gcloud projects add-iam-policy-binding ${PROJECT_ID} \ 25 | --member serviceAccount:algo-vpn@${PROJECT_ID}.iam.gserviceaccount.com \ 26 | --role roles/compute.admin 27 | gcloud projects add-iam-policy-binding ${PROJECT_ID} \ 28 | --member serviceAccount:algo-vpn@${PROJECT_ID}.iam.gserviceaccount.com \ 29 | --role roles/iam.serviceAccountUser 30 | 31 | ## Enable the services 32 | gcloud services enable compute.googleapis.com 33 | 34 | ./algo -e "provider=gce" -e "gce_credentials_file=$(pwd)/configs/gce.json" 35 | 36 | ``` 37 | 38 | **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. 39 | 40 | 41 | There are more advanced arguments available for deployment [using ansible](deploy-from-ansible.md). 42 | -------------------------------------------------------------------------------- /roles/privacy/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Privacy enhancement configuration defaults 3 | # These settings can be overridden in config.cfg 4 | 5 | # Enable privacy enhancements (disabled for debugging when false) 6 | privacy_enhancements_enabled: true 7 | 8 | # Log rotation settings 9 | privacy_log_rotation: 10 | # Maximum age for system logs in days 11 | max_age: 7 12 | # Maximum size for individual log files in MB 13 | max_size: 10 14 | # Number of rotated files to keep 15 | rotate_count: 3 16 | # Compress rotated logs 17 | compress: true 18 | # Force daily rotation regardless of size 19 | daily_rotation: true 20 | 21 | # History clearing settings 22 | privacy_history_clearing: 23 | # Clear bash history after deployment 24 | clear_bash_history: true 25 | # Clear system command history 26 | clear_system_history: true 27 | # Disable bash history persistence for service users 28 | disable_service_history: true 29 | 30 | # Log filtering settings 31 | privacy_log_filtering: 32 | # Exclude VPN connection logs from persistent storage 33 | exclude_vpn_logs: true 34 | # Exclude authentication logs (be careful with this) 35 | exclude_auth_logs: false 36 | # Filter kernel logs related to VPN traffic 37 | filter_kernel_vpn_logs: true 38 | 39 | # Automatic cleanup settings 40 | privacy_auto_cleanup: 41 | # Enable automatic log cleanup 42 | enabled: true 43 | # Cleanup frequency (daily, weekly, monthly) 44 | frequency: "daily" 45 | # Clean up temporary files older than N days 46 | temp_files_max_age: 1 47 | # Clean up old package cache 48 | clean_package_cache: true 49 | 50 | # Advanced privacy settings 51 | privacy_advanced: 52 | # Disable logging of successful SSH connections 53 | disable_ssh_success_logs: false 54 | # Reduce kernel log verbosity 55 | reduce_kernel_verbosity: true 56 | # Clear logs on shutdown (use with caution) 57 | clear_logs_on_shutdown: false 58 | -------------------------------------------------------------------------------- /tests/test-local-config.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Simple test that verifies Algo can generate configurations without errors 3 | 4 | set -e 5 | 6 | echo "Testing Algo configuration generation..." 7 | 8 | # Generate SSH key if it doesn't exist 9 | if [ ! -f ~/.ssh/id_rsa ]; then 10 | ssh-keygen -f ~/.ssh/id_rsa -t rsa -N '' 11 | fi 12 | 13 | # Create a minimal test configuration 14 | cat > test-config.cfg << 'EOF' 15 | users: 16 | - test-user 17 | cloud_providers: 18 | local: 19 | server: localhost 20 | endpoint: 127.0.0.1 21 | wireguard_enabled: true 22 | ipsec_enabled: false 23 | dns_adblocking: false 24 | ssh_tunneling: false 25 | store_pki: true 26 | tests: true 27 | algo_provider: local 28 | algo_server_name: test-server 29 | algo_ondemand_cellular: false 30 | algo_ondemand_wifi: false 31 | algo_ondemand_wifi_exclude: "" 32 | algo_dns_adblocking: false 33 | algo_ssh_tunneling: false 34 | wireguard_PersistentKeepalive: 0 35 | wireguard_network: 10.19.49.0/24 36 | wireguard_network_ipv6: fd9d:bc11:4020::/48 37 | wireguard_port: 51820 38 | dns_encryption: false 39 | subjectAltName_type: IP 40 | subjectAltName: 127.0.0.1 41 | IP_subject_alt_name: 127.0.0.1 42 | algo_server: localhost 43 | algo_user: ubuntu 44 | ansible_ssh_user: ubuntu 45 | algo_ssh_port: 22 46 | endpoint: 127.0.0.1 47 | server: localhost 48 | ssh_user: ubuntu 49 | CA_password: "test-password-123" 50 | p12_export_password: "test-export-password" 51 | EOF 52 | 53 | # Run Ansible in check mode to verify templates work 54 | echo "Running Ansible in check mode..." 55 | uv run ansible-playbook main.yml \ 56 | -i "localhost," \ 57 | -c local \ 58 | -e @test-config.cfg \ 59 | -e "provider=local" \ 60 | --check \ 61 | --diff \ 62 | --tags "configuration" \ 63 | --skip-tags "restart_services,tests,assert,cloud,facts_install" 64 | 65 | echo "Configuration generation test passed!" 66 | 67 | # Clean up 68 | rm -f test-config.cfg 69 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Create and publish a Docker image 3 | 4 | 'on': 5 | push: 6 | branches: ['master'] 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push-image: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5.0.1 22 | with: 23 | persist-credentials: false 24 | 25 | - name: Set up QEMU 26 | uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 27 | 28 | - name: Set up Docker Buildx 29 | uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 30 | 31 | - name: Log in to the Container registry 32 | uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 33 | with: 34 | registry: ${{ env.REGISTRY }} 35 | username: ${{ github.actor }} 36 | password: ${{ secrets.GITHUB_TOKEN }} 37 | 38 | - name: Extract metadata (tags, labels) for Docker 39 | id: meta 40 | uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 41 | with: 42 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 43 | tags: | 44 | # set latest tag for master branch 45 | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} 46 | 47 | - name: Build and push Docker image 48 | uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 49 | with: 50 | context: . 51 | push: true 52 | platforms: linux/amd64,linux/arm64 53 | tags: ${{ steps.meta.outputs.tags }} 54 | labels: ${{ steps.meta.outputs.labels }} 55 | -------------------------------------------------------------------------------- /roles/cloud-scaleway/tasks/prompts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - pause: 3 | prompt: | 4 | Enter your auth token (https://trailofbits.github.io/algo/cloud-scaleway.html) 5 | echo: false 6 | register: _scaleway_token 7 | when: 8 | - scaleway_token is undefined 9 | - lookup('env', 'SCW_TOKEN')|length <= 0 10 | no_log: true 11 | 12 | - pause: 13 | prompt: | 14 | What region should the server be located in? 15 | {% for r in scaleway_regions %} 16 | {{ loop.index }}. {{ r['alias'] }} 17 | {% endfor %} 18 | 19 | Enter the number of your desired region 20 | [{{ scaleway_regions.0.alias }}] 21 | register: _algo_region 22 | when: region is undefined 23 | 24 | - pause: 25 | prompt: | 26 | Enter your Scaleway Organization ID (also serves as your default Project ID) 27 | You can find this in your Scaleway console: 28 | 1. Go to https://console.scaleway.com/organization/settings 29 | 2. Copy the Organization ID from the Organization Settings page 30 | 31 | Note: For the default project, the Project ID is the same as the Organization ID. 32 | (https://trailofbits.github.io/algo/cloud-scaleway.html) 33 | register: _scaleway_org_id 34 | when: 35 | - scaleway_org_id is undefined 36 | - lookup('env', 'SCW_DEFAULT_ORGANIZATION_ID')|length <= 0 37 | 38 | - name: Set scaleway facts 39 | set_fact: 40 | algo_scaleway_token: "{{ scaleway_token | default(_scaleway_token.user_input) | default(lookup('env', 'SCW_TOKEN'), true) }}" 41 | algo_region: >- 42 | {% if region is defined %}{{ region }} 43 | {%- elif _algo_region.user_input %}{{ scaleway_regions[_algo_region.user_input | int - 1]['alias'] }} 44 | {%- else %}{{ scaleway_regions.0.alias }}{% endif %} 45 | algo_scaleway_org_id: "{{ scaleway_org_id | default(_scaleway_org_id.user_input) | default(lookup('env', 'SCW_DEFAULT_ORGANIZATION_ID'), true) }}" 46 | no_log: true 47 | -------------------------------------------------------------------------------- /roles/cloud-azure/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include prompts 3 | import_tasks: prompts.yml 4 | 5 | - set_fact: 6 | algo_region: >- 7 | {% 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 %} 8 | 9 | - name: Create AlgoVPN Server 10 | azure.azcollection.azure_rm_deployment: 11 | state: present 12 | deployment_name: "{{ algo_server_name }}" 13 | template: "{{ lookup('file', role_path + '/files/deployment.json') }}" 14 | secret: "{{ secret }}" 15 | tenant: "{{ tenant }}" 16 | client_id: "{{ client_id }}" 17 | subscription_id: "{{ subscription_id }}" 18 | resource_group_name: "{{ algo_server_name }}" 19 | location: "{{ algo_region }}" 20 | parameters: 21 | sshKeyData: 22 | value: "{{ lookup('file', SSH_keys.public) }}" 23 | WireGuardPort: 24 | value: "{{ wireguard_port }}" 25 | vmSize: 26 | value: "{{ cloud_providers.azure.size }}" 27 | imageReferencePublisher: 28 | value: "{{ cloud_providers.azure.image.publisher }}" 29 | imageReferenceOffer: 30 | value: "{{ cloud_providers.azure.image.offer }}" 31 | imageReferenceSku: 32 | value: "{{ cloud_providers.azure.image.sku }}" 33 | imageReferenceVersion: 34 | value: "{{ cloud_providers.azure.image.version }}" 35 | osDiskType: 36 | value: "{{ cloud_providers.azure.osDisk.type }}" 37 | SshPort: 38 | value: "{{ ssh_port }}" 39 | UserData: 40 | value: "{{ lookup('template', 'files/cloud-init/base.yml') | b64encode }}" 41 | register: azure_rm_deployment 42 | 43 | - set_fact: 44 | cloud_instance_ip: "{{ azure_rm_deployment.deployment.outputs.publicIPAddresses.value }}" 45 | ansible_ssh_user: algo 46 | ansible_ssh_port: "{{ ssh_port }}" 47 | cloudinit: true 48 | -------------------------------------------------------------------------------- /roles/privacy/tasks/log_rotation.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Aggressive log rotation configuration for privacy 3 | # Reduces log retention time and implements more frequent rotation 4 | 5 | - name: Check if default rsyslog logrotate config exists 6 | stat: 7 | path: /etc/logrotate.d/rsyslog 8 | register: rsyslog_logrotate 9 | 10 | - name: Disable default rsyslog logrotate to prevent conflicts 11 | command: mv /etc/logrotate.d/rsyslog /etc/logrotate.d/rsyslog.disabled 12 | when: rsyslog_logrotate.stat.exists 13 | changed_when: rsyslog_logrotate.stat.exists 14 | 15 | - name: Configure aggressive logrotate for system logs 16 | template: 17 | src: privacy-logrotate.j2 18 | dest: /etc/logrotate.d/99-privacy-enhanced 19 | mode: '0644' 20 | owner: root 21 | group: root 22 | notify: restart rsyslog 23 | 24 | - name: Configure logrotate for auth logs with shorter retention 25 | template: 26 | src: auth-logrotate.j2 27 | dest: /etc/logrotate.d/99-auth-privacy 28 | mode: '0644' 29 | owner: root 30 | group: root 31 | notify: restart rsyslog 32 | 33 | - name: Configure logrotate for kern logs with VPN filtering 34 | template: 35 | src: kern-logrotate.j2 36 | dest: /etc/logrotate.d/99-kern-privacy 37 | mode: '0644' 38 | owner: root 39 | group: root 40 | notify: restart rsyslog 41 | 42 | - name: Set more frequent logrotate execution 43 | cron: 44 | name: "Enhanced privacy log rotation" 45 | job: "/usr/sbin/logrotate /etc/logrotate.conf" 46 | minute: "0" 47 | hour: "*/6" 48 | user: root 49 | state: present 50 | 51 | - name: Create privacy log cleanup script 52 | template: 53 | src: privacy-log-cleanup.sh.j2 54 | dest: /usr/local/bin/privacy-log-cleanup.sh 55 | mode: '0755' 56 | owner: root 57 | group: root 58 | 59 | # Note: We don't force immediate rotation as it can cause conflicts 60 | # The new settings will apply on the next scheduled rotation 61 | -------------------------------------------------------------------------------- /roles/privacy/tasks/log_filtering.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Configure rsyslog to filter out VPN-related logs for privacy 3 | 4 | - name: Create rsyslog privacy configuration directory 5 | file: 6 | path: /etc/rsyslog.d 7 | state: directory 8 | mode: '0755' 9 | owner: root 10 | group: root 11 | 12 | - name: Configure rsyslog to exclude VPN-related logs 13 | template: 14 | src: 49-privacy-vpn-filter.conf.j2 15 | dest: /etc/rsyslog.d/49-privacy-vpn-filter.conf 16 | mode: '0644' 17 | owner: root 18 | group: root 19 | notify: restart rsyslog 20 | when: privacy_log_filtering.exclude_vpn_logs | bool 21 | 22 | - name: Configure rsyslog to filter kernel VPN logs 23 | template: 24 | src: 48-privacy-kernel-filter.conf.j2 25 | dest: /etc/rsyslog.d/48-privacy-kernel-filter.conf 26 | mode: '0644' 27 | owner: root 28 | group: root 29 | notify: restart rsyslog 30 | when: privacy_log_filtering.filter_kernel_vpn_logs | bool 31 | 32 | - name: Configure rsyslog to exclude detailed auth logs (optional) 33 | template: 34 | src: 47-privacy-auth-filter.conf.j2 35 | dest: /etc/rsyslog.d/47-privacy-auth-filter.conf 36 | mode: '0644' 37 | owner: root 38 | group: root 39 | notify: restart rsyslog 40 | when: privacy_log_filtering.exclude_auth_logs | bool 41 | 42 | - name: Create rsyslog privacy filter for SSH success logs 43 | template: 44 | src: 46-privacy-ssh-filter.conf.j2 45 | dest: /etc/rsyslog.d/46-privacy-ssh-filter.conf 46 | mode: '0644' 47 | owner: root 48 | group: root 49 | notify: restart rsyslog 50 | when: privacy_advanced.disable_ssh_success_logs | bool 51 | 52 | - name: Test rsyslog configuration 53 | command: rsyslogd -N1 54 | register: rsyslog_test 55 | changed_when: false 56 | failed_when: rsyslog_test.rc != 0 57 | 58 | - name: Display rsyslog test results 59 | debug: 60 | msg: "Rsyslog configuration test passed" 61 | when: rsyslog_test.rc == 0 62 | -------------------------------------------------------------------------------- /roles/strongswan/tasks/ubuntu.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Set OS specific facts 3 | set_fact: 4 | strongswan_additional_plugins: [] 5 | 6 | - name: Ubuntu | Ensure af_key kernel module is loaded 7 | modprobe: 8 | name: af_key 9 | state: present 10 | persistent: present 11 | 12 | - name: Ubuntu | Install strongSwan (individual) 13 | apt: 14 | name: strongswan 15 | state: present 16 | update_cache: true 17 | install_recommends: true 18 | when: not performance_parallel_packages | default(true) 19 | 20 | - block: 21 | # https://bugs.launchpad.net/ubuntu/+source/strongswan/+bug/1826238 22 | - name: Ubuntu | Charon profile for apparmor configured 23 | copy: 24 | dest: /etc/apparmor.d/local/usr.lib.ipsec.charon 25 | content: " capability setpcap," 26 | owner: root 27 | group: root 28 | mode: '0644' 29 | notify: restart strongswan 30 | 31 | - name: Ubuntu | Enforcing ipsec with apparmor 32 | command: aa-enforce "{{ item }}" 33 | changed_when: false 34 | loop: 35 | - /usr/lib/ipsec/charon 36 | - /usr/lib/ipsec/lookip 37 | - /usr/lib/ipsec/stroke 38 | tags: apparmor 39 | when: apparmor_enabled|default(false)|bool 40 | 41 | - name: Ubuntu | Enable services 42 | service: name={{ item }} enabled=yes 43 | loop: 44 | - apparmor 45 | - "{{ strongswan_service }}" 46 | - netfilter-persistent 47 | 48 | - name: Ubuntu | Ensure that the strongswan service directory exists 49 | file: 50 | path: /etc/systemd/system/{{ strongswan_service }}.service.d/ 51 | state: directory 52 | mode: '0755' 53 | owner: root 54 | group: root 55 | 56 | - name: Ubuntu | Setup the cgroup limitations for the ipsec daemon 57 | template: 58 | src: 100-CustomLimitations.conf.j2 59 | dest: /etc/systemd/system/{{ strongswan_service }}.service.d/100-CustomLimitations.conf 60 | mode: '0644' 61 | notify: 62 | - daemon-reload 63 | - restart strongswan 64 | -------------------------------------------------------------------------------- /roles/cloud-vultr/tasks/prompts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - pause: 3 | prompt: | 4 | Enter the local path to your configuration INI file 5 | (https://trailofbits.github.io/algo/cloud-vultr.html): 6 | register: _vultr_config 7 | when: 8 | - vultr_config is undefined 9 | - lookup('env', 'VULTR_API_CONFIG')|length <= 0 10 | no_log: true 11 | 12 | - name: Set the token as a fact 13 | set_fact: 14 | algo_vultr_config: "{{ vultr_config | default(_vultr_config.user_input) | default(lookup('env', 'VULTR_API_CONFIG'), true) }}" 15 | no_log: true 16 | 17 | - name: Set the Vultr API Key as a fact 18 | set_fact: 19 | vultr_api_key: "{{ lookup('ansible.builtin.ini', 'key', section='default', file=algo_vultr_config) }}" 20 | 21 | - name: Get regions 22 | uri: 23 | url: https://api.vultr.com/v2/regions 24 | method: GET 25 | status_code: 200 26 | headers: 27 | Authorization: "Bearer {{ vultr_api_key }}" 28 | register: _vultr_regions 29 | 30 | - name: Format regions 31 | set_fact: 32 | regions: "{{ _vultr_regions.json['regions'] }}" 33 | 34 | - name: Set regions as a fact 35 | set_fact: 36 | vultr_regions: "{{ regions | sort(attribute='country') }}" 37 | 38 | - name: Set default region 39 | set_fact: 40 | default_region: 1 41 | 42 | - pause: 43 | prompt: | 44 | What region should the server be located in? 45 | (https://www.vultr.com/locations/): 46 | {% for r in vultr_regions %} 47 | {{ loop.index }}. {{ r['city'] }} ({{ r['id'] }}) 48 | {% endfor %} 49 | 50 | Enter the number of your desired region 51 | [{{ default_region }}] 52 | register: _algo_region 53 | when: region is undefined 54 | 55 | - name: Set the desired region as a fact 56 | set_fact: 57 | algo_vultr_region: >- 58 | {%- if region is defined -%}{{ region }}{%- elif _algo_region.user_input -%}{{ vultr_regions[_algo_region.user_input | int - 1]['id'] }}{%- else -%}{{ vultr_regions[default_region | int - 1]['id'] }}{%- endif -%} 59 | -------------------------------------------------------------------------------- /tests/test-wireguard-fix.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Test the corrected WireGuard async pattern 3 | - name: Test corrected WireGuard async pattern 4 | hosts: localhost 5 | gather_facts: no 6 | vars: 7 | test_users: ["testuser1", "testuser2"] 8 | IP_subject_alt_name: "127.0.0.1" 9 | wireguard_pki_path: "/tmp/test-fixed-wireguard" 10 | 11 | tasks: 12 | - name: Create test directory 13 | file: 14 | path: "{{ wireguard_pki_path }}/private" 15 | state: directory 16 | mode: '0700' 17 | 18 | - name: Generate keys (parallel) - simulating wg genkey 19 | command: echo "mock_private_key_for_{{ item }}" 20 | register: wg_genkey 21 | loop: "{{ test_users + [IP_subject_alt_name] }}" 22 | async: 10 23 | poll: 0 24 | 25 | - name: Wait for completion - simulating async_status 26 | async_status: 27 | jid: "{{ item.ansible_job_id }}" 28 | loop: "{{ wg_genkey.results }}" 29 | register: wg_genkey_results 30 | until: wg_genkey_results.finished 31 | retries: 15 32 | delay: 1 33 | 34 | - name: Save using CORRECTED pattern - item.item.item 35 | copy: 36 | dest: "{{ wireguard_pki_path }}/private/{{ item.item.item }}" 37 | content: "{{ item.stdout }}" 38 | mode: "0600" 39 | when: item.changed 40 | loop: "{{ wg_genkey_results.results }}" 41 | 42 | - name: Verify files were created with correct names 43 | stat: 44 | path: "{{ wireguard_pki_path }}/private/{{ item }}" 45 | register: file_check 46 | loop: 47 | - "testuser1" 48 | - "testuser2" 49 | - "127.0.0.1" 50 | 51 | - name: Assert all files exist 52 | assert: 53 | that: 54 | - item.stat.exists 55 | msg: "File should exist: {{ item.stat.path }}" 56 | loop: "{{ file_check.results }}" 57 | 58 | - name: Cleanup 59 | file: 60 | path: "{{ wireguard_pki_path }}" 61 | state: absent 62 | 63 | - debug: 64 | msg: "✅ WireGuard async fix test PASSED - item.item.item is the correct pattern!" 65 | -------------------------------------------------------------------------------- /roles/wireguard/tasks/keys.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Ensure the WireGuard pki directory does not exist 3 | file: 4 | dest: "{{ wireguard_pki_path }}" 5 | state: absent 6 | when: keys_clean_all | bool 7 | 8 | - name: Ensure the WireGuard pki directories exist 9 | file: 10 | dest: "{{ wireguard_pki_path }}/{{ item }}" 11 | state: directory 12 | recurse: true 13 | mode: "0700" 14 | loop: 15 | - preshared 16 | - private 17 | - public 18 | 19 | - name: Generate raw private keys 20 | community.crypto.openssl_privatekey: 21 | type: X25519 22 | path: "{{ wireguard_pki_path }}/private/{{ item }}.raw" 23 | format: raw 24 | mode: "0600" 25 | loop: "{{ users + [IP_subject_alt_name] }}" 26 | 27 | - name: Save base64 encoded private key 28 | copy: 29 | dest: "{{ wireguard_pki_path }}/private/{{ item }}" 30 | content: "{{ lookup('file', wireguard_pki_path + '/private/' + item + '.raw') | b64encode }}" 31 | mode: "0600" 32 | loop: "{{ users + [IP_subject_alt_name] }}" 33 | no_log: true 34 | 35 | - name: Generate raw preshared keys 36 | community.crypto.openssl_privatekey: 37 | type: X25519 38 | path: "{{ wireguard_pki_path }}/preshared/{{ item }}.raw" 39 | format: raw 40 | mode: "0600" 41 | loop: "{{ users + [IP_subject_alt_name] }}" 42 | 43 | - name: Save base64 encoded preshared keys 44 | copy: 45 | dest: "{{ wireguard_pki_path }}/preshared/{{ item }}" 46 | content: "{{ lookup('file', wireguard_pki_path + '/preshared/' + item + '.raw') | b64encode }}" 47 | mode: "0600" 48 | loop: "{{ users + [IP_subject_alt_name] }}" 49 | no_log: true 50 | 51 | - name: Generate public keys 52 | x25519_pubkey: 53 | private_key_path: "{{ wireguard_pki_path }}/private/{{ item }}.raw" 54 | public_key_path: "{{ wireguard_pki_path }}/public/{{ item }}" 55 | loop: "{{ users + [IP_subject_alt_name] }}" 56 | no_log: true 57 | 58 | - name: Set permissions for public keys 59 | file: 60 | path: "{{ wireguard_pki_path }}/public/{{ item }}" 61 | mode: '0644' 62 | loop: "{{ users + [IP_subject_alt_name] }}" 63 | no_log: true 64 | -------------------------------------------------------------------------------- /roles/strongswan/tasks/ipsec_configuration.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Setup the config files from our templates 3 | template: 4 | src: "{{ item.src }}" 5 | dest: "{{ config_prefix | default('/') }}etc/{{ item.dest }}" 6 | owner: "{{ item.owner }}" 7 | group: "{{ item.group }}" 8 | mode: "{{ item.mode }}" 9 | loop: 10 | - src: strongswan.conf.j2 11 | dest: strongswan.conf 12 | owner: root 13 | group: "{{ root_group | default('root') }}" 14 | mode: "0644" 15 | - src: ipsec.conf.j2 16 | dest: ipsec.conf 17 | owner: root 18 | group: "{{ root_group | default('root') }}" 19 | mode: "0644" 20 | - src: ipsec.secrets.j2 21 | dest: ipsec.secrets 22 | owner: strongswan 23 | group: "{{ root_group | default('root') }}" 24 | mode: "0600" 25 | - src: charon.conf.j2 26 | dest: strongswan.d/charon.conf 27 | owner: root 28 | group: "{{ root_group | default('root') }}" 29 | mode: "0644" 30 | notify: 31 | - restart strongswan 32 | 33 | - name: Get loaded plugins 34 | shell: | 35 | set -o pipefail 36 | find {{ config_prefix | default('/') }}etc/strongswan.d/charon/ -type f -name '*.conf' -exec basename {} \; | 37 | cut -f1 -d. 38 | changed_when: false 39 | args: 40 | executable: bash 41 | register: strongswan_plugins 42 | 43 | - name: Disable unneeded plugins 44 | lineinfile: 45 | dest: "{{ config_prefix | default('/') }}etc/strongswan.d/charon/{{ item }}.conf" 46 | regexp: .*load.* 47 | line: load = no 48 | state: present 49 | notify: 50 | - restart strongswan 51 | when: item not in strongswan_enabled_plugins and item not in strongswan_additional_plugins 52 | loop: "{{ strongswan_plugins.stdout_lines }}" 53 | 54 | - name: Ensure that required plugins are enabled 55 | lineinfile: dest="{{ config_prefix | default('/') }}etc/strongswan.d/charon/{{ item }}.conf" regexp='.*load.*' line='load = yes' state=present 56 | notify: 57 | - restart strongswan 58 | when: item in strongswan_enabled_plugins or item in strongswan_additional_plugins 59 | loop: "{{ strongswan_plugins.stdout_lines }}" 60 | -------------------------------------------------------------------------------- /docs/aws-credentials.md: -------------------------------------------------------------------------------- 1 | # AWS Credential Configuration 2 | 3 | Algo supports multiple methods for providing AWS credentials, following standard AWS practices: 4 | 5 | ## Methods (in order of precedence) 6 | 7 | 1. **Command-line variables** (highest priority) 8 | ```bash 9 | ./algo -e "aws_access_key=YOUR_KEY aws_secret_key=YOUR_SECRET" 10 | ``` 11 | 12 | 2. **Environment variables** 13 | ```bash 14 | export AWS_ACCESS_KEY_ID=YOUR_KEY 15 | export AWS_SECRET_ACCESS_KEY=YOUR_SECRET 16 | export AWS_SESSION_TOKEN=YOUR_TOKEN # Optional, for temporary credentials 17 | ./algo 18 | ``` 19 | 20 | 3. **AWS credentials file** (lowest priority) 21 | - Default location: `~/.aws/credentials` 22 | - Custom location: Set `AWS_SHARED_CREDENTIALS_FILE` environment variable 23 | - Profile selection: Set `AWS_PROFILE` environment variable (defaults to "default") 24 | 25 | ## Using AWS Credentials File 26 | 27 | After running `aws configure` or manually creating `~/.aws/credentials`: 28 | 29 | ```ini 30 | [default] 31 | aws_access_key_id = YOUR_KEY_ID 32 | aws_secret_access_key = YOUR_SECRET_KEY 33 | 34 | [work] 35 | aws_access_key_id = WORK_KEY_ID 36 | aws_secret_access_key = WORK_SECRET_KEY 37 | aws_session_token = TEMPORARY_TOKEN # Optional 38 | ``` 39 | 40 | To use a specific profile: 41 | ```bash 42 | AWS_PROFILE=work ./algo 43 | ``` 44 | 45 | ## Security Considerations 46 | 47 | - Credentials files should have restricted permissions (600) 48 | - Consider using AWS IAM roles or temporary credentials when possible 49 | - Tools like [aws-vault](https://github.com/99designs/aws-vault) can provide additional security by storing credentials encrypted 50 | 51 | ## Troubleshooting 52 | 53 | If Algo isn't finding your credentials: 54 | 55 | 1. Check file permissions: `ls -la ~/.aws/credentials` 56 | 2. Verify the profile name matches: `AWS_PROFILE=your-profile` 57 | 3. Test with AWS CLI: `aws sts get-caller-identity` 58 | 59 | If credentials are found but authentication fails: 60 | - Ensure your IAM user has the required permissions (see [EC2 deployment guide](deploy-from-ansible.md)) 61 | - Check if you need session tokens for temporary credentials 62 | -------------------------------------------------------------------------------- /docs/client-linux-ipsec.md: -------------------------------------------------------------------------------- 1 | # Linux strongSwan IPsec Clients (e.g., OpenWRT, Ubuntu Server, etc.) 2 | 3 | Install 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. 4 | 5 | ## Ubuntu Server example 6 | 7 | 1. `sudo apt install strongswan libstrongswan-standard-plugins`: install strongSwan 8 | 2. `/etc/ipsec.d/certs`: copy `.crt` from `algo-master/configs//ipsec/.pki/certs/.crt` 9 | 3. `/etc/ipsec.d/private`: copy `.key` from `algo-master/configs//ipsec/.pki/private/.key` 10 | 4. `/etc/ipsec.d/cacerts`: copy `cacert.pem` from `algo-master/configs//ipsec/manual/cacert.pem` 11 | 5. `/etc/ipsec.secrets`: add your `user.key` to the list, e.g. ` : ECDSA .key` 12 | 6. `/etc/ipsec.conf`: add the connection from `ipsec_user.conf` and ensure `leftcert` matches the `.crt` filename 13 | 7. `sudo ipsec restart`: pick up config changes 14 | 8. `sudo ipsec up `: start the ipsec tunnel 15 | 9. `sudo ipsec down `: shutdown the ipsec tunnel 16 | 17 | One 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`: 18 | 19 | conn lan-passthrough 20 | leftsubnet=192.168.1.1/24 # Replace with your LAN subnet 21 | rightsubnet=192.168.1.1/24 # Replace with your LAN subnet 22 | authby=never # No authentication necessary 23 | type=pass # passthrough 24 | auto=route # no need to ipsec up lan-passthrough 25 | 26 | To configure the connection to come up at boot time replace `auto=add` with `auto=start`. 27 | 28 | ## Notes on SELinux 29 | 30 | If you use a system with SELinux enabled, you might need to set appropriate file contexts: 31 | 32 | ```` 33 | semanage fcontext -a -t ipsec_key_file_t "$(pwd)(/.*)?" 34 | restorecon -R -v $(pwd) 35 | ```` 36 | 37 | See [this comment](https://github.com/trailofbits/algo/issues/263#issuecomment-328053950). 38 | -------------------------------------------------------------------------------- /roles/cloud-lightsail/files/stack.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | AWSTemplateFormatVersion: '2010-09-09' 3 | Description: 'Algo VPN stack (LightSail)' 4 | Parameters: 5 | InstanceTypeParameter: 6 | Type: String 7 | Default: 'nano_2_0' 8 | ImageIdParameter: 9 | Type: String 10 | Default: 'ubuntu_20_04' 11 | WireGuardPort: 12 | Type: String 13 | Default: '51820' 14 | SshPort: 15 | Type: String 16 | Default: '4160' 17 | UserData: 18 | Type: String 19 | Default: 'true' 20 | Resources: 21 | Instance: 22 | Type: AWS::Lightsail::Instance 23 | Properties: 24 | BlueprintId: 25 | Ref: ImageIdParameter 26 | BundleId: 27 | Ref: InstanceTypeParameter 28 | InstanceName: !Ref AWS::StackName 29 | Networking: 30 | Ports: 31 | - AccessDirection: inbound 32 | Cidrs: ['0.0.0.0/0'] 33 | Ipv6Cidrs: ['::/0'] 34 | CommonName: SSH 35 | FromPort: !Ref SshPort 36 | ToPort: !Ref SshPort 37 | Protocol: tcp 38 | - AccessDirection: inbound 39 | Cidrs: ['0.0.0.0/0'] 40 | Ipv6Cidrs: ['::/0'] 41 | CommonName: WireGuard 42 | FromPort: !Ref WireGuardPort 43 | ToPort: !Ref WireGuardPort 44 | Protocol: udp 45 | - AccessDirection: inbound 46 | Cidrs: ['0.0.0.0/0'] 47 | Ipv6Cidrs: ['::/0'] 48 | CommonName: IPSec-4500 49 | FromPort: 4500 50 | ToPort: 4500 51 | Protocol: udp 52 | - AccessDirection: inbound 53 | Cidrs: ['0.0.0.0/0'] 54 | Ipv6Cidrs: ['::/0'] 55 | CommonName: IPSec-500 56 | FromPort: 500 57 | ToPort: 500 58 | Protocol: udp 59 | Tags: 60 | - Key: Name 61 | Value: !Ref AWS::StackName 62 | UserData: !Ref UserData 63 | 64 | StaticIP: 65 | Type: AWS::Lightsail::StaticIp 66 | Properties: 67 | AttachedTo: !Ref Instance 68 | StaticIpName: !Join ["-", [!Ref AWS::StackName, "ip"]] 69 | DependsOn: 70 | - Instance 71 | 72 | Outputs: 73 | IpAddress: 74 | Value: !GetAtt [StaticIP, IpAddress] 75 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Claude Code 3 | 4 | 'on': 5 | issue_comment: 6 | types: [created] 7 | pull_request_review_comment: 8 | types: [created] 9 | issues: 10 | types: [opened, assigned] 11 | pull_request_review: 12 | types: [submitted] 13 | 14 | jobs: 15 | claude: 16 | if: | 17 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 19 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 20 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | actions: read # Required for Claude to read CI results on PRs 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v6.0.1 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code 35 | id: claude 36 | uses: anthropics/claude-code-action@v1 37 | with: 38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 | 40 | # This is an optional setting that allows Claude to read CI results on PRs 41 | additional_permissions: | 42 | actions: read 43 | 44 | # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. 45 | # prompt: 'Update the pull request description to include a summary of changes.' 46 | 47 | # Add allowed tools for Algo project 48 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 49 | claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*),Bash(ansible-playbook * --syntax-check),Bash(ansible-lint *),Bash(ruff check *),Bash(yamllint *),Bash(shellcheck *),Bash(python -m pytest *)"' 50 | -------------------------------------------------------------------------------- /tests/test-wireguard-real-async.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # CRITICAL TEST: WireGuard Async Structure Debugging 3 | # ================================================== 4 | # This test validates the complex triple-nested data structure created by: 5 | # async + register + loop -> async_status + register + loop 6 | # 7 | # DO NOT DELETE: This test prevented production deployment failures by revealing 8 | # that the access pattern is item.item.item (not item.item as initially assumed). 9 | # 10 | # Run with: ansible-playbook tests/test-wireguard-real-async.yml -v 11 | # Purpose: Debug and validate the async result structure when using with_items 12 | - name: Test real WireGuard async pattern 13 | hosts: localhost 14 | gather_facts: no 15 | vars: 16 | test_users: ["testuser1", "testuser2"] 17 | IP_subject_alt_name: "127.0.0.1" 18 | wireguard_pki_path: "/tmp/test-real-wireguard" 19 | 20 | tasks: 21 | - name: Create test directory 22 | file: 23 | path: "{{ wireguard_pki_path }}/private" 24 | state: directory 25 | mode: '0700' 26 | 27 | - name: Simulate the actual async pattern - Generate keys (parallel) 28 | command: echo "mock_private_key_for_{{ item }}" 29 | register: wg_genkey 30 | loop: "{{ test_users + [IP_subject_alt_name] }}" 31 | async: 10 32 | poll: 0 33 | 34 | - name: Debug - Show wg_genkey structure 35 | debug: 36 | var: wg_genkey 37 | 38 | - name: Simulate the actual async pattern - Wait for completion 39 | async_status: 40 | jid: "{{ item.ansible_job_id }}" 41 | loop: "{{ wg_genkey.results }}" 42 | register: wg_genkey_results 43 | until: wg_genkey_results.finished 44 | retries: 15 45 | delay: 1 46 | 47 | - name: Debug - Show wg_genkey_results structure (the real issue) 48 | debug: 49 | var: wg_genkey_results 50 | 51 | - name: Try to save using the current failing pattern 52 | copy: 53 | dest: "{{ wireguard_pki_path }}/private/{{ item.item }}" 54 | content: "{{ item.stdout }}" 55 | mode: "0600" 56 | when: item.changed 57 | loop: "{{ wg_genkey_results.results }}" 58 | failed_when: false 59 | 60 | - name: Cleanup 61 | file: 62 | path: "{{ wireguard_pki_path }}" 63 | state: absent 64 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM python:3.12-alpine 3 | 4 | ARG VERSION="git" 5 | # Removed rust/cargo (not needed with uv), simplified package list 6 | ARG PACKAGES="bash openssh-client openssl rsync tini" 7 | 8 | LABEL name="algo" \ 9 | version="${VERSION}" \ 10 | description="Set up a personal IPsec VPN in the cloud" \ 11 | maintainer="Trail of Bits " \ 12 | org.opencontainers.image.source="https://github.com/trailofbits/algo" \ 13 | org.opencontainers.image.description="Algo VPN - Set up a personal IPsec VPN in the cloud" \ 14 | org.opencontainers.image.licenses="AGPL-3.0" 15 | 16 | # Install system packages in a single layer 17 | RUN apk --no-cache add ${PACKAGES} && \ 18 | adduser -D -H -u 19857 algo && \ 19 | mkdir -p /algo /algo/configs 20 | 21 | WORKDIR /algo 22 | 23 | # Copy uv binary from official image (using latest tag for automatic updates) 24 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv 25 | 26 | # Copy dependency files and install in single layer for better optimization 27 | COPY pyproject.toml uv.lock ./ 28 | RUN uv sync --locked --no-dev 29 | 30 | # Copy application code 31 | COPY . . 32 | 33 | # Install Ansible Galaxy collections for cloud provider modules 34 | RUN uv run ansible-galaxy collection install -r requirements.yml 35 | 36 | # Set executable permissions and prepare runtime 37 | # Note: /algo must remain root-owned for --cap-drop=all compatibility 38 | # (root without CAP_DAC_OVERRIDE cannot write to files owned by others) 39 | RUN chmod 0755 /algo/algo-docker.sh && \ 40 | mkdir -p /data && \ 41 | chown algo:algo /data 42 | 43 | # Multi-arch support metadata 44 | ARG TARGETPLATFORM 45 | ARG BUILDPLATFORM 46 | RUN printf "Built on: %s\nTarget: %s\n" "${BUILDPLATFORM}" "${TARGETPLATFORM}" > /algo/build-info 47 | 48 | # Note: Running as root for bind mount compatibility with algo-docker.sh 49 | # The script handles /data volume permissions and needs root access 50 | # This is a Docker limitation with bind-mounted volumes 51 | USER root 52 | 53 | # Health check to ensure container is functional 54 | HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 55 | CMD /bin/uv --version || exit 1 56 | 57 | VOLUME ["/data"] 58 | CMD [ "/algo/algo-docker.sh" ] 59 | ENTRYPOINT [ "/sbin/tini", "--" ] 60 | -------------------------------------------------------------------------------- /files/cloud-init/README.md: -------------------------------------------------------------------------------- 1 | # Cloud-Init Files - Critical Format Requirements 2 | 3 | ## ⚠️ CRITICAL WARNING ⚠️ 4 | 5 | The files in this directory have **STRICT FORMAT REQUIREMENTS** that must not be changed by linters or automated formatting tools. 6 | 7 | ## Cloud-Config Header Format 8 | 9 | The first line of `base.yml` **MUST** be exactly: 10 | ``` 11 | #cloud-config 12 | ``` 13 | 14 | ### ❌ DO NOT CHANGE TO: 15 | - `# cloud-config` (space after #) - **BREAKS CLOUD-INIT PARSING** 16 | - Add YAML document start `---` - **NOT ALLOWED IN CLOUD-INIT** 17 | 18 | ### Why This Matters 19 | 20 | Cloud-init's YAML parser expects the exact string `#cloud-config` as the first line. Any deviation causes: 21 | 22 | 1. **Complete parsing failure** - All directives are skipped 23 | 2. **SSH configuration not applied** - Servers remain on port 22 instead of 4160 24 | 3. **Deployment timeouts** - Ansible cannot connect to configure the VPN 25 | 4. **DigitalOcean specific impact** - Other providers may be more tolerant 26 | 27 | ## Historical Context 28 | 29 | - **Working**: All versions before PR #14775 (August 2025) 30 | - **Broken**: PR #14775 "Apply ansible-lint improvements" added space by mistake 31 | - **Fixed**: PR #14801 restored correct format + added protections 32 | 33 | See GitHub issue #14800 for full technical details. 34 | 35 | ## Linter Configuration 36 | 37 | These files are **excluded** from: 38 | - `yamllint` (`.yamllint` config) 39 | - `ansible-lint` (`.ansible-lint` config) 40 | 41 | This prevents automated tools from "fixing" the format and breaking deployments. 42 | 43 | ## Template Variables 44 | 45 | The cloud-init files use Jinja2 templating: 46 | - `{{ ssh_port }}` - Configured SSH port (typically 4160) 47 | - `{{ lookup('file', '{{ SSH_keys.public }}') }}` - SSH public key 48 | 49 | ## Editing Guidelines 50 | 51 | 1. **Never** run automated formatters on these files 52 | 2. **Test immediately** after any changes with real deployments 53 | 3. **Check yamllint warnings** are expected (missing space in comment, missing ---) 54 | 4. **Verify first line** remains exactly `#cloud-config` 55 | 56 | ## References 57 | 58 | - [Cloud-init documentation](https://cloudinit.readthedocs.io/) 59 | - [Cloud-config examples](https://cloudinit.readthedocs.io/en/latest/reference/examples.html) 60 | - [GitHub Issue #14800](https://github.com/trailofbits/algo/issues/14800) 61 | -------------------------------------------------------------------------------- /roles/privacy/tasks/auto_cleanup.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Automatic cleanup tasks for enhanced privacy 3 | 4 | - name: Create privacy cleanup script 5 | template: 6 | src: privacy-auto-cleanup.sh.j2 7 | dest: /usr/local/bin/privacy-auto-cleanup.sh 8 | mode: '0755' 9 | owner: root 10 | group: root 11 | 12 | - name: Set up automatic privacy cleanup cron job 13 | cron: 14 | name: "Privacy auto cleanup" 15 | job: "/usr/local/bin/privacy-auto-cleanup.sh" 16 | minute: "30" 17 | hour: "2" 18 | user: root 19 | state: "{{ 'present' if privacy_auto_cleanup.enabled else 'absent' }}" 20 | when: privacy_auto_cleanup.frequency == 'daily' 21 | 22 | - name: Set up weekly privacy cleanup cron job 23 | cron: 24 | name: "Privacy auto cleanup weekly" 25 | job: "/usr/local/bin/privacy-auto-cleanup.sh" 26 | minute: "30" 27 | hour: "2" 28 | weekday: "0" 29 | user: root 30 | state: "{{ 'present' if privacy_auto_cleanup.enabled else 'absent' }}" 31 | when: privacy_auto_cleanup.frequency == 'weekly' 32 | 33 | - name: Set up monthly privacy cleanup cron job 34 | cron: 35 | name: "Privacy auto cleanup monthly" 36 | job: "/usr/local/bin/privacy-auto-cleanup.sh" 37 | minute: "30" 38 | hour: "2" 39 | day: "1" 40 | user: root 41 | state: "{{ 'present' if privacy_auto_cleanup.enabled else 'absent' }}" 42 | when: privacy_auto_cleanup.frequency == 'monthly' 43 | 44 | - name: Create systemd service for privacy cleanup on shutdown 45 | template: 46 | src: privacy-shutdown-cleanup.service.j2 47 | dest: /etc/systemd/system/privacy-shutdown-cleanup.service 48 | mode: '0644' 49 | owner: root 50 | group: root 51 | when: privacy_advanced.clear_logs_on_shutdown | bool 52 | notify: 53 | - reload systemd 54 | - enable privacy shutdown cleanup 55 | 56 | - name: Clean up temporary files immediately 57 | shell: | 58 | find /tmp -type f -mtime +{{ privacy_auto_cleanup.temp_files_max_age }} -delete 59 | find /var/tmp -type f -mtime +{{ privacy_auto_cleanup.temp_files_max_age }} -delete 60 | changed_when: false 61 | when: privacy_auto_cleanup.enabled | bool 62 | 63 | - name: Clean package cache immediately 64 | apt: 65 | autoclean: true 66 | changed_when: false 67 | when: 68 | - privacy_auto_cleanup.enabled | bool 69 | - privacy_auto_cleanup.clean_package_cache | bool 70 | -------------------------------------------------------------------------------- /roles/cloud-cloudstack/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include prompts 3 | import_tasks: prompts.yml 4 | 5 | - block: 6 | - set_fact: 7 | algo_region: >- 8 | {%- 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 -%} 9 | 10 | - name: Security group created 11 | cs_securitygroup: 12 | name: "{{ algo_server_name }}-security_group" 13 | description: AlgoVPN security group 14 | register: cs_security_group 15 | 16 | - name: Security rules created 17 | cs_securitygroup_rule: 18 | security_group: "{{ cs_security_group.name }}" 19 | protocol: "{{ item.proto }}" 20 | start_port: "{{ item.start_port }}" 21 | end_port: "{{ item.end_port }}" 22 | cidr: "{{ item.range }}" 23 | loop: 24 | - { proto: tcp, start_port: "{{ ssh_port }}", end_port: "{{ ssh_port }}", range: 0.0.0.0/0 } 25 | - { proto: udp, start_port: 4500, end_port: 4500, range: 0.0.0.0/0 } 26 | - { proto: udp, start_port: 500, end_port: 500, range: 0.0.0.0/0 } 27 | - { proto: udp, start_port: "{{ wireguard_port }}", end_port: "{{ wireguard_port }}", range: 0.0.0.0/0 } 28 | 29 | - name: Set facts 30 | set_fact: 31 | image_id: "{{ cloud_providers.cloudstack.image }}" 32 | size: "{{ cloud_providers.cloudstack.size }}" 33 | disk: "{{ cloud_providers.cloudstack.disk }}" 34 | 35 | - name: Server created 36 | cs_instance: 37 | name: "{{ algo_server_name }}" 38 | root_disk_size: "{{ disk }}" 39 | template: "{{ image_id }}" 40 | security_groups: "{{ cs_security_group.name }}" 41 | zone: "{{ algo_region }}" 42 | service_offering: "{{ size }}" 43 | user_data: "{{ lookup('template', 'files/cloud-init/base.yml') }}" 44 | register: cs_server 45 | 46 | - set_fact: 47 | cloud_instance_ip: "{{ cs_server.default_ip }}" 48 | ansible_ssh_user: algo 49 | ansible_ssh_port: "{{ ssh_port }}" 50 | cloudinit: true 51 | environment: 52 | CLOUDSTACK_KEY: "{{ algo_cs_key }}" 53 | CLOUDSTACK_SECRET: "{{ algo_cs_token }}" 54 | CLOUDSTACK_ENDPOINT: "{{ algo_cs_url }}" 55 | no_log: true 56 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | --- 3 | # Apply to all files without committing: 4 | # pre-commit run --all-files 5 | # Update this file: 6 | # pre-commit autoupdate 7 | 8 | repos: 9 | # General file checks 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v5.0.0 12 | hooks: 13 | - id: check-yaml 14 | args: [--allow-multiple-documents] 15 | exclude: '(files/cloud-init/base\.yml|roles/cloud-.*/files/stack\.yaml)' 16 | - id: end-of-file-fixer 17 | - id: trailing-whitespace 18 | - id: check-added-large-files 19 | args: ['--maxkb=500'] 20 | - id: check-merge-conflict 21 | - id: mixed-line-ending 22 | args: [--fix=lf] 23 | 24 | # Python linting with ruff (fast, replaces many tools) 25 | - repo: https://github.com/astral-sh/ruff-pre-commit 26 | rev: v0.8.6 27 | hooks: 28 | - id: ruff 29 | args: [--fix, --exit-non-zero-on-fix] 30 | - id: ruff-format 31 | 32 | # YAML linting 33 | - repo: https://github.com/adrienverge/yamllint 34 | rev: v1.35.1 35 | hooks: 36 | - id: yamllint 37 | args: [-c=.yamllint] 38 | exclude: '.git/.*' 39 | 40 | # Shell script linting 41 | - repo: https://github.com/shellcheck-py/shellcheck-py 42 | rev: v0.10.0.1 43 | hooks: 44 | - id: shellcheck 45 | exclude: '.git/.*' 46 | 47 | # Local hooks that use the project's installed tools 48 | - repo: local 49 | hooks: 50 | - id: ansible-lint 51 | name: Ansible-lint 52 | entry: bash -c 'uv run ansible-lint --force-color || echo "Ansible-lint had issues - check output"' 53 | language: system 54 | types: [yaml] 55 | files: \.(yml|yaml)$ 56 | exclude: '^(.git/|.github/|requirements\.yml)' 57 | pass_filenames: false 58 | 59 | - id: ansible-syntax 60 | name: Ansible syntax check 61 | entry: bash -c 'uv run ansible-playbook main.yml --syntax-check' 62 | language: system 63 | files: 'main\.yml|server\.yml|users\.yml' 64 | pass_filenames: false 65 | 66 | # Configuration for the pre-commit tool itself 67 | default_language_version: 68 | python: python3.11 69 | 70 | # Files to exclude globally 71 | exclude: | 72 | (?x)^( 73 | .env/.*| 74 | .venv/.*| 75 | .git/.*| 76 | __pycache__/.*| 77 | .*\.egg-info/.* 78 | )$ 79 | -------------------------------------------------------------------------------- /roles/cloud-vultr/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include prompts 3 | import_tasks: prompts.yml 4 | 5 | - block: 6 | - name: Set cloud-init script as fact 7 | set_fact: 8 | algo_cloud_init_script: "{{ lookup('template', 'files/cloud-init/base.yml') }}" 9 | 10 | - name: Creating a firewall group 11 | vultr.cloud.firewall_group: 12 | name: "{{ algo_server_name }}" 13 | 14 | - name: Creating firewall rules 15 | vultr.cloud.firewall_rule: 16 | group: "{{ algo_server_name }}" 17 | protocol: "{{ item.protocol }}" 18 | port: "{{ item.port }}" 19 | ip_type: "{{ item.ip }}" 20 | subnet: "{{ item.cidr.split('/')[0] }}" 21 | subnet_size: "{{ item.cidr.split('/')[1] }}" 22 | loop: 23 | - { protocol: tcp, port: "{{ ssh_port }}", ip: v4, cidr: 0.0.0.0/0 } 24 | - { protocol: tcp, port: "{{ ssh_port }}", ip: v6, cidr: "::/0" } 25 | - { protocol: udp, port: 500, ip: v4, cidr: 0.0.0.0/0 } 26 | - { protocol: udp, port: 500, ip: v6, cidr: "::/0" } 27 | - { protocol: udp, port: 4500, ip: v4, cidr: 0.0.0.0/0 } 28 | - { protocol: udp, port: 4500, ip: v6, cidr: "::/0" } 29 | - { protocol: udp, port: "{{ wireguard_port }}", ip: v4, cidr: 0.0.0.0/0 } 30 | - { protocol: udp, port: "{{ wireguard_port }}", ip: v6, cidr: "::/0" } 31 | 32 | - name: Upload the startup script 33 | vultr.cloud.startup_script: 34 | name: algo-startup 35 | script: "{{ algo_cloud_init_script }}" 36 | 37 | - name: Creating a server 38 | vultr.cloud.instance: 39 | name: "{{ algo_server_name }}" 40 | startup_script: algo-startup 41 | hostname: "{{ algo_server_name }}" 42 | os: "{{ cloud_providers.vultr.os }}" 43 | plan: "{{ cloud_providers.vultr.size }}" 44 | region: "{{ algo_vultr_region }}" 45 | firewall_group: "{{ algo_server_name }}" 46 | state: started 47 | tags: 48 | - Environment:Algo 49 | enable_ipv6: true 50 | backups: false 51 | activation_email: false 52 | register: vultr_server 53 | 54 | - set_fact: 55 | cloud_instance_ip: "{{ vultr_server.vultr_instance.main_ip }}" 56 | ansible_ssh_user: algo 57 | ansible_ssh_port: "{{ ssh_port }}" 58 | cloudinit: true 59 | 60 | environment: 61 | VULTR_API_KEY: "{{ lookup('ini', 'key', section='default', file=algo_vultr_config) }}" 62 | -------------------------------------------------------------------------------- /docs/deploy-to-ubuntu.md: -------------------------------------------------------------------------------- 1 | # Local Installation 2 | 3 | **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. 4 | 5 | ## Requirements 6 | 7 | Algo currently supports **Ubuntu 22.04 LTS only**. Your target server must be running an unmodified installation of Ubuntu 22.04. 8 | 9 | ## Installation 10 | 11 | You 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. 12 | 13 | 1. Follow the normal Algo installation instructions 14 | 2. When prompted, choose: `Install to existing Ubuntu latest LTS server (for advanced users)` 15 | 3. The target can be: 16 | - The same system where you installed Algo (requires `sudo ./algo`) 17 | - A remote Ubuntu server accessible via SSH without password prompts (use `ssh-agent`) 18 | 19 | For local installation on the same machine, you must run: 20 | ```bash 21 | sudo ./algo 22 | ``` 23 | 24 | ## Confirmation Prompt 25 | 26 | Local 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. 27 | 28 | For automated deployments or CI/CD pipelines, skip the confirmation with: 29 | ```bash 30 | ansible-playbook main.yml -e "provider=local local_install_confirmed=true server=localhost endpoint=YOUR_IP" 31 | ``` 32 | 33 | Only use `local_install_confirmed=true` when you have already taken a backup and understand the risks. 34 | 35 | ## Road Warrior Setup 36 | 37 | A "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. 38 | 39 | **Network Configuration:** 40 | - Forward the necessary ports from your router to the Algo server (see [firewall documentation](/docs/firewalls.md#external-firewall)) 41 | 42 | **Algo Configuration** (edit `config.cfg` before deployment): 43 | - Set `BetweenClients_DROP` to `false` (allows VPN clients to reach your LAN) 44 | - Consider setting `block_smb` and `block_netbios` to `false` (enables SMB/NetBIOS traffic) 45 | - For local DNS resolution (e.g., Pi-hole), set `dns_encryption` to `false` and update `dns_servers` to your local DNS server IP 46 | -------------------------------------------------------------------------------- /docs/client-linux-wireguard.md: -------------------------------------------------------------------------------- 1 | # Using Ubuntu as a Client with WireGuard 2 | 3 | ## Install WireGuard 4 | 5 | To connect to your AlgoVPN using [WireGuard](https://www.wireguard.com) from Ubuntu, make sure your system is up-to-date then install WireGuard: 6 | 7 | ```shell 8 | # Update your system: 9 | sudo apt update && sudo apt upgrade 10 | 11 | # If the file /var/run/reboot-required exists then reboot: 12 | [ -e /var/run/reboot-required ] && sudo reboot 13 | 14 | # Install WireGuard: 15 | sudo apt install wireguard 16 | # Note: openresolv is no longer needed on Ubuntu 22.04 LTS+ 17 | ``` 18 | 19 | For installation on other Linux distributions, see the [Installation](https://www.wireguard.com/install/) page on the WireGuard site. 20 | 21 | ## Locate the Config File 22 | 23 | The Algo-generated config files for WireGuard are named `configs//wireguard/.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. 24 | 25 | ## Configure WireGuard 26 | 27 | Finally, install the config file on your client as `/etc/wireguard/wg0.conf` and start WireGuard: 28 | 29 | ```shell 30 | # Install the config file to the WireGuard configuration directory on your 31 | # Linux client: 32 | sudo install -o root -g root -m 600 .conf /etc/wireguard/wg0.conf 33 | 34 | # Start the WireGuard VPN: 35 | sudo systemctl start wg-quick@wg0 36 | 37 | # Check that it started properly: 38 | sudo systemctl status wg-quick@wg0 39 | 40 | # Verify the connection to the AlgoVPN: 41 | sudo wg 42 | 43 | # See that your client is using the IP address of your AlgoVPN: 44 | curl ipv4.icanhazip.com 45 | 46 | # Optionally configure the connection to come up at boot time: 47 | sudo systemctl enable wg-quick@wg0 48 | ``` 49 | 50 | If your Linux distribution does not use `systemd` you can bring up WireGuard with `sudo wg-quick up wg0`. 51 | 52 | ## Using a DNS Search Domain 53 | 54 | As 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: 55 | ``` 56 | DNS = 172.27.153.31, fd00::b:991f, mydomain.com 57 | ``` 58 | will cause your `/etc/resolv.conf` to contain: 59 | ``` 60 | search mydomain.com 61 | nameserver 172.27.153.31 62 | nameserver fd00::b:991f 63 | ``` 64 | -------------------------------------------------------------------------------- /.github/workflows/test-effectiveness.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Test Effectiveness Tracking 3 | 4 | 'on': 5 | schedule: 6 | - cron: '0 0 * * 0' # Weekly on Sunday 7 | workflow_dispatch: # Allow manual runs 8 | 9 | permissions: 10 | contents: write 11 | issues: write 12 | pull-requests: read 13 | actions: read 14 | 15 | jobs: 16 | track-effectiveness: 17 | name: Analyze Test Effectiveness 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5.0.1 21 | with: 22 | persist-credentials: true 23 | 24 | - uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 25 | with: 26 | python-version: '3.11' 27 | 28 | - name: Analyze test effectiveness 29 | env: 30 | GH_TOKEN: ${{ github.token }} 31 | run: | 32 | python scripts/track-test-effectiveness.py 33 | 34 | - name: Upload metrics 35 | uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 36 | with: 37 | name: test-effectiveness-metrics 38 | path: .metrics/ 39 | 40 | - name: Create issue if tests are ineffective 41 | env: 42 | GH_TOKEN: ${{ github.token }} 43 | run: | 44 | # Check if we need to create an issue 45 | if grep -q "⚠️" .metrics/test-effectiveness-report.md; then 46 | # Check if issue already exists 47 | existing=$(gh issue list --label "test-effectiveness" --state open --json number --jq '.[0].number') 48 | 49 | if [ -z "$existing" ]; then 50 | gh issue create \ 51 | --title "Test Effectiveness Review Needed" \ 52 | --body-file .metrics/test-effectiveness-report.md \ 53 | --label "test-effectiveness,maintenance" 54 | else 55 | # Update existing issue 56 | gh issue comment $existing --body-file .metrics/test-effectiveness-report.md 57 | fi 58 | fi 59 | 60 | - name: Commit metrics if changed 61 | run: | 62 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 63 | git config --local user.name "github-actions[bot]" 64 | 65 | if [[ -n $(git status -s .metrics/) ]]; then 66 | git add .metrics/ 67 | git commit -m "chore: Update test effectiveness metrics [skip ci]" 68 | git push 69 | fi 70 | -------------------------------------------------------------------------------- /.ansible-lint: -------------------------------------------------------------------------------- 1 | # Ansible-lint configuration 2 | exclude_paths: 3 | - .cache/ 4 | - .github/ 5 | - tests/ 6 | - files/cloud-init/ # Cloud-init files have special format requirements 7 | - playbooks/ # These are task files included by other playbooks, not standalone playbooks 8 | - roles/cloud-ec2/files/ # AWS CloudFormation templates use YAML tags ansible-lint can't parse 9 | - roles/cloud-lightsail/files/ # AWS CloudFormation templates use YAML tags ansible-lint can't parse 10 | 11 | skip_list: 12 | - 'package-latest' # Package installs should not use latest - needed for updates 13 | - 'experimental' # Experimental rules 14 | - 'fqcn[action]' # Use FQCN for module actions - gradual migration 15 | - 'fqcn[action-core]' # Use FQCN for builtin actions - gradual migration 16 | - 'var-naming[no-role-prefix]' # Variable naming 17 | - 'var-naming[pattern]' # Variable naming patterns 18 | - 'no-free-form' # Avoid free-form syntax - some legacy usage 19 | - 'key-order[task]' # Task key order 20 | - 'name[casing]' # Name casing 21 | - 'yaml[document-start]' # YAML document start 22 | - 'role-name' # Role naming convention - too many cloud-* roles 23 | - 'no-handler' # Handler usage - some legitimate non-handler use cases 24 | - 'name[missing]' # All tasks should be named - 113 issues to fix (temporary) 25 | 26 | warn_list: 27 | - no-changed-when 28 | - yaml[line-length] 29 | - risky-file-permissions 30 | 31 | # Enable additional rules 32 | enable_list: 33 | - no-log-password 34 | - no-same-owner 35 | - partial-become 36 | - name[play] # All plays should be named 37 | - yaml[new-line-at-end-of-file] # Files should end with newline 38 | - jinja[invalid] # Invalid Jinja2 syntax (catches template errors) 39 | - jinja[spacing] # Proper spacing in Jinja2 expressions 40 | 41 | # Rules we're actively working on fixing 42 | # Move these from skip_list to enable_list as we fix them 43 | # - 'name[missing]' # All tasks should be named - 113 issues to fix 44 | # - 'no-changed-when' # Commands should not change things 45 | # - 'yaml[line-length]' # Line length limit 46 | # - 'risky-file-permissions' # File permissions 47 | 48 | verbosity: 1 49 | 50 | # Mock custom modules in library/ that ansible-lint can't auto-discover 51 | # These modules exist and work at runtime, but need to be declared for static analysis 52 | mock_modules: 53 | - gcp_compute_location_info 54 | - lightsail_region_facts 55 | - x25519_pubkey 56 | - scaleway_compute 57 | 58 | # vim: ft=yaml 59 | -------------------------------------------------------------------------------- /roles/common/tasks/packages.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Initialize package lists 3 | set_fact: 4 | algo_packages: "{{ tools | default([]) if not performance_preinstall_packages | default(false) else [] }}" 5 | algo_packages_optional: [] 6 | when: performance_parallel_packages | default(true) 7 | 8 | - name: Add StrongSwan packages 9 | set_fact: 10 | algo_packages: "{{ algo_packages + ['strongswan'] }}" 11 | when: 12 | - performance_parallel_packages | default(true) 13 | - ipsec_enabled | default(false) 14 | 15 | - name: Add WireGuard packages 16 | set_fact: 17 | algo_packages: "{{ algo_packages + ['wireguard'] }}" 18 | when: 19 | - performance_parallel_packages | default(true) 20 | - wireguard_enabled | default(true) 21 | 22 | - name: Add DNS packages 23 | set_fact: 24 | algo_packages: "{{ algo_packages + ['dnscrypt-proxy'] }}" 25 | when: 26 | - performance_parallel_packages | default(true) 27 | # dnscrypt-proxy handles both DNS ad-blocking and DNS-over-HTTPS/TLS encryption 28 | # Install if user wants either ad-blocking OR encrypted DNS (or both) 29 | - algo_dns_adblocking | default(false) or dns_encryption | default(false) 30 | 31 | - name: Add kernel headers to optional packages 32 | set_fact: 33 | algo_packages_optional: "{{ algo_packages_optional + ['linux-headers-generic', 'linux-headers-' + ansible_kernel] }}" 34 | when: 35 | - performance_parallel_packages | default(true) 36 | - install_headers | default(false) 37 | 38 | - name: Install all packages in batch (performance optimization) 39 | apt: 40 | name: "{{ algo_packages | unique }}" 41 | state: present 42 | update_cache: true 43 | install_recommends: true 44 | when: 45 | - performance_parallel_packages | default(true) 46 | - algo_packages | length > 0 47 | 48 | - name: Install optional packages in batch 49 | apt: 50 | name: "{{ algo_packages_optional | unique }}" 51 | state: present 52 | when: 53 | - performance_parallel_packages | default(true) 54 | - algo_packages_optional | length > 0 55 | 56 | - name: Debug - Show batched packages 57 | debug: 58 | msg: 59 | - "Batch installed {{ algo_packages | length }} main packages: {{ algo_packages | unique | join(', ') }}" 60 | - "Batch installed {{ algo_packages_optional | length }} optional packages: {{ algo_packages_optional | unique | join(', ') }}" 61 | when: 62 | - performance_parallel_packages | default(true) 63 | - (algo_packages | length > 0 or algo_packages_optional | length > 0) 64 | -------------------------------------------------------------------------------- /playbooks/cloud-pre.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - block: 3 | - name: Display the invocation environment 4 | shell: > 5 | ./algo-showenv.sh \ 6 | 'algo_provider "{{ algo_provider }}"' \ 7 | {% if ipsec_enabled %} 8 | 'algo_ondemand_cellular "{{ algo_ondemand_cellular }}"' \ 9 | 'algo_ondemand_wifi "{{ algo_ondemand_wifi }}"' \ 10 | 'algo_ondemand_wifi_exclude "{{ algo_ondemand_wifi_exclude }}"' \ 11 | {% endif %} 12 | 'algo_dns_adblocking "{{ algo_dns_adblocking }}"' \ 13 | 'algo_ssh_tunneling "{{ algo_ssh_tunneling }}"' \ 14 | 'wireguard_enabled "{{ wireguard_enabled }}"' \ 15 | 'dns_encryption "{{ dns_encryption }}"' \ 16 | > /dev/tty || true 17 | tags: debug 18 | 19 | # Install cloud provider specific dependencies 20 | - name: Install cloud provider dependencies 21 | shell: uv pip install '.[{{ cloud_provider_extra }}]' 22 | vars: 23 | cloud_provider_extra: >- 24 | {%- if algo_provider in ['ec2', 'lightsail'] -%}aws 25 | {%- elif algo_provider == 'azure' -%}azure 26 | {%- elif algo_provider == 'gce' -%}gcp 27 | {%- elif algo_provider == 'hetzner' -%}hetzner 28 | {%- elif algo_provider == 'linode' -%}linode 29 | {%- elif algo_provider == 'openstack' -%}openstack 30 | {%- elif algo_provider == 'cloudstack' -%}cloudstack 31 | {%- else -%}{{ algo_provider }} 32 | {%- endif -%} 33 | when: algo_provider != "local" 34 | changed_when: false 35 | 36 | # Note: pyOpenSSL and segno are now included in pyproject.toml dependencies 37 | # and installed automatically by uv sync 38 | delegate_to: localhost 39 | become: false 40 | 41 | - block: 42 | - name: Generate the SSH private key 43 | community.crypto.openssl_privatekey: 44 | path: "{{ SSH_keys.private }}" 45 | size: 4096 46 | mode: "0600" 47 | type: RSA 48 | 49 | - name: Generate the SSH public key 50 | community.crypto.openssl_publickey: 51 | path: "{{ SSH_keys.public }}" 52 | privatekey_path: "{{ SSH_keys.private }}" 53 | format: OpenSSH 54 | 55 | - name: Copy the private SSH key to /tmp 56 | copy: 57 | src: "{{ SSH_keys.private }}" 58 | dest: "{{ SSH_keys.private_tmp }}" 59 | force: true 60 | mode: "0600" 61 | delegate_to: localhost 62 | become: false 63 | when: algo_provider != "local" 64 | -------------------------------------------------------------------------------- /roles/client/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Gather Facts 3 | setup: 4 | - name: Include system based facts and tasks 5 | import_tasks: systems/main.yml 6 | 7 | - name: Install prerequisites 8 | package: name="{{ item }}" state=present 9 | loop: "{{ prerequisites }}" 10 | register: result 11 | until: result is succeeded 12 | retries: 10 13 | delay: 3 14 | 15 | - name: Install strongSwan 16 | package: name=strongswan state=present 17 | register: result 18 | until: result is succeeded 19 | retries: 10 20 | delay: 3 21 | 22 | - name: Setup the ipsec config 23 | template: 24 | src: roles/strongswan/templates/client_ipsec.conf.j2 25 | dest: "{{ configs_prefix }}/ipsec.{{ IP_subject_alt_name }}.conf" 26 | mode: "0644" 27 | notify: 28 | - restart strongswan 29 | 30 | - name: Setup the ipsec secrets 31 | template: 32 | src: roles/strongswan/templates/client_ipsec.secrets.j2 33 | dest: "{{ configs_prefix }}/ipsec.{{ IP_subject_alt_name }}.secrets" 34 | mode: "0600" 35 | notify: 36 | - restart strongswan 37 | 38 | - name: Include additional ipsec config 39 | lineinfile: 40 | dest: "{{ item.dest }}" 41 | line: "{{ item.line }}" 42 | create: true 43 | mode: "{{ item.mode }}" 44 | loop: 45 | - dest: "{{ configs_prefix }}/ipsec.conf" 46 | line: include ipsec.{{ IP_subject_alt_name }}.conf 47 | mode: '0644' 48 | - dest: "{{ configs_prefix }}/ipsec.secrets" 49 | line: include ipsec.{{ IP_subject_alt_name }}.secrets 50 | mode: '0600' 51 | notify: 52 | - restart strongswan 53 | 54 | - name: Configure libstrongswan to relax CA constraints 55 | copy: 56 | src: libstrongswan-relax-constraints.conf 57 | dest: "{{ configs_prefix }}/strongswan.d/relax-ca-constraints.conf" 58 | owner: root 59 | group: root 60 | mode: '0644' 61 | 62 | - name: Setup the certificates and keys 63 | template: 64 | src: "{{ item.src }}" 65 | dest: "{{ item.dest }}" 66 | mode: "{{ item.mode }}" 67 | loop: 68 | - src: configs/{{ IP_subject_alt_name }}/ipsec/.pki/certs/{{ vpn_user }}.crt 69 | dest: "{{ configs_prefix }}/ipsec.d/certs/{{ vpn_user }}.crt" 70 | mode: '0644' 71 | - src: configs/{{ IP_subject_alt_name }}/ipsec/.pki/cacert.pem 72 | dest: "{{ configs_prefix }}/ipsec.d/cacerts/{{ IP_subject_alt_name }}.pem" 73 | mode: '0644' 74 | - src: configs/{{ IP_subject_alt_name }}/ipsec/.pki/private/{{ vpn_user }}.key 75 | dest: "{{ configs_prefix }}/ipsec.d/private/{{ vpn_user }}.key" 76 | mode: '0600' 77 | notify: 78 | - restart strongswan 79 | -------------------------------------------------------------------------------- /playbooks/cloud-post.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Set subjectAltName as a fact 3 | set_fact: 4 | IP_subject_alt_name: "{{ (IP_subject_alt_name if algo_provider == 'local' else cloud_instance_ip) | lower }}" 5 | 6 | - name: Add the server to an inventory group 7 | add_host: 8 | name: "{% if cloud_instance_ip == 'localhost' %}localhost{% else %}{{ cloud_instance_ip }}{% endif %}" 9 | groups: vpn-host 10 | ansible_connection: "{% if cloud_instance_ip == 'localhost' %}local{% else %}ssh{% endif %}" 11 | ansible_ssh_user: "{{ ansible_ssh_user | default('root') }}" 12 | ansible_ssh_port: "{{ ansible_ssh_port | default(22) }}" 13 | ansible_python_interpreter: /usr/bin/python3 14 | algo_provider: "{{ algo_provider }}" 15 | algo_server_name: "{{ algo_server_name }}" 16 | algo_ondemand_cellular: "{{ algo_ondemand_cellular }}" 17 | algo_ondemand_wifi: "{{ algo_ondemand_wifi }}" 18 | algo_ondemand_wifi_exclude: "{{ algo_ondemand_wifi_exclude }}" 19 | algo_dns_adblocking: "{{ algo_dns_adblocking }}" 20 | algo_ssh_tunneling: "{{ algo_ssh_tunneling }}" 21 | algo_store_pki: "{{ algo_store_pki }}" 22 | IP_subject_alt_name: "{{ IP_subject_alt_name }}" 23 | alternative_ingress_ip: "{{ alternative_ingress_ip | default(omit) }}" 24 | cloudinit: "{{ cloudinit | default(false) }}" 25 | 26 | - name: Additional variables for the server 27 | add_host: 28 | name: "{% if cloud_instance_ip == 'localhost' %}localhost{% else %}{{ cloud_instance_ip }}{% endif %}" 29 | ansible_ssh_private_key_file: "{{ SSH_keys.private_tmp }}" 30 | when: algo_provider != 'local' 31 | 32 | - name: Wait until SSH becomes ready... 33 | wait_for: 34 | port: "{{ ansible_ssh_port | default(22) }}" 35 | host: "{{ cloud_instance_ip }}" 36 | search_regex: OpenSSH 37 | delay: 10 38 | timeout: 320 39 | state: present 40 | when: cloud_instance_ip != "localhost" 41 | 42 | - name: Mount tmpfs 43 | import_tasks: tmpfs/main.yml 44 | when: 45 | - pki_in_tmpfs 46 | - not algo_store_pki 47 | - ansible_system == "Darwin" or ansible_system == "Linux" 48 | 49 | - debug: 50 | var: IP_subject_alt_name 51 | 52 | - name: Wait for target connection to become reachable/usable 53 | wait_for_connection: 54 | delay: 10 # Wait 10 seconds before first attempt (conservative) 55 | timeout: 480 # Reduce from 600 to 480 seconds (8 minutes - safer) 56 | sleep: 10 # Check every 10 seconds (less aggressive polling) 57 | delegate_to: "{{ item }}" 58 | loop: "{{ groups['vpn-host'] }}" 59 | when: cloud_instance_ip != "localhost" 60 | -------------------------------------------------------------------------------- /docs/firewalls.md: -------------------------------------------------------------------------------- 1 | # AlgoVPN and Firewalls 2 | 3 | Your AlgoVPN requires properly configured firewalls. The key points to know are: 4 | 5 | * If you deploy to a **cloud** provider all firewall configuration will done automatically. 6 | 7 | * 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. 8 | 9 | ## The Two Types of Firewall 10 | 11 | ![Firewall Illustration](/docs/images/firewalls.png) 12 | 13 | ### Server Firewall 14 | 15 | During 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. 16 | 17 | ### External Firewall 18 | 19 | Most 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. 20 | 21 | External firewalls are not configured when performing a **local** installation, even when using a server from a cloud service provider. 22 | 23 | Any external firewall must be configured to pass the following incoming ports over IPv4 : 24 | 25 | Port | Protocol | Description | Related variables in `config.cfg` 26 | ---- | -------- | ----------- | --------------------------------- 27 | 4160 | TCP | Secure Shell (SSH) | `ssh_port` (**cloud** only; for **local** port remains 22) 28 | 500 | UDP | IPsec IKEv2 | `ipsec_enabled` 29 | 4500 | UDP | IPsec NAT-T | `ipsec_enabled` 30 | 51820 | UDP | WireGuard | `wireguard_enabled`, `wireguard_port` 31 | 32 | If 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`. 33 | 34 | Even 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. 35 | -------------------------------------------------------------------------------- /tests/integration/mock_modules/shell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # Mock shell module for Docker testing 3 | 4 | import subprocess 5 | 6 | from ansible.module_utils.basic import AnsibleModule 7 | 8 | 9 | def main(): 10 | module = AnsibleModule( 11 | argument_spec={ 12 | "_raw_params": {"type": "str"}, 13 | "cmd": {"type": "str"}, 14 | "creates": {"type": "path"}, 15 | "removes": {"type": "path"}, 16 | "chdir": {"type": "path"}, 17 | "executable": {"type": "path", "default": "/bin/sh"}, 18 | "warn": {"type": "bool", "default": False}, 19 | "stdin": {"type": "str"}, 20 | "stdin_add_newline": {"type": "bool", "default": True}, 21 | }, 22 | supports_check_mode=True, 23 | ) 24 | 25 | # Get the command 26 | raw_params = module.params.get("_raw_params") 27 | cmd = module.params.get("cmd") or raw_params 28 | 29 | if not cmd: 30 | module.fail_json(msg="no command given") 31 | 32 | result = {"changed": False, "cmd": cmd, "rc": 0, "stdout": "", "stderr": "", "stdout_lines": [], "stderr_lines": []} 33 | 34 | # Log the operation 35 | with open("/var/log/mock-shell-module.log", "a") as f: 36 | f.write(f"shell module called: cmd={cmd}\n") 37 | 38 | # Handle specific commands 39 | if "echo 1 > /proc/sys/net/ipv4/route/flush" in cmd: 40 | # Routing cache flush - just pretend it worked 41 | result["stdout"] = "" 42 | result["changed"] = True 43 | else: 44 | # For other commands, try to run them 45 | try: 46 | proc = subprocess.run( 47 | cmd, 48 | shell=True, 49 | capture_output=True, 50 | text=True, 51 | executable=module.params.get("executable"), 52 | cwd=module.params.get("chdir"), 53 | ) 54 | result["rc"] = proc.returncode 55 | result["stdout"] = proc.stdout 56 | result["stderr"] = proc.stderr 57 | result["stdout_lines"] = proc.stdout.splitlines() 58 | result["stderr_lines"] = proc.stderr.splitlines() 59 | result["changed"] = True 60 | except Exception as e: 61 | result["rc"] = 1 62 | result["stderr"] = str(e) 63 | result["msg"] = str(e) 64 | module.fail_json(msg=result["msg"], **result) 65 | 66 | if result["rc"] == 0: 67 | module.exit_json(**result) 68 | else: 69 | if "msg" not in result: 70 | result["msg"] = f"Command failed with return code {result['rc']}" 71 | module.fail_json(msg=result["msg"], **result) 72 | 73 | 74 | if __name__ == "__main__": 75 | main() 76 | -------------------------------------------------------------------------------- /roles/cloud-gce/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Include prompts 3 | import_tasks: prompts.yml 4 | 5 | - name: Network configured 6 | gcp_compute_network: 7 | auth_kind: serviceaccount 8 | service_account_file: "{{ credentials_file_path }}" 9 | project: "{{ project_id }}" 10 | name: algovpn 11 | auto_create_subnetworks: true 12 | routing_config: 13 | routing_mode: REGIONAL 14 | register: gcp_compute_network 15 | 16 | - name: Firewall configured 17 | gcp_compute_firewall: 18 | auth_kind: serviceaccount 19 | service_account_file: "{{ credentials_file_path }}" 20 | project: "{{ project_id }}" 21 | name: algovpn 22 | network: "{{ gcp_compute_network }}" 23 | direction: INGRESS 24 | allowed: 25 | - ip_protocol: udp 26 | ports: 27 | - "500" 28 | - "4500" 29 | - "{{ wireguard_port | string }}" 30 | - ip_protocol: tcp 31 | ports: 32 | - "{{ ssh_port }}" 33 | - ip_protocol: icmp 34 | 35 | - block: 36 | - name: External IP allocated 37 | gcp_compute_address: 38 | auth_kind: serviceaccount 39 | service_account_file: "{{ credentials_file_path }}" 40 | project: "{{ project_id }}" 41 | name: "{{ algo_server_name }}" 42 | region: "{{ algo_region }}" 43 | register: gcp_compute_address 44 | 45 | - name: Set External IP as a fact 46 | set_fact: 47 | external_ip: "{{ gcp_compute_address.address }}" 48 | when: cloud_providers.gce.external_static_ip 49 | 50 | - name: Instance created 51 | gcp_compute_instance: 52 | auth_kind: serviceaccount 53 | service_account_file: "{{ credentials_file_path }}" 54 | project: "{{ project_id }}" 55 | name: "{{ algo_server_name }}" 56 | zone: "{{ algo_zone }}" 57 | machine_type: "{{ cloud_providers.gce.size }}" 58 | disks: 59 | - auto_delete: true 60 | boot: true 61 | initialize_params: 62 | source_image: projects/ubuntu-os-cloud/global/images/family/{{ cloud_providers.gce.image }} 63 | metadata: 64 | ssh-keys: algo:{{ ssh_public_key_lookup }} 65 | user-data: "{{ lookup('template', 'files/cloud-init/base.yml') }}" 66 | network_interfaces: 67 | - network: "{{ gcp_compute_network }}" 68 | access_configs: 69 | - name: "{{ algo_server_name }}" 70 | nat_ip: "{{ gcp_compute_address | default(None) }}" 71 | type: ONE_TO_ONE_NAT 72 | tags: 73 | items: 74 | - environment-algo 75 | register: gcp_compute_instance 76 | 77 | - set_fact: 78 | cloud_instance_ip: "{{ gcp_compute_instance.networkInterfaces[0].accessConfigs[0].natIP }}" 79 | ansible_ssh_user: algo 80 | ansible_ssh_port: "{{ ssh_port }}" 81 | cloudinit: true 82 | -------------------------------------------------------------------------------- /roles/cloud-lightsail/tasks/prompts.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - pause: 3 | prompt: | 4 | Enter your aws_access_key (http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html) 5 | 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) 6 | echo: false 7 | register: _aws_access_key 8 | when: 9 | - aws_access_key is undefined 10 | - lookup('env', 'AWS_ACCESS_KEY_ID')|length <= 0 11 | no_log: true 12 | 13 | - pause: 14 | prompt: | 15 | Enter your aws_secret_key (http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html) 16 | echo: false 17 | register: _aws_secret_key 18 | when: 19 | - aws_secret_key is undefined 20 | - lookup('env', 'AWS_SECRET_ACCESS_KEY')|length <= 0 21 | no_log: true 22 | 23 | - set_fact: 24 | access_key: "{{ aws_access_key | default(_aws_access_key.user_input | default(None)) | default(lookup('env', 'AWS_ACCESS_KEY_ID'), true) }}" 25 | secret_key: "{{ aws_secret_key | default(_aws_secret_key.user_input | default(None)) | default(lookup('env', 'AWS_SECRET_ACCESS_KEY'), true) }}" 26 | no_log: true 27 | 28 | - block: 29 | - name: Get regions 30 | lightsail_region_facts: 31 | aws_access_key: "{{ access_key }}" 32 | aws_secret_key: "{{ secret_key }}" 33 | region: us-east-1 34 | register: _lightsail_regions 35 | no_log: true 36 | 37 | - name: Set facts about the regions 38 | set_fact: 39 | lightsail_regions: "{{ _lightsail_regions.data.regions | sort(attribute='name') }}" 40 | 41 | - name: Set the default region 42 | set_fact: 43 | default_region: >- 44 | {% for r in lightsail_regions -%} 45 | {% if r['name'] == "us-east-1" %}{{ loop.index }}{% endif %} 46 | {%- endfor %} 47 | 48 | - pause: 49 | prompt: | 50 | What region should the server be located in? 51 | (https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/) 52 | {% for r in lightsail_regions %} 53 | {{ (loop.index | string + '.').ljust(3) }} {{ r['name'].ljust(20) }} {{ r['displayName'] }} 54 | {% endfor %} 55 | 56 | Enter the number of your desired region 57 | [{{ default_region }}] 58 | register: _algo_region 59 | when: region is undefined 60 | 61 | - set_fact: 62 | stack_name: "{{ algo_server_name | replace('.', '-') }}" 63 | algo_region: >- 64 | {% if region is defined %}{{ region -}} 65 | {% elif _algo_region.user_input %}{{ lightsail_regions[_algo_region.user_input | int - 1]['name'] -}} 66 | {% else %}{{ lightsail_regions[default_region | int - 1]['name'] }}{% endif %} 67 | -------------------------------------------------------------------------------- /docs/client-linux.md: -------------------------------------------------------------------------------- 1 | # Linux client setup 2 | 3 | ## Provision client config 4 | 5 | After 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`. 6 | 7 | ### Required variables 8 | 9 | * `client_ip` - The IP address of your client machine (You can use `localhost` in order to deploy locally) 10 | * `vpn_user` - The username. (Ensure that you have valid certificates and keys in the `configs/SERVER_ip/pki/` directory) 11 | * `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) 12 | * `server_ip` - The vpn server ip address 13 | 14 | ### Example 15 | 16 | ```shell 17 | ansible-playbook deploy_client.yml -e 'client_ip=client.com vpn_user=jack server_ip=vpn-server.com ssh_user=root' 18 | ``` 19 | 20 | ### Additional options 21 | 22 | If the user requires sudo password use the following argument: `--ask-become-pass`. 23 | 24 | ## OS Specific instructions 25 | 26 | Some Linux clients may require more specific and details instructions to configure a connection to the deployed Algo VPN, these are documented here. 27 | 28 | ### Fedora Workstation 29 | 30 | #### (Gnome) Network Manager install 31 | 32 | First, install the required plugins. 33 | 34 | ```` 35 | dnf install NetworkManager-strongswan NetworkManager-strongswan-gnome 36 | ```` 37 | 38 | #### (Gnome) Network Manager configuration 39 | 40 | In 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`. 41 | 42 | * Go to *Settings* > *Network* 43 | * Add a new Network (`+` bottom left of the window) 44 | * Select *IPsec/IKEv2 (strongswan)* 45 | * Fill out the options: 46 | * Name: your choice, e.g.: *ikev2-1.2.3.4* 47 | * Gateway: 48 | * Address: IP of the Algo VPN server, e.g: `1.2.3.4` 49 | * Certificate: `cacert.pem` found at `/path/to/algo/configs/1.2.3.4/ipsec/.pki/cacert.pem` 50 | * Client: 51 | * Authentication: *Certificate/Private key* 52 | * Certificate: `user-name.crt` found at `/path/to/algo/configs/1.2.3.4/ipsec/.pki/certs/user-name.crt` 53 | * Private key: `user-name.key` found at `/path/to/algo/configs/1.2.3.4/ipsec/.pki/private/user-name.key` 54 | * Options: 55 | * Check *Request an inner IP address*, connection will fail without this option 56 | * Optionally check *Enforce UDP encapsulation* 57 | * Optionally check *Use IP compression* 58 | * For the later 2 options, hover to option in the settings to see a description 59 | * Cipher proposal: 60 | * Check *Enable custom proposals* 61 | * IKE: `aes256gcm16-prfsha512-ecp384` 62 | * ESP: `aes256gcm16-ecp384` 63 | * Apply and turn the connection on, you should now be connected 64 | -------------------------------------------------------------------------------- /tests/integration/test-configs/10.99.0.10/wireguard/apple/ios/testuser1.mobileconfig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PayloadContent 6 | 7 | 8 | IPv4 9 | 10 | OverridePrimary 11 | 1 12 | 13 | PayloadDescription 14 | Configures VPN settings 15 | PayloadDisplayName 16 | algo-test-server 17 | PayloadIdentifier 18 | com.apple.vpn.managed.algo-test-server9D18FBEE-C185-5F8B-9A48-C873BB65BB8E 19 | PayloadType 20 | com.apple.vpn.managed 21 | PayloadUUID 22 | algo-test-server9D18FBEE-C185-5F8B-9A48-C873BB65BB8E 23 | PayloadVersion 24 | 1 25 | Proxies 26 | 27 | HTTPEnable 28 | 0 29 | HTTPSEnable 30 | 0 31 | 32 | UserDefinedName 33 | AlgoVPN algo-test-server 34 | VPN 35 | 36 | OnDemandEnabled 37 | 0 38 | OnDemandRules 39 | 40 | 41 | Action 42 | Connect 43 | 44 | 45 | AuthenticationMethod 46 | Password 47 | RemoteAddress 48 | 10.99.0.10:51820 49 | 50 | VPNSubType 51 | com.wireguard.ios 52 | VPNType 53 | VPN 54 | VendorConfig 55 | 56 | WgQuickConfig 57 | [Interface] 58 | PrivateKey = OC3EYbHLzszqmYqFlC22bnPDoTHp4ORULg9vCphbcEY= 59 | Address = 10.19.49.2 60 | DNS = 8.8.8.8,8.8.4.4 61 | 62 | [Peer] 63 | PublicKey = IJFSpegTMGKoK5EtJaX2uH/hBWxq8ZpNOJIBMZnE4w0= 64 | PresharedKey = CSLeU66thNW52DkY6MVJ2A5VodnxbsC7EklcrHPCKco= 65 | AllowedIPs = 0.0.0.0/0,::/0 66 | Endpoint = 10.99.0.10:51820 67 | 68 | 69 | 70 | PayloadDisplayName 71 | AlgoVPN algo-test-server WireGuard 72 | PayloadIdentifier 73 | donut.local.D503FCD6-107F-5C1A-94C2-EE8821F144CD 74 | PayloadOrganization 75 | AlgoVPN 76 | PayloadRemovalDisallowed 77 | 78 | PayloadType 79 | Configuration 80 | PayloadUUID 81 | 2B1947FC-5FDD-56F5-8C60-E553E7F8C788 82 | PayloadVersion 83 | 1 84 | 85 | 86 | --------------------------------------------------------------------------------