diff --git a/.gitignore b/.gitignore index 4438ad8fb..666db0211 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,20 @@ *.retry .idea/ configs/* +~config.cfg +config.cfg.* inventory_users *.kate-swp -.env/ -.venv/ .DS_Store .vagrant .ansible/ +algo.egg-info/ +.context/ + +# Python +.env/ +.venv/ +bin/ +lib/ __pycache__/ *.pyc -algo.egg-info/ 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..e633e21da 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. @@ -224,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/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 798fe7423..c4611a689 100644 --- a/config.cfg +++ b/config.cfg @@ -20,16 +20,25 @@ 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) 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) +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 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 8dbf3daac..2f12470f7 100644 --- a/input.yml +++ b/input.yml @@ -54,6 +54,37 @@ - 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: Set VPN protocol facts + set_fact: + _vpn_choice: "{{ vpn_protocols | default(_vpn_protocols.user_input | default('1') | trim) | string | trim }}" + + - 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: Debug protocol selection + debug: + msg: "Choice={{ _vpn_choice }} -> xray={{ xray_enabled }}, wg={{ wireguard_enabled }}, ipsec={{ ipsec_enabled }}" + - name: Cellular On Demand prompt pause: prompt: | @@ -88,7 +119,7 @@ register: _store_pki when: - store_pki is undefined - - ipsec_enabled + - ipsec_enabled | bool - name: DNS adblocking prompt pause: @@ -125,6 +156,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/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: 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 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 diff --git a/roles/common/tasks/ubuntu.yml b/roles/common/tasks/ubuntu.yml index a73f472f1..6e4b08781 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 @@ -182,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/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/common/templates/rules.v4.j2 b/roles/common/templates/rules.v4.j2 index 9ed8a502d..782ee1a6c 100644 --- a/roles/common/templates/rules.v4.j2 +++ b/roles/common/templates/rules.v4.j2 @@ -70,8 +70,14 @@ 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 +{% endif %} # Allow new traffic to port {{ ansible_ssh_port }} (SSH) -A INPUT -p tcp --dport {{ ansible_ssh_port }} -m conntrack --ctstate NEW -j ACCEPT @@ -85,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 @@ -97,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 e060b5138..1b7adbcb8 100644 --- a/roles/common/templates/rules.v6.j2 +++ b/roles/common/templates/rules.v6.j2 @@ -76,8 +76,14 @@ 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 +{% endif %} # Allow new traffic to port {{ ansible_ssh_port }} (SSH) -A INPUT -p tcp --dport {{ ansible_ssh_port }} -m conntrack --ctstate NEW -j ACCEPT @@ -95,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 @@ -102,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/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: diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml new file mode 100644 index 000000000..96a30e739 --- /dev/null +++ b/roles/xray/defaults/main.yml @@ -0,0 +1,26 @@ +--- +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_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..2f46a647c --- /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('^PrivateKey: ', '') }}" + + - name: Parse public key + set_fact: + xray_reality_public_key: "{{ xray_x25519_output.stdout_lines[1] | regex_replace('^Password: ', '') }}" + + - 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..2ff963382 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) @@ -162,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/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..87f66840f --- /dev/null +++ b/tests/unit/test_xray_vless.py @@ -0,0 +1,337 @@ +#!/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 + + 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.""" + + 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"]) 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') }}"