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/.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/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/README.md b/roles/xray/README.md
index 38410be..e55c6b9 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,10 @@ 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_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) |
## Playbook
diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml
index 5259e1f..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,11 +24,29 @@ 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 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://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"
@@ -80,23 +98,12 @@ xray_xhttp:
##### Xray reality #####
# xray_reality:
-# private_key: "..." # xray x25519
-# public_key: "..." # xray x25519
-# spiderX: "/"
+# private_key: "PrivateKeyHere" # X25519 private key (xray x25519)
+# public_key: "PublicKeyHere" # X25519 public key (xray x25519)
+# spiderX: "spiderXHere" # spiderX path (e.g. "/")
# short_id:
-# - "abc12345" # 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: "..."
-
-# 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"
+# - "shortId1Here" # 8-byte hex short ID (openssl rand -hex 8)
+
# xray_users:
# - id: "UUID_1" # Replace with your actual UUID
@@ -108,3 +115,30 @@ xray_vless_decryption: "none"
# - 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/templates/config.json.j2 b/roles/xray/exampl/config.json.j2
similarity index 100%
rename from roles/xray/templates/config.json.j2
rename to roles/xray/exampl/config.json.j2
diff --git a/roles/xray/tasks/main.yml.bak b/roles/xray/exampl/main.yml.bak
similarity index 99%
rename from roles/xray/tasks/main.yml.bak
rename to roles/xray/exampl/main.yml.bak
index a435097..afb6566 100644
--- a/roles/xray/tasks/main.yml.bak
+++ b/roles/xray/exampl/main.yml.bak
@@ -1,4 +1,4 @@
-# File: roles/xray/tasks/main.yml
+Л# File: roles/xray/tasks/main.yml
---
- name: "Ensure the {{ xray_group }} group exists"
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 cca3b83..f66373c 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
@@ -34,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/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
new file mode 100644
index 0000000..95943f3
--- /dev/null
+++ b/roles/xray/tasks/validate.yml
@@ -0,0 +1,67 @@
+---
+- name: Xray | Validate xray_vless_decryption value
+ ansible.builtin.assert:
+ that:
+ - xray_vless_decryption | default('none') == 'none'
+ or (xray_vless_decryption | default('none')).startswith('mlkem768x25519plus.')
+ fail_msg: >-
+ 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:
+ - 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 | 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:
+ - 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"
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/200-in-vless-reality.json.j2 b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2
index 630086a..3db78af 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 | default(xray_vless_default_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 bed111a..4212c84 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,13 @@
"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 %}
+ {% set _flow = user.flow | default(xray_vless_default_flow) %}
{
"id": "{{ user.id }}",
- "flow": "",
+ "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}",
"email": "{{ user.email | default('') }}",
"level": 0
}{{ "," if not loop.last }}
@@ -18,12 +20,13 @@
"decryption": "{{ xray_vless_decryption | default('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..774f09f 100644
--- a/roles/xray/templates/conf/routing/400-routing.json.j2
+++ b/roles/xray/templates/conf/routing/400-routing.json.j2
@@ -2,15 +2,7 @@
"routing": {
"domainStrategy": "IPIfNonMatch",
"rules": [
- {
- "type": "field",
- "inboundTag": [
- {% for key, value in xray_common.inbound_tag.items() %}
- "{{ value }}"{{ "," if not loop.last }}
- {% endfor %}
- ],
- "outboundTag": "freedom"
- },
+ {% if xray_blocked_domains | length > 0 %}
{
"type": "field",
"domain": [
@@ -19,15 +11,23 @@
{% endfor %}
],
"outboundTag": "blocked"
- }
+ },
+ {% endif %}
{% if xray_api.enable %}
- ,
{
"type": "field",
"inboundTag": ["{{ xray_api.inbound.tag }}"],
"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
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..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
@@ -3,17 +3,19 @@
{
"tag": "{{ xray_common.inbound_tag.vless_xhttp_in }}",
"settings": {
- "clients": [
+ "clients": [
+ {% set _pq = xray_vless_decryption | default('none') != 'none' %}
{% for user in xray_users %}
+ {% set _flow = user.flow | default(xray_vless_default_flow) %}
{
"id": "{{ user.id }}",
- "flow": "",
+ "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}",
"email": "{{ user.email | default('') }}",
"level": 0
}{{ "," 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..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
@@ -3,18 +3,19 @@
{
"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(xray_vless_default_flow) %}
{
"id": "{{ user.id }}",
- "flow": "",
+ "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}",
"email": "{{ user.email | default('') }}",
"level": 0
}{{ "," if not loop.last }}
{% endfor %}
],
- "decryption": "none",
- "security": "reality"
+ "decryption": "{{ xray_vless_decryption | default('none') }}"
}
}
]
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
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..527c6bb
--- /dev/null
+++ b/tests/fixtures/render_overrides.yml
@@ -0,0 +1,13 @@
+# Переопределения только для рендера в 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
+
+# geosite.dat отсутствует в тестовой среде — используем пустой список.
+xray_blocked_domains: []
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 <