From e1f8ff05513a8ca3cb2ea559f00ef46dbd25fbbf Mon Sep 17 00:00:00 2001 From: findias Date: Fri, 20 Mar 2026 15:02:06 +0300 Subject: [PATCH 01/12] fix: correct Xray configuration logic bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 050-api: remove duplicate "stats":{} — already defined in 010-stats.json.j2, duplicate top-level key causes Xray to reject split config - 400-routing: move blocked domains rule before freedom catch-all — previously all inbound traffic matched the first rule (freedom) so ad blocking never fired; correct order: blocked → api → freedom - 210-in-xhttp: add routeOnly:true to sniffing, consistent with VLESS-reality inbound Co-Authored-By: Claude Sonnet 4.6 --- roles/xray/templates/conf/api/050-api.json.j2 | 1 - .../conf/inbounds/210-in-xhttp.json.j2 | 3 ++- .../templates/conf/routing/400-routing.json.j2 | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/roles/xray/templates/conf/api/050-api.json.j2 b/roles/xray/templates/conf/api/050-api.json.j2 index c2e0da4..4a1c31a 100644 --- a/roles/xray/templates/conf/api/050-api.json.j2 +++ b/roles/xray/templates/conf/api/050-api.json.j2 @@ -1,6 +1,5 @@ { {% if xray_api.enable %} - "stats": {}, "api": { "tag": "{{ xray_api.inbound.tag }}", "listen": "{{ xray_api.inbound.address }}:{{ xray_api.inbound.port }}", diff --git a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 index e1631ec..26cf479 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -18,12 +18,13 @@ "decryption": "none" }, "sniffing": { + "enabled": true, "destOverride": [ "http", "tls", "quic" ], - "enabled": true + "routeOnly": true }, "streamSettings": { "network": "{{ xray_xhttp.network }}", diff --git a/roles/xray/templates/conf/routing/400-routing.json.j2 b/roles/xray/templates/conf/routing/400-routing.json.j2 index 4bb7a18..07ee9ca 100644 --- a/roles/xray/templates/conf/routing/400-routing.json.j2 +++ b/roles/xray/templates/conf/routing/400-routing.json.j2 @@ -2,15 +2,6 @@ "routing": { "domainStrategy": "IPIfNonMatch", "rules": [ - { - "type": "field", - "inboundTag": [ - {% for key, value in xray_common.inbound_tag.items() %} - "{{ value }}"{{ "," if not loop.last }} - {% endfor %} - ], - "outboundTag": "freedom" - }, { "type": "field", "domain": [ @@ -28,6 +19,15 @@ "outboundTag": "{{ xray_api.inbound.tag }}" } {% endif %} + , + { + "type": "field", + "inboundTag": [ + "{{ xray_common.inbound_tag.vless_reality_in }}", + "{{ xray_common.inbound_tag.vless_xhttp_in }}" + ], + "outboundTag": "freedom" + } ] } } \ No newline at end of file From 917213e8de092ffc86b223c36cd28bde217a9358 Mon Sep 17 00:00:00 2001 From: findias Date: Fri, 20 Mar 2026 15:09:16 +0300 Subject: [PATCH 02/12] feat: add postquantum VLESS encryption and MLDSA65 REALITY support - defaults: add xray_vless_decryption variable ("none" by default) supports postquantum cipher string (mlkem768x25519plus.native.0rtt.*) - defaults: document xray_reality.mldsa65_seed and mldsa65_verify in secrets.yml example with generation command (xray mldsa65) - 200-in-vless-reality, 210-in-xhttp: dynamic decryption field via xray_vless_decryption; conditional mldsa65Seed/mldsa65Verify in realitySettings when variables are defined - 240-in-vless-users: fix hardcoded flow "" -> user.flow with default fallback; fix decryption to use xray_vless_decryption; remove incorrect "security" field from settings block - 230-in-xhttp-users: dynamic decryption field - README: document MLDSA65 + new encryption setup, key variables table Co-Authored-By: Claude Sonnet 4.6 --- roles/xray/README.md | 29 +++++++++++++++++++ roles/xray/defaults/main.yml | 24 ++++++++++++--- .../inbounds/200-in-vless-reality.json.j2 | 8 ++++- .../conf/inbounds/210-in-xhttp.json.j2 | 8 ++++- .../conf/users/230-in-xhttp-users.json.j2 | 2 +- .../conf/users/240-in-vless-users.json.j2 | 5 ++-- 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/roles/xray/README.md b/roles/xray/README.md index 38410be..5c3ad24 100644 --- a/roles/xray/README.md +++ b/roles/xray/README.md @@ -30,8 +30,34 @@ Generate keys: ```bash xray x25519 # private_key / public_key openssl rand -hex 8 # short_id +xray mldsa65 # mldsa65_seed + mldsa65_verify (postquantum) ``` +### Postquantum MLDSA65 + new VLESS encryption (optional) + +MLDSA65 adds ML-DSA-65 post-quantum signatures to the REALITY handshake (Xray-core >= 25.x). +New VLESS encryption adds postquantum payload encryption on top of REALITY. + +To enable both, add to `secrets.yml`: + +```yaml +xray_reality: + private_key: "..." + public_key: "..." + spiderX: "..." + short_id: + - "abc123ef" + mldsa65_seed: "..." # Server secret — never share, encrypt with ansible-vault + mldsa65_verify: "..." # Public key — give to clients alongside public_key + +xray_vless_decryption: "mlkem768x25519plus.native.0rtt.100-111-1111-1111-1111-111" +``` + +**Client config** must include `mldsa65Verify` in `realitySettings` and matching `encryption` in VLESS user settings. + +> Postquantum encryption requires ALL clients connecting to the inbound to support it. +> Do not mix legacy and postquantum clients on the same inbound. + ## Key variables | Variable | Default | Description | @@ -41,6 +67,9 @@ openssl rand -hex 8 # short_id | `xray_reality_dest` | `askubuntu.com:443` | REALITY handshake destination | | `xray_reality_server_names` | `[askubuntu.com]` | SNI server names | | `xray_api.inbound.port` | `10085` | Xray gRPC API port (localhost only) | +| `xray_vless_decryption` | `none` | VLESS payload decryption mode (`none` or postquantum cipher string) | +| `xray_reality.mldsa65_seed` | — | ML-DSA-65 server seed (secrets.yml only) | +| `xray_reality.mldsa65_verify` | — | ML-DSA-65 public verification key (share with clients) | ## Playbook diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index 3c52c27..003c3fd 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -24,6 +24,15 @@ xray_reality_dest: "askubuntu.com:443" # Destination for XTLS-Reality (us xray_reality_server_names: # List of domain names for SNI/ServerName (used in Reality) - "askubuntu.com" # Replace with your actual domain or leave empty for random +# VLESS decryption mode for inbound settings. +# "none" — classic VLESS, no payload encryption (default, all clients supported). +# "mlkem768x25519plus.native.0rtt." +# — postquantum VLESS encryption (Xray-core >= 25.x). +# ALL clients connecting to this inbound MUST support the cipher. +# Example: "mlkem768x25519plus.native.0rtt.100-111-1111-1111-1111-111" +# Do NOT mix legacy and postquantum clients on the same inbound. +xray_vless_decryption: "none" + # DNS for config xray_dns_servers: # List of DNS servers for Xray - "tcp+local://dns.adguard.com" # AdGuard DNS via QUIC @@ -80,11 +89,18 @@ xray_xhttp: ##### Xray reality ##### # xray_reality: -# private_key: "PrivateKeyHere" # Replace with your actual reality private key -# spiderX: "spiderXHere" # Replace with your actual spiderX value +# private_key: "PrivateKeyHere" # X25519 private key (xray x25519) +# public_key: "PublicKeyHere" # X25519 public key (xray x25519) +# spiderX: "spiderXHere" # spiderX path (e.g. "/") # short_id: -# - "shortId1Here" # Replace with your actual short ID (openssl rand -hex 8) -# public_key: "public keyHere" # Replace with your actual reality public key. +# - "shortId1Here" # 8-byte hex short ID (openssl rand -hex 8) +# +# # ML-DSA-65 postquantum signatures for REALITY (optional, Xray-core >= 25.x). +# # mldsa65_seed is the server secret — NEVER share it with clients. +# # mldsa65_verify is the public key clients use to verify the server — include in client configs. +# # Generate: xray mldsa65 (outputs seed and verify) +# mldsa65_seed: "mldsa65SeedHere" # Server secret — store only in encrypted secrets.yml +# mldsa65_verify: "mldsa65VerifyHere" # Public key — safe to share with clients # xray_users: # - id: "UUID_1" # Replace with your actual UUID diff --git a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 index c2962fc..05a3b06 100644 --- a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 +++ b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 @@ -15,7 +15,7 @@ }{{ "," if not loop.last }} {% endfor %} ], - "decryption": "none" + "decryption": "{{ xray_vless_decryption | default('none') }}" }, "streamSettings": { "network": "tcp", @@ -31,6 +31,12 @@ {% endfor %} ], "privateKey": "{{ xray_reality.private_key }}", + {% if xray_reality.mldsa65_seed is defined and xray_reality.mldsa65_seed %} + "mldsa65Seed": "{{ xray_reality.mldsa65_seed }}", + {% if xray_reality.mldsa65_verify is defined and xray_reality.mldsa65_verify %} + "mldsa65Verify": "{{ xray_reality.mldsa65_verify }}", + {% endif %} + {% endif %} "shortIds": [ {% for short_id in xray_reality.short_id %} "{{ short_id }}"{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 index 26cf479..9a4c411 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -15,7 +15,7 @@ }{{ "," if not loop.last }} {% endfor %} ], - "decryption": "none" + "decryption": "{{ xray_vless_decryption | default('none') }}" }, "sniffing": { "enabled": true, @@ -32,6 +32,12 @@ "dest": "{{ xray_reality_dest }}", "privateKey": "{{ xray_reality.private_key }}", "spiderX": "{{ xray_reality.spiderX }}", + {% if xray_reality.mldsa65_seed is defined and xray_reality.mldsa65_seed %} + "mldsa65Seed": "{{ xray_reality.mldsa65_seed }}", + {% if xray_reality.mldsa65_verify is defined and xray_reality.mldsa65_verify %} + "mldsa65Verify": "{{ xray_reality.mldsa65_verify }}", + {% endif %} + {% endif %} "serverNames": [ {% for name in xray_reality_server_names %} "{{ name }}"{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 index eb95639..1b613d0 100644 --- a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 +++ b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 @@ -13,7 +13,7 @@ }{{ "," if not loop.last }} {% endfor %} ], - "decryption": "none" + "decryption": "{{ xray_vless_decryption | default('none') }}" } } ] diff --git a/roles/xray/templates/conf/users/240-in-vless-users.json.j2 b/roles/xray/templates/conf/users/240-in-vless-users.json.j2 index fc7fcf7..367c844 100644 --- a/roles/xray/templates/conf/users/240-in-vless-users.json.j2 +++ b/roles/xray/templates/conf/users/240-in-vless-users.json.j2 @@ -7,14 +7,13 @@ {% for user in xray_users %} { "id": "{{ user.id }}", - "flow": "", + "flow": "{{ user.flow | default('xtls-rprx-vision') }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} {% endfor %} ], - "decryption": "none", - "security": "reality" + "decryption": "{{ xray_vless_decryption | default('none') }}" } } ] From fea0bfaf94e075fa13795f7df262cff461bcc21b Mon Sep 17 00:00:00 2001 From: findias Date: Fri, 20 Mar 2026 16:33:19 +0300 Subject: [PATCH 03/12] refactor: shorten MLDSA65 lines to fit line-length limit - defaults: trim MLDSA65 comment block and vless_decryption comments to < 80 chars per line - templates: replace long {% if ... is defined and ... %} conditions with {% set _seed / _verify %} + {% if %} (max 72 chars per line) Co-Authored-By: Claude Sonnet 4.6 --- roles/xray/defaults/main.yml | 22 ++++++++----------- .../inbounds/200-in-vless-reality.json.j2 | 6 +++-- .../conf/inbounds/210-in-xhttp.json.j2 | 6 +++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index 003c3fd..32d0931 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -24,13 +24,10 @@ xray_reality_dest: "askubuntu.com:443" # Destination for XTLS-Reality (us xray_reality_server_names: # List of domain names for SNI/ServerName (used in Reality) - "askubuntu.com" # Replace with your actual domain or leave empty for random -# VLESS decryption mode for inbound settings. -# "none" — classic VLESS, no payload encryption (default, all clients supported). -# "mlkem768x25519plus.native.0rtt." -# — postquantum VLESS encryption (Xray-core >= 25.x). -# ALL clients connecting to this inbound MUST support the cipher. -# Example: "mlkem768x25519plus.native.0rtt.100-111-1111-1111-1111-111" -# Do NOT mix legacy and postquantum clients on the same inbound. +# VLESS payload encryption mode. +# "none" — standard VLESS, compatible with all clients. +# For post-quantum set cipher string (Xray-core >= 25.x). +# All clients on the inbound must support the chosen cipher. xray_vless_decryption: "none" # DNS for config @@ -95,12 +92,11 @@ xray_xhttp: # short_id: # - "shortId1Here" # 8-byte hex short ID (openssl rand -hex 8) # -# # ML-DSA-65 postquantum signatures for REALITY (optional, Xray-core >= 25.x). -# # mldsa65_seed is the server secret — NEVER share it with clients. -# # mldsa65_verify is the public key clients use to verify the server — include in client configs. -# # Generate: xray mldsa65 (outputs seed and verify) -# mldsa65_seed: "mldsa65SeedHere" # Server secret — store only in encrypted secrets.yml -# mldsa65_verify: "mldsa65VerifyHere" # Public key — safe to share with clients +# # MLDSA65 post-quantum signatures (optional, Xray >= 25.x) +# # mldsa65_seed: server secret — keep in encrypted secrets.yml +# # mldsa65_verify: public key — share with clients +# mldsa65_seed: "..." +# mldsa65_verify: "..." # xray_users: # - id: "UUID_1" # Replace with your actual UUID diff --git a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 index 05a3b06..630086a 100644 --- a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 +++ b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 @@ -31,9 +31,11 @@ {% endfor %} ], "privateKey": "{{ xray_reality.private_key }}", - {% if xray_reality.mldsa65_seed is defined and xray_reality.mldsa65_seed %} + {% set _seed = xray_reality.mldsa65_seed | default('') %} + {% if _seed %} "mldsa65Seed": "{{ xray_reality.mldsa65_seed }}", - {% if xray_reality.mldsa65_verify is defined and xray_reality.mldsa65_verify %} + {% set _verify = xray_reality.mldsa65_verify | default('') %} + {% if _verify %} "mldsa65Verify": "{{ xray_reality.mldsa65_verify }}", {% endif %} {% endif %} diff --git a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 index 9a4c411..1300ade 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -32,9 +32,11 @@ "dest": "{{ xray_reality_dest }}", "privateKey": "{{ xray_reality.private_key }}", "spiderX": "{{ xray_reality.spiderX }}", - {% if xray_reality.mldsa65_seed is defined and xray_reality.mldsa65_seed %} + {% set _seed = xray_reality.mldsa65_seed | default('') %} + {% if _seed %} "mldsa65Seed": "{{ xray_reality.mldsa65_seed }}", - {% if xray_reality.mldsa65_verify is defined and xray_reality.mldsa65_verify %} + {% set _verify = xray_reality.mldsa65_verify | default('') %} + {% if _verify %} "mldsa65Verify": "{{ xray_reality.mldsa65_verify }}", {% endif %} {% endif %} From 3893bfe08a6c69dcec10118e6637882f0598603a Mon Sep 17 00:00:00 2001 From: findias Date: Fri, 20 Mar 2026 19:00:13 +0300 Subject: [PATCH 04/12] Force xtls-rprx-vision flow when PQ encryption is enabled When xray_vless_decryption is set to a non-none cipher string, xtls-rprx-vision is required. All four inbound/user templates now auto-set flow to xtls-rprx-vision if _pq is true. Co-Authored-By: Claude Sonnet 4.6 --- .../templates/conf/inbounds/200-in-vless-reality.json.j2 | 3 ++- roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 | 5 +++-- roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 | 5 +++-- roles/xray/templates/conf/users/240-in-vless-users.json.j2 | 6 ++++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 index 630086a..1ec0d8d 100644 --- a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 +++ b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 @@ -6,10 +6,11 @@ "tag": "{{ xray_vless_tag }}", "settings": { "clients": [ + {% set _pq = xray_vless_decryption | default('none') != 'none' %} {% for user in xray_users %} { "id": "{{ user.id }}", - "flow": "{{ user.flow }}", + "flow": "{{ 'xtls-rprx-vision' if _pq else user.flow }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 index 1300ade..043cddc 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -5,11 +5,12 @@ "protocol": "vless", "tag": "{{ xray_common.inbound_tag.vless_xhttp_in }}", "settings": { - "clients": [ + "clients": [ + {% set _pq = xray_vless_decryption | default('none') != 'none' %} {% for user in xray_users %} { "id": "{{ user.id }}", - "flow": "", + "flow": "{{ 'xtls-rprx-vision' if _pq else '' }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 index 1b613d0..0b9f1c6 100644 --- a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 +++ b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 @@ -3,11 +3,12 @@ { "tag": "{{ xray_common.inbound_tag.vless_xhttp_in }}", "settings": { - "clients": [ + "clients": [ + {% set _pq = xray_vless_decryption | default('none') != 'none' %} {% for user in xray_users %} { "id": "{{ user.id }}", - "flow": "", + "flow": "{{ 'xtls-rprx-vision' if _pq else '' }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/users/240-in-vless-users.json.j2 b/roles/xray/templates/conf/users/240-in-vless-users.json.j2 index 367c844..530d79c 100644 --- a/roles/xray/templates/conf/users/240-in-vless-users.json.j2 +++ b/roles/xray/templates/conf/users/240-in-vless-users.json.j2 @@ -3,11 +3,13 @@ { "tag": "{{ xray_common.inbound_tag.vless_reality_in }}", "settings": { - "clients": [ + "clients": [ + {% set _pq = xray_vless_decryption | default('none') != 'none' %} {% for user in xray_users %} + {% set _flow = user.flow | default('xtls-rprx-vision') %} { "id": "{{ user.id }}", - "flow": "{{ user.flow | default('xtls-rprx-vision') }}", + "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} From 5019d98f33d20f4c43d86db88ae552570a4056ae Mon Sep 17 00:00:00 2001 From: findias Date: Fri, 20 Mar 2026 19:13:25 +0300 Subject: [PATCH 05/12] Add per-client encryption field to all VLESS inbound templates Adds xray_vless_encryption default variable (default: "none"). Each client entry now includes an encryption field that auto-syncs with xray_vless_decryption when PQ mode is active, or falls back to per-user user.encryption / xray_vless_encryption otherwise. Co-Authored-By: Claude Sonnet 4.6 --- roles/xray/defaults/main.yml | 4 ++++ .../xray/templates/conf/inbounds/200-in-vless-reality.json.j2 | 4 ++++ roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 | 4 ++++ roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 | 4 ++++ roles/xray/templates/conf/users/240-in-vless-users.json.j2 | 4 ++++ 5 files changed, 20 insertions(+) diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index 32d0931..d90f17a 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -30,6 +30,10 @@ xray_reality_server_names: # List of domain names for SNI/Ser # All clients on the inbound must support the chosen cipher. xray_vless_decryption: "none" +# Per-client encryption (outbound side, inbound enforcement). +# Overridden automatically when xray_vless_decryption != "none". +xray_vless_encryption: "none" + # DNS for config xray_dns_servers: # List of DNS servers for Xray - "tcp+local://dns.adguard.com" # AdGuard DNS via QUIC diff --git a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 index 1ec0d8d..5319179 100644 --- a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 +++ b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 @@ -7,10 +7,14 @@ "settings": { "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} + {% set _def_enc = xray_vless_encryption | default('none') %} {% for user in xray_users %} + {% set _u_enc = user.encryption | default(_def_enc) %} + {% set _enc = xray_vless_decryption if _pq else _u_enc %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else user.flow }}", + "encryption": "{{ _enc }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 index 043cddc..620f16c 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -7,10 +7,14 @@ "settings": { "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} + {% set _def_enc = xray_vless_encryption | default('none') %} {% for user in xray_users %} + {% set _u_enc = user.encryption | default(_def_enc) %} + {% set _enc = xray_vless_decryption if _pq else _u_enc %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else '' }}", + "encryption": "{{ _enc }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 index 0b9f1c6..c29f231 100644 --- a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 +++ b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 @@ -5,10 +5,14 @@ "settings": { "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} + {% set _def_enc = xray_vless_encryption | default('none') %} {% for user in xray_users %} + {% set _u_enc = user.encryption | default(_def_enc) %} + {% set _enc = xray_vless_decryption if _pq else _u_enc %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else '' }}", + "encryption": "{{ _enc }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/users/240-in-vless-users.json.j2 b/roles/xray/templates/conf/users/240-in-vless-users.json.j2 index 530d79c..d2e7f0b 100644 --- a/roles/xray/templates/conf/users/240-in-vless-users.json.j2 +++ b/roles/xray/templates/conf/users/240-in-vless-users.json.j2 @@ -5,11 +5,15 @@ "settings": { "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} + {% set _def_enc = xray_vless_encryption | default('none') %} {% for user in xray_users %} {% set _flow = user.flow | default('xtls-rprx-vision') %} + {% set _u_enc = user.encryption | default(_def_enc) %} + {% set _enc = xray_vless_decryption if _pq else _u_enc %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}", + "encryption": "{{ _enc }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} From 51d204515acb9a4dc77458a0cd344c8a97aa5869 Mon Sep 17 00:00:00 2001 From: findias Date: Fri, 20 Mar 2026 19:21:42 +0300 Subject: [PATCH 06/12] Remove encryption field from inbound client configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xray rejects "encryption" in inbound settings — it is only valid in outbound (client-side) configs. Removed from all 4 templates and cleaned up the unused xray_vless_encryption default var. Co-Authored-By: Claude Sonnet 4.6 --- roles/xray/defaults/main.yml | 9 ++++----- .../templates/conf/inbounds/200-in-vless-reality.json.j2 | 4 ---- roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 | 4 ---- .../xray/templates/conf/users/230-in-xhttp-users.json.j2 | 4 ---- .../xray/templates/conf/users/240-in-vless-users.json.j2 | 4 ---- 5 files changed, 4 insertions(+), 21 deletions(-) diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index d90f17a..e630725 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -24,16 +24,12 @@ xray_reality_dest: "askubuntu.com:443" # Destination for XTLS-Reality (us xray_reality_server_names: # List of domain names for SNI/ServerName (used in Reality) - "askubuntu.com" # Replace with your actual domain or leave empty for random -# VLESS payload encryption mode. +# VLESS payload encryption mode (server inbound setting). # "none" — standard VLESS, compatible with all clients. # For post-quantum set cipher string (Xray-core >= 25.x). # All clients on the inbound must support the chosen cipher. xray_vless_decryption: "none" -# Per-client encryption (outbound side, inbound enforcement). -# Overridden automatically when xray_vless_decryption != "none". -xray_vless_encryption: "none" - # DNS for config xray_dns_servers: # List of DNS servers for Xray - "tcp+local://dns.adguard.com" # AdGuard DNS via QUIC @@ -101,6 +97,9 @@ xray_xhttp: # # mldsa65_verify: public key — share with clients # mldsa65_seed: "..." # mldsa65_verify: "..." +# xray_vless_encryption: "none" +# xray_vless_decryption: "none" + # xray_users: # - id: "UUID_1" # Replace with your actual UUID diff --git a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 index 5319179..1ec0d8d 100644 --- a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 +++ b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 @@ -7,14 +7,10 @@ "settings": { "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} - {% set _def_enc = xray_vless_encryption | default('none') %} {% for user in xray_users %} - {% set _u_enc = user.encryption | default(_def_enc) %} - {% set _enc = xray_vless_decryption if _pq else _u_enc %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else user.flow }}", - "encryption": "{{ _enc }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 index 620f16c..043cddc 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -7,14 +7,10 @@ "settings": { "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} - {% set _def_enc = xray_vless_encryption | default('none') %} {% for user in xray_users %} - {% set _u_enc = user.encryption | default(_def_enc) %} - {% set _enc = xray_vless_decryption if _pq else _u_enc %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else '' }}", - "encryption": "{{ _enc }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 index c29f231..0b9f1c6 100644 --- a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 +++ b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 @@ -5,14 +5,10 @@ "settings": { "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} - {% set _def_enc = xray_vless_encryption | default('none') %} {% for user in xray_users %} - {% set _u_enc = user.encryption | default(_def_enc) %} - {% set _enc = xray_vless_decryption if _pq else _u_enc %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else '' }}", - "encryption": "{{ _enc }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/users/240-in-vless-users.json.j2 b/roles/xray/templates/conf/users/240-in-vless-users.json.j2 index d2e7f0b..530d79c 100644 --- a/roles/xray/templates/conf/users/240-in-vless-users.json.j2 +++ b/roles/xray/templates/conf/users/240-in-vless-users.json.j2 @@ -5,15 +5,11 @@ "settings": { "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} - {% set _def_enc = xray_vless_encryption | default('none') %} {% for user in xray_users %} {% set _flow = user.flow | default('xtls-rprx-vision') %} - {% set _u_enc = user.encryption | default(_def_enc) %} - {% set _enc = xray_vless_decryption if _pq else _u_enc %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}", - "encryption": "{{ _enc }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} From 21d75ce8aee2d36abbd5b4d9821b06740d79a7cd Mon Sep 17 00:00:00 2001 From: findias Date: Fri, 20 Mar 2026 19:28:39 +0300 Subject: [PATCH 07/12] Add early validation task for xray_vless_decryption and required vars New validate.yml runs before any config is deployed (tags: always). Fails immediately with a clear message if xray_vless_decryption is not "none", xray_users is empty, or xray_reality keys are missing. Co-Authored-By: Claude Sonnet 4.6 --- roles/xray/tasks/main.yml | 4 +++ roles/xray/tasks/validate.yml | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 roles/xray/tasks/validate.yml diff --git a/roles/xray/tasks/main.yml b/roles/xray/tasks/main.yml index cca3b83..7d84e09 100644 --- a/roles/xray/tasks/main.yml +++ b/roles/xray/tasks/main.yml @@ -1,4 +1,8 @@ --- +- name: Xray | Validate + ansible.builtin.import_tasks: validate.yml + tags: always + - name: Xray | Install ansible.builtin.import_tasks: install.yml tags: xray_install diff --git a/roles/xray/tasks/validate.yml b/roles/xray/tasks/validate.yml new file mode 100644 index 0000000..1b7aab6 --- /dev/null +++ b/roles/xray/tasks/validate.yml @@ -0,0 +1,47 @@ +--- +- name: Xray | Validate xray_vless_decryption value + ansible.builtin.assert: + that: + - xray_vless_decryption | default('none') == 'none' + fail_msg: >- + xray_vless_decryption is set to an unsupported value: + "{{ xray_vless_decryption }}". + Xray 26.x only accepts "none" for VLESS inbound decryption. + PQ protection is provided by MLDSA65 in the REALITY handshake. + Set xray_vless_decryption: "none" in your secrets.yml. + success_msg: "xray_vless_decryption is valid" + +- name: Xray | Validate xray_users is defined and non-empty + ansible.builtin.assert: + that: + - xray_users is defined + - xray_users | length > 0 + fail_msg: >- + xray_users is not defined or empty. + Define at least one user in secrets.yml. + success_msg: "xray_users is valid" + +- name: Xray | Validate each user has id and email + ansible.builtin.assert: + that: + - item.id is defined and item.id != '' + - item.email is defined and item.email != '' + fail_msg: >- + User entry is missing required fields. + Each user must have 'id' (UUID) and 'email'. + Offending entry: {{ item }} + success_msg: "User {{ item.email }} is valid" + loop: "{{ xray_users }}" + +- name: Xray | Validate xray_reality keys are defined + ansible.builtin.assert: + that: + - xray_reality is defined + - xray_reality.private_key is defined + - xray_reality.private_key != '' + - xray_reality.short_id is defined + - xray_reality.short_id | length > 0 + fail_msg: >- + xray_reality is missing required keys. + Ensure private_key and short_id are set in secrets.yml. + success_msg: "xray_reality keys are valid" From d0c4dc6f1a97bb629b03e94ae5061d77773bff52 Mon Sep 17 00:00:00 2001 From: findias Date: Fri, 20 Mar 2026 19:40:51 +0300 Subject: [PATCH 08/12] Fix DNS config: replace DoH with tcp+local to avoid closed pipe errors DoH servers (https://) route through the Xray proxy chain and fail with "io: read/write on closed pipe" when the connection is reused after being closed. Switch to tcp+local://8.8.8.8 and tcp+local://1.1.1.1 which bypass the proxy. Added validation assert to catch DoH in user vars. Co-Authored-By: Claude Sonnet 4.6 --- roles/xray/defaults/main.yml | 7 ++++--- roles/xray/tasks/validate.yml | 10 ++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index e630725..6ac8cef 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -32,9 +32,10 @@ xray_vless_decryption: "none" # DNS for config xray_dns_servers: # List of DNS servers for Xray - - "tcp+local://dns.adguard.com" # AdGuard DNS via QUIC - - "8.8.8.8" # Google DNS (plain) - - "1.1.1.1" # Cloudflare DNS (plain) + - "tcp+local://8.8.8.8" # Google DNS, local (bypasses proxy) + - "tcp+local://1.1.1.1" # Cloudflare DNS, local (bypasses proxy) + # DoH servers (https://) are NOT recommended here — they route + # through the proxy chain and fail with "closed pipe" on reconnect. xray_dns_disable_fallback: false # Set to true to disable fallback to system DNS xray_dns_query_strategy: "UseIP" # DNS query strategy: "UseIP", "UseIPIfNonMatch", "UseIPv4", "UseIPv6" diff --git a/roles/xray/tasks/validate.yml b/roles/xray/tasks/validate.yml index 1b7aab6..d2d14ec 100644 --- a/roles/xray/tasks/validate.yml +++ b/roles/xray/tasks/validate.yml @@ -33,6 +33,16 @@ success_msg: "User {{ item.email }} is valid" loop: "{{ xray_users }}" +- name: Xray | Warn if DoH servers are in xray_dns_servers + ansible.builtin.assert: + that: + - xray_dns_servers | select('match', '^https://') | list | length == 0 + fail_msg: >- + xray_dns_servers contains DoH URL (https://). + DoH routes through the proxy chain and causes + "closed pipe" errors. Use tcp+local://8.8.8.8 instead. + success_msg: "DNS servers config is valid" + - name: Xray | Validate xray_reality keys are defined ansible.builtin.assert: that: From 7660bf68eb8d58847d208f65e1d7b325d933940a Mon Sep 17 00:00:00 2001 From: findias Date: Fri, 20 Mar 2026 19:50:36 +0300 Subject: [PATCH 09/12] Use user.flow in xhttp templates instead of hardcoded empty string Templates 210 and 230 were ignoring user.flow and always outputting "". Now they read user.flow (default '') so xtls-rprx-vision is set when defined per user, consistent with templates 200 and 240. Co-Authored-By: Claude Sonnet 4.6 --- roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 | 3 ++- roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 index 043cddc..76941f2 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -8,9 +8,10 @@ "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} {% for user in xray_users %} + {% set _flow = user.flow | default('') %} { "id": "{{ user.id }}", - "flow": "{{ 'xtls-rprx-vision' if _pq else '' }}", + "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 index 0b9f1c6..bc10fa0 100644 --- a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 +++ b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 @@ -6,9 +6,10 @@ "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} {% for user in xray_users %} + {% set _flow = user.flow | default('') %} { "id": "{{ user.id }}", - "flow": "{{ 'xtls-rprx-vision' if _pq else '' }}", + "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} From 1cb7f6dd55c29df9569261136e7a22c35d9881fe Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 21 Mar 2026 01:24:44 +0300 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20actualize=20Ansible=20role=20?= =?UTF-8?q?=E2=80=94=20add=20Raven-subscribe=20deploy=20and=20VLESS=20Encr?= =?UTF-8?q?yption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xray role: - defaults/main.yml: add xray_vless_decryption/xray_vless_client_encryption vars, full raven_subscribe_* vars block, remove mldsa65 references - validate.yml: assert xray_vless_decryption format and client encryption consistency - inbounds: remove mldsa65 blocks, use decryption/flow logic for VLESS Encryption - handlers/main.yml: fix handler order (Validate before Restart to catch invalid configs) - tasks/main.yml: import raven_subscribe.yml task Raven-subscribe deploy (new): - tasks/raven_subscribe.yml: download binary, deploy config, install systemd service - templates/raven-subscribe/config.json.j2: config template with vless_client_encryption - templates/raven-subscribe/xray-subscription.service.j2: hardened systemd unit Cleanup: - Remove obsolete config.json.j2 and main.yml.bak Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 + roles/xray/README.md | 1 + roles/xray/defaults/main.yml | 58 +++-- roles/xray/handlers/main.yml | 24 +- roles/xray/tasks/main.yml | 4 + roles/xray/tasks/main.yml.bak | 192 --------------- roles/xray/tasks/raven_subscribe.yml | 100 ++++++++ roles/xray/tasks/validate.yml | 20 +- .../inbounds/200-in-vless-reality.json.j2 | 10 +- .../conf/inbounds/210-in-xhttp.json.j2 | 10 +- .../conf/users/230-in-xhttp-users.json.j2 | 2 +- .../conf/users/240-in-vless-users.json.j2 | 2 +- roles/xray/templates/config.json.j2 | 227 ------------------ .../templates/raven-subscribe/config.json.j2 | 17 ++ .../xray-subscription.service.j2 | 23 ++ 15 files changed, 230 insertions(+), 465 deletions(-) delete mode 100644 roles/xray/tasks/main.yml.bak create mode 100644 roles/xray/tasks/raven_subscribe.yml delete mode 100644 roles/xray/templates/config.json.j2 create mode 100644 roles/xray/templates/raven-subscribe/config.json.j2 create mode 100644 roles/xray/templates/raven-subscribe/xray-subscription.service.j2 diff --git a/.gitignore b/.gitignore index dddf783..a783af6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ roles/hosts.yml roles/xray/defaults/secrets.yml roles/xray/defaults/vic_secret.yml +# Generated by tests/run.sh +tests/.cache/ +tests/.output/ +tests/fixtures/test_secrets.yml + diff --git a/roles/xray/README.md b/roles/xray/README.md index 5c3ad24..e55c6b9 100644 --- a/roles/xray/README.md +++ b/roles/xray/README.md @@ -68,6 +68,7 @@ xray_vless_decryption: "mlkem768x25519plus.native.0rtt.100-111-1111-1111-1111-11 | `xray_reality_server_names` | `[askubuntu.com]` | SNI server names | | `xray_api.inbound.port` | `10085` | Xray gRPC API port (localhost only) | | `xray_vless_decryption` | `none` | VLESS payload decryption mode (`none` or postquantum cipher string) | +| `xray_vless_default_flow` | `xtls-rprx-vision` | `flow` для пользователя, если в `xray_users` не задан ([VLESS inbound](https://xtls.github.io/en/config/inbounds/vless.html)) | | `xray_reality.mldsa65_seed` | — | ML-DSA-65 server seed (secrets.yml only) | | `xray_reality.mldsa65_verify` | — | ML-DSA-65 public verification key (share with clients) | diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index 6ac8cef..bf6a17a 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -11,8 +11,8 @@ xray_config_dir: /etc/xray/ # Directory for Xray configuratio xray_service_name: xray # Xray systemd service name xray_github_repo: XTLS/Xray-core # Xray GitHub repository xray_download_url: "https://github.com/{{ xray_github_repo }}/releases/download/{{ xray_version }}/Xray-linux-64.zip" # Download URL for Xray -xray_user: "xrayuser" # System user to run Xray -xray_group: "xrayuser" # System group to run Xray +xray_user: "xrayuser" # System user to run Xray (shared with raven-subscribe) +xray_group: "xrayuser" # System group to run Xray (shared with raven-subscribe) xray_log_dir: "/var/log/Xray" # Directory for Xray logs xray_bin_dir: "/usr/local/bin" # Directory for Xray binaries xray_nologin_shell: "/usr/sbin/nologin" # Shell for the Xray user (to prevent login) @@ -24,12 +24,23 @@ xray_reality_dest: "askubuntu.com:443" # Destination for XTLS-Reality (us xray_reality_server_names: # List of domain names for SNI/ServerName (used in Reality) - "askubuntu.com" # Replace with your actual domain or leave empty for random -# VLESS payload encryption mode (server inbound setting). -# "none" — standard VLESS, compatible with all clients. -# For post-quantum set cipher string (Xray-core >= 25.x). -# All clients on the inbound must support the chosen cipher. +# VLESS Encryption — server inbound decryption string (Xray-core >= 25.x, PR #5067). +# "none" — standard VLESS, no extra encryption layer, compatible with all clients. +# VLESS Encryption: full mlkem768x25519plus string generated by: xray vlessenc +# Format: "mlkem768x25519plus.native/xorpub/random.600s.(pad).(X25519 PrivKey).(ML-KEM-768 Seed)" +# When set: all clients must use xray_vless_client_encryption (different string, public keys only). +# When set: flow is forced to xtls-rprx-vision for all users. xray_vless_decryption: "none" +# VLESS Encryption — client outbound encryption string (public keys, safe to share). +# Must match xray_vless_decryption. "none" when decryption is "none". +# Format: "mlkem768x25519plus.native/xorpub/random.0rtt/1rtt.(pad).(X25519 Password).(ML-KEM-768 Client)" +xray_vless_client_encryption: "none" + +# Если в xray_users у пользователя не задан flow — используется это значение (Vision для TCP/XHTTP+REALITY). +# См. https://xtls.github.io/en/config/inbounds/vless.html — flow +xray_vless_default_flow: "xtls-rprx-vision" + # DNS for config xray_dns_servers: # List of DNS servers for Xray - "tcp+local://8.8.8.8" # Google DNS, local (bypasses proxy) @@ -92,14 +103,6 @@ xray_xhttp: # spiderX: "spiderXHere" # spiderX path (e.g. "/") # short_id: # - "shortId1Here" # 8-byte hex short ID (openssl rand -hex 8) -# -# # MLDSA65 post-quantum signatures (optional, Xray >= 25.x) -# # mldsa65_seed: server secret — keep in encrypted secrets.yml -# # mldsa65_verify: public key — share with clients -# mldsa65_seed: "..." -# mldsa65_verify: "..." -# xray_vless_encryption: "none" -# xray_vless_decryption: "none" # xray_users: @@ -112,3 +115,30 @@ xray_xhttp: # - id: UUID_3" # flow: "xtls-rprx-vision" # email: "someEmailForIdentyfy" + +# --------------------------------------------------------------------------- +# Raven-subscribe (subscription server) +# --------------------------------------------------------------------------- + +raven_subscribe_github_repo: "alchemylink/raven-subscribe" +raven_subscribe_install_dir: "/usr/local/bin" +raven_subscribe_config_dir: "/etc/xray-subscription" +raven_subscribe_db_dir: "/var/lib/xray-subscription" +raven_subscribe_service_name: "xray-subscription" + +raven_subscribe_listen_addr: ":8080" +raven_subscribe_sync_interval_seconds: 60 +raven_subscribe_rate_limit_sub_per_min: 60 +raven_subscribe_rate_limit_admin_per_min: 30 + +# The inbound tag Raven manages users in (must match an inbound in config.d) +raven_subscribe_api_inbound_tag: "{{ xray_common.inbound_tag.vless_reality_in }}" + +# Use Xray gRPC API for user sync instead of file writes. +# Requires xray_api.enable = true. Value: "127.0.0.1:" +raven_subscribe_xray_api_addr: "127.0.0.1:{{ xray_api.inbound.port }}" + +##### Set these in secrets.yml (ansible-vault encrypted) ##### +# raven_subscribe_server_host: "your-server.com" # Public IP or domain +# raven_subscribe_base_url: "http://your-server.com:8080" +# raven_subscribe_admin_token: "" # Strong random secret (required) diff --git a/roles/xray/handlers/main.yml b/roles/xray/handlers/main.yml index 9186962..b9d8cc9 100644 --- a/roles/xray/handlers/main.yml +++ b/roles/xray/handlers/main.yml @@ -1,4 +1,15 @@ --- +# IMPORTANT: Ansible executes handlers in definition order, not notification order. +# Validate must come BEFORE Restart so invalid configs are caught before reload. + +- name: Validate xray + ansible.builtin.command: + cmd: "xray -test -confdir {{ xray_config_dir }}config.d" + register: validate_result + changed_when: false + failed_when: validate_result.rc != 0 + when: ansible_facts['service_mgr'] in ['systemd', 'openrc'] + - name: Restart xray ansible.builtin.systemd: name: "{{ xray_service_name }}" @@ -22,10 +33,9 @@ changed_when: false when: ansible_facts['service_mgr'] == "openrc" -- name: Validate xray - ansible.builtin.command: - cmd: "xray -test -confdir {{ xray_config_dir }}config.d" - register: validate_result - changed_when: false - failed_when: validate_result.rc != 0 - when: ansible_facts['service_mgr'] in ['systemd', 'openrc'] +- name: Restart raven-subscribe + ansible.builtin.systemd: + name: "{{ raven_subscribe_service_name }}" + state: restarted + daemon_reload: true + when: ansible_facts['service_mgr'] == "systemd" diff --git a/roles/xray/tasks/main.yml b/roles/xray/tasks/main.yml index 7d84e09..f66373c 100644 --- a/roles/xray/tasks/main.yml +++ b/roles/xray/tasks/main.yml @@ -38,3 +38,7 @@ - name: Xray | GRPCurl ansible.builtin.import_tasks: grpcurl.yml tags: grpcurl + +- name: Raven-subscribe | Deploy subscription server + ansible.builtin.import_tasks: raven_subscribe.yml + tags: raven_subscribe diff --git a/roles/xray/tasks/main.yml.bak b/roles/xray/tasks/main.yml.bak deleted file mode 100644 index a435097..0000000 --- a/roles/xray/tasks/main.yml.bak +++ /dev/null @@ -1,192 +0,0 @@ -# File: roles/xray/tasks/main.yml - ---- -- name: "Ensure the {{ xray_group }} group exists" - ansible.builtin.group: - name: "{{ xray_group }}" - state: present - system: true - -- name: Set nologin shell path based on OS family - ansible.builtin.set_fact: - nologin_shell: | - {% if ansible_facts['os_family'] == 'Alpine' %} - /sbin/nologin - {% else %} - /usr/sbin/nologin - {% endif %} - -- name: Create a dedicated system user for Xray ({{ xray_user }}) - ansible.builtin.user: - name: "{{ xray_user }}" - state: present - system: true - shell: "{{ xray_nologin_shell }}" - group: "{{ xray_group }}" - -- name: Ensure Xray log directory exists and has correct permissions - ansible.builtin.file: - path: "{{ xray_log_dir }}" - state: directory - owner: "{{ xray_user }}" - group: "{{ xray_group }}" - mode: '0755' - recurse: true - -- name: Ensure Xray configuration directory exists and has correct permissions - ansible.builtin.file: - path: "{{ xray_config_dir }}" - state: directory - owner: "{{ xray_user }}" - group: "{{ xray_group }}" - mode: '0755' - recurse: true - -- name: "Correct ownership for Xray executable (if needed)" - ansible.builtin.file: - path: "{{ xray_bin_dir }}/xray" - owner: "{{ xray_user }}" - group: "{{ xray_group }}" - mode: '0755' - ignore_errors: true - when: xray_bin_dir is defined and xray_bin_dir != '' - -- name: Correct permissions for Xray service files (if necessary) - ansible.builtin.file: - path: "{{ xray_install_dir }}" - owner: "{{ xray_user }}" - group: "{{ xray_group }}" - -- name: Ensure Xray install directory exists - ansible.builtin.file: - path: "{{ xray_install_dir }}" - state: directory - mode: '0755' - -- name: Ensure Xray config directory exists - ansible.builtin.file: - path: "{{ xray_config_dir }}" - state: directory - mode: '0755' - -- name: Get latest Xray release version - ansible.builtin.uri: - url: "https://api.github.com/repos/{{ xray_github_repo }}/releases/latest" - method: GET - return_content: true - headers: - Accept: "application/vnd.github.v3+json" - register: xray_release_info - run_once: true - -- name: Set Xray version fact - ansible.builtin.set_fact: - xray_version: "{{ xray_release_info.json.tag_name }}" - ansible_version_full: "{{ ansible_version.full }}" - -- name: Download Xray using get_url (Ansible >= 2.16.0) - block: - - name: Download Xray archive (Ansible >= 2.16.0) - ansible.builtin.get_url: - url: "{{ xray_download_url }}" - dest: "/tmp/Xray-{{ xray_version }}-linux-64.zip" - mode: '0644' - register: download_xray - - name: Unarchive Xray - ansible.builtin.unarchive: - src: "{{ download_xray.dest }}" - dest: "{{ xray_install_dir }}" - remote_src: true - when: ansible_version_full is version('2.16.0', '>=') - notify: "Restart xray service" - -- name: Download Xray using curl (Ansible < 2.16.0) - block: - - name: Download Xray archive (Ansible < 2.16.0) - ansible.builtin.command: > - curl -L --output /tmp/Xray-{{ xray_version }}-linux-64.zip "{{ xray_download_url }}" - args: - creates: "/tmp/Xray-{{ xray_version }}-linux-64.zip" - register: download_xray - changed_when: download_xray.rc == 0 and not "already exists" in download_xray.stdout - - name: Unarchive Xray - ansible.builtin.unarchive: - src: "/tmp/Xray-{{ xray_version }}-linux-64.zip" - dest: "{{ xray_install_dir }}" - remote_src: true - when: ansible_version_full is version('2.16.0', '<') - notify: "Restart xray service" - -- name: Create symlink for Xray executable - ansible.builtin.file: - src: "{{ xray_install_dir }}/xray" - dest: /usr/local/bin/xray - state: link - force: true - -- name: Deploy Xray systemd service file - ansible.builtin.template: - src: xray.service.j2 - dest: /etc/systemd/system/{{ xray_service_name }}.service - mode: '0644' - when: ansible_facts['service_mgr'] == "systemd" - notify: - - Reload systemd - - Restart xray - -- name: Deploy Xray OpenRC service file - ansible.builtin.template: - src: xray.openrc.j2 - dest: /etc/init.d/{{ xray_service_name }} - mode: '0755' - when: ansible_facts['service_mgr'] == "openrc" - notify: - - Reload openrc - - Restart xray - -- name: Generate Xray configuration - ansible.builtin.template: - src: config.json.j2 - dest: "{{ xray_config_dir }}/config.json" - mode: '0644' - notify: "Restart xray service" - tags: - - xray_config - -- name: Ensure Xray service is started and enabled (systemd) - ansible.builtin.systemd_service: - name: "{{ xray_service_name }}" - state: started - enabled: true - register: xray_service_status_systemd - when: ansible_facts['service_mgr'] == "systemd" - tags: - - xray_config - -- name: Ensure Xray service is started and enabled (OpenRC) - ansible.builtin.service: - name: "{{ xray_service_name }}" - state: started - enabled: true - register: xray_service_status_openrc - when: ansible_facts['service_mgr'] == "openrc" - tags: - - xray_config - -- name: Fail if Xray service is not running (systemd) - ansible.builtin.fail: - msg: "Xray service failed to start! Check logs with 'journalctl -u {{ xray_service_name }}'" - when: - - ansible_facts['service_mgr'] == "systemd" - - not xray_service_status_systemd.status.ActiveState == "active" - tags: - - xray_config - -- name: Fail if Xray service is not running (OpenRC) - ansible.builtin.fail: - msg: "Xray service failed to start! Check logs with 'rc-service {{ xray_service_name }} status' or '/var/log/messages'" - when: - - ansible_facts['service_mgr'] == "openrc" - - not xray_service_status_openrc.status.active - tags: - - xray_config diff --git a/roles/xray/tasks/raven_subscribe.yml b/roles/xray/tasks/raven_subscribe.yml new file mode 100644 index 0000000..0801305 --- /dev/null +++ b/roles/xray/tasks/raven_subscribe.yml @@ -0,0 +1,100 @@ +--- +- name: Raven-subscribe | Validate required vars + ansible.builtin.assert: + that: + - raven_subscribe_admin_token is defined + - raven_subscribe_admin_token != '' + - raven_subscribe_server_host is defined + - raven_subscribe_server_host != '' + fail_msg: >- + raven_subscribe_admin_token and raven_subscribe_server_host must be set in secrets.yml. + Generate a strong token: openssl rand -hex 32 + success_msg: "Raven-subscribe vars are valid" + +- name: Raven-subscribe | Get latest release info + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ raven_subscribe_github_repo }}/releases/latest" + method: GET + return_content: true + headers: + Accept: "application/vnd.github.v3+json" + status_code: 200 + register: raven_release_info + run_once: true + retries: 3 + delay: 3 + until: raven_release_info.status == 200 + +- name: Raven-subscribe | Set version and arch facts + ansible.builtin.set_fact: + raven_subscribe_version: "{{ raven_release_info.json.tag_name }}" + raven_subscribe_arch: >- + {{ + 'linux-amd64' if ansible_architecture in ['x86_64', 'amd64'] + else 'linux-arm64' if ansible_architecture in ['aarch64', 'arm64'] + else 'linux-arm' + }} + +- name: Raven-subscribe | Download binary + ansible.builtin.get_url: + url: "https://github.com/{{ raven_subscribe_github_repo }}/releases/download/\ + {{ raven_subscribe_version }}/xray-subscription-{{ raven_subscribe_arch }}" + dest: "{{ raven_subscribe_install_dir }}/xray-subscription" + mode: "0755" + owner: root + group: root + notify: Restart raven-subscribe + +- name: Raven-subscribe | Ensure config directory exists + ansible.builtin.file: + path: "{{ raven_subscribe_config_dir }}" + state: directory + owner: root + group: "{{ xray_group }}" + mode: "0750" + +- name: Raven-subscribe | Ensure data directory exists + ansible.builtin.file: + path: "{{ raven_subscribe_db_dir }}" + state: directory + owner: "{{ xray_user }}" + group: "{{ xray_group }}" + mode: "0750" + +- name: Raven-subscribe | Deploy config + ansible.builtin.template: + src: raven-subscribe/config.json.j2 + dest: "{{ raven_subscribe_config_dir }}/config.json" + owner: root + group: "{{ xray_group }}" + mode: "0640" + notify: Restart raven-subscribe + +- name: Raven-subscribe | Deploy systemd service + ansible.builtin.template: + src: raven-subscribe/xray-subscription.service.j2 + dest: "/etc/systemd/system/{{ raven_subscribe_service_name }}.service" + owner: root + group: root + mode: "0644" + when: ansible_facts.service_mgr == "systemd" + notify: + - Reload systemd + - Restart raven-subscribe + +- name: Raven-subscribe | Enable and start service + ansible.builtin.service: + name: "{{ raven_subscribe_service_name }}" + enabled: true + state: started + +- name: Raven-subscribe | Gather service facts + ansible.builtin.service_facts: + +- name: Raven-subscribe | Validate service is running + ansible.builtin.fail: + msg: "xray-subscription service is not running" + when: + - ansible_facts.services is defined + - ansible_facts.services[raven_subscribe_service_name + '.service'] is defined + - ansible_facts.services[raven_subscribe_service_name + '.service'].state != 'running' diff --git a/roles/xray/tasks/validate.yml b/roles/xray/tasks/validate.yml index d2d14ec..95943f3 100644 --- a/roles/xray/tasks/validate.yml +++ b/roles/xray/tasks/validate.yml @@ -3,14 +3,24 @@ ansible.builtin.assert: that: - xray_vless_decryption | default('none') == 'none' + or (xray_vless_decryption | default('none')).startswith('mlkem768x25519plus.') fail_msg: >- - xray_vless_decryption is set to an unsupported value: - "{{ xray_vless_decryption }}". - Xray 26.x only accepts "none" for VLESS inbound decryption. - PQ protection is provided by MLDSA65 in the REALITY handshake. - Set xray_vless_decryption: "none" in your secrets.yml. + xray_vless_decryption must be "none" or a valid VLESS Encryption string + starting with "mlkem768x25519plus.". + Generate with: xray vlessenc + See https://xtls.github.io/config/inbounds/vless.html success_msg: "xray_vless_decryption is valid" +- name: Xray | Validate xray_vless_client_encryption matches decryption mode + ansible.builtin.assert: + that: + - (xray_vless_decryption | default('none') == 'none') == (xray_vless_client_encryption | default('none') == 'none') + fail_msg: >- + xray_vless_decryption and xray_vless_client_encryption must be both "none" + or both set to VLESS Encryption strings (server and client keys differ). + Generate both with: xray vlessenc + success_msg: "xray_vless_client_encryption is consistent with decryption" + - name: Xray | Validate xray_users is defined and non-empty ansible.builtin.assert: that: diff --git a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 index 1ec0d8d..06bad6e 100644 --- a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 +++ b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 @@ -10,7 +10,7 @@ {% for user in xray_users %} { "id": "{{ user.id }}", - "flow": "{{ 'xtls-rprx-vision' if _pq else user.flow }}", + "flow": "{{ 'xtls-rprx-vision' if _pq else (user.flow | default(xray_vless_default_flow)) }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} @@ -32,14 +32,6 @@ {% endfor %} ], "privateKey": "{{ xray_reality.private_key }}", - {% set _seed = xray_reality.mldsa65_seed | default('') %} - {% if _seed %} - "mldsa65Seed": "{{ xray_reality.mldsa65_seed }}", - {% set _verify = xray_reality.mldsa65_verify | default('') %} - {% if _verify %} - "mldsa65Verify": "{{ xray_reality.mldsa65_verify }}", - {% endif %} - {% endif %} "shortIds": [ {% for short_id in xray_reality.short_id %} "{{ short_id }}"{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 index 76941f2..3190684 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -8,7 +8,7 @@ "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} {% for user in xray_users %} - {% set _flow = user.flow | default('') %} + {% set _flow = user.flow | default(xray_vless_default_flow) %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}", @@ -34,14 +34,6 @@ "dest": "{{ xray_reality_dest }}", "privateKey": "{{ xray_reality.private_key }}", "spiderX": "{{ xray_reality.spiderX }}", - {% set _seed = xray_reality.mldsa65_seed | default('') %} - {% if _seed %} - "mldsa65Seed": "{{ xray_reality.mldsa65_seed }}", - {% set _verify = xray_reality.mldsa65_verify | default('') %} - {% if _verify %} - "mldsa65Verify": "{{ xray_reality.mldsa65_verify }}", - {% endif %} - {% endif %} "serverNames": [ {% for name in xray_reality_server_names %} "{{ name }}"{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 index bc10fa0..aad54ec 100644 --- a/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 +++ b/roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 @@ -6,7 +6,7 @@ "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} {% for user in xray_users %} - {% set _flow = user.flow | default('') %} + {% set _flow = user.flow | default(xray_vless_default_flow) %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}", diff --git a/roles/xray/templates/conf/users/240-in-vless-users.json.j2 b/roles/xray/templates/conf/users/240-in-vless-users.json.j2 index 530d79c..e327dd2 100644 --- a/roles/xray/templates/conf/users/240-in-vless-users.json.j2 +++ b/roles/xray/templates/conf/users/240-in-vless-users.json.j2 @@ -6,7 +6,7 @@ "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} {% for user in xray_users %} - {% set _flow = user.flow | default('xtls-rprx-vision') %} + {% set _flow = user.flow | default(xray_vless_default_flow) %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}", diff --git a/roles/xray/templates/config.json.j2 b/roles/xray/templates/config.json.j2 deleted file mode 100644 index ce234ec..0000000 --- a/roles/xray/templates/config.json.j2 +++ /dev/null @@ -1,227 +0,0 @@ -{ - "log": { - "loglevel": "{{ xray_log_level }}", - "access": "{{ xray_access_log }}", - "error": "{{ xray_error_log }}", - "dnsLog": {{ xray_dns_log | to_json }} - }, - "dns": { - "servers": [ - {% for server in xray_dns_servers %} - "{{ server }}"{{ "," if not loop.last }} - {% endfor %} - ], - "disableFallback": {{ xray_dns_disable_fallback | to_json }}, - "queryStrategy": "{{ xray_dns_query_strategy }}" - }, - {% if xray_api.enable %} - "stats": {}, - "api": { - "tag": "{{ xray_api.tag }}", - "listen": "{{ xray_api.inbound.address }}:{{ xray_api.inbound.port }}", - "tag": "{{ xray_api.tag }}", - "services": [ - {% for service in xray_api.services %} - "{{ service }}"{{ "," if not loop.last }} - {% endfor %} - ] - }, - "policy": { - "levels": { - "0": { - "statsUserUplink": true, - "statsUserDownlink": true - } - }, - "system": { - "statsInboundUplink": true, - "statsInboundDownlink": true, - "statsOutboundUplink": true, - "statsOutboundDownlink": true - } - }, - {% endif %} - "inbounds": [ - {% if xray_api.enable %} - { - "listen": "{{ xray_api.inbound.address }}", - "port": {{ xray_api.inbound.port }}, - "protocol": "{{ xray_api.inbound.protocol }}", - "settings": { - "address": "{{ xray_api.inbound.address }}" - }, - "tag": "{{ xray_api.inbound.tag}}", - "sniffing": null - }, - {% endif %} - { - "port": {{ xray_vless_port }}, - "protocol": "vless", - "tag": "{{ xray_vless_tag }}", - "settings": { - "clients": [ - {% for user in xray_users %} - { - "id": "{{ user.id }}", - "flow": "{{ user.flow }}", - "email": "{{ user.email | default('') }}", - "level": 0 - }{{ "," if not loop.last }} - {% endfor %} - ], - "decryption": "none" - }, - "streamSettings": { - "network": "tcp", - "security": "reality", - "realitySettings": { - "show": false, - "dest": "{{ xray_reality_dest }}", - "spiderX": "{{ xray_reality.spiderX }}", - "xver": 0, - "serverNames": [ - {% for name in xray_reality_server_names %} - "{{ name }}"{{ "," if not loop.last }} - {% endfor %} - ], - "privateKey": "{{ xray_reality.private_key }}", - "shortIds": [ - {% for short_id in xray_reality.short_id %} - "{{ short_id }}"{{ "," if not loop.last }} - {% endfor %} - ] - } - }, - "sniffing": { - "enabled": true, - "destOverride": [ - "http", - "tls", - "quic" - ], - "routeOnly": true - } - }, - { - "port": 2053, - "protocol": "vless", - "settings": { - "clients": [ - {% for user in xray_users %} - { - "id": "{{ user.id }}", - "flow": "", - "email": "{{ user.email | default('') }}", - "level": 0 - }{{ "," if not loop.last }} - {% endfor %} - ], - "decryption": "none" - }, - "sniffing": { - "destOverride": [ - "http", - "tls", - "quic" - ], - "enabled": true - }, - "streamSettings": { - "network": "xhttp", - "realitySettings": { - "dest": "{{ xray_reality_dest }}", - "privateKey": "{{ xray_reality.private_key }}", - "serverNames": [ - {% for name in xray_reality_server_names %} - "{{ name }}"{{ "," if not loop.last }} - {% endfor %} - ], - "shortIds": [ - {% for short_id in xray_reality.short_id %} - "{{ short_id }}"{{ "," if not loop.last }} - {% endfor %} - ], - "show": false, - "xver": 0 - }, - "security": "reality", - "xhttpSettings": { - "mode": "{{ xray_xhttp.xhttpSettings.mode }}", - "path": "{{ xray_xhttp.xhttpSettings.path }}", - "scMaxPacketSize": "{{ xray_xhttp.xhttpSettings.scMaxPacketSize }}", - "xmux": { - "cids": [ - 1 - ], - "maxConcurrency": 16 - } - } - }, - "tag": "vless-xhttp-in" - } - ], - "outbounds": [ - { - "protocol": "freedom", - "settings": {}, - "tag": "freedom" - }, - { - "protocol": "blackhole", - "settings": {}, - "tag": "blocked" - } - ], - "routing": { - "domainStrategy": "IPIfNonMatch", - "rules": [ - {% if xray_api.enable %} - { - "type": "field", - "inboundTag": [ - "{{ xray_api.inbound.tag}}" - ], - "outboundTag": "{{ xray_api.tag }}" - }, - {% endif %} - { - "type": "field", - "inboundTag": [ - "{{ xray_vless_tag }}", - "{{ xray_xhttp.xray_xhttp_tag }}" - ], - "outboundTag": "freedom" - }, - { - "type": "field", - "domain": [ - "geosite:category-ads", - "geosite:category-public-tracker" - ], - "outboundTag": "blocked" - }, - { - "type": "field", - "domain": [ - {% for domain in xray_blocked_domains %} - "{{ domain }}"{{ "," if not loop.last }} - {% endfor %} - ], - "outboundTag": "blocked", - "settings": { - "response": { - "type": - "http" - } - } - }, - {% if xray_api.enable %} - { - "type": "field", - "inboundTag": ["${xray_api.inbound.tag}"], - "outboundTag": "api" - } - {% endif %} - ] - } -} \ No newline at end of file diff --git a/roles/xray/templates/raven-subscribe/config.json.j2 b/roles/xray/templates/raven-subscribe/config.json.j2 new file mode 100644 index 0000000..ee168e3 --- /dev/null +++ b/roles/xray/templates/raven-subscribe/config.json.j2 @@ -0,0 +1,17 @@ +{ + "listen_addr": "{{ raven_subscribe_listen_addr }}", + "server_host": "{{ raven_subscribe_server_host }}", + "config_dir": "{{ xray_config_dir }}config.d", + "db_path": "{{ raven_subscribe_db_dir }}/db.sqlite", + "sync_interval_seconds": {{ raven_subscribe_sync_interval_seconds }}, + "base_url": "{{ raven_subscribe_base_url }}", + "admin_token": "{{ raven_subscribe_admin_token }}", + "rate_limit_sub_per_min": {{ raven_subscribe_rate_limit_sub_per_min }}, + "rate_limit_admin_per_min": {{ raven_subscribe_rate_limit_admin_per_min }}, + "api_user_inbound_tag": "{{ raven_subscribe_api_inbound_tag }}", + "xray_api_addr": "{{ raven_subscribe_xray_api_addr }}"{% if xray_vless_client_encryption | default('none') != 'none' %}, + "vless_client_encryption": { + "{{ xray_common.inbound_tag.vless_reality_in }}": "{{ xray_vless_client_encryption }}", + "{{ xray_common.inbound_tag.vless_xhttp_in }}": "{{ xray_vless_client_encryption }}" + }{% endif %} +} diff --git a/roles/xray/templates/raven-subscribe/xray-subscription.service.j2 b/roles/xray/templates/raven-subscribe/xray-subscription.service.j2 new file mode 100644 index 0000000..2e6d80b --- /dev/null +++ b/roles/xray/templates/raven-subscribe/xray-subscription.service.j2 @@ -0,0 +1,23 @@ +[Unit] +Description=Xray Subscription Server (Raven-subscribe) +Documentation=https://github.com/AlchemyLink/Raven-subscribe +After=network.target {{ xray_service_name }}.service + +[Service] +Type=simple +User={{ xray_user }} +Group={{ xray_group }} +ExecStart={{ raven_subscribe_install_dir }}/xray-subscription -config {{ raven_subscribe_config_dir }}/config.json +Restart=on-failure +RestartSec=5s + +# Security hardening +NoNewPrivileges=yes +ProtectSystem=strict +ReadWritePaths={{ raven_subscribe_db_dir }} {{ xray_config_dir }}config.d +ReadOnlyPaths={{ raven_subscribe_config_dir }} +PrivateTmp=yes +ProtectHome=yes + +[Install] +WantedBy=multi-user.target From f5bf355d39fa31e6db8713dfacb448e7490d6459 Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 21 Mar 2026 01:42:53 +0300 Subject: [PATCH 11/12] update deploy for encrypt --- .github/workflows/xray-config-test.yml | 30 +++ docker/test/README.md | 93 +++++++ docker/test/docker-compose.3x-ui.yml | 26 ++ docker/test/docker-compose.yml | 26 ++ docker/test/mock-sub/index.html | 3 + docker/test/mock-sub/nginx.conf | 15 ++ .../test/mock-sub/sample-client-config.json | 19 ++ docker/test/mock-sub/sub.b64 | 1 + .../scripts/generate-mock-subscription.sh | 14 ++ docker/test/scripts/test-subscription.sh | 43 ++++ docker/test/xray-client/Dockerfile | 17 ++ docker/test/xray-client/config.example.json | 19 ++ docker/test/xray-client/entrypoint.sh | 38 +++ roles/xray/exampl/config.json.j2 | 227 ++++++++++++++++++ roles/xray/exampl/main.yml.bak | 192 +++++++++++++++ tests/README.md | 62 +++++ tests/ansible.cfg | 6 + tests/fixtures/README.md | 10 + tests/fixtures/render_overrides.yml | 10 + tests/fixtures/test_secrets.yml.example | 13 + tests/inventory/localhost.yml | 6 + tests/playbooks/render_conf.yml | 37 +++ tests/playbooks/validate_vars.yml | 12 + tests/run.sh | 56 +++++ tests/scripts/gen-reality-keys.sh | 42 ++++ 25 files changed, 1017 insertions(+) create mode 100644 .github/workflows/xray-config-test.yml create mode 100644 docker/test/README.md create mode 100644 docker/test/docker-compose.3x-ui.yml create mode 100644 docker/test/docker-compose.yml create mode 100644 docker/test/mock-sub/index.html create mode 100644 docker/test/mock-sub/nginx.conf create mode 100644 docker/test/mock-sub/sample-client-config.json create mode 100644 docker/test/mock-sub/sub.b64 create mode 100755 docker/test/scripts/generate-mock-subscription.sh create mode 100755 docker/test/scripts/test-subscription.sh create mode 100644 docker/test/xray-client/Dockerfile create mode 100644 docker/test/xray-client/config.example.json create mode 100644 docker/test/xray-client/entrypoint.sh create mode 100644 roles/xray/exampl/config.json.j2 create mode 100644 roles/xray/exampl/main.yml.bak create mode 100644 tests/README.md create mode 100644 tests/ansible.cfg create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/render_overrides.yml create mode 100644 tests/fixtures/test_secrets.yml.example create mode 100644 tests/inventory/localhost.yml create mode 100644 tests/playbooks/render_conf.yml create mode 100644 tests/playbooks/validate_vars.yml create mode 100755 tests/run.sh create mode 100755 tests/scripts/gen-reality-keys.sh diff --git a/.github/workflows/xray-config-test.yml b/.github/workflows/xray-config-test.yml new file mode 100644 index 0000000..f9b9294 --- /dev/null +++ b/.github/workflows/xray-config-test.yml @@ -0,0 +1,30 @@ +name: Xray config (Ansible + xray -test) + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Ansible + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq ansible unzip curl openssl + + - name: Install Xray (for -test) + run: | + curl -fsSL -o /tmp/xray.zip "https://github.com/XTLS/Xray-core/releases/download/v26.2.6/Xray-linux-64.zip" + sudo unzip -q -o /tmp/xray.zip xray -d /usr/local/bin + sudo chmod +x /usr/local/bin/xray + + - name: Run tests + working-directory: ${{ github.workspace }} + run: | + chmod +x tests/run.sh tests/scripts/gen-reality-keys.sh + SKIP_XRAY_TEST=0 ./tests/run.sh diff --git a/docker/test/README.md b/docker/test/README.md new file mode 100644 index 0000000..26498f2 --- /dev/null +++ b/docker/test/README.md @@ -0,0 +1,93 @@ +# Docker: тест подписок и Xray-клиента + +Стек для проверки **HTTP-подписки** (base64 → JSON или share links) и **Xray в Docker** (клиент с SOCKS). + +## Быстрый тест (mock подписка + клиент) + +```bash +cd docker/test +docker compose up -d --build +``` + +- Мок подписки: `http://127.0.0.1:18088/sub` — отдаёт base64 от `mock-sub/sample-client-config.json` (минимальный JSON: SOCKS 1080 → direct). +- Клиент Xray: SOCKS на хосте **`127.0.0.1:11080`** (контейнер `xray-client` подтягивает ту же подписку и запускает Xray). + +Проверка подписки (ручной прогон скрипта в одноразовом контейнере): + +```bash +docker compose run --rm -v "$(pwd)/scripts:/scripts:ro" alpine:3.20 \ + sh -c "apk add --no-cache curl jq bash >/dev/null && chmod +x /scripts/test-subscription.sh && /scripts/test-subscription.sh http://subscription-mock/sub" +``` + +Проверка SOCKS: + +```bash +curl -x socks5h://127.0.0.1:11080 -sI --max-time 15 https://example.com +``` + +Остановка: + +```bash +docker compose down +``` + +## Панель 3x-ui (Xray + БД + подписки в UI) + +Отдельный compose — веб-панель, встроенный Xray и SQLite: + +```bash +cd docker/test +docker compose -f docker-compose.3x-ui.yml up -d +``` + +Откройте в браузере **`http://127.0.0.1:2053/`** (если порт занят — смотрите логи контейнера и документацию [3x-ui](https://github.com/MHSanaei/3x-ui)). + +1. Войдите (часто `admin` / `admin` — **сразу смените пароль**). +2. Создайте inbound (VLESS и т.д.). +3. Скопируйте **ссылку подписки** для клиента. + +### Почему `import_sub` / `premature_eof`? + +Типичные причины: + +- URL подписки открывается не полностью (обрыв по таймауту, TLS, прокси). +- Подписка не base64 или не тот формат (ожидается то, что отдаёт панель). +- Блокировка или редирект без тела ответа. + +Проверьте с хоста: + +```bash +curl -vS --max-time 60 'https://YOUR_PANEL/sub/YOUR_TOKEN' +``` + +Должен прийти **текст** (часто одна строка base64). Декод: + +```bash +curl -fsS 'URL' | base64 -d | head -c 200 +``` + +### Клиент в Docker и подписка 3x-ui + +Образ `xray-client` умеет брать **только JSON** из base64 (как в моке). У **3x-ui** подписка часто — **набор `vless://...` строк**, а не готовый `config.json`; такой формат нужно импортировать в приложении (v2rayN, Nekobox и т.д.) или собрать конфиг вручную. + +Для проверки только «подписка отдаётся и декодится» используйте `test-subscription.sh` с URL от панели. + +## Перегенерация мока `sub.b64` + +После правки `mock-sub/sample-client-config.json`: + +```bash +./scripts/generate-mock-subscription.sh +``` + +## Переменные + +| Переменная | Описание | +|-------------------|----------| +| `SUBSCRIPTION_URL` | URL для `xray-client` (по умолчанию `http://subscription-mock/sub`) | + +Пример с внешней подпиской (если отдаёт **JSON в base64**): + +```bash +SUBSCRIPTION_URL='https://example.com/sub/xxx' docker compose up -d --build xray-client +``` diff --git a/docker/test/docker-compose.3x-ui.yml b/docker/test/docker-compose.3x-ui.yml new file mode 100644 index 0000000..f56abe6 --- /dev/null +++ b/docker/test/docker-compose.3x-ui.yml @@ -0,0 +1,26 @@ +# 3x-ui: Xray + SQLite + web panel (subscription links in UI). +# Panel default path/port depend on image version — check container logs after start. +# +# cd docker/test && docker compose -f docker-compose.3x-ui.yml up -d +# # Open http://127.0.0.1:2053/ (or see README), login admin/admin, change password, +# # add inbound, copy subscription URL for clients. + +services: + x-ui: + image: ghcr.io/mhsanaei/3x-ui:latest + container_name: raven-3x-ui + restart: unless-stopped + environment: + - XRAY_VMESS_AEAD_FORCED=false + volumes: + - xui_db:/etc/x-ui + - xui_cert:/root/cert + ports: + - "2053:2053" + # Map additional ports for Xray inbounds after you create them in the panel + # - "443:443" + # - "8443:8443" + +volumes: + xui_db: + xui_cert: diff --git a/docker/test/docker-compose.yml b/docker/test/docker-compose.yml new file mode 100644 index 0000000..f488fa2 --- /dev/null +++ b/docker/test/docker-compose.yml @@ -0,0 +1,26 @@ +# Docker test stack: mock subscription HTTP + subscription checker + optional Xray client. +# Usage: +# cd docker/test && docker compose up -d --build +# docker compose run --rm subtest ./scripts/test-subscription.sh http://subscription-mock/sub +# curl -x socks5h://127.0.0.1:11080 -sI https://example.com # after xray-client up + +services: + subscription-mock: + image: nginx:alpine + container_name: raven-subscription-mock + volumes: + - ./mock-sub/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./mock-sub:/usr/share/nginx/html:ro + ports: + - "18088:80" + + xray-client: + build: ./xray-client + container_name: raven-xray-client + environment: + # JSON base64 subscription (same mock as /sub): works with entrypoint. + SUBSCRIPTION_URL: ${SUBSCRIPTION_URL:-http://subscription-mock/sub} + ports: + - "11080:1080" + depends_on: + - subscription-mock diff --git a/docker/test/mock-sub/index.html b/docker/test/mock-sub/index.html new file mode 100644 index 0000000..1fbbbfc --- /dev/null +++ b/docker/test/mock-sub/index.html @@ -0,0 +1,3 @@ + +Subscription mock +

Mock subscription server. Use /sub for base64 payload.

diff --git a/docker/test/mock-sub/nginx.conf b/docker/test/mock-sub/nginx.conf new file mode 100644 index 0000000..00171b2 --- /dev/null +++ b/docker/test/mock-sub/nginx.conf @@ -0,0 +1,15 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + default_type text/plain; + location / { + try_files $uri $uri/ =404; + } + # GET /sub — body = base64 (one line), same idea as panel subscription URL + location = /sub { + default_type text/plain; + add_header Cache-Control "no-store"; + alias /usr/share/nginx/html/sub.b64; + } +} diff --git a/docker/test/mock-sub/sample-client-config.json b/docker/test/mock-sub/sample-client-config.json new file mode 100644 index 0000000..6ccd754 --- /dev/null +++ b/docker/test/mock-sub/sample-client-config.json @@ -0,0 +1,19 @@ +{ + "log": { "loglevel": "warning" }, + "inbounds": [ + { + "tag": "socks-in", + "port": 1080, + "listen": "0.0.0.0", + "protocol": "socks", + "settings": { "udp": true } + } + ], + "outbounds": [ + { + "tag": "direct", + "protocol": "freedom", + "settings": {} + } + ] +} diff --git a/docker/test/mock-sub/sub.b64 b/docker/test/mock-sub/sub.b64 new file mode 100644 index 0000000..c78aba7 --- /dev/null +++ b/docker/test/mock-sub/sub.b64 @@ -0,0 +1 @@ +ewogICJsb2ciOiB7ICJsb2dsZXZlbCI6ICJ3YXJuaW5nIiB9LAogICJpbmJvdW5kcyI6IFsKICAgIHsKICAgICAgInRhZyI6ICJzb2Nrcy1pbiIsCiAgICAgICJwb3J0IjogMTA4MCwKICAgICAgImxpc3RlbiI6ICIwLjAuMC4wIiwKICAgICAgInByb3RvY29sIjogInNvY2tzIiwKICAgICAgInNldHRpbmdzIjogeyAidWRwIjogdHJ1ZSB9CiAgICB9CiAgXSwKICAib3V0Ym91bmRzIjogWwogICAgewogICAgICAidGFnIjogImRpcmVjdCIsCiAgICAgICJwcm90b2NvbCI6ICJmcmVlZG9tIiwKICAgICAgInNldHRpbmdzIjoge30KICAgIH0KICBdCn0K \ No newline at end of file diff --git a/docker/test/scripts/generate-mock-subscription.sh b/docker/test/scripts/generate-mock-subscription.sh new file mode 100755 index 0000000..d52a967 --- /dev/null +++ b/docker/test/scripts/generate-mock-subscription.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Generates mock-sub/sub.b64 from a minimal Xray JSON (for nginx /sub endpoint). +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +JSON="$ROOT/mock-sub/sample-client-config.json" +OUT="$ROOT/mock-sub/sub.b64" + +if [[ ! -f "$JSON" ]]; then + echo "Missing $JSON" + exit 1 +fi + +base64 -w0 "$JSON" > "$OUT" 2>/dev/null || base64 "$JSON" | tr -d '\n' > "$OUT" +echo "Wrote $OUT ($(wc -c < "$OUT") bytes base64)" diff --git a/docker/test/scripts/test-subscription.sh b/docker/test/scripts/test-subscription.sh new file mode 100755 index 0000000..6a7a65c --- /dev/null +++ b/docker/test/scripts/test-subscription.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Test subscription URL: HTTP 200, body decodes from base64, optional JSON or vless links. +set -eo pipefail + +SUB_URL="${1:-http://subscription-mock:80/sub}" +echo "==> Fetching: $SUB_URL" + +RAW="$(curl -fsS --connect-timeout 10 --max-time 60 "$SUB_URL")" || { + echo "ERROR: curl failed (TLS/firewall/DNS?)" + exit 1 +} + +echo "==> Body length: ${#RAW} bytes" + +# Trim whitespace +TRIM="$(echo "$RAW" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + +decode_b64() { + echo "$1" | base64 -d 2>/dev/null || echo "$1" | base64 --decode 2>/dev/null +} + +DECODED="$(decode_b64 "$TRIM" 2>/dev/null || true)" +if [[ -z "$DECODED" || "$DECODED" == "$TRIM" ]]; then + echo "WARN: Treating body as plain text (not base64 or decode failed)" + DECODED="$TRIM" +fi + +if echo "$DECODED" | jq -e . >/dev/null 2>&1; then + echo "==> Valid JSON (jq ok)" + echo "$DECODED" | jq -c '{has_outbounds: (.outbounds != null), has_inbounds: (.inbounds != null)}' 2>/dev/null || true + exit 0 +fi + +if echo "$DECODED" | grep -qE '^vless://|^vmess://|^trojan://|^ss://'; then + N="$(echo "$DECODED" | grep -cE '^vless://|^vmess://|^trojan://|^ss://' || true)" + echo "==> Subscription contains $N share link(s) (vless/vmess/trojan/ss)" + exit 0 +fi + +echo "ERROR: Decoded payload is neither JSON nor known share links" +echo "---- first 200 chars ----" +echo "${DECODED:0:200}" +exit 1 diff --git a/docker/test/xray-client/Dockerfile b/docker/test/xray-client/Dockerfile new file mode 100644 index 0000000..c6d8471 --- /dev/null +++ b/docker/test/xray-client/Dockerfile @@ -0,0 +1,17 @@ +# Minimal Xray client (SOCKS). Config mounted or generated from subscription URL. +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates curl jq unzip wget + +ARG XRAY_VERSION=26.2.6 +RUN wget -q -O /tmp/xray.zip "https://github.com/XTLS/Xray-core/releases/download/v${XRAY_VERSION}/Xray-linux-64.zip" \ + && unzip -q /tmp/xray.zip -d /usr/local/bin xray \ + && chmod +x /usr/local/bin/xray \ + && rm /tmp/xray.zip + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 1080 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/test/xray-client/config.example.json b/docker/test/xray-client/config.example.json new file mode 100644 index 0000000..6ccd754 --- /dev/null +++ b/docker/test/xray-client/config.example.json @@ -0,0 +1,19 @@ +{ + "log": { "loglevel": "warning" }, + "inbounds": [ + { + "tag": "socks-in", + "port": 1080, + "listen": "0.0.0.0", + "protocol": "socks", + "settings": { "udp": true } + } + ], + "outbounds": [ + { + "tag": "direct", + "protocol": "freedom", + "settings": {} + } + ] +} diff --git a/docker/test/xray-client/entrypoint.sh b/docker/test/xray-client/entrypoint.sh new file mode 100644 index 0000000..ad2de7f --- /dev/null +++ b/docker/test/xray-client/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +set -eu + +CONFIG="${XRAY_CONFIG:-/etc/xray/config.json}" + +if [ -n "${SUBSCRIPTION_URL:-}" ]; then + echo "==> Fetching subscription: $SUBSCRIPTION_URL" + RAW="$(curl -fsS --connect-timeout 15 --max-time 120 "$SUBSCRIPTION_URL")" + TRIM="$(echo "$RAW" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + DECODED="$(printf '%s' "$TRIM" | base64 -d 2>/dev/null || true)" + if [ -z "$DECODED" ]; then + echo "ERROR: failed to base64-decode subscription body" + exit 1 + fi + if echo "$DECODED" | jq -e . >/dev/null 2>&1; then + echo "$DECODED" > /tmp/config-from-sub.json + CONFIG=/tmp/config-from-sub.json + echo "==> Using JSON config from subscription ($(wc -c < "$CONFIG") bytes)" + else + echo "ERROR: Subscription is not a JSON Xray config (3x-ui often returns vless:// links)." + echo "Use a client app to import, or mount a static config.json." + echo "---- first 120 chars ----" + printf '%s' "$DECODED" | head -c 120 + echo + exit 1 + fi +fi + +if [ ! -f "$CONFIG" ]; then + echo "ERROR: No config at $CONFIG (set SUBSCRIPTION_URL for JSON base64 or mount config)" + exit 1 +fi + +echo "==> xray -test" +/usr/local/bin/xray -test -c "$CONFIG" + +echo "==> xray run" +exec /usr/local/bin/xray run -c "$CONFIG" diff --git a/roles/xray/exampl/config.json.j2 b/roles/xray/exampl/config.json.j2 new file mode 100644 index 0000000..ce234ec --- /dev/null +++ b/roles/xray/exampl/config.json.j2 @@ -0,0 +1,227 @@ +{ + "log": { + "loglevel": "{{ xray_log_level }}", + "access": "{{ xray_access_log }}", + "error": "{{ xray_error_log }}", + "dnsLog": {{ xray_dns_log | to_json }} + }, + "dns": { + "servers": [ + {% for server in xray_dns_servers %} + "{{ server }}"{{ "," if not loop.last }} + {% endfor %} + ], + "disableFallback": {{ xray_dns_disable_fallback | to_json }}, + "queryStrategy": "{{ xray_dns_query_strategy }}" + }, + {% if xray_api.enable %} + "stats": {}, + "api": { + "tag": "{{ xray_api.tag }}", + "listen": "{{ xray_api.inbound.address }}:{{ xray_api.inbound.port }}", + "tag": "{{ xray_api.tag }}", + "services": [ + {% for service in xray_api.services %} + "{{ service }}"{{ "," if not loop.last }} + {% endfor %} + ] + }, + "policy": { + "levels": { + "0": { + "statsUserUplink": true, + "statsUserDownlink": true + } + }, + "system": { + "statsInboundUplink": true, + "statsInboundDownlink": true, + "statsOutboundUplink": true, + "statsOutboundDownlink": true + } + }, + {% endif %} + "inbounds": [ + {% if xray_api.enable %} + { + "listen": "{{ xray_api.inbound.address }}", + "port": {{ xray_api.inbound.port }}, + "protocol": "{{ xray_api.inbound.protocol }}", + "settings": { + "address": "{{ xray_api.inbound.address }}" + }, + "tag": "{{ xray_api.inbound.tag}}", + "sniffing": null + }, + {% endif %} + { + "port": {{ xray_vless_port }}, + "protocol": "vless", + "tag": "{{ xray_vless_tag }}", + "settings": { + "clients": [ + {% for user in xray_users %} + { + "id": "{{ user.id }}", + "flow": "{{ user.flow }}", + "email": "{{ user.email | default('') }}", + "level": 0 + }{{ "," if not loop.last }} + {% endfor %} + ], + "decryption": "none" + }, + "streamSettings": { + "network": "tcp", + "security": "reality", + "realitySettings": { + "show": false, + "dest": "{{ xray_reality_dest }}", + "spiderX": "{{ xray_reality.spiderX }}", + "xver": 0, + "serverNames": [ + {% for name in xray_reality_server_names %} + "{{ name }}"{{ "," if not loop.last }} + {% endfor %} + ], + "privateKey": "{{ xray_reality.private_key }}", + "shortIds": [ + {% for short_id in xray_reality.short_id %} + "{{ short_id }}"{{ "," if not loop.last }} + {% endfor %} + ] + } + }, + "sniffing": { + "enabled": true, + "destOverride": [ + "http", + "tls", + "quic" + ], + "routeOnly": true + } + }, + { + "port": 2053, + "protocol": "vless", + "settings": { + "clients": [ + {% for user in xray_users %} + { + "id": "{{ user.id }}", + "flow": "", + "email": "{{ user.email | default('') }}", + "level": 0 + }{{ "," if not loop.last }} + {% endfor %} + ], + "decryption": "none" + }, + "sniffing": { + "destOverride": [ + "http", + "tls", + "quic" + ], + "enabled": true + }, + "streamSettings": { + "network": "xhttp", + "realitySettings": { + "dest": "{{ xray_reality_dest }}", + "privateKey": "{{ xray_reality.private_key }}", + "serverNames": [ + {% for name in xray_reality_server_names %} + "{{ name }}"{{ "," if not loop.last }} + {% endfor %} + ], + "shortIds": [ + {% for short_id in xray_reality.short_id %} + "{{ short_id }}"{{ "," if not loop.last }} + {% endfor %} + ], + "show": false, + "xver": 0 + }, + "security": "reality", + "xhttpSettings": { + "mode": "{{ xray_xhttp.xhttpSettings.mode }}", + "path": "{{ xray_xhttp.xhttpSettings.path }}", + "scMaxPacketSize": "{{ xray_xhttp.xhttpSettings.scMaxPacketSize }}", + "xmux": { + "cids": [ + 1 + ], + "maxConcurrency": 16 + } + } + }, + "tag": "vless-xhttp-in" + } + ], + "outbounds": [ + { + "protocol": "freedom", + "settings": {}, + "tag": "freedom" + }, + { + "protocol": "blackhole", + "settings": {}, + "tag": "blocked" + } + ], + "routing": { + "domainStrategy": "IPIfNonMatch", + "rules": [ + {% if xray_api.enable %} + { + "type": "field", + "inboundTag": [ + "{{ xray_api.inbound.tag}}" + ], + "outboundTag": "{{ xray_api.tag }}" + }, + {% endif %} + { + "type": "field", + "inboundTag": [ + "{{ xray_vless_tag }}", + "{{ xray_xhttp.xray_xhttp_tag }}" + ], + "outboundTag": "freedom" + }, + { + "type": "field", + "domain": [ + "geosite:category-ads", + "geosite:category-public-tracker" + ], + "outboundTag": "blocked" + }, + { + "type": "field", + "domain": [ + {% for domain in xray_blocked_domains %} + "{{ domain }}"{{ "," if not loop.last }} + {% endfor %} + ], + "outboundTag": "blocked", + "settings": { + "response": { + "type": + "http" + } + } + }, + {% if xray_api.enable %} + { + "type": "field", + "inboundTag": ["${xray_api.inbound.tag}"], + "outboundTag": "api" + } + {% endif %} + ] + } +} \ No newline at end of file diff --git a/roles/xray/exampl/main.yml.bak b/roles/xray/exampl/main.yml.bak new file mode 100644 index 0000000..afb6566 --- /dev/null +++ b/roles/xray/exampl/main.yml.bak @@ -0,0 +1,192 @@ +Л# File: roles/xray/tasks/main.yml + +--- +- name: "Ensure the {{ xray_group }} group exists" + ansible.builtin.group: + name: "{{ xray_group }}" + state: present + system: true + +- name: Set nologin shell path based on OS family + ansible.builtin.set_fact: + nologin_shell: | + {% if ansible_facts['os_family'] == 'Alpine' %} + /sbin/nologin + {% else %} + /usr/sbin/nologin + {% endif %} + +- name: Create a dedicated system user for Xray ({{ xray_user }}) + ansible.builtin.user: + name: "{{ xray_user }}" + state: present + system: true + shell: "{{ xray_nologin_shell }}" + group: "{{ xray_group }}" + +- name: Ensure Xray log directory exists and has correct permissions + ansible.builtin.file: + path: "{{ xray_log_dir }}" + state: directory + owner: "{{ xray_user }}" + group: "{{ xray_group }}" + mode: '0755' + recurse: true + +- name: Ensure Xray configuration directory exists and has correct permissions + ansible.builtin.file: + path: "{{ xray_config_dir }}" + state: directory + owner: "{{ xray_user }}" + group: "{{ xray_group }}" + mode: '0755' + recurse: true + +- name: "Correct ownership for Xray executable (if needed)" + ansible.builtin.file: + path: "{{ xray_bin_dir }}/xray" + owner: "{{ xray_user }}" + group: "{{ xray_group }}" + mode: '0755' + ignore_errors: true + when: xray_bin_dir is defined and xray_bin_dir != '' + +- name: Correct permissions for Xray service files (if necessary) + ansible.builtin.file: + path: "{{ xray_install_dir }}" + owner: "{{ xray_user }}" + group: "{{ xray_group }}" + +- name: Ensure Xray install directory exists + ansible.builtin.file: + path: "{{ xray_install_dir }}" + state: directory + mode: '0755' + +- name: Ensure Xray config directory exists + ansible.builtin.file: + path: "{{ xray_config_dir }}" + state: directory + mode: '0755' + +- name: Get latest Xray release version + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ xray_github_repo }}/releases/latest" + method: GET + return_content: true + headers: + Accept: "application/vnd.github.v3+json" + register: xray_release_info + run_once: true + +- name: Set Xray version fact + ansible.builtin.set_fact: + xray_version: "{{ xray_release_info.json.tag_name }}" + ansible_version_full: "{{ ansible_version.full }}" + +- name: Download Xray using get_url (Ansible >= 2.16.0) + block: + - name: Download Xray archive (Ansible >= 2.16.0) + ansible.builtin.get_url: + url: "{{ xray_download_url }}" + dest: "/tmp/Xray-{{ xray_version }}-linux-64.zip" + mode: '0644' + register: download_xray + - name: Unarchive Xray + ansible.builtin.unarchive: + src: "{{ download_xray.dest }}" + dest: "{{ xray_install_dir }}" + remote_src: true + when: ansible_version_full is version('2.16.0', '>=') + notify: "Restart xray service" + +- name: Download Xray using curl (Ansible < 2.16.0) + block: + - name: Download Xray archive (Ansible < 2.16.0) + ansible.builtin.command: > + curl -L --output /tmp/Xray-{{ xray_version }}-linux-64.zip "{{ xray_download_url }}" + args: + creates: "/tmp/Xray-{{ xray_version }}-linux-64.zip" + register: download_xray + changed_when: download_xray.rc == 0 and not "already exists" in download_xray.stdout + - name: Unarchive Xray + ansible.builtin.unarchive: + src: "/tmp/Xray-{{ xray_version }}-linux-64.zip" + dest: "{{ xray_install_dir }}" + remote_src: true + when: ansible_version_full is version('2.16.0', '<') + notify: "Restart xray service" + +- name: Create symlink for Xray executable + ansible.builtin.file: + src: "{{ xray_install_dir }}/xray" + dest: /usr/local/bin/xray + state: link + force: true + +- name: Deploy Xray systemd service file + ansible.builtin.template: + src: xray.service.j2 + dest: /etc/systemd/system/{{ xray_service_name }}.service + mode: '0644' + when: ansible_facts['service_mgr'] == "systemd" + notify: + - Reload systemd + - Restart xray + +- name: Deploy Xray OpenRC service file + ansible.builtin.template: + src: xray.openrc.j2 + dest: /etc/init.d/{{ xray_service_name }} + mode: '0755' + when: ansible_facts['service_mgr'] == "openrc" + notify: + - Reload openrc + - Restart xray + +- name: Generate Xray configuration + ansible.builtin.template: + src: config.json.j2 + dest: "{{ xray_config_dir }}/config.json" + mode: '0644' + notify: "Restart xray service" + tags: + - xray_config + +- name: Ensure Xray service is started and enabled (systemd) + ansible.builtin.systemd_service: + name: "{{ xray_service_name }}" + state: started + enabled: true + register: xray_service_status_systemd + when: ansible_facts['service_mgr'] == "systemd" + tags: + - xray_config + +- name: Ensure Xray service is started and enabled (OpenRC) + ansible.builtin.service: + name: "{{ xray_service_name }}" + state: started + enabled: true + register: xray_service_status_openrc + when: ansible_facts['service_mgr'] == "openrc" + tags: + - xray_config + +- name: Fail if Xray service is not running (systemd) + ansible.builtin.fail: + msg: "Xray service failed to start! Check logs with 'journalctl -u {{ xray_service_name }}'" + when: + - ansible_facts['service_mgr'] == "systemd" + - not xray_service_status_systemd.status.ActiveState == "active" + tags: + - xray_config + +- name: Fail if Xray service is not running (OpenRC) + ansible.builtin.fail: + msg: "Xray service failed to start! Check logs with 'rc-service {{ xray_service_name }} status' or '/var/log/messages'" + when: + - ansible_facts['service_mgr'] == "openrc" + - not xray_service_status_openrc.status.active + tags: + - xray_config diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..ff424f4 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,62 @@ +# Тесты конфигурации Xray (роль Ansible) + +Проверяют **тот же сервис**, что и прод: переменные из `roles/xray/defaults/main.yml` + секреты, задачи **`validate.yml`**, рендер **`templates/conf/*.j2`** в каталог как на сервере (`conf.d`), затем **`xray -test -confdir`** в Docker. + +## Требования + +- `ansible-playbook` (ansible-core) +- `curl`, `unzip`, `openssl` (для `gen-reality-keys.sh`) +- Docker (для шага `xray -test`); без Docker: `SKIP_XRAY_TEST=1 ./tests/run.sh` — только Ansible + +## Запуск + +Из корня репозитория: + +```bash +chmod +x tests/run.sh tests/scripts/gen-reality-keys.sh +./tests/run.sh +``` + +Скрипт: + +1. Скачивает Xray (кэш в `tests/.cache/`), выполняет `xray x25519`, пишет `tests/fixtures/test_secrets.yml` (в `.gitignore`). +2. Запускает `playbooks/validate_vars.yml` — импорт `roles/xray/tasks/validate.yml`. +3. Рендерит все шаблоны `roles/xray/templates/conf/*/*.j2` в `tests/.output/conf.d/`. +4. Собирает образ из `docker/test/xray-client` (если ещё нет) и выполняет `xray -test -confdir /etc/xray/config.d`. + +Только Ansible (без Docker): + +```bash +SKIP_XRAY_TEST=1 ./tests/run.sh +``` + +Отдельные шаги: + +```bash +export ANSIBLE_CONFIG="${PWD}/tests/ansible.cfg" +tests/scripts/gen-reality-keys.sh > tests/fixtures/test_secrets.yml +ansible-playbook tests/playbooks/validate_vars.yml +export RAVEN_TEST_CONF_DIR="${PWD}/tests/.output/conf.d" +mkdir -p "$RAVEN_TEST_CONF_DIR" +ansible-playbook tests/playbooks/render_conf.yml +``` + +## Структура + +| Путь | Назначение | +|------|------------| +| `playbooks/validate_vars.yml` | Проверки из роли | +| `playbooks/render_conf.yml` | Рендер `conf.d` | +| `fixtures/test_secrets.yml.example` | Пример секретов | +| `fixtures/render_overrides.yml` | Логи в `/tmp/...`, чтобы `xray -test` не требовал `/var/log/Xray` | +| `scripts/gen-reality-keys.sh` | Генерация валидных ключей Reality (`xray x25519`) | + +Рендер **не включает** `templates/conf/users/*.j2` — в роли `users.yml` по умолчанию отключён; фрагменты users только дополняют inbounds по tag вместе с основными файлами, отдельно для `-test` дают ошибку «no Port set». + +## CI + +Workflow `.github/workflows/xray-config-test.yml` — Ansible + установка Xray на runner + `./tests/run.sh`. + +## CI + +Пример (GitHub Actions): установить Docker и Ansible, выполнить `./tests/run.sh`. diff --git a/tests/ansible.cfg b/tests/ansible.cfg new file mode 100644 index 0000000..d38820e --- /dev/null +++ b/tests/ansible.cfg @@ -0,0 +1,6 @@ +[defaults] +inventory = inventory/localhost.yml +roles_path = ../roles +host_key_checking = False +retry_files_enabled = False +# ansible-core 2.13+: use default callback with result_format=yaml if needed diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..0862c06 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,10 @@ +# Фикстуры + +- **`test_secrets.yml`** — создаётся скриптом `tests/scripts/gen-reality-keys.sh` или полным прогоном `tests/run.sh`. В `.gitignore`, не коммитить. + +Для ручного запуска без генерации скопируйте пример и подставьте ключи из `xray x25519`: + +```bash +cp tests/fixtures/test_secrets.yml.example tests/fixtures/test_secrets.yml +# отредактируйте private_key / short_id / users +``` diff --git a/tests/fixtures/render_overrides.yml b/tests/fixtures/render_overrides.yml new file mode 100644 index 0000000..c5a997d --- /dev/null +++ b/tests/fixtures/render_overrides.yml @@ -0,0 +1,10 @@ +# Переопределения только для рендера в tests/ (пути логов в /tmp). +--- +xray_log_config: + service_rotate_name: logrotate.service + log_level: "warning" + log_path: "/tmp/raven-xray-test-logs" + access_log: "access.log" + error_log: "error.log" + logrotate_config: "/etc/logrotate.d/xray" + dns_log: false diff --git a/tests/fixtures/test_secrets.yml.example b/tests/fixtures/test_secrets.yml.example new file mode 100644 index 0000000..fa7993f --- /dev/null +++ b/tests/fixtures/test_secrets.yml.example @@ -0,0 +1,13 @@ +# Пример: скопируйте в test_secrets.yml и задайте ключи (`xray x25519`). +--- +xray_reality: + private_key: "REPLACE_WITH_xray_x25519_PrivateKey" + public_key: "REPLACE_WITH_xray_x25519_Password_PublicKey" + spiderX: "/" + short_id: + - "a1b2c3d4e5f67890" + +xray_users: + - id: "11111111-2222-3333-4444-555555555555" + flow: "xtls-rprx-vision" + email: "test@raven.local" diff --git a/tests/inventory/localhost.yml b/tests/inventory/localhost.yml new file mode 100644 index 0000000..3ade526 --- /dev/null +++ b/tests/inventory/localhost.yml @@ -0,0 +1,6 @@ +--- +all: + hosts: + localhost: + ansible_connection: local + ansible_python_interpreter: auto_silent diff --git a/tests/playbooks/render_conf.yml b/tests/playbooks/render_conf.yml new file mode 100644 index 0000000..4ff4b81 --- /dev/null +++ b/tests/playbooks/render_conf.yml @@ -0,0 +1,37 @@ +--- +# Рендерит templates/conf/*.j2 роли xray в test_conf_dir (как на сервере: conf.d). +- name: Render Raven xray conf.d templates + hosts: localhost + connection: local + gather_facts: true + vars_files: + - ../../roles/xray/defaults/main.yml + - ../fixtures/test_secrets.yml + - ../fixtures/render_overrides.yml + vars: + role_path: "{{ playbook_dir }}/../../roles/xray" + test_conf_dir: "{{ lookup('env', 'RAVEN_TEST_CONF_DIR') | default(playbook_dir + '/../.output/conf.d', true) }}" + tasks: + - name: Ensure output directory + ansible.builtin.file: + path: "{{ test_conf_dir }}" + state: directory + mode: "0755" + + - name: Discover conf.d Jinja templates + ansible.builtin.find: + paths: "{{ role_path }}/templates/conf" + patterns: "*.j2" + recurse: true + register: xray_conf_templates + + # Фрагменты conf/users/ — дополнения к inbounds по tag; без основного inbound из inbounds/ + # дают «no Port set». В роли tasks users.yml по умолчанию отключён — тестируем как прод. + - name: Template conf.d fragments (excluding conf/users) + ansible.builtin.template: + src: "{{ item.path }}" + dest: "{{ test_conf_dir }}/{{ item.path | basename | regex_replace('\\.j2$', '') }}" + mode: "0644" + loop: "{{ xray_conf_templates.files | rejectattr('path', 'contains', '/users/') | list }}" + loop_control: + label: "{{ item.path | basename }}" diff --git a/tests/playbooks/validate_vars.yml b/tests/playbooks/validate_vars.yml new file mode 100644 index 0000000..c3e5747 --- /dev/null +++ b/tests/playbooks/validate_vars.yml @@ -0,0 +1,12 @@ +--- +# Импорт проверок из роли (те же assert, что перед деплоем). +- name: Run role validate tasks + hosts: localhost + connection: local + gather_facts: true + vars_files: + - ../../roles/xray/defaults/main.yml + - ../fixtures/test_secrets.yml + tasks: + - name: Import xray validate.yml + ansible.builtin.import_tasks: ../../roles/xray/tasks/validate.yml diff --git a/tests/run.sh b/tests/run.sh new file mode 100755 index 0000000..be4588c --- /dev/null +++ b/tests/run.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env sh +# Полный тест: секреты → validate → рендер → xray -test (Docker). +set -eu +ROOT="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$ROOT/.." && pwd)" +export ANSIBLE_CONFIG="${ROOT}/ansible.cfg" +cd "$ROOT" +# ansible-playbook ищет роли относительно tests/ansible.cfg (roles_path = ../roles) + +echo "==> [1/4] Generate test_secrets.yml (x25519)" +"$ROOT/scripts/gen-reality-keys.sh" > "$ROOT/fixtures/test_secrets.yml" + +echo "==> [2/4] ansible-playbook validate_vars.yml" +ansible-playbook "$ROOT/playbooks/validate_vars.yml" + +OUT="$ROOT/.output/conf.d" +rm -rf "$ROOT/.output" +mkdir -p "$OUT" +export RAVEN_TEST_CONF_DIR="$OUT" + +echo "==> [3/4] ansible-playbook render_conf.yml" +ansible-playbook "$ROOT/playbooks/render_conf.yml" + +echo "==> [4/4] xray -test" +if [ "${SKIP_XRAY_TEST:-}" = "1" ]; then + echo "SKIP_XRAY_TEST=1 — пропуск xray -test." + exit 0 +fi + +run_xray_test() { + _dir="$1" + mkdir -p /tmp/raven-xray-test-logs + if command -v xray >/dev/null 2>&1; then + echo "Using host binary: $(command -v xray)" + xray -test -confdir "$_dir" + return 0 + fi + if ! command -v docker >/dev/null 2>&1; then + echo "ERROR: нужен xray в PATH или Docker. Или: SKIP_XRAY_TEST=1 $0" + return 1 + fi + IMG="${RAVEN_XRAY_TEST_IMAGE:-raven-xray-test:local}" + if ! docker image inspect "$IMG" >/dev/null 2>&1; then + echo "Building $IMG from docker/test/xray-client ..." + docker build -t "$IMG" -f "$REPO/docker/test/xray-client/Dockerfile" "$REPO/docker/test/xray-client" + fi + docker run --rm \ + -v "$_dir:/etc/xray/config.d:ro" \ + --entrypoint /bin/sh \ + "$IMG" \ + -c "mkdir -p /tmp/raven-xray-test-logs && exec /usr/local/bin/xray -test -confdir /etc/xray/config.d" +} + +run_xray_test "$OUT" + +echo "OK: all tests passed." diff --git a/tests/scripts/gen-reality-keys.sh b/tests/scripts/gen-reality-keys.sh new file mode 100755 index 0000000..ef5f98a --- /dev/null +++ b/tests/scripts/gen-reality-keys.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env sh +# Скачивает Xray (linux-amd64), генерирует пару x25519 и печатает YAML-фрагмент для test_secrets.yml +set -eu +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +CACHE="${ROOT}/tests/.cache" +VER="${XRAY_VERSION:-26.2.6}" +ZIP_URL="https://github.com/XTLS/Xray-core/releases/download/v${VER}/Xray-linux-64.zip" +mkdir -p "$CACHE" +XRAY_BIN="${CACHE}/xray-${VER}" +if [ ! -x "$XRAY_BIN" ]; then + echo "Downloading Xray ${VER}..." >&2 + curl -fsSL "$ZIP_URL" -o "${CACHE}/xray.zip" + unzip -p "${CACHE}/xray.zip" xray > "$XRAY_BIN" + chmod +x "$XRAY_BIN" +fi +OUT="${CACHE}/x25519.txt" +"$XRAY_BIN" x25519 > "$OUT" +PRIV=$(grep '^PrivateKey:' "$OUT" | sed 's/^PrivateKey:[[:space:]]*//' | tr -d '\r') +# Xray 26+: "Password:" is the public key material (see main/commands/all/curve25519.go) +PUB=$(grep '^Password:' "$OUT" | sed 's/^Password:[[:space:]]*//' | tr -d '\r') +if [ -z "$PRIV" ] || [ -z "$PUB" ]; then + echo "Failed to parse x25519 output:" >&2 + cat "$OUT" >&2 + exit 1 +fi +SID=$(openssl rand -hex 8 2>/dev/null || echo "a1b2c3d4e5f67890") +cat < Date: Sat, 21 Mar 2026 01:59:54 +0300 Subject: [PATCH 12/12] fix: skip geosite rules in tests; guard routing rule on empty blocked_domains - render_overrides.yml: xray_blocked_domains: [] to avoid geosite.dat lookup in CI - 400-routing.json.j2: wrap blocked domains rule in length > 0 guard to prevent invalid empty domain array; clean up comma handling with inline trailing commas --- roles/xray/templates/conf/routing/400-routing.json.j2 | 8 ++++---- tests/fixtures/render_overrides.yml | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/roles/xray/templates/conf/routing/400-routing.json.j2 b/roles/xray/templates/conf/routing/400-routing.json.j2 index 07ee9ca..774f09f 100644 --- a/roles/xray/templates/conf/routing/400-routing.json.j2 +++ b/roles/xray/templates/conf/routing/400-routing.json.j2 @@ -2,6 +2,7 @@ "routing": { "domainStrategy": "IPIfNonMatch", "rules": [ + {% if xray_blocked_domains | length > 0 %} { "type": "field", "domain": [ @@ -10,16 +11,15 @@ {% endfor %} ], "outboundTag": "blocked" - } + }, + {% endif %} {% if xray_api.enable %} - , { "type": "field", "inboundTag": ["{{ xray_api.inbound.tag }}"], "outboundTag": "{{ xray_api.inbound.tag }}" - } + }, {% endif %} - , { "type": "field", "inboundTag": [ diff --git a/tests/fixtures/render_overrides.yml b/tests/fixtures/render_overrides.yml index c5a997d..527c6bb 100644 --- a/tests/fixtures/render_overrides.yml +++ b/tests/fixtures/render_overrides.yml @@ -8,3 +8,6 @@ xray_log_config: error_log: "error.log" logrotate_config: "/etc/logrotate.d/xray" dns_log: false + +# geosite.dat отсутствует в тестовой среде — используем пустой список. +xray_blocked_domains: []