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 <