From a1b7332fc932427395ec51271ea277420e7bc2ea Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Wed, 14 Jan 2026 13:51:04 +0100 Subject: [PATCH 01/14] Update gitignore and some experiments --- .gitignore | 4 ++++ algo-ubuntu.sh | 1 + config.cfg | 14 ++++++++------ pyvenv.cfg | 5 +++++ 4 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 algo-ubuntu.sh create mode 100644 pyvenv.cfg diff --git a/.gitignore b/.gitignore index 57f0926d8..ac7bc5169 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ *.retry .idea/ configs/* +~config.cfg +config.cfg.* inventory_users *.kate-swp *env @@ -8,3 +10,5 @@ inventory_users venvs/* !venvs/.gitinit .vagrant +bin/ +lib/ \ No newline at end of file diff --git a/algo-ubuntu.sh b/algo-ubuntu.sh new file mode 100644 index 000000000..b33f6a088 --- /dev/null +++ b/algo-ubuntu.sh @@ -0,0 +1 @@ +cd ~ && git clone https://github.com/trailofbits/algo.git && apt update && cd ./algo && apt install -y --no-install-recommends python3-virtualenv && python3 -m virtualenv --python="$(command -v python3)" .env && source .env/bin/activate && python3 -m pip install -U pip virtualenv && python3 -m pip install -r requirements.txt && ./algo diff --git a/config.cfg b/config.cfg index 1c93e74b6..ac93d89a5 100644 --- a/config.cfg +++ b/config.cfg @@ -24,7 +24,9 @@ ipsec_enabled: true # if your network blocks this one. Be aware that 53/UDP (DNS) is blocked on some # mobile data networks. wireguard_enabled: true -wireguard_port: 51820 +# wireguard_port: 53825 +wireguard_port: 59778 +wireguard_port_avoid: 51820 # 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. @@ -59,13 +61,13 @@ dns_encryption: true # connected clients to reach each other, as well as other computers on the # same LAN as your Algo server (i.e. the "road warrior" setup). In this # case, you may also want to enable SMB/CIFS and NETBIOS traffic below. -BetweenClients_DROP: true +BetweenClients_DROP: false # Block SMB/CIFS traffic -block_smb: true +block_smb: false # Block NETBIOS traffic -block_netbios: true +block_netbios: false # Your Algo server will automatically install security updates. Some updates # require a reboot to take effect but your Algo server will not reboot itself @@ -73,8 +75,8 @@ block_netbios: true # which case a reboot will take place if necessary at the time specified (as # HH:MM) in the time zone of your Algo server. The default time zone is UTC. unattended_reboot: - enabled: false - time: 06:00 + enabled: true + time: 02:00 ### Advanced users only below this line ### diff --git a/pyvenv.cfg b/pyvenv.cfg new file mode 100644 index 000000000..a9f2971b8 --- /dev/null +++ b/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /opt/homebrew/opt/python@3.13/bin +include-system-site-packages = false +version = 3.13.1 +executable = /opt/homebrew/Cellar/python@3.13/3.13.1/Frameworks/Python.framework/Versions/3.13/bin/python3.13 +command = /opt/homebrew/opt/python@3.13/bin/python3.13 -m venv /Users/andrey/Developer/libraries/algo From 79ee62fe9f2b86322d02831ccc76c10cea9e05bb Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Wed, 14 Jan 2026 18:26:12 +0100 Subject: [PATCH 02/14] Add support for XRAY protocol --- CLAUDE.md | 38 +++++++- README.md | 22 ++++- config.cfg | 31 +++--- input.yml | 31 +++++- roles/common/templates/rules.v4.j2 | 6 +- roles/common/templates/rules.v6.j2 | 4 + roles/xray/defaults/main.yml | 27 ++++++ roles/xray/handlers/main.yml | 12 +++ roles/xray/meta/main.yml | 3 + roles/xray/tasks/clients.yml | 38 ++++++++ roles/xray/tasks/configure.yml | 14 +++ roles/xray/tasks/install.yml | 108 +++++++++++++++++++++ roles/xray/tasks/keys.yml | 99 +++++++++++++++++++ roles/xray/tasks/main.yml | 39 ++++++++ roles/xray/templates/client-config.json.j2 | 84 ++++++++++++++++ roles/xray/templates/config.json.j2 | 93 ++++++++++++++++++ roles/xray/templates/vless-link.txt.j2 | 1 + roles/xray/templates/xray.service.j2 | 20 ++++ server.yml | 16 ++- 19 files changed, 663 insertions(+), 23 deletions(-) create mode 100644 roles/xray/defaults/main.yml create mode 100644 roles/xray/handlers/main.yml create mode 100644 roles/xray/meta/main.yml create mode 100644 roles/xray/tasks/clients.yml create mode 100644 roles/xray/tasks/configure.yml create mode 100644 roles/xray/tasks/install.yml create mode 100644 roles/xray/tasks/keys.yml create mode 100644 roles/xray/tasks/main.yml create mode 100644 roles/xray/templates/client-config.json.j2 create mode 100644 roles/xray/templates/config.json.j2 create mode 100644 roles/xray/templates/vless-link.txt.j2 create mode 100644 roles/xray/templates/xray.service.j2 diff --git a/CLAUDE.md b/CLAUDE.md index b9a4b516f..cf2f9442c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ Algo is an Ansible-based tool that sets up a personal VPN in the cloud. It's des - **Privacy-preserving**: No logging, minimal data retention ### Core Technologies -- **VPN Protocols**: WireGuard (preferred) and IPsec/IKEv2 +- **VPN Protocols**: WireGuard (preferred), IPsec/IKEv2, VLESS+Reality (stealth/anti-censorship) - **Configuration Management**: Ansible (v12+) - **Languages**: Python, YAML, Shell, Jinja2 templates - **Supported Providers**: AWS, Azure, DigitalOcean, GCP, Vultr, Hetzner, local deployment @@ -40,6 +40,7 @@ algo/ │ ├── common/ # Base system configuration, firewall, hardening │ ├── wireguard/ # WireGuard VPN setup │ ├── strongswan/ # IPsec/IKEv2 setup +│ ├── xray/ # VLESS+Reality stealth VPN (xray-core) │ ├── dns/ # DNS configuration (dnscrypt-proxy) │ └── cloud-*/ # Cloud provider specific roles ├── library/ # Custom Ansible modules @@ -369,7 +370,38 @@ ansible-playbook main.yml -vvv ## Platform Support -- **Primary OS**: Ubuntu 22.04/24.04 LTS -- **Secondary**: Debian 11/12 +- **Primary OS**: Ubuntu 24.04 LTS +- **Secondary**: Ubuntu 22.04, Debian 11/12 - **Architectures**: x86_64 and ARM64 - **Testing tip**: DigitalOcean droplets have both public and private IPs on eth0, making them good test cases for multi-IP NAT scenarios + +## VLESS+Reality (Stealth VPN) + +The `xray` role provides censorship-resistant VPN using VLESS protocol with XTLS-Reality transport: + +### Why Reality? +- Traffic is indistinguishable from regular HTTPS to a legitimate website +- No TLS certificate needed (uses target site's real certificate) +- Cannot be detected by active probing +- Proven effective in China, Russia, Iran + +### Configuration +```yaml +# config.cfg +xray_enabled: true +xray_port: 443 +xray_reality_dest: "www.microsoft.com:443" # Site to mimic +xray_reality_sni: "www.microsoft.com" +``` + +### Client Configuration +Generated files in `configs//xray/`: +- `.json` - Full xray client config +- `.txt` - VLESS share link +- `.png` - QR code for mobile apps + +### Recommended Clients +- **iOS/macOS**: Shadowrocket, Streisand +- **Android**: v2rayNG, NekoBox +- **Windows**: v2rayN, Nekoray +- **Linux**: v2rayA, Nekoray diff --git a/README.md b/README.md index 93ed5164f..f6c1c857e 100644 --- a/README.md +++ b/README.md @@ -10,23 +10,39 @@ See our [release announcement](https://blog.trailofbits.com/2016/12/12/meet-algo * Supports only IKEv2 with strong crypto (AES-GCM, SHA2, and P-256) for iOS, MacOS, and Linux * Supports [WireGuard](https://www.wireguard.com/) for all of the above, in addition to Android and Windows 11 +* Supports **VLESS+Reality** (xray-core) for censorship-resistant VPN that is undetectable by DPI * Generates .conf files and QR codes for iOS, macOS, Android, and Windows WireGuard clients +* Generates VLESS share links and QR codes for stealth VPN clients * Generates Apple profiles to auto-configure iOS and macOS devices for IPsec - no client software required * Includes helper scripts to add, remove, and manage users * Blocks ads with a local DNS resolver (optional) * Sets up limited SSH users for tunneling traffic (optional) * Privacy-focused with minimal logging, automatic log rotation, and configurable privacy enhancements -* Based on Ubuntu 22.04 LTS with automatic security updates +* Based on Ubuntu 24.04 LTS with automatic security updates * Installs to DigitalOcean, Amazon Lightsail, Amazon EC2, Vultr, Microsoft Azure, Google Compute Engine, Scaleway, OpenStack, CloudStack, Hetzner Cloud, Linode, or [your own Ubuntu server (for advanced users)](docs/deploy-to-ubuntu.md) ## Anti-features * Does not support legacy cipher suites or protocols like L2TP, IKEv1, or RSA * Does not install Tor, OpenVPN, or other risky servers -* Does not depend on the security of [TLS](https://tools.ietf.org/html/rfc7457) -* Does not claim to provide anonymity or censorship avoidance +* Does not depend on the security of [TLS](https://tools.ietf.org/html/rfc7457) for WireGuard/IPsec (VLESS+Reality uses TLS for stealth) +* Does not claim to provide anonymity * Does not claim to protect you from the [FSB](https://en.wikipedia.org/wiki/Federal_Security_Service), [MSS](https://en.wikipedia.org/wiki/Ministry_of_State_Security_(China)), [DGSE](https://en.wikipedia.org/wiki/Directorate-General_for_External_Security), or [FSM](https://en.wikipedia.org/wiki/Flying_Spaghetti_Monster) +## Stealth VPN (VLESS+Reality) + +For networks where WireGuard is blocked, Algo now supports VLESS+Reality protocol: + +```yaml +# In config.cfg, enable stealth VPN: +xray_enabled: true +xray_port: 443 +xray_reality_dest: "www.microsoft.com:443" +xray_reality_sni: "www.microsoft.com" +``` + +Traffic appears as regular HTTPS to the configured destination. Works in China, Russia, Iran and other censored networks. Client apps: Shadowrocket (iOS), v2rayNG (Android), v2rayN (Windows), Nekoray (Linux/macOS). + ## Deploy the Algo Server The easiest way to get an Algo server running is to run it on your local system or from [Google Cloud Shell](docs/deploy-from-cloudshell.md) and let it set up a _new_ virtual machine in the cloud for you. diff --git a/config.cfg b/config.cfg index c5fc7aebc..4952e1e5f 100644 --- a/config.cfg +++ b/config.cfg @@ -30,6 +30,13 @@ ipsec_enabled: true wireguard_enabled: true wireguard_port: 51820 # Change if blocked by your network (avoid 53/UDP) +# Stealth VPN (VLESS + XTLS-Reality) - undetectable by DPI +xray_enabled: true +xray_port: 443 +xray_reality_dest: "www.microsoft.com:443" # Target site to mimic +xray_reality_sni: "www.microsoft.com" # SNI for TLS handshake +xray_flow: "xtls-rprx-vision" # XTLS flow control + # Use different IP for outbound traffic (DigitalOcean only) alternative_ingress_ip: false @@ -159,14 +166,14 @@ cloud_providers: type: Standard_LRS image: publisher: Canonical - offer: 0001-com-ubuntu-minimal-jammy-daily - sku: minimal-22_04-daily-lts + offer: ubuntu-24_04-lts + sku: server version: latest digitalocean: # See docs for extended droplet options, pricing, and availability. # Possible values: 's-1vcpu-512mb-10gb', 's-1vcpu-1gb', ... size: s-1vcpu-1gb - image: "ubuntu-22-04-x64" + image: "ubuntu-24-04-x64" ec2: # Change the encrypted flag to "false" to disable AWS volume encryption. encrypted: true @@ -175,7 +182,7 @@ cloud_providers: use_existing_eip: false size: t2.micro image: - name: "ubuntu-jammy-22.04" + name: "ubuntu-noble-24.04" arch: x86_64 owner: "099720109477" # Change instance_market_type from "on-demand" to "spot" to launch a spot @@ -183,31 +190,31 @@ cloud_providers: instance_market_type: on-demand gce: size: e2-micro - image: ubuntu-2204-lts + image: ubuntu-2404-lts external_static_ip: false lightsail: size: nano_2_0 - image: ubuntu_22_04 + image: ubuntu_24_04 scaleway: size: DEV1-S - image: Ubuntu 22.04 Jammy Jellyfish + image: Ubuntu 24.04 Noble Numbat arch: x86_64 hetzner: server_type: cpx22 - image: ubuntu-22.04 + image: ubuntu-24.04 openstack: flavor_ram: ">=512" - image: Ubuntu-22.04 + image: Ubuntu-24.04 cloudstack: size: Micro - image: Linux Ubuntu 22.04 LTS 64-bit + image: Linux Ubuntu 24.04 LTS 64-bit disk: 10 vultr: - os: Ubuntu 22.04 LTS x64 + os: Ubuntu 24.04 LTS x64 size: vc2-1c-1gb linode: type: g6-nanode-1 - image: linode/ubuntu22.04 + image: linode/ubuntu24.04 local: fail_hint: diff --git a/input.yml b/input.yml index 8dbf3daac..02187b15f 100644 --- a/input.yml +++ b/input.yml @@ -54,6 +54,23 @@ - server_name is undefined - algo_provider != "local" + - name: VPN protocols prompt + pause: + prompt: | + Which VPN protocols do you want to enable? + 1. XRAY (VLESS+Reality) - stealth, undetectable by DPI [recommended] + 2. WireGuard - fast, but easily blocked + 3. IPsec/IKEv2 - native on Apple devices + 4. All protocols + 5. XRAY + WireGuard + 6. XRAY + IPsec + 7. WireGuard + IPsec + + Enter the number of your choice + [1] + register: _vpn_protocols + when: vpn_protocols is undefined + - name: Cellular On Demand prompt pause: prompt: | @@ -88,7 +105,7 @@ register: _store_pki when: - store_pki is undefined - - ipsec_enabled + - ipsec_enabled | bool - name: DNS adblocking prompt pause: @@ -106,6 +123,16 @@ register: _ssh_tunneling when: ssh_tunneling is undefined + - name: Set VPN protocol facts + set_fact: + _vpn_choice: "{{ vpn_protocols | default(_vpn_protocols.user_input | default('1')) | string }}" + + - name: Set protocol flags based on choice + set_fact: + xray_enabled: "{{ _vpn_choice in ['1', '4', '5', '6'] }}" + wireguard_enabled: "{{ _vpn_choice in ['2', '4', '5', '7'] }}" + ipsec_enabled: "{{ _vpn_choice in ['3', '4', '6', '7'] }}" + - name: Set facts based on the input set_fact: algo_server_name: >- @@ -125,6 +152,6 @@ algo_ssh_tunneling: >- {% if ssh_tunneling is defined %}{{ ssh_tunneling | bool }}{%- elif _ssh_tunneling.user_input is defined %}{{ booleans_map[_ssh_tunneling.user_input] | default(defaults['ssh_tunneling']) }}{%- else %}{{ false }}{% endif %} algo_store_pki: >- - {% if ipsec_enabled %}{%- if store_pki is defined %}{{ store_pki | bool }}{%- elif _store_pki.user_input is defined %}{{ booleans_map[_store_pki.user_input] | default(defaults['store_pki']) }}{%- else %}{{ false }}{% endif %}{% endif %} + {% if ipsec_enabled %}{%- if store_pki is defined %}{{ store_pki | bool }}{%- elif _store_pki.user_input is defined %}{{ booleans_map[_store_pki.user_input] | default(defaults['store_pki']) }}{%- else %}{{ false }}{% endif %}{% else %}{{ false }}{% endif %} rescue: - include_tasks: playbooks/rescue.yml diff --git a/roles/common/templates/rules.v4.j2 b/roles/common/templates/rules.v4.j2 index 9ed8a502d..3744f6f56 100644 --- a/roles/common/templates/rules.v4.j2 +++ b/roles/common/templates/rules.v4.j2 @@ -1,4 +1,4 @@ -{% set subnets = ([strongswan_network] if ipsec_enabled else []) + ([wireguard_network_ipv4] if wireguard_enabled else []) %} +{% set subnets = ([strongswan_network] if ipsec_enabled else []) + ([wireguard_network_ipv4] if wireguard_enabled else []) + ([xray_network_ipv4] if xray_enabled | default(false) else []) %} {% set ports = (['500', '4500'] if ipsec_enabled else []) + ([wireguard_port] if wireguard_enabled else []) + ([wireguard_port_actual] if wireguard_enabled and wireguard_port | int == wireguard_port_avoid | int else []) %} #### The mangle table @@ -72,6 +72,10 @@ COMMIT -A INPUT -p icmp --icmp-type echo-request -m hashlimit --hashlimit-upto 5/s --hashlimit-mode srcip --hashlimit-srcmask 32 --hashlimit-name icmp-echo-drop -j ACCEPT # Accept IPSEC/WireGuard traffic to ports {{ subnets | join(',') }} -A INPUT -p udp -m multiport --dports {{ ports | join(',') }} -j ACCEPT +{% if xray_enabled | default(false) %} +# Accept VLESS/Reality traffic (stealth VPN) +-A INPUT -p tcp --dport {{ xray_port }} -m conntrack --ctstate NEW -j ACCEPT +{% endif %} # Allow new traffic to port {{ ansible_ssh_port }} (SSH) -A INPUT -p tcp --dport {{ ansible_ssh_port }} -m conntrack --ctstate NEW -j ACCEPT diff --git a/roles/common/templates/rules.v6.j2 b/roles/common/templates/rules.v6.j2 index e060b5138..f0000b709 100644 --- a/roles/common/templates/rules.v6.j2 +++ b/roles/common/templates/rules.v6.j2 @@ -78,6 +78,10 @@ COMMIT -A INPUT -p icmpv6 --icmpv6-type echo-request -m hashlimit --hashlimit-upto 5/s --hashlimit-mode srcip --hashlimit-srcmask 32 --hashlimit-name icmp-echo-drop -j ACCEPT # Accept IPSEC/WireGuard traffic to ports {{ subnets | join(',') }} -A INPUT -p udp -m multiport --dports {{ ports | join(',') }} -j ACCEPT +{% if xray_enabled | default(false) %} +# Accept VLESS/Reality traffic (stealth VPN) +-A INPUT -p tcp --dport {{ xray_port }} -m conntrack --ctstate NEW -j ACCEPT +{% endif %} # Allow new traffic to port {{ ansible_ssh_port }} (SSH) -A INPUT -p tcp --dport {{ ansible_ssh_port }} -m conntrack --ctstate NEW -j ACCEPT diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml new file mode 100644 index 000000000..3e30f14ed --- /dev/null +++ b/roles/xray/defaults/main.yml @@ -0,0 +1,27 @@ +--- +xray_version: "latest" +xray_bin_path: "/usr/local/bin/xray" +xray_config_path: "/etc/xray" +xray_asset_path: "/usr/local/share/xray" +xray_log_path: "/var/log/xray" + +xray_config_file: "{{ xray_config_path }}/config.json" +xray_pki_path: "configs/{{ IP_subject_alt_name }}/xray" +xray_client_config_path: "{{ xray_pki_path }}" + +# Network configuration +xray_network_ipv4: 10.50.0.0/16 +xray_listen_addr: "0.0.0.0" + +# Reality settings (from config.cfg) +xray_short_id_length: 8 + +# Service name +xray_service_name: "xray" + +# Architecture mapping +xray_arch_map: + x86_64: "64" + amd64: "64" + aarch64: "arm64-v8a" + arm64: "arm64-v8a" diff --git a/roles/xray/handlers/main.yml b/roles/xray/handlers/main.yml new file mode 100644 index 000000000..da17f3fd0 --- /dev/null +++ b/roles/xray/handlers/main.yml @@ -0,0 +1,12 @@ +--- +- name: restart xray + service: + name: "{{ xray_service_name }}" + state: restarted + when: ansible_connection != 'local' or xray_service_name in ansible_facts.services + +- name: reload xray + service: + name: "{{ xray_service_name }}" + state: reloaded + when: ansible_connection != 'local' or xray_service_name in ansible_facts.services diff --git a/roles/xray/meta/main.yml b/roles/xray/meta/main.yml new file mode 100644 index 000000000..fdda41bb3 --- /dev/null +++ b/roles/xray/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - role: common diff --git a/roles/xray/tasks/clients.yml b/roles/xray/tasks/clients.yml new file mode 100644 index 000000000..e2ba0f570 --- /dev/null +++ b/roles/xray/tasks/clients.yml @@ -0,0 +1,38 @@ +--- +- name: Generate client configuration files + template: + src: client-config.json.j2 + dest: "{{ xray_client_config_path }}/{{ item }}.json" + mode: "0600" + loop: "{{ xray_users }}" + loop_control: + index_var: index + when: item in users + vars: + user_uuid: "{{ lookup('file', xray_pki_path + '/' + item + '.uuid') }}" + +- name: Generate VLESS share links + template: + src: vless-link.txt.j2 + dest: "{{ xray_client_config_path }}/{{ item }}.txt" + mode: "0600" + loop: "{{ xray_users }}" + loop_control: + index_var: index + when: item in users + vars: + user_uuid: "{{ lookup('file', xray_pki_path + '/' + item + '.uuid') }}" + +- name: Generate QR codes for VLESS links + shell: | + umask 077 + which segno && segno --scale=5 --output={{ item }}.png \ + "$(cat {{ item }}.txt)" || true + args: + chdir: "{{ xray_client_config_path }}" + executable: /bin/bash + changed_when: false + loop: "{{ xray_users }}" + when: item in users + vars: + ansible_python_interpreter: "{{ ansible_playbook_python }}" diff --git a/roles/xray/tasks/configure.yml b/roles/xray/tasks/configure.yml new file mode 100644 index 000000000..0a40e4909 --- /dev/null +++ b/roles/xray/tasks/configure.yml @@ -0,0 +1,14 @@ +--- +- name: Read all user UUIDs + set_fact: + xray_user_uuids: "{{ xray_user_uuids | default({}) | combine({item: lookup('file', xray_pki_path + '/' + item + '.uuid')}) }}" + loop: "{{ xray_users }}" + delegate_to: localhost + become: false + +- name: Generate xray server configuration + template: + src: config.json.j2 + dest: "{{ xray_config_file }}" + mode: "0600" + notify: restart xray diff --git a/roles/xray/tasks/install.yml b/roles/xray/tasks/install.yml new file mode 100644 index 000000000..3a0775e13 --- /dev/null +++ b/roles/xray/tasks/install.yml @@ -0,0 +1,108 @@ +--- +- name: Get system architecture + set_fact: + xray_arch: "{{ xray_arch_map[ansible_architecture] | default('64') }}" + +- name: Get latest xray release version + uri: + url: "https://api.github.com/repos/XTLS/Xray-core/releases/latest" + return_content: true + register: xray_latest_release + delegate_to: localhost + become: false + when: xray_version == "latest" + +- name: Set xray version + set_fact: + xray_download_version: "{{ xray_latest_release.json.tag_name if xray_version == 'latest' else xray_version }}" + +- name: Check if xray is already installed + stat: + path: "{{ xray_bin_path }}" + register: xray_binary + +- name: Get installed xray version + command: "{{ xray_bin_path }} version" + register: xray_installed_version + changed_when: false + failed_when: false + when: xray_binary.stat.exists + +- name: Download and install xray + when: not xray_binary.stat.exists or (xray_installed_version.stdout is defined and xray_download_version not in xray_installed_version.stdout) + block: + - name: Create temporary directory for xray download + tempfile: + state: directory + suffix: xray + register: xray_temp_dir + + - name: Download xray release + get_url: + url: "https://github.com/XTLS/Xray-core/releases/download/{{ xray_download_version }}/Xray-linux-{{ xray_arch }}.zip" + dest: "{{ xray_temp_dir.path }}/xray.zip" + mode: "0644" + + - name: Install unzip package + apt: + name: unzip + state: present + update_cache: true + + - name: Extract xray archive + unarchive: + src: "{{ xray_temp_dir.path }}/xray.zip" + dest: "{{ xray_temp_dir.path }}" + remote_src: true + + - name: Install xray binary + copy: + src: "{{ xray_temp_dir.path }}/xray" + dest: "{{ xray_bin_path }}" + mode: "0755" + remote_src: true + + - name: Create xray asset directory + file: + path: "{{ xray_asset_path }}" + state: directory + mode: "0755" + + - name: Install geoip and geosite databases + copy: + src: "{{ xray_temp_dir.path }}/{{ item }}" + dest: "{{ xray_asset_path }}/{{ item }}" + mode: "0644" + remote_src: true + loop: + - geoip.dat + - geosite.dat + failed_when: false + + - name: Cleanup temporary directory + file: + path: "{{ xray_temp_dir.path }}" + state: absent + +- name: Create xray config directory + file: + path: "{{ xray_config_path }}" + state: directory + mode: "0755" + +- name: Create xray log directory + file: + path: "{{ xray_log_path }}" + state: directory + mode: "0755" + +- name: Install xray systemd service + template: + src: xray.service.j2 + dest: /etc/systemd/system/xray.service + mode: "0644" + notify: restart xray + +- name: Reload systemd daemon + systemd: + daemon_reload: true diff --git a/roles/xray/tasks/keys.yml b/roles/xray/tasks/keys.yml new file mode 100644 index 000000000..6b112dd1f --- /dev/null +++ b/roles/xray/tasks/keys.yml @@ -0,0 +1,99 @@ +--- +- name: Check if Reality keypair exists + stat: + path: "{{ xray_pki_path }}/reality_private.key" + register: reality_keypair + +- name: Generate Reality x25519 keypair + when: not reality_keypair.stat.exists + block: + - name: Generate keypair using xray on remote host + command: "{{ xray_bin_path }} x25519" + register: xray_x25519_output + delegate_to: "{{ inventory_hostname }}" + become: true + changed_when: true + + - name: Parse private key + set_fact: + xray_reality_private_key: "{{ xray_x25519_output.stdout_lines[0] | regex_replace('^Private key: ', '') }}" + + - name: Parse public key + set_fact: + xray_reality_public_key: "{{ xray_x25519_output.stdout_lines[1] | regex_replace('^Public key: ', '') }}" + + - name: Generate short ID + set_fact: + xray_reality_short_id: "{{ lookup('password', '/dev/null chars=hexdigits length=' ~ xray_short_id_length) | lower }}" + + - name: Save Reality private key + copy: + content: "{{ xray_reality_private_key }}" + dest: "{{ xray_pki_path }}/reality_private.key" + mode: "0600" + + - name: Save Reality public key + copy: + content: "{{ xray_reality_public_key }}" + dest: "{{ xray_pki_path }}/reality_public.key" + mode: "0644" + + - name: Save Reality short ID + copy: + content: "{{ xray_reality_short_id }}" + dest: "{{ xray_pki_path }}/reality_short_id" + mode: "0644" + +- name: Load existing Reality keys + when: reality_keypair.stat.exists + block: + - name: Load Reality private key + set_fact: + xray_reality_private_key: "{{ lookup('file', xray_pki_path + '/reality_private.key') }}" + + - name: Load Reality public key + set_fact: + xray_reality_public_key: "{{ lookup('file', xray_pki_path + '/reality_public.key') }}" + + - name: Load Reality short ID + set_fact: + xray_reality_short_id: "{{ lookup('file', xray_pki_path + '/reality_short_id') }}" + +- name: Ensure user index file exists + file: + path: "{{ xray_pki_path }}/index.txt" + state: touch + mode: "0600" + modification_time: preserve + access_time: preserve + +- name: Generate UUIDs for users + block: + - name: Update user list + lineinfile: + dest: "{{ xray_pki_path }}/index.txt" + create: true + mode: "0600" + insertafter: EOF + line: "{{ item }}" + loop: "{{ users }}" + + - name: Read user list + set_fact: + xray_users: "{{ (lookup('file', xray_pki_path + '/index.txt')).split('\n') | select() | list }}" + + - name: Check for existing UUIDs + stat: + path: "{{ xray_pki_path }}/{{ item }}.uuid" + register: user_uuid_files + loop: "{{ xray_users }}" + + - name: Generate UUID for new users + copy: + content: "{{ lookup('pipe', 'uuidgen') | lower }}" + dest: "{{ xray_pki_path }}/{{ item.item }}.uuid" + mode: "0600" + when: not item.stat.exists + loop: "{{ user_uuid_files.results }}" + loop_control: + label: "{{ item.item }}" diff --git a/roles/xray/tasks/main.yml b/roles/xray/tasks/main.yml new file mode 100644 index 000000000..ded4d5a69 --- /dev/null +++ b/roles/xray/tasks/main.yml @@ -0,0 +1,39 @@ +--- +- name: Ensure xray config directories exist locally + file: + dest: "{{ item }}" + state: directory + recurse: true + mode: "0755" + loop: + - "{{ xray_pki_path }}" + - "{{ xray_client_config_path }}" + delegate_to: localhost + become: false + +- name: Install xray-core + import_tasks: install.yml + +- name: Generate Reality keys + import_tasks: keys.yml + delegate_to: localhost + become: false + tags: update-users + +- name: Configure xray server + import_tasks: configure.yml + tags: update-users + +- name: Generate client configurations + import_tasks: clients.yml + delegate_to: localhost + become: false + tags: update-users + +- name: Ensure xray is enabled and started + service: + name: "{{ xray_service_name }}" + state: started + enabled: true + +- meta: flush_handlers diff --git a/roles/xray/templates/client-config.json.j2 b/roles/xray/templates/client-config.json.j2 new file mode 100644 index 000000000..28b0a12fd --- /dev/null +++ b/roles/xray/templates/client-config.json.j2 @@ -0,0 +1,84 @@ +{ + "log": { + "loglevel": "warning" + }, + "inbounds": [ + { + "tag": "socks", + "port": 10808, + "listen": "127.0.0.1", + "protocol": "socks", + "settings": { + "udp": true + } + }, + { + "tag": "http", + "port": 10809, + "listen": "127.0.0.1", + "protocol": "http", + "settings": {} + } + ], + "outbounds": [ + { + "tag": "proxy", + "protocol": "vless", + "settings": { + "vnext": [ + { + "address": "{{ IP_subject_alt_name }}", + "port": {{ xray_port }}, + "users": [ + { + "id": "{{ user_uuid }}", + "flow": "{{ xray_flow }}", + "encryption": "none" + } + ] + } + ] + }, + "streamSettings": { + "network": "tcp", + "security": "reality", + "realitySettings": { + "serverName": "{{ xray_reality_sni }}", + "fingerprint": "chrome", + "publicKey": "{{ xray_reality_public_key }}", + "shortId": "{{ xray_reality_short_id }}", + "spiderX": "" + } + } + }, + { + "tag": "direct", + "protocol": "freedom", + "settings": {} + }, + { + "tag": "block", + "protocol": "blackhole", + "settings": {} + } + ], + "routing": { + "domainStrategy": "AsIs", + "rules": [ + { + "type": "field", + "ip": [ + "geoip:private" + ], + "outboundTag": "direct" + }, + { + "type": "field", + "domain": [ + "geosite:category-ads-all" + ], + "outboundTag": "block" + } + ] + } +} diff --git a/roles/xray/templates/config.json.j2 b/roles/xray/templates/config.json.j2 new file mode 100644 index 000000000..4d1959469 --- /dev/null +++ b/roles/xray/templates/config.json.j2 @@ -0,0 +1,93 @@ +{ + "log": { + "loglevel": "warning", + "access": "{{ xray_log_path }}/access.log", + "error": "{{ xray_log_path }}/error.log" + }, + "inbounds": [ + { + "tag": "vless-reality", + "listen": "{{ xray_listen_addr }}", + "port": {{ xray_port }}, + "protocol": "vless", + "settings": { + "clients": [ +{% for user in xray_users %} +{% if user in users %} + { + "id": "{{ xray_user_uuids[user] }}", + "flow": "{{ xray_flow }}", + "email": "{{ user }}@{{ algo_server_name }}" + }{% if not loop.last %},{% endif %} + +{% endif %} +{% endfor %} + ], + "decryption": "none" + }, + "streamSettings": { + "network": "tcp", + "security": "reality", + "realitySettings": { + "show": false, + "dest": "{{ xray_reality_dest }}", + "xver": 0, + "serverNames": [ + "{{ xray_reality_sni }}" + ], + "privateKey": "{{ xray_reality_private_key }}", + "shortIds": [ + "{{ xray_reality_short_id }}" + ] + } + }, + "sniffing": { + "enabled": true, + "destOverride": [ + "http", + "tls", + "quic" + ] + } + } + ], + "outbounds": [ + { + "tag": "direct", + "protocol": "freedom", + "settings": {} + }, + { + "tag": "block", + "protocol": "blackhole", + "settings": {} + } + ], + "routing": { + "domainStrategy": "AsIs", + "rules": [ + { + "type": "field", + "ip": [ + "geoip:private" + ], + "outboundTag": "block" + } + ] + }, + "policy": { + "levels": { + "0": { + "handshake": 4, + "connIdle": 300, + "uplinkOnly": 2, + "downlinkOnly": 5, + "bufferSize": 4 + } + }, + "system": { + "statsInboundUplink": false, + "statsInboundDownlink": false + } + } +} diff --git a/roles/xray/templates/vless-link.txt.j2 b/roles/xray/templates/vless-link.txt.j2 new file mode 100644 index 000000000..9c381668a --- /dev/null +++ b/roles/xray/templates/vless-link.txt.j2 @@ -0,0 +1 @@ +vless://{{ user_uuid }}@{{ IP_subject_alt_name }}:{{ xray_port }}?encryption=none&flow={{ xray_flow }}&security=reality&sni={{ xray_reality_sni }}&fp=chrome&pbk={{ xray_reality_public_key }}&sid={{ xray_reality_short_id }}&type=tcp#{{ algo_server_name }}-{{ item }} diff --git a/roles/xray/templates/xray.service.j2 b/roles/xray/templates/xray.service.j2 new file mode 100644 index 000000000..04f33ea0a --- /dev/null +++ b/roles/xray/templates/xray.service.j2 @@ -0,0 +1,20 @@ +[Unit] +Description=Xray Service +Documentation=https://xtls.github.io/ +After=network.target nss-lookup.target + +[Service] +Type=simple +User=root +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE +NoNewPrivileges=true +ExecStart={{ xray_bin_path }} run -config {{ xray_config_file }} +Restart=on-failure +RestartPreventExitStatus=23 +LimitNPROC=10000 +LimitNOFILE=1000000 +Environment=XRAY_LOCATION_ASSET={{ xray_asset_path }} + +[Install] +WantedBy=multi-user.target diff --git a/server.yml b/server.yml index 9da8a617d..f766660cf 100644 --- a/server.yml +++ b/server.yml @@ -53,7 +53,7 @@ - name: Configure VPN services (parallel mode) when: performance_parallel_services | default(true) - tags: [dns, wireguard, ipsec, ssh_tunneling] + tags: [dns, wireguard, ipsec, ssh_tunneling, xray] block: # --- Launch all services asynchronously --- - import_role: {name: dns} @@ -84,6 +84,13 @@ when: algo_ssh_tunneling tags: ssh_tunneling + - import_role: {name: xray} + async: 300 + poll: 0 + register: xray_job + when: xray_enabled + tags: xray + # --- Build job list and wait for completion --- - name: Build async job list set_fact: @@ -92,6 +99,7 @@ - {name: wireguard, job: "{{ wireguard_job | default({}) }}"} - {name: strongswan, job: "{{ strongswan_job | default({}) }}"} - {name: ssh_tunneling, job: "{{ ssh_tunneling_job | default({}) }}"} + - {name: xray, job: "{{ xray_job | default({}) }}"} - name: Wait for VPN services to complete async_status: @@ -116,7 +124,7 @@ # --- Sequential mode (fallback when parallel disabled) --- - name: Configure VPN services (sequential mode) when: not (performance_parallel_services | default(true)) - tags: [dns, wireguard, ipsec, ssh_tunneling] + tags: [dns, wireguard, ipsec, ssh_tunneling, xray] block: - import_role: {name: dns} when: algo_dns_adblocking or dns_encryption @@ -134,6 +142,10 @@ when: algo_ssh_tunneling tags: ssh_tunneling + - import_role: {name: xray} + when: xray_enabled + tags: xray + - import_role: name: privacy when: privacy_enhancements_enabled | default(true) From dfe050d94d0a73f4d7167f78a4eb8c9f50daaa26 Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Thu, 15 Jan 2026 10:23:28 +0100 Subject: [PATCH 03/14] Debug restart dnscrypt-proxy service error --- roles/common/tasks/ubuntu.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/roles/common/tasks/ubuntu.yml b/roles/common/tasks/ubuntu.yml index a73f472f1..4cf58aece 100644 --- a/roles/common/tasks/ubuntu.yml +++ b/roles/common/tasks/ubuntu.yml @@ -103,6 +103,27 @@ - meta: flush_handlers +- name: Ensure local service IP is configured on loopback + command: ip addr add {{ local_service_ip }}/32 dev lo + register: lo_ipv4_result + changed_when: lo_ipv4_result.rc == 0 + failed_when: false + +- name: Ensure local service IPv6 is configured on loopback + command: ip addr add {{ local_service_ipv6 }}/128 dev lo + register: lo_ipv6_result + changed_when: lo_ipv6_result.rc == 0 + failed_when: false + when: ipv6_support + +- name: Wait for local service IP to be active + command: ip addr show dev lo + register: lo_addr_check + until: local_service_ip in lo_addr_check.stdout + retries: 10 + delay: 1 + changed_when: false + - name: Check apparmor support command: apparmor_status failed_when: false From 33470d239eedabe805a102c4576032259bbfde57 Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Thu, 15 Jan 2026 12:51:42 +0100 Subject: [PATCH 04/14] Debugging IPv6 support --- .../common/templates/10-algo-lo100.network.j2 | 2 ++ roles/dns/tasks/ubuntu.yml | 35 ++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/roles/common/templates/10-algo-lo100.network.j2 b/roles/common/templates/10-algo-lo100.network.j2 index ccdca7e6b..3de1d72e9 100644 --- a/roles/common/templates/10-algo-lo100.network.j2 +++ b/roles/common/templates/10-algo-lo100.network.j2 @@ -4,4 +4,6 @@ Name=lo [Network] Description=lo:100 Address={{ local_service_ip }}/32 +{% if ipv6_support | default(false) %} Address={{ local_service_ipv6 }}/128 +{% endif %} diff --git a/roles/dns/tasks/ubuntu.yml b/roles/dns/tasks/ubuntu.yml index c13084ecb..194b03116 100644 --- a/roles/dns/tasks/ubuntu.yml +++ b/roles/dns/tasks/ubuntu.yml @@ -58,6 +58,27 @@ owner: root group: root +- name: Ubuntu | Stop dnscrypt-proxy socket before reconfiguration + systemd: + name: dnscrypt-proxy.socket + state: stopped + failed_when: false + +- name: Ubuntu | Stop dnscrypt-proxy service before reconfiguration + systemd: + name: dnscrypt-proxy.service + state: stopped + failed_when: false + +- name: Ubuntu | Check if IPv6 is configured on loopback + command: ip -6 addr show dev lo + register: lo_ipv6_check + changed_when: false + +- name: Ubuntu | Set IPv6 socket support fact + set_fact: + ipv6_socket_support: "{{ ipv6_support and (local_service_ipv6 in lo_ipv6_check.stdout) }}" + - name: Ubuntu | Configure dnscrypt-proxy socket to listen on VPN IPs copy: dest: /etc/systemd/system/dnscrypt-proxy.socket.d/10-algo-override.conf @@ -66,15 +87,14 @@ # Clear default listeners ListenStream= ListenDatagram= - # Add VPN service IPs + # Add VPN service IPs (IPv4) ListenStream={{ local_service_ip }}:53 ListenDatagram={{ local_service_ip }}:53 - {% if ipv6_support %} + {% if ipv6_socket_support %} + # IPv6 ListenStream=[{{ local_service_ipv6 }}]:53 ListenDatagram=[{{ local_service_ipv6 }}]:53 {% endif %} - NoDelay=true - DeferAcceptSec=1 mode: '0644' register: socket_override notify: @@ -85,13 +105,12 @@ - name: Ubuntu | Reload systemd daemon after socket configuration systemd: daemon_reload: true - when: socket_override.changed -- name: Ubuntu | Restart dnscrypt-proxy socket to apply configuration +- name: Ubuntu | Start dnscrypt-proxy socket systemd: name: dnscrypt-proxy.socket - state: restarted - when: socket_override.changed + state: started + enabled: true - name: Ubuntu | Add custom requirements to successfully start the unit copy: From 23356a85e7d1b600594407f7ea8523b998b605eb Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Thu, 15 Jan 2026 13:11:41 +0100 Subject: [PATCH 05/14] Fix parsing keys for X-Ray protocol --- roles/xray/tasks/keys.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/roles/xray/tasks/keys.yml b/roles/xray/tasks/keys.yml index 6b112dd1f..2f46a647c 100644 --- a/roles/xray/tasks/keys.yml +++ b/roles/xray/tasks/keys.yml @@ -16,11 +16,11 @@ - name: Parse private key set_fact: - xray_reality_private_key: "{{ xray_x25519_output.stdout_lines[0] | regex_replace('^Private key: ', '') }}" + xray_reality_private_key: "{{ xray_x25519_output.stdout_lines[0] | regex_replace('^PrivateKey: ', '') }}" - name: Parse public key set_fact: - xray_reality_public_key: "{{ xray_x25519_output.stdout_lines[1] | regex_replace('^Public key: ', '') }}" + xray_reality_public_key: "{{ xray_x25519_output.stdout_lines[1] | regex_replace('^Password: ', '') }}" - name: Generate short ID set_fact: From ab106c5dcbfd380ab8fd220c42ac886d4504b255 Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Thu, 15 Jan 2026 14:32:36 +0100 Subject: [PATCH 06/14] Fix protocol selection --- input.yml | 20 ++++++++++---------- playbooks/cloud-post.yml | 3 +++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/input.yml b/input.yml index 02187b15f..3fd257605 100644 --- a/input.yml +++ b/input.yml @@ -71,6 +71,16 @@ register: _vpn_protocols when: vpn_protocols is undefined + - name: Set VPN protocol facts + set_fact: + _vpn_choice: "{{ vpn_protocols | default(_vpn_protocols.user_input | default('1')) | string }}" + + - name: Set protocol flags based on choice + set_fact: + xray_enabled: "{{ _vpn_choice in ['1', '4', '5', '6'] }}" + wireguard_enabled: "{{ _vpn_choice in ['2', '4', '5', '7'] }}" + ipsec_enabled: "{{ _vpn_choice in ['3', '4', '6', '7'] }}" + - name: Cellular On Demand prompt pause: prompt: | @@ -123,16 +133,6 @@ register: _ssh_tunneling when: ssh_tunneling is undefined - - name: Set VPN protocol facts - set_fact: - _vpn_choice: "{{ vpn_protocols | default(_vpn_protocols.user_input | default('1')) | string }}" - - - name: Set protocol flags based on choice - set_fact: - xray_enabled: "{{ _vpn_choice in ['1', '4', '5', '6'] }}" - wireguard_enabled: "{{ _vpn_choice in ['2', '4', '5', '7'] }}" - ipsec_enabled: "{{ _vpn_choice in ['3', '4', '6', '7'] }}" - - name: Set facts based on the input set_fact: algo_server_name: >- diff --git a/playbooks/cloud-post.yml b/playbooks/cloud-post.yml index e03a8f5e3..d1c7bc7f3 100644 --- a/playbooks/cloud-post.yml +++ b/playbooks/cloud-post.yml @@ -22,6 +22,9 @@ IP_subject_alt_name: "{{ IP_subject_alt_name }}" alternative_ingress_ip: "{{ alternative_ingress_ip | default(omit) }}" cloudinit: "{{ cloudinit | default(false) }}" + xray_enabled: "{{ xray_enabled }}" + wireguard_enabled: "{{ wireguard_enabled }}" + ipsec_enabled: "{{ ipsec_enabled }}" - name: Additional variables for the server add_host: From c306c41fbd4f13219d62241f3d66d45923dbb6e7 Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Thu, 15 Jan 2026 14:33:29 +0100 Subject: [PATCH 07/14] Remove unused parameter `xray_network_ipv4` --- roles/common/templates/rules.v4.j2 | 2 +- roles/xray/defaults/main.yml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/roles/common/templates/rules.v4.j2 b/roles/common/templates/rules.v4.j2 index 3744f6f56..a0e0d0eb2 100644 --- a/roles/common/templates/rules.v4.j2 +++ b/roles/common/templates/rules.v4.j2 @@ -1,4 +1,4 @@ -{% set subnets = ([strongswan_network] if ipsec_enabled else []) + ([wireguard_network_ipv4] if wireguard_enabled else []) + ([xray_network_ipv4] if xray_enabled | default(false) else []) %} +{% set subnets = ([strongswan_network] if ipsec_enabled else []) + ([wireguard_network_ipv4] if wireguard_enabled else []) %} {% set ports = (['500', '4500'] if ipsec_enabled else []) + ([wireguard_port] if wireguard_enabled else []) + ([wireguard_port_actual] if wireguard_enabled and wireguard_port | int == wireguard_port_avoid | int else []) %} #### The mangle table diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index 3e30f14ed..96a30e739 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -10,7 +10,6 @@ xray_pki_path: "configs/{{ IP_subject_alt_name }}/xray" xray_client_config_path: "{{ xray_pki_path }}" # Network configuration -xray_network_ipv4: 10.50.0.0/16 xray_listen_addr: "0.0.0.0" # Reality settings (from config.cfg) From d2b86fc8bd45f095ce1f556c0407fd8a4bb532a4 Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Mon, 19 Jan 2026 20:20:30 +0100 Subject: [PATCH 08/14] Add missed docs and pre-PR fixes --- README.md | 1 + config.cfg | 11 +-- docs/client-xray.md | 136 ++++++++++++++++++++++++++++++++++ input.yml | 6 +- roles/common/tasks/ubuntu.yml | 7 ++ server.yml | 1 + users.yml | 5 ++ 7 files changed, 161 insertions(+), 6 deletions(-) create mode 100644 docs/client-xray.md diff --git a/README.md b/README.md index f6c1c857e..e633e21da 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,7 @@ For the highest level of privacy, treat your Algo servers as disposable. Spin up * Deploy from a [Docker container](docs/deploy-from-docker.md) ### Setup VPN Clients to Connect to the Server +* Setup [VLESS+Reality](docs/client-xray.md) stealth VPN clients (Shadowrocket, v2rayNG, etc.) * Setup [Windows](docs/client-windows.md) clients * Setup [Android](docs/client-android.md) clients * Setup [Linux](docs/client-linux.md) clients with Ansible diff --git a/config.cfg b/config.cfg index 4952e1e5f..2ebe2965a 100644 --- a/config.cfg +++ b/config.cfg @@ -25,13 +25,14 @@ users: # SSH port for cloud deployments (doesn't apply to existing Ubuntu servers) ssh_port: 4160 -# VPN protocols to deploy -ipsec_enabled: true -wireguard_enabled: true +# VPN protocols to deploy (defaults, can be overridden in interactive mode) +ipsec_enabled_default: true +wireguard_enabled_default: true +xray_enabled_default: true + wireguard_port: 51820 # Change if blocked by your network (avoid 53/UDP) -# Stealth VPN (VLESS + XTLS-Reality) - undetectable by DPI -xray_enabled: true +# Stealth VPN (VLESS + XTLS-Reality) xray_port: 443 xray_reality_dest: "www.microsoft.com:443" # Target site to mimic xray_reality_sni: "www.microsoft.com" # SNI for TLS handshake diff --git a/docs/client-xray.md b/docs/client-xray.md new file mode 100644 index 000000000..925740047 --- /dev/null +++ b/docs/client-xray.md @@ -0,0 +1,136 @@ +# VLESS+Reality Client Setup Guide + +VLESS with XTLS-Reality is a stealth VPN protocol that makes your traffic indistinguishable from regular HTTPS traffic. It's effective in networks where WireGuard and other VPN protocols are blocked. + +## Generated Configuration Files + +After deployment, client configuration files are located in: + +``` +configs//xray/ +├── .json # Full xray client config +├── .txt # VLESS share link +└── .png # QR code for mobile apps +``` + +## Client Applications + +### iOS/macOS + +**Shadowrocket** (Recommended) +1. Open Shadowrocket +2. Tap the QR code icon in the top-left corner +3. Scan the QR code from `.png` +4. Or copy the VLESS link from `.txt` and paste it + +**Streisand** +1. Open Streisand +2. Tap "+" to add a new server +3. Select "Scan QR Code" or "Import from Clipboard" +4. Use the provided QR code or VLESS link + +### Android + +**v2rayNG** (Recommended) +1. Install from [Google Play](https://play.google.com/store/apps/details?id=com.v2ray.ang) or [GitHub](https://github.com/2dust/v2rayNG) +2. Tap "+" button → "Import config from Clipboard" +3. Paste the VLESS link from `.txt` +4. Or use the QR code scanner + +**NekoBox** +1. Install from [GitHub](https://github.com/MatsuriDayo/NekoBoxForAndroid) +2. Tap "+" → "Import from Clipboard" +3. Paste the VLESS link + +### Windows + +**v2rayN** (Recommended) +1. Download from [GitHub](https://github.com/2dust/v2rayN) +2. Extract and run `v2rayN.exe` +3. Right-click tray icon → "Import from clipboard" +4. Paste the VLESS link + +**Nekoray** +1. Download from [GitHub](https://github.com/MatsuriDayo/nekoray) +2. Server → Add Profile from Clipboard +3. Paste the VLESS link + +### Linux + +**v2rayA** (Web UI) +1. Install v2rayA following [official docs](https://v2raya.org/) +2. Open web interface (default: http://localhost:2017) +3. Import → Paste VLESS link + +**Nekoray** +1. Download from [GitHub](https://github.com/MatsuriDayo/nekoray) +2. Extract and run +3. Server → Add Profile from Clipboard + +**Command Line (xray-core)** +1. Install xray-core +2. Copy `.json` to `/etc/xray/config.json` +3. Start service: `sudo systemctl start xray` + +## Manual Configuration + +If you need to configure manually, use these parameters from your `.txt` file: + +| Parameter | Description | +|-----------|-------------| +| Protocol | VLESS | +| Address | Server IP | +| Port | 443 | +| UUID | Your unique user ID | +| Flow | xtls-rprx-vision | +| Security | reality | +| SNI | Configured destination domain | +| Fingerprint | chrome | +| Public Key | Reality public key | +| Short ID | Reality short ID | + +## VLESS Link Format + +``` +vless://UUID@SERVER:443?encryption=none&flow=xtls-rprx-vision&security=reality&sni=DOMAIN&fp=chrome&pbk=PUBLIC_KEY&sid=SHORT_ID&type=tcp#NAME +``` + +## Troubleshooting + +### Connection fails immediately +- Verify server IP and port are correct +- Check if port 443 is open on the server +- Ensure the VLESS link was copied completely + +### Connection drops after a few seconds +- Try a different Reality destination domain in `config.cfg` +- Some domains work better in certain regions + +### Slow speeds +- VLESS+Reality has minimal overhead, similar to WireGuard +- Check your network connection quality +- Try different client applications + +### QR code not scanning +- Ensure good lighting and camera focus +- Try copying the text link from `.txt` file instead + +## Security Notes + +- Keep your UUID and configuration private +- Each user has a unique UUID +- Sharing configurations allows others to use your server quota +- Reality protocol does not require you to own the destination domain + +## Comparison with WireGuard + +| Feature | WireGuard | VLESS+Reality | +|---------|-----------|---------------| +| Speed | Excellent | Excellent | +| Stealth | Poor (easily detected) | Excellent | +| Setup | Very simple | Simple | +| Blocked by DPI | Yes, commonly | No | +| Battery usage | Low | Low | +| Protocol | UDP | TCP | + +Use WireGuard when it works, VLESS+Reality when WireGuard is blocked. diff --git a/input.yml b/input.yml index 3fd257605..2f12470f7 100644 --- a/input.yml +++ b/input.yml @@ -73,7 +73,7 @@ - name: Set VPN protocol facts set_fact: - _vpn_choice: "{{ vpn_protocols | default(_vpn_protocols.user_input | default('1')) | string }}" + _vpn_choice: "{{ vpn_protocols | default(_vpn_protocols.user_input | default('1') | trim) | string | trim }}" - name: Set protocol flags based on choice set_fact: @@ -81,6 +81,10 @@ wireguard_enabled: "{{ _vpn_choice in ['2', '4', '5', '7'] }}" ipsec_enabled: "{{ _vpn_choice in ['3', '4', '6', '7'] }}" + - name: Debug protocol selection + debug: + msg: "Choice={{ _vpn_choice }} -> xray={{ xray_enabled }}, wg={{ wireguard_enabled }}, ipsec={{ ipsec_enabled }}" + - name: Cellular On Demand prompt pause: prompt: | diff --git a/roles/common/tasks/ubuntu.yml b/roles/common/tasks/ubuntu.yml index 4cf58aece..6e4b08781 100644 --- a/roles/common/tasks/ubuntu.yml +++ b/roles/common/tasks/ubuntu.yml @@ -203,3 +203,10 @@ - include_tasks: iptables.yml tags: iptables + +- name: Enable and start netfilter-persistent + systemd: + name: netfilter-persistent + state: started + enabled: true + tags: iptables diff --git a/server.yml b/server.yml index f766660cf..2ff963382 100644 --- a/server.yml +++ b/server.yml @@ -174,6 +174,7 @@ IP_subject_alt_name: {{ IP_subject_alt_name }} ipsec_enabled: {{ ipsec_enabled }} wireguard_enabled: {{ wireguard_enabled }} + xray_enabled: {{ xray_enabled }} local_service_ip: {{ local_service_ip }} local_service_ipv6: {{ local_service_ipv6 }} {% if tests | default(false) | bool %} diff --git a/users.yml b/users.yml index 1522d8753..17b77b7ba 100644 --- a/users.yml +++ b/users.yml @@ -167,6 +167,11 @@ name: ssh_tunneling when: algo_ssh_tunneling + - import_role: + name: xray + when: xray_enabled | default(false) + tags: xray + - debug: msg: - "{{ congrats.common.split('\n') }}" From 4eba3e540692424ee61c9d368f939300af7048e3 Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Mon, 19 Jan 2026 20:21:11 +0100 Subject: [PATCH 09/14] Fix support for IPv6 iptables --- roles/common/tasks/iptables.yml | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/roles/common/tasks/iptables.yml b/roles/common/tasks/iptables.yml index dc921aa70..48a1f1207 100644 --- a/roles/common/tasks/iptables.yml +++ b/roles/common/tasks/iptables.yml @@ -1,25 +1,27 @@ --- -- name: Iptables configured +- name: Iptables IPv4 rules configured template: - src: "{{ item.src }}" - dest: "{{ item.dest }}" + src: rules.v4.j2 + dest: /etc/iptables/rules.v4 owner: root group: root mode: '0640' - loop: - - { src: rules.v4.j2, dest: /etc/iptables/rules.v4 } - notify: - - restart iptables + register: iptables_v4 -- name: Iptables configured +- name: Iptables IPv6 rules configured template: - src: "{{ item.src }}" - dest: "{{ item.dest }}" + src: rules.v6.j2 + dest: /etc/iptables/rules.v6 owner: root group: root mode: '0640' when: ipv6_support - loop: - - { src: rules.v6.j2, dest: /etc/iptables/rules.v6 } - notify: - - restart iptables + register: iptables_v6 + +- name: Apply iptables IPv4 rules + shell: iptables-restore < /etc/iptables/rules.v4 + when: iptables_v4.changed + +- name: Apply iptables IPv6 rules + shell: ip6tables-restore < /etc/iptables/rules.v6 + when: ipv6_support and iptables_v6.changed From 73137daac9c6a84d708ddf09c8112facf2fd6ec5 Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Mon, 19 Jan 2026 20:29:13 +0100 Subject: [PATCH 10/14] Update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index fbe127ae9..666db0211 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ inventory_users .vagrant .ansible/ algo.egg-info/ +.context/ # Python .env/ From 55e016020468f9de8631aebefebf566729de988c Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Mon, 2 Feb 2026 22:23:05 +0100 Subject: [PATCH 11/14] Add tests for Xray/VLESS role --- tests/fixtures/test_variables.yml | 27 +++ tests/unit/test_template_rendering.py | 8 + tests/unit/test_xray_vless.py | 305 ++++++++++++++++++++++++++ 3 files changed, 340 insertions(+) create mode 100644 tests/unit/test_xray_vless.py diff --git a/tests/fixtures/test_variables.yml b/tests/fixtures/test_variables.yml index 42e22f8ba..97c6123f0 100644 --- a/tests/fixtures/test_variables.yml +++ b/tests/fixtures/test_variables.yml @@ -114,6 +114,33 @@ provider_dns_servers: - 1.0.0.1 ansible_ssh_private_key_file: ~/.ssh/id_rsa +# Xray/VLESS+Reality +xray_enabled: true +xray_port: 443 +xray_listen_addr: "0.0.0.0" +xray_flow: "xtls-rprx-vision" +xray_reality_dest: "www.microsoft.com:443" +xray_reality_sni: "www.microsoft.com" +xray_reality_private_key: "MOCK_PRIVATE_KEY_BASE64" +xray_reality_public_key: "MOCK_PUBLIC_KEY_BASE64" +xray_reality_short_id: "abcd1234" +xray_pki_path: "configs/10.0.0.1/xray" +xray_log_path: "/var/log/xray" +xray_users: + - alice + - bob + - charlie +xray_user_uuids: + alice: "11111111-1111-1111-1111-111111111111" + bob: "22222222-2222-2222-2222-222222222222" + charlie: "33333333-3333-3333-3333-333333333333" +algo_server_name: test-algo-vpn + +# SSTP (future, placeholder) +sstp_enabled: false +sstp_port: 443 +sstp_network: 10.50.0.0/16 + # Defaults inventory_hostname: localhost hostvars: diff --git a/tests/unit/test_template_rendering.py b/tests/unit/test_template_rendering.py index 2f30addbb..12490a698 100644 --- a/tests/unit/test_template_rendering.py +++ b/tests/unit/test_template_rendering.py @@ -108,6 +108,9 @@ def test_critical_templates(): "roles/dns/templates/dnsmasq.conf.j2", "roles/common/templates/rules.v4.j2", "roles/common/templates/rules.v6.j2", + "roles/xray/templates/config.json.j2", + "roles/xray/templates/client-config.json.j2", + "roles/xray/templates/vless-link.txt.j2", ] test_vars = get_test_variables() @@ -135,6 +138,11 @@ def test_critical_templates(): if "client" in template_name: test_vars["item"] = "test-user" + # Add xray-specific context + if "xray" in template_path or "vless" in template_name: + test_vars["item"] = "alice" + test_vars["user_uuid"] = test_vars.get("xray_user_uuids", {}).get("alice", "test-uuid") + # Try to render output = template.render(**test_vars) diff --git a/tests/unit/test_xray_vless.py b/tests/unit/test_xray_vless.py new file mode 100644 index 000000000..d7325972e --- /dev/null +++ b/tests/unit/test_xray_vless.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +Tests for Xray VLESS+Reality VPN role. + +These tests verify: +- Template rendering for server and client configs +- VLESS link generation format +- Firewall rules for xray port +- Configuration validation +""" + +import json +import os +import re +import sys +import urllib.parse +from pathlib import Path + +import pytest +from jinja2 import Environment, FileSystemLoader, StrictUndefined + +# Add parent directory to path for fixtures +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from fixtures import load_test_variables + + +def load_template(role, template_name): + """Load a Jinja2 template from a role's templates directory.""" + template_dir = Path(__file__).parent.parent.parent / "roles" / role / "templates" + if not template_dir.exists(): + pytest.skip(f"Role {role} templates directory not found") + env = Environment(loader=FileSystemLoader(str(template_dir)), undefined=StrictUndefined) + return env.get_template(template_name) + + +class TestXrayServerConfig: + """Tests for xray server configuration template.""" + + def test_server_config_renders_valid_json(self): + """Test that server config.json renders as valid JSON.""" + template = load_template("xray", "config.json.j2") + test_vars = load_test_variables() + + result = template.render(**test_vars) + + # Should be valid JSON + config = json.loads(result) + assert isinstance(config, dict) + + def test_server_config_has_vless_inbound(self): + """Test that server config has VLESS inbound configured.""" + template = load_template("xray", "config.json.j2") + test_vars = load_test_variables() + + result = template.render(**test_vars) + config = json.loads(result) + + # Check inbounds + assert "inbounds" in config + assert len(config["inbounds"]) > 0 + + vless_inbound = config["inbounds"][0] + assert vless_inbound["protocol"] == "vless" + assert vless_inbound["port"] == test_vars["xray_port"] + + def test_server_config_has_reality_settings(self): + """Test that server config has Reality TLS settings.""" + template = load_template("xray", "config.json.j2") + test_vars = load_test_variables() + + result = template.render(**test_vars) + config = json.loads(result) + + stream_settings = config["inbounds"][0]["streamSettings"] + assert stream_settings["security"] == "reality" + assert "realitySettings" in stream_settings + + reality = stream_settings["realitySettings"] + assert reality["dest"] == test_vars["xray_reality_dest"] + assert test_vars["xray_reality_sni"] in reality["serverNames"] + + def test_server_config_includes_all_users(self): + """Test that all users are included in server config.""" + template = load_template("xray", "config.json.j2") + test_vars = load_test_variables() + + result = template.render(**test_vars) + config = json.loads(result) + + clients = config["inbounds"][0]["settings"]["clients"] + client_ids = [c["id"] for c in clients] + + # All user UUIDs should be present + for user, uuid in test_vars["xray_user_uuids"].items(): + if user in test_vars["users"]: + assert uuid in client_ids, f"User {user} UUID not found in config" + + def test_server_config_has_correct_flow(self): + """Test that XTLS flow is configured correctly.""" + template = load_template("xray", "config.json.j2") + test_vars = load_test_variables() + + result = template.render(**test_vars) + config = json.loads(result) + + clients = config["inbounds"][0]["settings"]["clients"] + for client in clients: + assert client["flow"] == test_vars["xray_flow"] + + def test_server_config_blocks_private_ips(self): + """Test that routing blocks private IP ranges.""" + template = load_template("xray", "config.json.j2") + test_vars = load_test_variables() + + result = template.render(**test_vars) + config = json.loads(result) + + # Check routing rules + assert "routing" in config + rules = config["routing"]["rules"] + + # Should have a rule blocking private IPs + private_ip_rule = None + for rule in rules: + if "geoip:private" in rule.get("ip", []): + private_ip_rule = rule + break + + assert private_ip_rule is not None, "No rule blocking private IPs" + assert private_ip_rule["outboundTag"] == "block" + + +class TestXrayVlessLink: + """Tests for VLESS share link generation.""" + + def test_vless_link_format(self): + """Test that VLESS link has correct format.""" + template = load_template("xray", "vless-link.txt.j2") + test_vars = load_test_variables() + test_vars["item"] = "alice" + test_vars["user_uuid"] = test_vars["xray_user_uuids"]["alice"] + + result = template.render(**test_vars).strip() + + # Should start with vless:// + assert result.startswith("vless://"), f"Link should start with vless://, got: {result[:20]}" + + # Parse the URI + parsed = urllib.parse.urlparse(result) + assert parsed.scheme == "vless" + + def test_vless_link_contains_uuid(self): + """Test that VLESS link contains user UUID.""" + template = load_template("xray", "vless-link.txt.j2") + test_vars = load_test_variables() + test_vars["item"] = "bob" + test_vars["user_uuid"] = test_vars["xray_user_uuids"]["bob"] + + result = template.render(**test_vars).strip() + + assert test_vars["user_uuid"] in result + + def test_vless_link_contains_reality_params(self): + """Test that VLESS link contains Reality parameters.""" + template = load_template("xray", "vless-link.txt.j2") + test_vars = load_test_variables() + test_vars["item"] = "alice" + test_vars["user_uuid"] = test_vars["xray_user_uuids"]["alice"] + + result = template.render(**test_vars).strip() + + # Parse query parameters + parsed = urllib.parse.urlparse(result) + params = urllib.parse.parse_qs(parsed.query) + + assert params.get("security") == ["reality"] + assert params.get("sni") == [test_vars["xray_reality_sni"]] + assert params.get("flow") == [test_vars["xray_flow"]] + assert "pbk" in params # public key + + def test_vless_link_has_correct_server(self): + """Test that VLESS link points to correct server.""" + template = load_template("xray", "vless-link.txt.j2") + test_vars = load_test_variables() + test_vars["item"] = "alice" + test_vars["user_uuid"] = test_vars["xray_user_uuids"]["alice"] + + result = template.render(**test_vars).strip() + + # Should contain server IP and port + assert f"@{test_vars['IP_subject_alt_name']}:{test_vars['xray_port']}" in result + + +class TestXrayClientConfig: + """Tests for xray client configuration template.""" + + def test_client_config_renders_valid_json(self): + """Test that client config renders as valid JSON.""" + template = load_template("xray", "client-config.json.j2") + test_vars = load_test_variables() + test_vars["item"] = "alice" + test_vars["user_uuid"] = test_vars["xray_user_uuids"]["alice"] + + result = template.render(**test_vars) + + # Should be valid JSON + config = json.loads(result) + assert isinstance(config, dict) + + def test_client_config_has_vless_outbound(self): + """Test that client config has VLESS outbound.""" + template = load_template("xray", "client-config.json.j2") + test_vars = load_test_variables() + test_vars["item"] = "alice" + test_vars["user_uuid"] = test_vars["xray_user_uuids"]["alice"] + + result = template.render(**test_vars) + config = json.loads(result) + + # Find VLESS outbound + vless_outbound = None + for outbound in config["outbounds"]: + if outbound["protocol"] == "vless": + vless_outbound = outbound + break + + assert vless_outbound is not None + + def test_client_config_has_local_proxies(self): + """Test that client config has SOCKS and HTTP inbounds.""" + template = load_template("xray", "client-config.json.j2") + test_vars = load_test_variables() + test_vars["item"] = "alice" + test_vars["user_uuid"] = test_vars["xray_user_uuids"]["alice"] + + result = template.render(**test_vars) + config = json.loads(result) + + protocols = [inb["protocol"] for inb in config["inbounds"]] + assert "socks" in protocols + assert "http" in protocols + + +class TestXrayIptables: + """Tests for xray firewall rules integration.""" + + def test_iptables_accepts_xray_port(self): + """Test that iptables rules accept traffic on xray port.""" + template_dir = Path(__file__).parent.parent.parent / "roles" / "common" / "templates" + env = Environment(loader=FileSystemLoader(str(template_dir))) + template = env.get_template("rules.v4.j2") + + test_vars = load_test_variables() + test_vars["xray_enabled"] = True + + result = template.render(**test_vars) + + # Should have rule accepting xray port + assert f"--dport {test_vars['xray_port']}" in result + assert "VLESS" in result or "xray" in result.lower() + + def test_iptables_no_xray_when_disabled(self): + """Test that no xray rules when xray_enabled is false.""" + template_dir = Path(__file__).parent.parent.parent / "roles" / "common" / "templates" + env = Environment(loader=FileSystemLoader(str(template_dir))) + template = env.get_template("rules.v4.j2") + + test_vars = load_test_variables() + test_vars["xray_enabled"] = False + + result = template.render(**test_vars) + + # Should NOT have VLESS-specific rules + assert "VLESS" not in result + + +class TestXrayConfigValidation: + """Tests for xray configuration validation.""" + + def test_reality_sni_matches_dest(self): + """Test that Reality SNI and destination are consistent.""" + test_vars = load_test_variables() + + # SNI should be the hostname part of dest + dest_host = test_vars["xray_reality_dest"].split(":")[0] + assert test_vars["xray_reality_sni"] == dest_host + + def test_short_id_is_hex(self): + """Test that short_id is valid hex string.""" + test_vars = load_test_variables() + + short_id = test_vars["xray_reality_short_id"] + assert re.match(r"^[0-9a-f]+$", short_id, re.IGNORECASE) + + def test_user_uuids_are_valid(self): + """Test that user UUIDs are valid UUID format.""" + test_vars = load_test_variables() + uuid_pattern = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE) + + for user, uuid in test_vars["xray_user_uuids"].items(): + assert uuid_pattern.match(uuid), f"Invalid UUID for {user}: {uuid}" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From d69e5b4ad4bd74e143772d0940ef0ee9e75561cd Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Mon, 2 Feb 2026 22:41:30 +0100 Subject: [PATCH 12/14] Remove unexpected edits in config --- config.cfg | 50 ++++++++++++++++++++------------------------------ 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/config.cfg b/config.cfg index 2ebe2965a..c4611a689 100644 --- a/config.cfg +++ b/config.cfg @@ -20,6 +20,7 @@ users: - laptop - desktop + ### Review these options BEFORE you run Algo, as they are very difficult/impossible to change after the server is deployed. # SSH port for cloud deployments (doesn't apply to existing Ubuntu servers) @@ -52,26 +53,15 @@ adblock_lists: # DNS encryption (required if using ad blocking) dns_encryption: true -# Block traffic between connected clients. Change this to false to enable -# connected clients to reach each other, as well as other computers on the -# same LAN as your Algo server (i.e. the "road warrior" setup). In this -# case, you may also want to enable SMB/CIFS and NETBIOS traffic below. -BetweenClients_DROP: false - -# Block SMB/CIFS traffic -block_smb: false - -# Block NETBIOS traffic -block_netbios: false +# Client isolation (set false for "road warrior" setup where clients can reach each other) +BetweenClients_DROP: true +block_smb: true # Block SMB/CIFS traffic +block_netbios: true # Block NETBIOS traffic -# Your Algo server will automatically install security updates. Some updates -# require a reboot to take effect but your Algo server will not reboot itself -# automatically unless you change 'enabled' below from 'false' to 'true', in -# which case a reboot will take place if necessary at the time specified (as -# HH:MM) in the time zone of your Algo server. The default time zone is UTC. +# Automatic reboot for security updates (time in server's timezone, default UTC) unattended_reboot: - enabled: true - time: 02:00 + enabled: false + time: 06:00 ### Privacy Settings ### # StrongSwan connection logging (-1 = disabled, 2 = debug) @@ -167,14 +157,14 @@ cloud_providers: type: Standard_LRS image: publisher: Canonical - offer: ubuntu-24_04-lts - sku: server + offer: 0001-com-ubuntu-minimal-jammy-daily + sku: minimal-22_04-daily-lts version: latest digitalocean: # See docs for extended droplet options, pricing, and availability. # Possible values: 's-1vcpu-512mb-10gb', 's-1vcpu-1gb', ... size: s-1vcpu-1gb - image: "ubuntu-24-04-x64" + image: "ubuntu-22-04-x64" ec2: # Change the encrypted flag to "false" to disable AWS volume encryption. encrypted: true @@ -183,7 +173,7 @@ cloud_providers: use_existing_eip: false size: t2.micro image: - name: "ubuntu-noble-24.04" + name: "ubuntu-jammy-22.04" arch: x86_64 owner: "099720109477" # Change instance_market_type from "on-demand" to "spot" to launch a spot @@ -191,31 +181,31 @@ cloud_providers: instance_market_type: on-demand gce: size: e2-micro - image: ubuntu-2404-lts + image: ubuntu-2204-lts external_static_ip: false lightsail: size: nano_2_0 - image: ubuntu_24_04 + image: ubuntu_22_04 scaleway: size: DEV1-S - image: Ubuntu 24.04 Noble Numbat + image: Ubuntu 22.04 Jammy Jellyfish arch: x86_64 hetzner: server_type: cpx22 - image: ubuntu-24.04 + image: ubuntu-22.04 openstack: flavor_ram: ">=512" - image: Ubuntu-24.04 + image: Ubuntu-22.04 cloudstack: size: Micro - image: Linux Ubuntu 24.04 LTS 64-bit + image: Linux Ubuntu 22.04 LTS 64-bit disk: 10 vultr: - os: Ubuntu 24.04 LTS x64 + os: Ubuntu 22.04 LTS x64 size: vc2-1c-1gb linode: type: g6-nanode-1 - image: linode/ubuntu24.04 + image: linode/ubuntu22.04 local: fail_hint: From f8271a7005ff8415c7e1aaa2dd0b6132b476e6fd Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Tue, 3 Feb 2026 00:59:35 +0100 Subject: [PATCH 13/14] Fix iptables templates for x-ray only --- roles/common/templates/rules.v4.j2 | 6 +++++- roles/common/templates/rules.v6.j2 | 6 +++++- tests/unit/test_xray_vless.py | 32 ++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/roles/common/templates/rules.v4.j2 b/roles/common/templates/rules.v4.j2 index a0e0d0eb2..782ee1a6c 100644 --- a/roles/common/templates/rules.v4.j2 +++ b/roles/common/templates/rules.v4.j2 @@ -70,8 +70,10 @@ COMMIT -A INPUT -p ah -j ACCEPT # rate limit ICMP traffic per source -A INPUT -p icmp --icmp-type echo-request -m hashlimit --hashlimit-upto 5/s --hashlimit-mode srcip --hashlimit-srcmask 32 --hashlimit-name icmp-echo-drop -j ACCEPT -# Accept IPSEC/WireGuard traffic to ports {{ subnets | join(',') }} +{% if ports %} +# Accept IPSEC/WireGuard traffic to ports {{ ports | join(',') }} -A INPUT -p udp -m multiport --dports {{ ports | join(',') }} -j ACCEPT +{% endif %} {% if xray_enabled | default(false) %} # Accept VLESS/Reality traffic (stealth VPN) -A INPUT -p tcp --dport {{ xray_port }} -m conntrack --ctstate NEW -j ACCEPT @@ -89,6 +91,7 @@ COMMIT # DUMMY interfaces are the proper way to install IPs without assigning them any # particular virtual (tun,tap,...) or physical (ethernet) interface. +{% if subnets %} # Accept DNS traffic to the local DNS resolver from VPN clients only -A INPUT -s {{ subnets | join(',') }} -d {{ local_service_ip }} -p udp --dport 53 -j ACCEPT @@ -101,6 +104,7 @@ COMMIT -A FORWARD -s {{ subnets | join(',') }} -d 169.254.0.0/16 -j DROP # Drop traffic to the link-local network from SSH tunnels -A OUTPUT -d 169.254.0.0/16 -m owner --gid-owner 15000 -j DROP +{% endif %} # Forward any packet that's part of an established connection -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT diff --git a/roles/common/templates/rules.v6.j2 b/roles/common/templates/rules.v6.j2 index f0000b709..1b7adbcb8 100644 --- a/roles/common/templates/rules.v6.j2 +++ b/roles/common/templates/rules.v6.j2 @@ -76,8 +76,10 @@ COMMIT -A INPUT -m ah -j ACCEPT # rate limit ICMP traffic per source -A INPUT -p icmpv6 --icmpv6-type echo-request -m hashlimit --hashlimit-upto 5/s --hashlimit-mode srcip --hashlimit-srcmask 32 --hashlimit-name icmp-echo-drop -j ACCEPT -# Accept IPSEC/WireGuard traffic to ports {{ subnets | join(',') }} +{% if ports %} +# Accept IPSEC/WireGuard traffic to ports {{ ports | join(',') }} -A INPUT -p udp -m multiport --dports {{ ports | join(',') }} -j ACCEPT +{% endif %} {% if xray_enabled | default(false) %} # Accept VLESS/Reality traffic (stealth VPN) -A INPUT -p tcp --dport {{ xray_port }} -m conntrack --ctstate NEW -j ACCEPT @@ -99,6 +101,7 @@ COMMIT # DUMMY interfaces are the proper way to install IPs without assigning them any # particular virtual (tun,tap,...) or physical (ethernet) interface. +{% if subnets %} # Accept DNS traffic to the local DNS resolver from VPN clients only -A INPUT -s {{ subnets | join(',') }} -d {{ local_service_ipv6 }}/128 -p udp --dport 53 -j ACCEPT @@ -106,6 +109,7 @@ COMMIT -A FORWARD -s {{ subnets | join(',') }} -d {{ subnets | join(',') }} -j {{ "DROP" if BetweenClients_DROP else "ACCEPT" }} # Drop traffic to VPN clients from SSH tunnels -A OUTPUT -d {{ subnets | join(',') }} -m owner --gid-owner 15000 -j {{ "DROP" if BetweenClients_DROP else "ACCEPT" }} +{% endif %} -A FORWARD -j ICMPV6-CHECK -A FORWARD -p tcp --dport 445 -j {{ "DROP" if block_smb else "ACCEPT" }} diff --git a/tests/unit/test_xray_vless.py b/tests/unit/test_xray_vless.py index d7325972e..87f66840f 100644 --- a/tests/unit/test_xray_vless.py +++ b/tests/unit/test_xray_vless.py @@ -273,6 +273,38 @@ def test_iptables_no_xray_when_disabled(self): # Should NOT have VLESS-specific rules assert "VLESS" not in result + def test_iptables_xray_only_mode(self): + """Test that iptables work when only xray is enabled (no WireGuard/IPsec). + + This is a regression test for the bug where empty ports/subnets lists + would generate invalid iptables rules like '--dports -j ACCEPT'. + """ + template_dir = Path(__file__).parent.parent.parent / "roles" / "common" / "templates" + env = Environment(loader=FileSystemLoader(str(template_dir))) + template = env.get_template("rules.v4.j2") + + test_vars = load_test_variables() + # Only xray enabled + test_vars["xray_enabled"] = True + test_vars["wireguard_enabled"] = False + test_vars["ipsec_enabled"] = False + + result = template.render(**test_vars) + + # Should NOT have empty --dports (would cause 'invalid port/service' error) + assert "--dports -j" not in result + assert "--dports -j" not in result + + # Should NOT have rules with empty source (-s) + assert "-s -d" not in result + assert "-s -d" not in result + + # Should still have xray rule + assert f"--dport {test_vars['xray_port']}" in result + + # Should still have SSH rule + assert f"--dport {test_vars['ansible_ssh_port']}" in result + class TestXrayConfigValidation: """Tests for xray configuration validation.""" From 761bf4d2c1df9ca2ff4d39e45275453f9baca07e Mon Sep 17 00:00:00 2001 From: Andrey Nekrasov Date: Thu, 14 May 2026 13:07:11 +0200 Subject: [PATCH 14/14] Fix wrong key in X-Ray configs --- .gitignore | 1 + configs/.gitinit | 0 roles/xray/tasks/keys.yml | 4 ++-- 3 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 configs/.gitinit diff --git a/.gitignore b/.gitignore index 666db0211..411e17fc1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.retry .idea/ configs/* +configs-archive/* ~config.cfg config.cfg.* inventory_users diff --git a/configs/.gitinit b/configs/.gitinit deleted file mode 100644 index e69de29bb..000000000 diff --git a/roles/xray/tasks/keys.yml b/roles/xray/tasks/keys.yml index 2f46a647c..53ed74f44 100644 --- a/roles/xray/tasks/keys.yml +++ b/roles/xray/tasks/keys.yml @@ -16,11 +16,11 @@ - name: Parse private key set_fact: - xray_reality_private_key: "{{ xray_x25519_output.stdout_lines[0] | regex_replace('^PrivateKey: ', '') }}" + xray_reality_private_key: "{{ xray_x25519_output.stdout_lines[0].split(': ') | last | trim }}" - name: Parse public key set_fact: - xray_reality_public_key: "{{ xray_x25519_output.stdout_lines[1] | regex_replace('^Password: ', '') }}" + xray_reality_public_key: "{{ xray_x25519_output.stdout_lines[1].split(': ') | last | trim }}" - name: Generate short ID set_fact: