diff --git a/.github/workflows/xray-config-test.yml b/.github/workflows/xray-config-test.yml index f9b9294..2073e71 100644 --- a/.github/workflows/xray-config-test.yml +++ b/.github/workflows/xray-config-test.yml @@ -2,9 +2,9 @@ name: Xray config (Ansible + xray -test) on: push: - branches: [main, master] + branches: [main, master, develop] pull_request: - branches: [main, master] + branches: [main, master, develop] jobs: test: diff --git a/.gitignore b/.gitignore index 80a5655..545906f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,14 @@ +# Secrets — never commit real credentials vault_password.txt +**/secrets.yml +**/*_secrets.yml +**/vault_password.txt +**/*.secret +**/*.vault + .vscode .ansible roles/hosts.yml -roles/sing-box/defaults/secrets.yml -roles/sing-box-playbook/defaults/secrets.yml -roles/hosts.yml -roles/xray/defaults/secrets.yml -roles/xray/defaults/vic_secret.yml # Generated by tests/run.sh tests/.cache/ @@ -14,5 +16,4 @@ tests/.output/ tests/fixtures/test_secrets.yml -CLAUDE.md - +CLAUDE.md \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..fdf11ce --- /dev/null +++ b/README.md @@ -0,0 +1,554 @@ +# Raven Server Install + +Languages: **English** | [Русский](README.ru.md) + +[![CI](https://github.com/AlchemyLink/Raven-server-install/actions/workflows/xray-config-test.yml/badge.svg)](https://github.com/AlchemyLink/Raven-server-install/actions/workflows/xray-config-test.yml) +[![License: MPL 2.0](https://img.shields.io/badge/License-MPL_2.0-brightgreen.svg)](LICENSE) + +Ansible playbooks for deploying a production-ready self-hosted VPN server stack based on [Xray-core](https://github.com/XTLS/Xray-core) and [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe). Designed for censorship circumvention with traffic indistinguishable from regular HTTPS. + +**What you get:** + +- Xray-core with VLESS + XTLS-Reality (TCP) and VLESS + XHTTP (HTTP/2) inbounds +- nginx SNI routing on port 443 — all VPN traffic goes through standard HTTPS port +- Optional post-quantum VLESS Encryption (mlkem768x25519plus, Xray-core ≥ 26.x) +- Optional Hysteria2 via [sing-box](https://github.com/SagerNet/sing-box) +- [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe) — subscription server: auto-discovers users, serves client configs via personal URLs +- [xray-stats-exporter](https://github.com/AlchemyLink/xray-stats-exporter) + VictoriaMetrics + Grafana — monitoring with per-user and per-inbound traffic dashboards +- nginx TLS frontend on EU VPS (`nginx_frontend` role) with PROXY protocol for real client IPs +- nginx SNI relay on RU VPS — hides EU server IP from clients (`relay` role) +- Systemd services with config validation before every reload +- Ad and tracker blocking via geosite routing rules (`geosite:category-ads-all`) +- BBR congestion control and sysctl tuning (`srv_prepare` role) + +--- + +## Table of Contents + +- [Architecture](#architecture) +- [Requirements](#requirements) +- [Quick Start](#quick-start) +- [Role Reference](#role-reference) +- [Secrets](#secrets) +- [Configuration](#configuration) +- [DNS Setup](#dns-setup) +- [VLESS Encryption (optional)](#vless-encryption-optional) +- [Hysteria2 / sing-box (optional)](#hysteria2--sing-box-optional) +- [Testing](#testing) +- [Related Projects](#related-projects) +- [License](#license) + +--- + +## Architecture + +This repo supports two deployment topologies: + +### Single-server (minimal) + +One VPS running Xray + Raven-subscribe + nginx frontend. All traffic enters on port 443 — nginx routes by SNI. + +``` +Client ──VLESS+Reality──► VPS:443 (nginx SNI) ──► VPS:4443 (Xray) +Client ──VLESS+XHTTP────► VPS:443 (nginx SNI) ──► VPS:2053 (Xray) +Client ──subscription───► VPS:443 (nginx SNI) ──► VPS:8443 (nginx HTTPS) ──► Raven:8080 +``` + +### Dual-server with RU relay (recommended for CIS users) + +EU VPS runs Xray + nginx_frontend + Raven-subscribe. +RU VPS runs an SNI relay that hides the EU IP from clients and passes traffic through. + +``` +EU VPS RU VPS (example.com) +┌────────────────────────────────┐ ┌─────────────────────────────────────┐ +│ nginx stream :443 (SNI routing)│ │ nginx stream :443 (SNI routing) │ +│ SNI dest.com → Xray :4443 │◄──│ SNI dest.com → EU:443 │ +│ SNI adobe.com → Xray :2053 │◄──│ SNI adobe.com → EU:443 │ +│ SNI my.domain → nginx :8443 │ │ SNI my.domain → local nginx :8443 │ +│ │ │ → EU:8443 → Raven :8080 │ +│ Raven-subscribe :8080 (local) │ └─────────────────────────────────────┘ +└────────────────────────────────┘ ▲ + clients +``` + +**Client connection flow:** +``` +VLESS Reality: client → RU:443 (SNI relay) → EU:443 (nginx SNI) → Xray:4443 +VLESS XHTTP: client → RU:443 (SNI relay) → EU:443 (nginx SNI) → Xray:2053 +Subscription: client → my.example.com:443 → RU nginx → EU:8443 → Raven:8080 +``` + +### Role map + +| Role | VPS | Playbook | What it does | +|------|-----|----------|--------------| +| `srv_prepare` | EU | `role_xray.yml` | BBR, sysctl tuning, system user `xrayuser` | +| `xray` | EU | `role_xray.yml` | Xray binary + split config in `/etc/xray/config.d/` | +| `raven_subscribe` | EU | `role_raven_subscribe.yml` | Subscription server, gRPC sync with Xray | +| `nginx_frontend` | EU | `role_nginx_frontend.yml` | nginx SNI routing on :443, HTTPS proxy on :8443, PROXY protocol | +| `monitoring` | EU | `role_monitoring.yml` | xray-stats-exporter + VictoriaMetrics + Grafana | +| `sing-box-playbook` | EU | `role_sing-box.yml` | sing-box + Hysteria2 (optional) | +| `relay` | RU | `role_relay.yml` | nginx SNI relay on :443 — forwards all VPN traffic to EU | + +--- + +## Requirements + +- **Ansible** >= 2.14 (`ansible-core`) +- **Target OS**: Debian/Ubuntu with systemd +- **Python 3** on the target server +- **ansible-vault** for secrets management +- **Docker** (optional, for local config validation tests) + +--- + +## Quick Start + +### 1. Clone + +```bash +git clone https://github.com/AlchemyLink/Raven-server-install.git +cd Raven-server-install +``` + +### 2. Create inventory + +For the **xray** and **raven_subscribe** roles, edit `roles/hosts.yml.example` (copy to `roles/hosts.yml`): + +```yaml +all: + children: + cloud: + hosts: + vm_my_srv: + ansible_host: "EU_VPS_IP" + ansible_port: 22 + vars: + ansible_user: deploy + ansible_python_interpreter: /usr/bin/python3 + ansible_ssh_private_key_file: ~/.ssh/id_ed25519 +``` + +For **nginx_frontend** and **relay** roles, edit their respective `inventory.ini` files: + +```ini +# roles/nginx_frontend/inventory.ini +[eu] +vpn ansible_host=EU_VPS_IP ansible_user=deploy + +# roles/relay/inventory.ini +[relay] +relay ansible_host=RU_VPS_IP ansible_user=deploy +``` + +### 3. Create secrets files + +Each role has a `defaults/secrets.yml.example`. Copy and fill in the values, then encrypt: + +```bash +# Xray +cp roles/xray/defaults/secrets.yml.example roles/xray/defaults/secrets.yml +# edit roles/xray/defaults/secrets.yml +ansible-vault encrypt roles/xray/defaults/secrets.yml --vault-password-file vault_password.txt + +# Raven-subscribe +cp roles/raven_subscribe/defaults/secrets.yml.example roles/raven_subscribe/defaults/secrets.yml +# edit roles/raven_subscribe/defaults/secrets.yml +ansible-vault encrypt roles/raven_subscribe/defaults/secrets.yml --vault-password-file vault_password.txt + +# nginx_frontend (EU VPS) +cp roles/nginx_frontend/defaults/secrets.yml.example roles/nginx_frontend/defaults/secrets.yml +# edit roles/nginx_frontend/defaults/secrets.yml +ansible-vault encrypt roles/nginx_frontend/defaults/secrets.yml --vault-password-file vault_password.txt + +# relay (RU VPS) +cp roles/relay/defaults/secrets.yml.example roles/relay/defaults/secrets.yml +# edit roles/relay/defaults/secrets.yml +ansible-vault encrypt roles/relay/defaults/secrets.yml --vault-password-file vault_password.txt +``` + +To edit an encrypted file later: + +```bash +ansible-vault edit roles/xray/defaults/secrets.yml --vault-password-file vault_password.txt +``` + +### 4. Generate Reality keys + +```bash +# On any machine with Xray installed: +xray x25519 +# Output: PrivateKey + PublicKey — put both into roles/xray/defaults/secrets.yml + +openssl rand -hex 8 # generates a short_id +``` + +### 5. Deploy + +```bash +# EU server: Xray + system preparation +ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file vault_password.txt + +# EU server: nginx TLS frontend + TCP stream relay +ansible-playbook roles/role_nginx_frontend.yml -i roles/nginx_frontend/inventory.ini --vault-password-file vault_password.txt + +# EU server: Raven-subscribe +ansible-playbook roles/role_raven_subscribe.yml -i roles/hosts.yml --vault-password-file vault_password.txt + +# RU server: nginx relay +ansible-playbook roles/role_relay.yml -i roles/relay/inventory.ini --vault-password-file vault_password.txt +``` + +Use `--tags` to deploy only a specific part: + +```bash +ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file vault_password.txt \ + --tags xray_inbounds +``` + +--- + +## Role Reference + +### `xray` role + +Installs and configures Xray-core. Config is split across numbered JSON files in `/etc/xray/config.d/` — Xray loads them in order. + +**Task files and tags:** + +| Tag | File | What it does | +|-----|------|--------------| +| `always` | `validate.yml` | Pre-flight assertions — runs before everything | +| `xray_install` | `install.yml` | Downloads Xray binary from GitHub releases | +| `xray_base` | `base.yml` | Writes `000-log.json`, `010-stats.json` | +| `xray_api` | `api.yml` | Writes `050-api.json` (dokodemo-door on 127.0.0.1:10085) | +| `xray_inbounds` | `inbounds.yml` | Writes `200-in-vless-reality.json`, `210-in-xhttp.json` | +| `xray_dns` | `dns.yml` | Writes `100-dns.json` | +| `xray_outbounds` | `outbounds.yml` | Writes `300-outbounds.json` | +| `xray_routing` | `routing.yml` | Writes `400-routing.json` | +| `xray_service` | `service.yml` | Deploys systemd unit, enables service | +| `grpcurl` | `grpcurl.yml` | Installs grpcurl tool | + +**Config files layout:** + +| File | Content | +|------|---------| +| `000-log.json` | Log levels, file paths | +| `010-stats.json` | Traffic statistics | +| `050-api.json` | gRPC API (127.0.0.1:10085) | +| `100-dns.json` | DNS servers and query strategy | +| `200-in-vless-reality.json` | VLESS + XTLS-Reality inbound (TCP :443) | +| `210-in-xhttp.json` | VLESS + XHTTP inbound (:2053) | +| `300-outbounds.json` | Freedom + blackhole outbounds | +| `400-routing.json` | Routing rules + ad blocking | + +**Handler safety:** `Validate xray` must be defined before `Restart xray` in `handlers/main.yml`. Ansible executes handlers in definition order — this ensures an invalid config never triggers a restart. + +--- + +### `raven_subscribe` role + +Deploys [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe) — a Go service that auto-discovers Xray users, syncs them via gRPC API, and serves personal subscription URLs. + +Listens on `127.0.0.1:8080`, proxied by nginx_frontend. + +--- + +### `nginx_frontend` role + +Deploys nginx on the EU VPS as a TLS frontend and SNI router. Port 443 handles all traffic. + +- **Stream SNI routing on :443** — reads SNI from TLS ClientHello, routes by hostname: + - SNI `xhttp-dest.com` → Xray XHTTP `:2053` + - SNI `your-domain.com` → nginx HTTPS `:8443` (Raven-subscribe) + - Default (any other SNI) → Xray VLESS Reality `:4443` +- **PROXY protocol** — passes real client IP to all upstreams (Xray uses `xver: 2`) +- **HTTPS on :8443** — proxies `/sub/`, `/c/`, `/api/` → Raven-subscribe `:8080` +- Obtains Let's Encrypt certificate for `nginx_frontend_domain` + +**Important:** When deploying nginx_frontend and Xray inbounds together, always deploy **Xray first** (`--tags xray_inbounds`), then nginx. nginx sends PROXY protocol headers immediately — Xray must be ready to accept them. + +--- + +### `relay` role + +Deploys nginx on the RU VPS as an SNI relay. Responsibilities: + +- **Stream SNI routing on :443** — forwards all VPN traffic to EU VPS:443 by default +- Serves a static stub site on `relay_domain` (camouflage, Let's Encrypt cert) +- Proxies `my.relay_domain` → EU VPS nginx_frontend `:8443` (Raven-subscribe) + +--- + +### `monitoring` role + +Deploys the full monitoring stack on the EU VPS: + +- **[xray-stats-exporter](https://github.com/AlchemyLink/xray-stats-exporter)** — Prometheus exporter for per-user and per-inbound traffic metrics +- **VictoriaMetrics** — Prometheus-compatible time series database +- **Grafana** — dashboards for traffic, server health, Raven-subscribe status, and alerting rules + +--- + +### `sing-box-playbook` role + +Optional. Deploys [sing-box](https://github.com/SagerNet/sing-box) with a Hysteria2 inbound. When deployed, Raven-subscribe automatically discovers Hysteria2 users and includes them in subscriptions. + +--- + +## Secrets + +Each role keeps secrets in `defaults/secrets.yml` (ansible-vault encrypted, not committed). Copy from the `.example` file. + +### `roles/xray/defaults/secrets.yml` + +```yaml +# Reality keys — generate with: xray x25519 +xray_reality: + private_key: "YOUR_PRIVATE_KEY" + public_key: "YOUR_PUBLIC_KEY" + spiderX: "/" + short_id: + - "a1b2c3d4e5f67890" # 8-byte hex — generate: openssl rand -hex 8 + +# VLESS users +xray_users: + - id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # UUID — generate: uuidgen + flow: "xtls-rprx-vision" + email: "alice@example.com" +``` + +### `roles/raven_subscribe/defaults/secrets.yml` + +```yaml +# Admin token for Raven API — generate: openssl rand -hex 32 +raven_subscribe_admin_token: "YOUR_ADMIN_TOKEN" + +# Public URL used in subscription links +raven_subscribe_base_url: "https://my.example.com" + +# EU VPS public domain or IP +raven_subscribe_server_host: "media.example.com" + +# Per-inbound host/port overrides (optional) +# Routes different protocols through different addresses in client configs. +# Useful when clients connect via relay for some protocols. +raven_subscribe_inbound_hosts: + vless-reality-in: "example.com" # RU relay domain for Reality + vless-xhttp-in: "media.example.com" +raven_subscribe_inbound_ports: + vless-reality-in: 8444 # RU relay TCP port for Reality +``` + +### `roles/nginx_frontend/defaults/secrets.yml` + +```yaml +nginx_frontend_certbot_email: "admin@example.com" +``` + +### `roles/relay/defaults/secrets.yml` + +```yaml +relay_upstream_host: "EU_VPS_IP" # EU server IP address +relay_certbot_email: "admin@example.com" +``` + +### `roles/sing-box-playbook/defaults/secrets.yml` + +```yaml +singbox_hysteria2_users: + - name: "alice@example.com" + password: "STRONG_RANDOM_PASSWORD" + +singbox: + tls_server_name: "media.example.com" + tls_acme_domain: "media.example.com" + tls_acme_email: "admin@example.com" +``` + +--- + +## Configuration + +### Xray (`roles/xray/defaults/main.yml`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `xray_vless_port` | `443` | VLESS + Reality listen port | +| `xray_reality_dest` | `askubuntu.com:443` | Reality camouflage destination (must be a real TLS site) | +| `xray_reality_server_names` | `["askubuntu.com"]` | SNI server names for Reality | +| `xray_xhttp.port` | `2053` | XHTTP inbound port | +| `xray_xhttp.xhttpSettings.path` | `/api/v3/data-sync` | XHTTP path (must match nginx_frontend) | +| `xray_dns_servers` | `tcp+local://8.8.8.8, ...` | DNS servers — do not use DoH (`https://`) | +| `xray_dns_query_strategy` | `UseIPv4` | `UseIPv4` if the server has no IPv6, `UseIP` otherwise | +| `xray_vless_decryption` | `"none"` | VLESS Encryption mode — see [VLESS Encryption](#vless-encryption-optional) | +| `xray_blocked_domains` | `[]` | Extra domains to block via routing rules | + +### Raven-subscribe (`roles/raven_subscribe/defaults/main.yml`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `raven_subscribe_listen_addr` | `:8080` | Listen address | +| `raven_subscribe_sync_interval_seconds` | `60` | Xray config rescan interval | +| `raven_subscribe_api_inbound_tag` | `vless-reality-in` | Default inbound tag for API-created users | +| `raven_subscribe_xray_api_addr` | `127.0.0.1:10085` | Xray gRPC API address | +| `raven_subscribe_inbound_hosts` | `{}` | Per-inbound host overrides (set in secrets.yml) | +| `raven_subscribe_inbound_ports` | `{}` | Per-inbound port overrides (set in secrets.yml) | +| `raven_subscribe_singbox_enabled` | `false` | Enable sing-box/Hysteria2 sync | + +### nginx_frontend (`roles/nginx_frontend/defaults/main.yml`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `nginx_frontend_domain` | `media.example.com` | EU VPS domain — used for TLS cert and SNI routing | +| `nginx_frontend_listen_port` | `8443` | nginx HTTPS internal port (proxied from :443 stream) | +| `nginx_frontend_raven_port` | `8080` | Raven-subscribe upstream port | +| `nginx_frontend_stream_xhttp_sni` | `www.adobe.com` | SNI that routes to Xray XHTTP inbound | +| `nginx_frontend_stream_xhttp_port` | `2053` | Xray XHTTP inbound port | +| `nginx_frontend_stream_reality_port` | `4443` | Xray VLESS Reality inbound port (default SNI target) | + +### relay (`roles/relay/defaults/main.yml`) + +| Variable | Default | Description | +|----------|---------|-------------| +| `relay_domain` | `example.com` | RU VPS domain — stub site + SNI routing | +| `relay_sub_my` | `my.example.com` | Subdomain proxied to EU Raven-subscribe | +| `relay_upstream_host` | `EU_VPS_IP` | EU server IP (set in secrets.yml) | +| `relay_upstream_raven_port` | `8443` | EU nginx HTTPS port for Raven-subscribe | +| `relay_stub_title` | `Welcome` | Stub site page title | +| `relay_stub_description` | `Personal website` | Stub site meta description | + +--- + +## DNS Setup + +Point the following DNS A records to the correct servers: + +| Domain | → | Server | Purpose | +|--------|---|--------|---------| +| `media.example.com` | → | EU VPS IP | nginx_frontend (SNI routing, TLS cert) | +| `example.com` | → | RU VPS IP | Relay stub site (camouflage) | +| `my.example.com` | → | RU VPS IP | Relay → Raven-subscribe (subscription links) | + +Clients connect to the RU VPS on port 443 for all protocols — no additional DNS records needed for VPN traffic. + +--- + +## Monitoring (optional) + +The `monitoring` role deploys a full observability stack on the EU VPS: + +```bash +ansible-playbook roles/role_monitoring.yml -i roles/hosts.yml --vault-password-file vault_password.txt +``` + +**Grafana dashboards included:** +- **Xray — per-user traffic** — upload/download timeseries, top users, per-inbound breakdown (Reality vs XHTTP) +- **Servers EU/RU — status** — CPU, RAM, network, disk, Xray health, Raven-subscribe latency + +**Alerting rules** (Grafana alerts via VictoriaMetrics): +- Xray down +- Raven-subscribe down +- EU/RU server unreachable +- Disk usage > 85% + +To deploy only the binary for [xray-stats-exporter](https://github.com/AlchemyLink/xray-stats-exporter): + +```bash +ansible-playbook roles/role_monitoring.yml -i roles/hosts.yml --vault-password-file vault_password.txt \ + --tags xray_stats_exporter \ + -e "xray_stats_exporter_local_binary=/path/to/xray-stats-exporter" +``` + +--- + +## VLESS Encryption (optional) + +Xray-core >= 25.x supports post-quantum VLESS Encryption (mlkem768x25519plus). Disabled by default. + +When enabled, all clients connecting to the inbound **must** support it — do not mix encrypted and plain clients on the same inbound. + +**Generate keys:** + +```bash +xray vlessenc +# Output: decryption string (server private) + encryption string (client public) +``` + +**Add to `roles/xray/defaults/secrets.yml`:** + +```yaml +xray_vless_decryption: "mlkem768x25519plus.PRIVATE..." # server — keep secret +xray_vless_client_encryption: "mlkem768x25519plus.PUBLIC..." # sent to clients via Raven +``` + +Both must be set together or both left as `"none"`. When enabled, `flow` is forced to `xtls-rprx-vision` for all users. + +--- + +## Hysteria2 / sing-box (optional) + +Deploy sing-box alongside Xray to provide Hysteria2 (QUIC-based protocol with Salamander obfuscation). + +```bash +# Copy and fill in secrets +cp roles/sing-box-playbook/defaults/secrets.yml.example roles/sing-box-playbook/defaults/secrets.yml +ansible-vault encrypt roles/sing-box-playbook/defaults/secrets.yml --vault-password-file vault_password.txt + +# Deploy +ansible-playbook roles/role_sing-box.yml -i roles/hosts.yml --vault-password-file vault_password.txt +``` + +After deployment, set `raven_subscribe_singbox_enabled: true` in `raven_subscribe/defaults/secrets.yml` and redeploy Raven-subscribe. It will discover Hysteria2 users and serve them via `/sub/{token}/singbox` and `/sub/{token}/hysteria2` endpoints. + +**Note:** Hysteria2 uses ACME (Let's Encrypt) directly in sing-box. Set `singbox.tls_acme_domain` and `singbox.tls_acme_email` in secrets. + +--- + +## Testing + +Run the full test suite — renders all Ansible templates and validates them with `xray -test` in Docker: + +```bash +./tests/run.sh +``` + +Ansible-only (no Docker needed): + +```bash +SKIP_XRAY_TEST=1 ./tests/run.sh +``` + +**Pipeline steps:** +1. Downloads Xray binary (cached in `tests/.cache/`) +2. Generates ephemeral Reality keys → `tests/fixtures/test_secrets.yml` +3. Runs `validate.yml` assertions +4. Renders all `templates/conf/*.j2` → `tests/.output/conf.d/` +5. Runs `xray -test -confdir` in Docker + +CI runs on every push and PR via `.github/workflows/xray-config-test.yml`. + +**Run individual steps manually:** + +```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 +ansible-playbook tests/playbooks/render_conf.yml +``` + +--- + +## Related Projects + +- [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe) — subscription server (Go): auto-discovers users from Xray config, syncs via gRPC API, serves personal subscription URLs in Xray JSON / sing-box JSON / share link formats +- [xray-stats-exporter](https://github.com/AlchemyLink/xray-stats-exporter) — Prometheus exporter for per-user and per-inbound Xray traffic metrics +- [Xray-core](https://github.com/XTLS/Xray-core) — the VPN core +- [sing-box](https://github.com/SagerNet/sing-box) — alternative VPN core (Hysteria2) + +--- + +## License + +[Mozilla Public License 2.0](LICENSE) diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..5b9a211 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,546 @@ +# Raven Server Install + +Языки: [English](README.md) | **Русский** + +[![CI](https://github.com/AlchemyLink/Raven-server-install/actions/workflows/xray-config-test.yml/badge.svg)](https://github.com/AlchemyLink/Raven-server-install/actions/workflows/xray-config-test.yml) +[![License: MPL 2.0](https://img.shields.io/badge/License-MPL_2.0-brightgreen.svg)](LICENSE) + +Ansible-плейбуки для развёртывания production-ready самохостинг VPN-стека на основе [Xray-core](https://github.com/XTLS/Xray-core) и [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe). Весь трафик неотличим от обычного HTTPS. + +**Что вы получаете:** + +- Xray-core с inbound'ами VLESS + XTLS-Reality (TCP) и VLESS + XHTTP (HTTP/2) +- nginx SNI routing на порту 443 — весь VPN-трафик идёт через стандартный HTTPS-порт +- Опциональное пост-квантовое VLESS Encryption (mlkem768x25519plus, Xray-core ≥ 26.x) +- Опциональный Hysteria2 через [sing-box](https://github.com/SagerNet/sing-box) +- [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe) — сервер подписок: автоматически находит пользователей, раздаёт клиентские конфиги по персональным ссылкам +- [xray-stats-exporter](https://github.com/AlchemyLink/xray-stats-exporter) + VictoriaMetrics + Grafana — мониторинг с дашбордами трафика по пользователям и протоколам +- nginx TLS frontend на EU VPS с SNI routing и PROXY protocol для реальных IP клиентов +- nginx SNI relay на RU VPS — скрывает EU сервер от клиентов (роль `relay`) +- systemd-сервисы с валидацией конфига перед каждым перезапуском +- Блокировка рекламы и публичных трекеров (`geosite:category-ads-all`) +- BBR и тюнинг sysctl (роль `srv_prepare`) + +--- + +## Содержание + +- [Архитектура](#архитектура) +- [Требования](#требования) +- [Быстрый старт](#быстрый-старт) +- [Описание ролей](#описание-ролей) +- [Секреты](#секреты) +- [Конфигурация](#конфигурация) +- [DNS-записи](#dns-записи) +- [VLESS Encryption (опционально)](#vless-encryption-опционально) +- [Hysteria2 / sing-box (опционально)](#hysteria2--sing-box-опционально) +- [Тестирование](#тестирование) +- [Связанные проекты](#связанные-проекты) +- [Лицензия](#лицензия) + +--- + +## Архитектура + +Поддерживаются две топологии деплоя. + +### Один сервер (минимальный вариант) + +Один VPS с Xray + Raven-subscribe + nginx. Весь трафик через порт 443 — nginx маршрутизирует по SNI. + +``` +Клиент ──VLESS+Reality──► VPS:443 (nginx SNI) ──► VPS:4443 (Xray) +Клиент ──VLESS+XHTTP────► VPS:443 (nginx SNI) ──► VPS:2053 (Xray) +Клиент ──подписка───────► VPS:443 (nginx SNI) ──► VPS:8443 (nginx HTTPS) ──► Raven:8080 +``` + +### Два сервера с RU-relay (рекомендуется для пользователей из СНГ) + +EU VPS: Xray + nginx_frontend + Raven-subscribe. +RU VPS: SNI relay — скрывает EU IP от клиентов, пробрасывает трафик насквозь. + +``` +EU VPS RU VPS (example.com) +┌────────────────────────────────┐ ┌─────────────────────────────────────┐ +│ nginx stream :443 (SNI routing)│ │ nginx stream :443 (SNI routing) │ +│ SNI dest.com → Xray :4443 │◄──│ SNI dest.com → EU:443 │ +│ SNI adobe.com → Xray :2053 │◄──│ SNI adobe.com → EU:443 │ +│ SNI my.domain → nginx :8443 │ │ SNI my.domain → local nginx :8443 │ +│ │ │ → EU:8443 → Raven :8080 │ +│ Raven-subscribe :8080 (локал.) │ └─────────────────────────────────────┘ +└────────────────────────────────┘ ▲ + клиенты +``` + +**Маршруты подключения клиентов:** +``` +VLESS Reality: клиент → RU:443 (SNI relay) → EU:443 (nginx SNI) → Xray:4443 +VLESS XHTTP: клиент → RU:443 (SNI relay) → EU:443 (nginx SNI) → Xray:2053 +Подписка: клиент → my.example.com:443 → RU nginx → EU:8443 → Raven:8080 +``` + +### Карта ролей + +| Роль | VPS | Плейбук | Что делает | +|------|-----|---------|-----------| +| `srv_prepare` | EU | `role_xray.yml` | BBR, sysctl, системный пользователь `xrayuser` | +| `xray` | EU | `role_xray.yml` | Бинарь Xray + split-конфиг в `/etc/xray/config.d/` | +| `raven_subscribe` | EU | `role_raven_subscribe.yml` | Сервер подписок, gRPC-синхронизация с Xray | +| `nginx_frontend` | EU | `role_nginx_frontend.yml` | nginx SNI routing на :443, HTTPS прокси на :8443, PROXY protocol | +| `monitoring` | EU | `role_monitoring.yml` | xray-stats-exporter + VictoriaMetrics + Grafana | +| `sing-box-playbook` | EU | `role_sing-box.yml` | sing-box + Hysteria2 (опционально) | +| `relay` | RU | `role_relay.yml` | nginx SNI relay на :443 — весь VPN-трафик на EU | + +--- + +## Требования + +- **Ansible** >= 2.14 (`ansible-core`) +- **ОС на сервере**: Debian/Ubuntu с systemd +- **Python 3** на целевых серверах +- **ansible-vault** для управления секретами +- **Docker** (опционально, для локального тестирования конфигов) + +--- + +## Быстрый старт + +### 1. Клонировать репозиторий + +```bash +git clone https://github.com/AlchemyLink/Raven-server-install.git +cd Raven-server-install +``` + +### 2. Создать inventory + +Для ролей **xray** и **raven_subscribe** — отредактируйте `roles/hosts.yml.example` (скопируйте в `roles/hosts.yml`): + +```yaml +all: + children: + cloud: + hosts: + vm_my_srv: + ansible_host: "EU_VPS_IP" + ansible_port: 22 + vars: + ansible_user: deploy + ansible_python_interpreter: /usr/bin/python3 + ansible_ssh_private_key_file: ~/.ssh/id_ed25519 +``` + +Для ролей **nginx_frontend** и **relay** — отредактируйте соответствующие файлы `inventory.ini`: + +```ini +# roles/nginx_frontend/inventory.ini +[eu] +vpn ansible_host=EU_VPS_IP ansible_user=deploy + +# roles/relay/inventory.ini +[relay] +relay ansible_host=RU_VPS_IP ansible_user=deploy +``` + +### 3. Создать файлы секретов + +У каждой роли есть `defaults/secrets.yml.example`. Скопируйте, заполните и зашифруйте: + +```bash +# Xray +cp roles/xray/defaults/secrets.yml.example roles/xray/defaults/secrets.yml +# заполнить roles/xray/defaults/secrets.yml +ansible-vault encrypt roles/xray/defaults/secrets.yml --vault-password-file vault_password.txt + +# Raven-subscribe +cp roles/raven_subscribe/defaults/secrets.yml.example roles/raven_subscribe/defaults/secrets.yml +# заполнить roles/raven_subscribe/defaults/secrets.yml +ansible-vault encrypt roles/raven_subscribe/defaults/secrets.yml --vault-password-file vault_password.txt + +# nginx_frontend (EU VPS) +cp roles/nginx_frontend/defaults/secrets.yml.example roles/nginx_frontend/defaults/secrets.yml +# заполнить roles/nginx_frontend/defaults/secrets.yml +ansible-vault encrypt roles/nginx_frontend/defaults/secrets.yml --vault-password-file vault_password.txt + +# relay (RU VPS) +cp roles/relay/defaults/secrets.yml.example roles/relay/defaults/secrets.yml +# заполнить roles/relay/defaults/secrets.yml +ansible-vault encrypt roles/relay/defaults/secrets.yml --vault-password-file vault_password.txt +``` + +Редактировать зашифрованный файл: + +```bash +ansible-vault edit roles/xray/defaults/secrets.yml --vault-password-file vault_password.txt +``` + +### 4. Сгенерировать ключи Reality + +```bash +# На любой машине с установленным Xray: +xray x25519 +# Вывод: PrivateKey + PublicKey — оба вносим в roles/xray/defaults/secrets.yml + +openssl rand -hex 8 # short_id +``` + +### 5. Задеплоить + +```bash +# EU сервер: Xray + системная подготовка +ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file vault_password.txt + +# EU сервер: nginx TLS frontend + TCP stream relay +ansible-playbook roles/role_nginx_frontend.yml -i roles/nginx_frontend/inventory.ini --vault-password-file vault_password.txt + +# EU сервер: Raven-subscribe +ansible-playbook roles/role_raven_subscribe.yml -i roles/hosts.yml --vault-password-file vault_password.txt + +# RU сервер: nginx relay +ansible-playbook roles/role_relay.yml -i roles/relay/inventory.ini --vault-password-file vault_password.txt +``` + +Деплой только конкретной части через теги: + +```bash +ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file vault_password.txt \ + --tags xray_inbounds +``` + +--- + +## Описание ролей + +### Роль `xray` + +Устанавливает и настраивает Xray-core. Конфиг разделён на пронумерованные JSON-файлы в `/etc/xray/config.d/` — Xray загружает их по порядку. + +**Файлы тасков и теги:** + +| Тег | Файл | Что делает | +|-----|------|-----------| +| `always` | `validate.yml` | Проверки переменных — всегда | +| `xray_install` | `install.yml` | Скачивает бинарь с GitHub | +| `xray_base` | `base.yml` | `000-log.json`, `010-stats.json` | +| `xray_api` | `api.yml` | `050-api.json` (dokodemo на 127.0.0.1:10085) | +| `xray_inbounds` | `inbounds.yml` | `200-in-vless-reality.json`, `210-in-xhttp.json` | +| `xray_dns` | `dns.yml` | `100-dns.json` | +| `xray_outbounds` | `outbounds.yml` | `300-outbounds.json` | +| `xray_routing` | `routing.yml` | `400-routing.json` | +| `xray_service` | `service.yml` | systemd unit, запуск сервиса | +| `grpcurl` | `grpcurl.yml` | Установка grpcurl | + +**Файлы конфигурации:** + +| Файл | Содержимое | +|------|-----------| +| `000-log.json` | Уровни логирования, пути файлов | +| `010-stats.json` | Статистика трафика | +| `050-api.json` | gRPC API (127.0.0.1:10085) | +| `100-dns.json` | DNS-серверы и стратегия запросов | +| `200-in-vless-reality.json` | VLESS + XTLS-Reality inbound (TCP :443) | +| `210-in-xhttp.json` | VLESS + XHTTP inbound (:2053) | +| `300-outbounds.json` | Freedom + blackhole outbound'ы | +| `400-routing.json` | Правила маршрутизации + блокировка рекламы | + +**Безопасность handlers:** `Validate xray` должен быть определён раньше `Restart xray` в `handlers/main.yml`. Ansible выполняет handlers в порядке определения — это гарантирует, что невалидный конфиг никогда не вызовет перезапуск. + +--- + +### Роль `raven_subscribe` + +Деплоит [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe) — Go-сервис, который автоматически находит пользователей Xray, синхронизирует их через gRPC API и раздаёт персональные ссылки подписки. + +Слушает на `127.0.0.1:8080`, проксируется через nginx_frontend. + +--- + +### Роль `nginx_frontend` + +Деплоит nginx на EU VPS как TLS frontend и SNI router. Порт 443 обрабатывает весь трафик. + +- **Stream SNI routing на :443** — читает SNI из TLS ClientHello, маршрутизирует по имени: + - SNI `xhttp-dest.com` → Xray XHTTP `:2053` + - SNI `your-domain.com` → nginx HTTPS `:8443` (Raven-subscribe) + - Default (любой другой SNI) → Xray VLESS Reality `:4443` +- **PROXY protocol** — передаёт реальный IP клиента всем upstream'ам (Xray использует `xver: 2`) +- **HTTPS на :8443** — проксирует `/sub/`, `/c/`, `/api/` → Raven-subscribe `:8080` +- Получает Let's Encrypt сертификат для `nginx_frontend_domain` + +**Важно:** При одновременном деплое nginx_frontend и Xray inbounds — сначала деплоить **Xray** (`--tags xray_inbounds`), потом nginx. nginx сразу начинает отправлять PROXY protocol заголовки — Xray должен быть готов их принять. + +--- + +### Роль `relay` + +Деплоит nginx на RU VPS как SNI relay. Функции: + +- **Stream SNI routing на :443** — по умолчанию весь VPN-трафик → EU VPS:443 +- Отдаёт статический stub-сайт на `relay_domain` (маскировка, сертификат Let's Encrypt) +- Проксирует `my.relay_domain` → EU VPS nginx_frontend `:8443` (Raven-subscribe) + +--- + +### Роль `monitoring` + +Деплоит полный стек мониторинга на EU VPS: + +- **[xray-stats-exporter](https://github.com/AlchemyLink/xray-stats-exporter)** — Prometheus exporter для метрик трафика по пользователям и inbound'ам +- **VictoriaMetrics** — Prometheus-совместимая TSDB +- **Grafana** — дашборды трафика, состояния серверов, Raven-subscribe, и правила алертинга + +--- + +### Роль `sing-box-playbook` + +Опционально. Деплоит [sing-box](https://github.com/SagerNet/sing-box) с inbound'ом Hysteria2. После деплоя Raven-subscribe автоматически находит Hysteria2-пользователей и включает их в подписки. + +--- + +## Секреты + +У каждой роли секреты хранятся в `defaults/secrets.yml` (зашифровано ansible-vault, не коммитится). Шаблоны — в `defaults/secrets.yml.example`. + +### `roles/xray/defaults/secrets.yml` + +```yaml +# Ключи Reality — генерация: xray x25519 +xray_reality: + private_key: "ВАШ_ПРИВАТНЫЙ_КЛЮЧ" + public_key: "ВАШ_ПУБЛИЧНЫЙ_КЛЮЧ" + spiderX: "/" + short_id: + - "a1b2c3d4e5f67890" # 8-байтный hex — генерация: openssl rand -hex 8 + +# VLESS пользователи +xray_users: + - id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # UUID — генерация: uuidgen + flow: "xtls-rprx-vision" + email: "alice@example.com" +``` + +### `roles/raven_subscribe/defaults/secrets.yml` + +```yaml +# Токен admin API — генерация: openssl rand -hex 32 +raven_subscribe_admin_token: "ВАШ_ADMIN_ТОКЕН" + +# Публичный URL для ссылок подписки +raven_subscribe_base_url: "https://my.example.com" + +# Публичный домен или IP EU VPS +raven_subscribe_server_host: "media.example.com" + +# Переопределение host/port по inbound (опционально) +# Позволяет разные адреса для разных протоколов в клиентских конфигах. +# Полезно когда клиенты подключаются через relay для части протоколов. +raven_subscribe_inbound_hosts: + vless-reality-in: "example.com" # RU relay для Reality + vless-xhttp-in: "media.example.com" +raven_subscribe_inbound_ports: + vless-reality-in: 8444 # TCP порт RU relay для Reality +``` + +### `roles/nginx_frontend/defaults/secrets.yml` + +```yaml +nginx_frontend_certbot_email: "admin@example.com" +``` + +### `roles/relay/defaults/secrets.yml` + +```yaml +relay_upstream_host: "EU_VPS_IP" # IP-адрес EU сервера +relay_certbot_email: "admin@example.com" +``` + +### `roles/sing-box-playbook/defaults/secrets.yml` + +```yaml +singbox_hysteria2_users: + - name: "alice@example.com" + password: "СИЛЬНЫЙ_СЛУЧАЙНЫЙ_ПАРОЛЬ" + +singbox: + tls_server_name: "media.example.com" + tls_acme_domain: "media.example.com" + tls_acme_email: "admin@example.com" +``` + +--- + +## Конфигурация + +### Xray (`roles/xray/defaults/main.yml`) + +| Переменная | По умолчанию | Описание | +|-----------|--------------|---------| +| `xray_vless_port` | `443` | Порт VLESS + Reality | +| `xray_reality_dest` | `askubuntu.com:443` | Camouflage-ресурс Reality (должен быть реальным TLS-сайтом) | +| `xray_reality_server_names` | `["askubuntu.com"]` | SNI имена для Reality | +| `xray_xhttp.port` | `2053` | Порт XHTTP inbound | +| `xray_xhttp.xhttpSettings.path` | `/api/v3/data-sync` | Путь XHTTP (должен совпадать с nginx_frontend) | +| `xray_dns_servers` | `tcp+local://8.8.8.8, ...` | DNS-серверы — не используйте DoH (`https://`) | +| `xray_dns_query_strategy` | `UseIPv4` | `UseIPv4` если нет глобального IPv6, иначе `UseIP` | +| `xray_vless_decryption` | `"none"` | Режим VLESS Encryption — см. [VLESS Encryption](#vless-encryption-опционально) | +| `xray_blocked_domains` | `[]` | Дополнительные домены для блокировки | + +### Raven-subscribe (`roles/raven_subscribe/defaults/main.yml`) + +| Переменная | По умолчанию | Описание | +|-----------|--------------|---------| +| `raven_subscribe_listen_addr` | `:8080` | Адрес для прослушивания | +| `raven_subscribe_sync_interval_seconds` | `60` | Интервал пересканирования конфигов Xray | +| `raven_subscribe_api_inbound_tag` | `vless-reality-in` | Inbound по умолчанию для пользователей через API | +| `raven_subscribe_xray_api_addr` | `127.0.0.1:10085` | Адрес gRPC API Xray | +| `raven_subscribe_inbound_hosts` | `{}` | Переопределение host по inbound (задать в secrets.yml) | +| `raven_subscribe_inbound_ports` | `{}` | Переопределение port по inbound (задать в secrets.yml) | +| `raven_subscribe_singbox_enabled` | `false` | Включить синхронизацию sing-box/Hysteria2 | + +### nginx_frontend (`roles/nginx_frontend/defaults/main.yml`) + +| Переменная | По умолчанию | Описание | +|-----------|--------------|---------| +| `nginx_frontend_domain` | `media.example.com` | Домен EU VPS — используется для TLS сертификата и SNI routing | +| `nginx_frontend_listen_port` | `8443` | Внутренний порт nginx HTTPS (проксируется из :443 через stream) | +| `nginx_frontend_raven_port` | `8080` | Порт upstream Raven-subscribe | +| `nginx_frontend_stream_xhttp_sni` | `www.adobe.com` | SNI для маршрутизации на Xray XHTTP inbound | +| `nginx_frontend_stream_xhttp_port` | `2053` | Порт Xray XHTTP inbound | +| `nginx_frontend_stream_reality_port` | `4443` | Порт Xray VLESS Reality inbound (цель по умолчанию для SNI) | + +### relay (`roles/relay/defaults/main.yml`) + +| Переменная | По умолчанию | Описание | +|-----------|--------------|---------| +| `relay_domain` | `example.com` | Домен RU VPS — stub-сайт и SNI routing | +| `relay_sub_my` | `my.example.com` | Поддомен, проксируемый на EU Raven-subscribe | +| `relay_upstream_host` | `EU_VPS_IP` | IP EU сервера (задать в secrets.yml) | +| `relay_upstream_raven_port` | `8443` | Порт nginx HTTPS на EU для Raven-subscribe | +| `relay_stub_title` | `Welcome` | Заголовок страницы stub-сайта | +| `relay_stub_description` | `Personal website` | Мета-описание stub-сайта | + +--- + +## DNS-записи + +Направьте следующие DNS A-записи на нужные серверы: + +| Домен | → | Сервер | Назначение | +|-------|---|--------|-----------| +| `media.example.com` | → | IP EU VPS | nginx_frontend (SNI routing, TLS сертификат) | +| `example.com` | → | IP RU VPS | Stub-сайт relay (маскировка) | +| `my.example.com` | → | IP RU VPS | Relay → Raven-subscribe (ссылки подписки) | + +Клиенты подключаются к RU VPS на порт 443 для всех протоколов — дополнительные DNS-записи для VPN-трафика не нужны. + +--- + +## VLESS Encryption (опционально) + +Xray-core >= 25.x поддерживает пост-квантовое VLESS Encryption (mlkem768x25519plus). По умолчанию отключено. + +При включении **все** клиенты, подключающиеся к inbound, должны поддерживать шифрование — нельзя смешивать зашифрованных и обычных клиентов на одном inbound. + +**Генерация ключей:** + +```bash +xray vlessenc +# Вывод: decryption string (приватный, для сервера) + encryption string (публичный, для клиентов) +``` + +**Добавить в `roles/xray/defaults/secrets.yml`:** + +```yaml +xray_vless_decryption: "mlkem768x25519plus.PRIVATE..." # сервер — держать в секрете +xray_vless_client_encryption: "mlkem768x25519plus.PUBLIC..." # передаётся клиентам через Raven +``` + +Оба значения задаются одновременно или оба остаются `"none"`. При включении `flow` принудительно устанавливается в `xtls-rprx-vision` для всех пользователей. + +--- + +## Hysteria2 / sing-box (опционально) + +Задеплойте sing-box рядом с Xray для поддержки Hysteria2 (QUIC-протокол с обфускацией Salamander). + +```bash +# Скопировать и заполнить секреты +cp roles/sing-box-playbook/defaults/secrets.yml.example roles/sing-box-playbook/defaults/secrets.yml +ansible-vault encrypt roles/sing-box-playbook/defaults/secrets.yml --vault-password-file vault_password.txt + +# Задеплоить +ansible-playbook roles/role_sing-box.yml -i roles/hosts.yml --vault-password-file vault_password.txt +``` + +После деплоя установите `raven_subscribe_singbox_enabled: true` в `raven_subscribe/defaults/secrets.yml` и передеплойте Raven-subscribe. Он обнаружит Hysteria2-пользователей и будет раздавать их через эндпоинты `/sub/{token}/singbox` и `/sub/{token}/hysteria2`. + +**Примечание:** Hysteria2 использует ACME (Let's Encrypt) напрямую в sing-box. Задайте `singbox.tls_acme_domain` и `singbox.tls_acme_email` в секретах. + +--- + +## Тестирование + +Полный тестовый прогон — рендер всех Ansible-шаблонов и валидация через `xray -test` в Docker: + +```bash +./tests/run.sh +``` + +Только Ansible (без Docker): + +```bash +SKIP_XRAY_TEST=1 ./tests/run.sh +``` + +**Шаги пайплайна:** +1. Скачивает бинарь Xray (кэшируется в `tests/.cache/`) +2. Генерирует временные ключи Reality → `tests/fixtures/test_secrets.yml` +3. Запускает проверки `validate.yml` +4. Рендерит все `templates/conf/*.j2` → `tests/.output/conf.d/` +5. Запускает `xray -test -confdir` в Docker + +CI запускается автоматически на каждый push и PR через `.github/workflows/xray-config-test.yml`. + +**Запуск отдельных шагов вручную:** + +```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 +ansible-playbook tests/playbooks/render_conf.yml +``` + +--- + +## Мониторинг (опционально) + +Роль `monitoring` разворачивает полный стек наблюдаемости на EU VPS: + +```bash +ansible-playbook roles/role_monitoring.yml -i roles/hosts.yml --vault-password-file vault_password.txt +``` + +**Включённые дашборды Grafana:** +- **Xray — трафик по пользователям** — timeseries upload/download, топ пользователей, разбивка по inbound (Reality vs XHTTP) +- **Серверы EU/RU — состояние** — CPU, RAM, сеть, диск, здоровье Xray, latency Raven-subscribe + +**Правила алертинга:** +- Xray недоступен +- Raven-subscribe недоступен +- EU/RU сервер недоступен +- Диск заполнен > 85% + +--- + +## Связанные проекты + +- [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe) — сервер подписок (Go): автоматически находит пользователей из конфигов Xray, синхронизирует через gRPC API, раздаёт персональные ссылки подписки в форматах Xray JSON / sing-box JSON / share-ссылки +- [xray-stats-exporter](https://github.com/AlchemyLink/xray-stats-exporter) — Prometheus exporter для метрик трафика Xray по пользователям и inbound'ам +- [Xray-core](https://github.com/XTLS/Xray-core) — ядро VPN +- [sing-box](https://github.com/SagerNet/sing-box) — альтернативное ядро VPN (Hysteria2) + +--- + +## Лицензия + +[Mozilla Public License 2.0](LICENSE) diff --git a/roles/hosts.yml.example b/roles/hosts.yml.example new file mode 100644 index 0000000..ed89d18 --- /dev/null +++ b/roles/hosts.yml.example @@ -0,0 +1,13 @@ +--- +all: + children: + cloud: + hosts: + vm_my_srv: + ansible_host: "EU_VPS_IP" # Replace with your EU VPS IP + ansible_port: 22 + vars: + ansible_user: deploy + ansible_python_interpreter: /usr/bin/python3 + ansible_ssh_private_key_file: ~/.ssh/id_ed25519 + ansible_ssh_host_key_checking: false diff --git a/roles/monitoring/defaults/main.yml b/roles/monitoring/defaults/main.yml new file mode 100644 index 0000000..a239fbb --- /dev/null +++ b/roles/monitoring/defaults/main.yml @@ -0,0 +1,71 @@ +--- + +# xray-exporter +xray_exporter_version: "latest" # "latest" or pinned e.g. "v0.2.0" +xray_exporter_github_repo: "compassvpn/xray-exporter" +xray_exporter_listen: "127.0.0.1:9550" +xray_exporter_metrics_path: "/scrape" +xray_exporter_xray_endpoint: "127.0.0.1:10085" +xray_exporter_log_path: "/var/log/Xray/access.log" +xray_exporter_log_time_window: 5 # minutes, sliding window for user activity metrics +xray_exporter_bin_dir: "/usr/local/bin" +xray_exporter_data_dir: "/var/lib/xray-exporter" # GeoLite2 *.mmdb download cwd (xrayuser must be writable) +xray_exporter_service_name: "xray-exporter" +xray_exporter_user: "xrayuser" # same user as Xray — needs read access to access.log +xray_exporter_group: "xrayuser" + +# VictoriaMetrics +victoriametrics_version: "latest" # "latest" or pinned e.g. "v1.101.0" +victoriametrics_github_repo: "VictoriaMetrics/VictoriaMetrics" +victoriametrics_listen: "127.0.0.1:8428" +victoriametrics_retention_months: 1 # 1 month retention +victoriametrics_data_dir: "/var/lib/victoriametrics" +victoriametrics_bin_dir: "/usr/local/bin" +victoriametrics_service_name: "victoriametrics" +victoriametrics_scrape_interval: "15s" +victoriametrics_scrape_timeout: "5s" +victoriametrics_scrape_config: "/etc/victoriametrics/scrape.yml" +victoriametrics_user: "victoriametrics" +victoriametrics_group: "victoriametrics" + +# WireGuard network (for ufw rules on RU) +wg_network: "10.10.0.0/24" + +# node_exporter (system metrics: CPU, RAM, disk, network) +node_exporter_version: "latest" +node_exporter_listen: "127.0.0.1:9100" # EU: localhost only +node_exporter_ru_listen: "10.10.0.2:9100" # RU: WireGuard interface only +node_exporter_ru_scrape: "10.10.0.2:9100" # EU scrapes RU via WireGuard +node_exporter_bin_dir: "/usr/local/bin" +node_exporter_service_name: "node_exporter" + +# xray-stats-exporter (per-user traffic via StatsService gRPC) +xray_stats_exporter_listen: "127.0.0.1:9551" +xray_stats_exporter_metrics_path: "/metrics" +xray_stats_exporter_xray_endpoint: "127.0.0.1:10085" +xray_stats_exporter_bin_dir: "/usr/local/bin" +xray_stats_exporter_service_name: "xray-stats-exporter" +xray_stats_exporter_user: "xrayuser" # needs read access to Xray gRPC API and access.log +xray_stats_exporter_group: "xrayuser" +xray_stats_exporter_log_path: "/var/log/Xray/access.log" +xray_stats_exporter_geo_city_db: "/var/lib/xray-exporter/GeoLite2-City.mmdb" +xray_stats_exporter_geo_asn_db: "/var/lib/xray-exporter/GeoLite2-ASN.mmdb" + +# raven-subscribe health probe (VictoriaMetrics scrapes /health → up metric) +raven_subscribe_scrape_addr: "127.0.0.1:8080" + +# Grafana +grafana_listen: "127.0.0.1:13000" +grafana_service_name: "grafana-server" +grafana_data_dir: "/var/lib/grafana" +grafana_log_dir: "/var/log/grafana" +grafana_provisioning_dir: "/etc/grafana/provisioning" +grafana_dashboard_id: 23181 # compassvpn dashboard on grafana.com (file: xray-compassvpn.json) +# Extra: xray-users-traffic.json — upload/download per user (xray-exporter + policy statsUser*) +# Dashboard 23181 embeds ${DS_GRAFANACLOUD-COMPASSVPN-PROM}; file provisioning often has no such variable — replaced in JSON after download +grafana_dashboard_datasource_placeholder: "${DS_GRAFANACLOUD-COMPASSVPN-PROM}" +grafana_prometheus_datasource_name: "grafanacloud-compassvpn-prom" +grafana_prometheus_datasource_uid: "grafanacloud-compassvpn-prom" +# grafana_admin_password: set in secrets.yml (ansible-vault) +# victoriametrics_auth_username: set in secrets.yml (ansible-vault) +# victoriametrics_auth_password: set in secrets.yml (ansible-vault) diff --git a/roles/monitoring/defaults/secrets.yml.example b/roles/monitoring/defaults/secrets.yml.example new file mode 100644 index 0000000..2b88d0b --- /dev/null +++ b/roles/monitoring/defaults/secrets.yml.example @@ -0,0 +1,7 @@ +--- +# Copy to secrets.yml and encrypt: ansible-vault encrypt secrets.yml --vault-password-file vault_password.txt +# Generate password: openssl rand -hex 16 + +grafana_admin_password: "CHANGE_ME" +victoriametrics_auth_username: "metrics" +victoriametrics_auth_password: "CHANGE_ME" diff --git a/roles/monitoring/handlers/main.yml b/roles/monitoring/handlers/main.yml new file mode 100644 index 0000000..5145e2b --- /dev/null +++ b/roles/monitoring/handlers/main.yml @@ -0,0 +1,35 @@ +--- +- name: Restart xray-exporter + ansible.builtin.systemd: + name: "{{ xray_exporter_service_name }}" + state: restarted + daemon_reload: true + when: ansible_facts['service_mgr'] == "systemd" + +- name: Restart node_exporter + ansible.builtin.systemd: + name: "{{ node_exporter_service_name }}" + state: restarted + daemon_reload: true + when: ansible_facts['service_mgr'] == "systemd" + +- name: Restart xray-stats-exporter + ansible.builtin.systemd: + name: "{{ xray_stats_exporter_service_name }}" + state: restarted + daemon_reload: true + when: ansible_facts['service_mgr'] == "systemd" + +- name: Restart victoriametrics + ansible.builtin.systemd: + name: "{{ victoriametrics_service_name }}" + state: restarted + daemon_reload: true + when: ansible_facts['service_mgr'] == "systemd" + +- name: Restart grafana + ansible.builtin.systemd: + name: "{{ grafana_service_name }}" + state: restarted + daemon_reload: true + when: ansible_facts['service_mgr'] == "systemd" diff --git a/roles/monitoring/tasks/grafana.yml b/roles/monitoring/tasks/grafana.yml new file mode 100644 index 0000000..a1a1d1f --- /dev/null +++ b/roles/monitoring/tasks/grafana.yml @@ -0,0 +1,137 @@ +--- +- name: Install Grafana apt dependencies + ansible.builtin.apt: + name: + - apt-transport-https + - software-properties-common + - wget + state: present + update_cache: true + +- name: Ensure /etc/apt/keyrings exists + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + owner: root + group: root + mode: "0755" + +- name: Remove stale Grafana GPG key if present + ansible.builtin.file: + path: /etc/apt/keyrings/grafana.gpg + state: absent + +- name: Download and dearmor Grafana GPG key + ansible.builtin.shell: + cmd: "curl -fsSL https://apt.grafana.com/gpg.key | gpg --dearmor -o /etc/apt/keyrings/grafana.gpg" + changed_when: true + +- name: Set permissions on Grafana GPG key + ansible.builtin.file: + path: /etc/apt/keyrings/grafana.gpg + owner: root + group: root + mode: "0644" + +- name: Add Grafana apt repository + ansible.builtin.apt_repository: + repo: "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" + filename: grafana + state: present + update_cache: true + +- name: Install Grafana + ansible.builtin.apt: + name: grafana + state: present + +- name: Ensure Grafana provisioning directories exist + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: grafana + group: grafana + mode: "0755" + loop: + - "{{ grafana_provisioning_dir }}/datasources" + - "{{ grafana_provisioning_dir }}/dashboards" + - "{{ grafana_provisioning_dir }}/alerting" + - "{{ grafana_data_dir }}/dashboards" + +- name: Deploy grafana.ini + ansible.builtin.template: + src: grafana.ini.j2 + dest: /etc/grafana/grafana.ini + owner: root + group: grafana + mode: "0640" + notify: Restart grafana + +- name: Deploy Grafana datasource (VictoriaMetrics) + ansible.builtin.template: + src: grafana-datasource.yml.j2 + dest: "{{ grafana_provisioning_dir }}/datasources/victoriametrics.yml" + owner: grafana + group: grafana + mode: "0640" + notify: Restart grafana + +- name: Deploy Grafana dashboard provisioner config + ansible.builtin.template: + src: grafana-dashboards.yml.j2 + dest: "{{ grafana_provisioning_dir }}/dashboards/xray.yml" + owner: grafana + group: grafana + mode: "0640" + notify: Restart grafana + +- name: Download compassvpn Grafana dashboard JSON + ansible.builtin.get_url: + url: "https://grafana.com/api/dashboards/{{ grafana_dashboard_id }}/revisions/latest/download" + dest: "{{ grafana_data_dir }}/dashboards/xray-compassvpn.json" + owner: grafana + group: grafana + mode: "0640" + retries: 3 + delay: 3 + +- name: Replace DS_* datasource placeholders with provisioned UID (file provisioning has no variable) + ansible.builtin.replace: + path: "{{ grafana_data_dir }}/dashboards/xray-compassvpn.json" + regexp: "{{ grafana_dashboard_datasource_placeholder | regex_escape }}" + replace: "{{ grafana_prometheus_datasource_uid }}" + notify: Restart grafana + +- name: Deploy Grafana Xray per-user traffic dashboard + ansible.builtin.template: + src: dashboards/xray-users-traffic.json.j2 + dest: "{{ grafana_data_dir }}/dashboards/xray-users-traffic.json" + owner: grafana + group: grafana + mode: "0640" + notify: Restart grafana + +- name: Deploy Grafana server status dashboard + ansible.builtin.template: + src: dashboards/server-status.json.j2 + dest: "{{ grafana_data_dir }}/dashboards/server-status.json" + owner: grafana + group: grafana + mode: "0640" + notify: Restart grafana + +- name: Deploy Grafana alerting rules + ansible.builtin.template: + src: grafana-alerting.yml.j2 + dest: "{{ grafana_provisioning_dir }}/alerting/raven-alerts.yml" + owner: grafana + group: grafana + mode: "0640" + notify: Restart grafana + +- name: Enable and start Grafana + ansible.builtin.systemd: + name: "{{ grafana_service_name }}" + enabled: true + state: started + daemon_reload: true diff --git a/roles/monitoring/tasks/main.yml b/roles/monitoring/tasks/main.yml new file mode 100644 index 0000000..72573c5 --- /dev/null +++ b/roles/monitoring/tasks/main.yml @@ -0,0 +1,29 @@ +--- +- name: Load monitoring secrets + ansible.builtin.include_vars: + file: "{{ role_path }}/defaults/secrets.yml" + tags: always + +- name: Deploy node_exporter + ansible.builtin.import_tasks: node_exporter.yml + tags: node_exporter + +- name: Deploy xray-exporter + ansible.builtin.import_tasks: xray_exporter.yml + when: inventory_hostname == 'vm_my_srv' + tags: xray_exporter + +- name: Deploy VictoriaMetrics + ansible.builtin.import_tasks: victoriametrics.yml + when: inventory_hostname == 'vm_my_srv' + tags: victoriametrics + +- name: Deploy xray-stats-exporter + ansible.builtin.import_tasks: xray_stats_exporter.yml + when: inventory_hostname == 'vm_my_srv' + tags: xray_stats_exporter + +- name: Deploy Grafana + ansible.builtin.import_tasks: grafana.yml + when: inventory_hostname == 'vm_my_srv' + tags: grafana diff --git a/roles/monitoring/tasks/node_exporter.yml b/roles/monitoring/tasks/node_exporter.yml new file mode 100644 index 0000000..a80ccf3 --- /dev/null +++ b/roles/monitoring/tasks/node_exporter.yml @@ -0,0 +1,101 @@ +--- +- name: Get latest node_exporter release version + ansible.builtin.uri: + url: "https://api.github.com/repos/prometheus/node_exporter/releases/latest" + method: GET + return_content: true + headers: + Accept: "application/vnd.github.v3+json" + status_code: 200 + register: node_exporter_release_info + run_once: true + retries: 3 + delay: 3 + until: node_exporter_release_info.status == 200 + when: node_exporter_version == "latest" + +- name: Set node_exporter version fact + ansible.builtin.set_fact: + node_exporter_resolved_version: >- + {{ + node_exporter_release_info.json.tag_name + if node_exporter_version == "latest" + else node_exporter_version + }} + +- name: Detect architecture for node_exporter + ansible.builtin.set_fact: + node_exporter_arch: >- + {{ + 'amd64' if ansible_architecture in ['x86_64', 'amd64'] + else 'arm64' if ansible_architecture in ['aarch64', 'arm64'] + else 'armv7' if ansible_architecture.startswith('arm') + else ansible_architecture + }} + +- name: Download node_exporter archive + ansible.builtin.get_url: + url: "https://github.com/prometheus/node_exporter/releases/download/{{ node_exporter_resolved_version }}/node_exporter-{{ node_exporter_resolved_version[1:] }}.linux-{{ node_exporter_arch }}.tar.gz" + dest: "/tmp/node_exporter-{{ node_exporter_resolved_version }}.tar.gz" + mode: "0644" + register: node_exporter_download + +- name: Extract node_exporter binary + ansible.builtin.unarchive: + src: "/tmp/node_exporter-{{ node_exporter_resolved_version }}.tar.gz" + dest: /tmp/ + remote_src: true + when: node_exporter_download.changed + +- name: Install node_exporter binary + ansible.builtin.copy: + src: "/tmp/node_exporter-{{ node_exporter_resolved_version[1:] }}.linux-{{ node_exporter_arch }}/node_exporter" + dest: "{{ node_exporter_bin_dir }}/node_exporter" + owner: root + group: root + mode: "0755" + remote_src: true + when: node_exporter_download.changed + notify: Restart node_exporter + +- name: Remove node_exporter temp files + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - "/tmp/node_exporter-{{ node_exporter_resolved_version }}.tar.gz" + - "/tmp/node_exporter-{{ node_exporter_resolved_version[1:] }}.linux-{{ node_exporter_arch }}" + +- name: Set node_exporter effective listen address + ansible.builtin.set_fact: + node_exporter_listen: >- + {{ + node_exporter_ru_listen + if inventory_hostname == 'vm_my_ru' + else node_exporter_listen + }} + +- name: Deploy node_exporter systemd unit + ansible.builtin.template: + src: node_exporter.service.j2 + dest: "/etc/systemd/system/node_exporter.service" + owner: root + group: root + mode: "0644" + notify: Restart node_exporter + +- name: Enable and start node_exporter + ansible.builtin.systemd: + name: "{{ node_exporter_service_name }}" + enabled: true + state: started + daemon_reload: true + +- name: Allow node_exporter from WireGuard subnet (RU ufw) + community.general.ufw: + rule: allow + src: "{{ wg_network }}" + port: "9100" + proto: tcp + comment: "node_exporter via WireGuard" + when: inventory_hostname == 'vm_my_ru' diff --git a/roles/monitoring/tasks/ssh_tunnel_ru.yml b/roles/monitoring/tasks/ssh_tunnel_ru.yml new file mode 100644 index 0000000..2e90311 --- /dev/null +++ b/roles/monitoring/tasks/ssh_tunnel_ru.yml @@ -0,0 +1,38 @@ +--- +# Sets up a persistent SSH tunnel on EU: 127.0.0.1:19100 → RU:9100 +# Requires: +# - /etc/ssh/tunnel_ru (private key, generated once manually or via keygen task) +# - /etc/ssh/tunnel_ru_known_hosts (RU host key, populated by ssh-keyscan) +# - 'tunnel' user on RU with authorized_keys: restrict,port-forwarding,permitopen="127.0.0.1:9100",from="EU_IP" + +- name: Scan RU host key into tunnel known_hosts + ansible.builtin.shell: + cmd: > + ssh-keyscan -p {{ hostvars['vm_my_ru']['ansible_port'] }} + -t ed25519 {{ hostvars['vm_my_ru']['ansible_host'] }} + > {{ ssh_tunnel_ru_known_hosts }} + creates: "{{ ssh_tunnel_ru_known_hosts }}" + changed_when: false + +- name: Set tunnel known_hosts permissions + ansible.builtin.file: + path: "{{ ssh_tunnel_ru_known_hosts }}" + owner: root + group: root + mode: "0644" + +- name: Deploy ssh-tunnel-ru systemd unit + ansible.builtin.template: + src: ssh-tunnel-ru.service.j2 + dest: "/etc/systemd/system/{{ ssh_tunnel_ru_service_name }}.service" + owner: root + group: root + mode: "0644" + notify: Restart ssh-tunnel-ru + +- name: Enable and start ssh-tunnel-ru + ansible.builtin.systemd: + name: "{{ ssh_tunnel_ru_service_name }}" + enabled: true + state: started + daemon_reload: true diff --git a/roles/monitoring/tasks/victoriametrics.yml b/roles/monitoring/tasks/victoriametrics.yml new file mode 100644 index 0000000..2fb6094 --- /dev/null +++ b/roles/monitoring/tasks/victoriametrics.yml @@ -0,0 +1,132 @@ +--- +- name: Create victoriametrics system group + ansible.builtin.group: + name: "{{ victoriametrics_group }}" + state: present + system: true + +- name: Create victoriametrics system user + ansible.builtin.user: + name: "{{ victoriametrics_user }}" + group: "{{ victoriametrics_group }}" + system: true + shell: /usr/sbin/nologin + create_home: false + +- name: Ensure VictoriaMetrics data directory exists + ansible.builtin.file: + path: "{{ victoriametrics_data_dir }}" + state: directory + owner: "{{ victoriametrics_user }}" + group: "{{ victoriametrics_group }}" + mode: "0750" + +- name: Ensure VictoriaMetrics config directory exists + ansible.builtin.file: + path: /etc/victoriametrics + state: directory + owner: root + group: root + mode: "0755" + +- name: Detect architecture for VictoriaMetrics + ansible.builtin.set_fact: + vm_arch: >- + {{ + 'amd64' if ansible_architecture in ['x86_64', 'amd64'] + else 'arm64' if ansible_architecture in ['aarch64', 'arm64'] + else ansible_architecture + }} + +- name: Get VictoriaMetrics releases list + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ victoriametrics_github_repo }}/releases?per_page=10" + method: GET + return_content: true + headers: + Accept: "application/vnd.github.v3+json" + status_code: 200 + register: vm_release_list + run_once: true + retries: 3 + delay: 3 + until: vm_release_list.status == 200 + when: victoriametrics_version == "latest" + +- name: Set VictoriaMetrics version fact + ansible.builtin.set_fact: + vm_resolved_version: >- + {%- if victoriametrics_version != "latest" -%} + {{ victoriametrics_version }} + {%- else -%} + {%- set asset_pattern = 'victoria-metrics-linux-' + vm_arch + '-' -%} + {%- set ns = namespace(version='') -%} + {%- for release in vm_release_list.json -%} + {%- if ns.version == '' -%} + {%- for asset in release.assets -%} + {%- if asset.name.startswith(asset_pattern) and asset.name.endswith('.tar.gz') and 'enterprise' not in asset.name and 'cluster' not in asset.name -%} + {%- set ns.version = release.tag_name -%} + {%- endif -%} + {%- endfor -%} + {%- endif -%} + {%- endfor -%} + {{ ns.version }} + {%- endif -%} + +- name: Download VictoriaMetrics binary + ansible.builtin.get_url: + url: "https://github.com/{{ victoriametrics_github_repo }}/releases/download/{{ vm_resolved_version }}/victoria-metrics-linux-{{ vm_arch }}-{{ vm_resolved_version }}.tar.gz" + dest: "/tmp/victoria-metrics-{{ vm_resolved_version }}.tar.gz" + mode: "0644" + register: vm_download + +- name: Extract VictoriaMetrics binary + ansible.builtin.unarchive: + src: "/tmp/victoria-metrics-{{ vm_resolved_version }}.tar.gz" + dest: /tmp/ + remote_src: true + when: vm_download.changed + +- name: Install VictoriaMetrics binary + ansible.builtin.copy: + src: /tmp/victoria-metrics-prod + dest: "{{ victoriametrics_bin_dir }}/victoria-metrics" + owner: root + group: root + mode: "0755" + remote_src: true + when: vm_download.changed + notify: Restart victoriametrics + +- name: Remove VictoriaMetrics temp files + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - "/tmp/victoria-metrics-{{ vm_resolved_version }}.tar.gz" + - "/tmp/victoria-metrics-prod" + +- name: Deploy scrape config + ansible.builtin.template: + src: scrape.yml.j2 + dest: "{{ victoriametrics_scrape_config }}" + owner: root + group: root + mode: "0644" + notify: Restart victoriametrics + +- name: Deploy VictoriaMetrics systemd unit + ansible.builtin.template: + src: victoriametrics.service.j2 + dest: "/etc/systemd/system/victoriametrics.service" + owner: root + group: root + mode: "0644" + notify: Restart victoriametrics + +- name: Enable and start VictoriaMetrics + ansible.builtin.systemd: + name: "{{ victoriametrics_service_name }}" + enabled: true + state: started + daemon_reload: true diff --git a/roles/monitoring/tasks/xray_exporter.yml b/roles/monitoring/tasks/xray_exporter.yml new file mode 100644 index 0000000..9beac2a --- /dev/null +++ b/roles/monitoring/tasks/xray_exporter.yml @@ -0,0 +1,67 @@ +--- +- name: Get latest xray-exporter release version + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ xray_exporter_github_repo }}/releases/latest" + method: GET + return_content: true + headers: + Accept: "application/vnd.github.v3+json" + status_code: 200 + register: xray_exporter_release_info + run_once: true + retries: 3 + delay: 3 + until: xray_exporter_release_info.status == 200 + when: xray_exporter_version == "latest" + +- name: Set xray-exporter version fact + ansible.builtin.set_fact: + xray_exporter_resolved_version: >- + {{ + xray_exporter_release_info.json.tag_name + if xray_exporter_version == "latest" + else xray_exporter_version + }} + +- name: Detect architecture for xray-exporter + ansible.builtin.set_fact: + xray_exporter_arch: >- + {{ + 'amd64' if ansible_architecture in ['x86_64', 'amd64'] + else 'arm64' if ansible_architecture in ['aarch64', 'arm64'] + else 'arm' if ansible_architecture.startswith('arm') + else ansible_architecture + }} + +- name: Ensure xray-exporter data directory exists (GeoLite DBs) + ansible.builtin.file: + path: "{{ xray_exporter_data_dir }}" + state: directory + owner: "{{ xray_exporter_user }}" + group: "{{ xray_exporter_group }}" + mode: "0750" + +- name: Download xray-exporter binary + ansible.builtin.get_url: + url: "https://github.com/{{ xray_exporter_github_repo }}/releases/download/{{ xray_exporter_resolved_version }}/xray-exporter-linux-{{ xray_exporter_arch }}" + dest: "{{ xray_exporter_bin_dir }}/xray-exporter" + owner: root + group: root + mode: "0755" + notify: Restart xray-exporter + +- name: Deploy xray-exporter systemd unit + ansible.builtin.template: + src: xray-exporter.service.j2 + dest: "/etc/systemd/system/xray-exporter.service" + owner: root + group: root + mode: "0644" + notify: Restart xray-exporter + +- name: Enable and start xray-exporter + ansible.builtin.systemd: + name: "{{ xray_exporter_service_name }}" + enabled: true + state: started + daemon_reload: true diff --git a/roles/monitoring/tasks/xray_stats_exporter.yml b/roles/monitoring/tasks/xray_stats_exporter.yml new file mode 100644 index 0000000..50fd341 --- /dev/null +++ b/roles/monitoring/tasks/xray_stats_exporter.yml @@ -0,0 +1,26 @@ +--- +- name: Copy xray-stats-exporter binary + ansible.builtin.copy: + src: "{{ xray_stats_exporter_local_binary }}" + dest: "{{ xray_stats_exporter_bin_dir }}/xray-stats-exporter" + owner: root + group: root + mode: "0755" + when: xray_stats_exporter_local_binary is defined + notify: Restart xray-stats-exporter + +- name: Deploy xray-stats-exporter systemd unit + ansible.builtin.template: + src: xray-stats-exporter.service.j2 + dest: "/etc/systemd/system/xray-stats-exporter.service" + owner: root + group: root + mode: "0644" + notify: Restart xray-stats-exporter + +- name: Enable and start xray-stats-exporter + ansible.builtin.systemd: + name: "{{ xray_stats_exporter_service_name }}" + enabled: true + state: started + daemon_reload: true diff --git a/roles/monitoring/templates/dashboards/server-status.json.j2 b/roles/monitoring/templates/dashboards/server-status.json.j2 new file mode 100644 index 0000000..55ef41c --- /dev/null +++ b/roles/monitoring/templates/dashboards/server-status.json.j2 @@ -0,0 +1,763 @@ +{ + "annotations": {"list": []}, + "editable": true, + "graphTooltip": 1, + "links": [], + "panels": [ + + { + "collapsed": false, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 0}, + "id": 100, + "title": "Доступность", + "type": "row" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "mappings": [ + {"options": {"0": {"color": "red", "text": "DOWN"}, "1": {"color": "green", "text": "UP"}}, "type": "value"} + ], + "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}, + "color": {"mode": "thresholds"} + }, + "overrides": [] + }, + "gridPos": {"h": 4, "w": 4, "x": 0, "y": 1}, + "id": 1, + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}, "orientation": "auto", "textMode": "auto", "colorMode": "background"}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "up{job=\"node\", server=\"eu\"}", + "legendFormat": "EU", + "instant": true, + "refId": "A" + } + ], + "title": "EU (64.226.79.239)", + "type": "stat" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "mappings": [ + {"options": {"0": {"color": "red", "text": "DOWN"}, "1": {"color": "green", "text": "UP"}}, "type": "value"} + ], + "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}, + "color": {"mode": "thresholds"} + }, + "overrides": [] + }, + "gridPos": {"h": 4, "w": 4, "x": 4, "y": 1}, + "id": 2, + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}, "orientation": "auto", "textMode": "auto", "colorMode": "background"}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "up{job=\"node\", server=\"ru\"}", + "legendFormat": "RU", + "instant": true, + "refId": "A" + } + ], + "title": "RU (195.19.92.182)", + "type": "stat" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "unit": "s", + "thresholds": {"mode": "absolute", "steps": [{"color": "green", "value": null}]}, + "color": {"mode": "thresholds"} + }, + "overrides": [] + }, + "gridPos": {"h": 4, "w": 4, "x": 8, "y": 1}, + "id": 3, + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}, "colorMode": "value", "textMode": "auto"}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "time() - node_boot_time_seconds{server=\"eu\"}", + "legendFormat": "EU uptime", + "instant": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "time() - node_boot_time_seconds{server=\"ru\"}", + "legendFormat": "RU uptime", + "instant": true, + "refId": "B" + } + ], + "title": "Uptime", + "type": "stat" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "mappings": [ + {"options": {"0": {"color": "red", "text": "DOWN"}, "1": {"color": "green", "text": "UP"}}, "type": "value"} + ], + "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}, + "color": {"mode": "thresholds"} + }, + "overrides": [] + }, + "gridPos": {"h": 4, "w": 4, "x": 12, "y": 1}, + "id": 4, + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}, "colorMode": "background", "textMode": "auto"}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "xray_up{job=\"xray\"}", + "legendFormat": "EU Xray", + "instant": true, + "refId": "A" + } + ], + "title": "EU Xray", + "type": "stat" + }, + + { + "collapsed": false, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 5}, + "id": 101, + "title": "CPU", + "type": "row" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, + "unit": "percentunit", + "min": 0, "max": 1 + }, + "overrides": [] + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 6}, + "id": 10, + "options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"]}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "1 - avg(rate(node_cpu_seconds_total{server=\"eu\", mode=\"idle\"}[$__rate_interval]))", + "legendFormat": "EU CPU usage", + "range": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "avg(rate(node_cpu_seconds_total{server=\"eu\", mode=\"iowait\"}[$__rate_interval]))", + "legendFormat": "EU iowait", + "range": true, + "refId": "B" + } + ], + "title": "EU CPU", + "type": "timeseries" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, + "unit": "percentunit", + "min": 0, "max": 1 + }, + "overrides": [] + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 6}, + "id": 11, + "options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"]}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "1 - avg(rate(node_cpu_seconds_total{server=\"ru\", mode=\"idle\"}[$__rate_interval]))", + "legendFormat": "RU CPU usage", + "range": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "avg(rate(node_cpu_seconds_total{server=\"ru\", mode=\"iowait\"}[$__rate_interval]))", + "legendFormat": "RU iowait", + "range": true, + "refId": "B" + } + ], + "title": "RU CPU", + "type": "timeseries" + }, + + { + "collapsed": false, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 14}, + "id": 102, + "title": "Память", + "type": "row" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 15, "showPoints": "never"}, + "unit": "bytes", + "min": 0 + }, + "overrides": [] + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 15}, + "id": 20, + "options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["mean", "lastNotNull"]}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "node_memory_MemTotal_bytes{server=\"eu\"} - node_memory_MemAvailable_bytes{server=\"eu\"}", + "legendFormat": "EU used", + "range": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "node_memory_MemTotal_bytes{server=\"eu\"}", + "legendFormat": "EU total", + "range": true, + "refId": "B" + } + ], + "title": "EU RAM", + "type": "timeseries" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 15, "showPoints": "never"}, + "unit": "bytes", + "min": 0 + }, + "overrides": [] + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 15}, + "id": 21, + "options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["mean", "lastNotNull"]}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "node_memory_MemTotal_bytes{server=\"ru\"} - node_memory_MemAvailable_bytes{server=\"ru\"}", + "legendFormat": "RU used", + "range": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "node_memory_MemTotal_bytes{server=\"ru\"}", + "legendFormat": "RU total", + "range": true, + "refId": "B" + } + ], + "title": "RU RAM", + "type": "timeseries" + }, + + { + "collapsed": false, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 23}, + "id": 103, + "title": "Сеть", + "type": "row" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, + "unit": "Bps" + }, + "overrides": [ + {"matcher": {"id": "byRegexp", "options": "rx"}, "properties": [{"id": "custom.transform", "value": "negative-Y"}]} + ] + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 24}, + "id": 30, + "options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"]}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "sum(rate(node_network_transmit_bytes_total{server=\"eu\", device!~\"lo|docker.*|veth.*\"}[$__rate_interval]))", + "legendFormat": "EU tx", + "range": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "sum(rate(node_network_receive_bytes_total{server=\"eu\", device!~\"lo|docker.*|veth.*\"}[$__rate_interval]))", + "legendFormat": "EU rx", + "range": true, + "refId": "B" + } + ], + "title": "EU сеть (tx вверх / rx вниз)", + "type": "timeseries" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, + "unit": "Bps" + }, + "overrides": [ + {"matcher": {"id": "byRegexp", "options": "rx"}, "properties": [{"id": "custom.transform", "value": "negative-Y"}]} + ] + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 24}, + "id": 31, + "options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"]}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "sum(rate(node_network_transmit_bytes_total{server=\"ru\", device!~\"lo|docker.*|veth.*\"}[$__rate_interval]))", + "legendFormat": "RU tx", + "range": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "sum(rate(node_network_receive_bytes_total{server=\"ru\", device!~\"lo|docker.*|veth.*\"}[$__rate_interval]))", + "legendFormat": "RU rx", + "range": true, + "refId": "B" + } + ], + "title": "RU сеть (tx вверх / rx вниз)", + "type": "timeseries" + }, + + { + "collapsed": false, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 32}, + "id": 104, + "title": "Диск", + "type": "row" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": {"mode": "percentage", "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 70}, + {"color": "red", "value": 85} + ]}, + "unit": "percentunit", "min": 0, "max": 1 + }, + "overrides": [] + }, + "gridPos": {"h": 6, "w": 6, "x": 0, "y": 33}, + "id": 40, + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}, "orientation": "horizontal", "displayMode": "basic", "valueMode": "color"}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "1 - node_filesystem_avail_bytes{server=\"eu\", mountpoint=\"/\"} / node_filesystem_size_bytes{server=\"eu\", mountpoint=\"/\"}", + "legendFormat": "EU /", + "instant": true, + "refId": "A" + } + ], + "title": "EU использование диска", + "type": "bargauge" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": {"mode": "percentage", "steps": [ + {"color": "green", "value": null}, + {"color": "yellow", "value": 70}, + {"color": "red", "value": 85} + ]}, + "unit": "percentunit", "min": 0, "max": 1 + }, + "overrides": [] + }, + "gridPos": {"h": 6, "w": 6, "x": 6, "y": 33}, + "id": 41, + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}, "orientation": "horizontal", "displayMode": "basic", "valueMode": "color"}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "1 - node_filesystem_avail_bytes{server=\"ru\", mountpoint=\"/\"} / node_filesystem_size_bytes{server=\"ru\", mountpoint=\"/\"}", + "legendFormat": "RU /", + "instant": true, + "refId": "A" + } + ], + "title": "RU использование диска", + "type": "bargauge" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 33}, + "id": 42, + "options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"]}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "sum(rate(node_disk_read_bytes_total{server=\"eu\"}[$__rate_interval]))", + "legendFormat": "EU read", + "range": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "sum(rate(node_disk_written_bytes_total{server=\"eu\"}[$__rate_interval]))", + "legendFormat": "EU write", + "range": true, + "refId": "B" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "sum(rate(node_disk_read_bytes_total{server=\"ru\"}[$__rate_interval]))", + "legendFormat": "RU read", + "range": true, + "refId": "C" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "sum(rate(node_disk_written_bytes_total{server=\"ru\"}[$__rate_interval]))", + "legendFormat": "RU write", + "range": true, + "refId": "D" + } + ], + "title": "Disk I/O", + "type": "timeseries" + }, + + { + "collapsed": false, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 41}, + "id": 105, + "title": "Xray (EU)", + "type": "row" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 42}, + "id": 50, + "options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"]}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "rate(xray_traffic_downlink_bytes_total{dimension=\"inbound\"}[$__rate_interval])", + "legendFormat": "{% raw %}{{target}}{% endraw %} down", + "range": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "rate(xray_traffic_uplink_bytes_total{dimension=\"inbound\"}[$__rate_interval])", + "legendFormat": "{% raw %}{{target}}{% endraw %} up", + "range": true, + "refId": "B" + } + ], + "title": "Xray трафик по inbound", + "type": "timeseries" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": {"mode": "absolute", "steps": [{"color": "blue", "value": null}]}, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": {"h": 4, "w": 4, "x": 12, "y": 42}, + "id": 51, + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}, "colorMode": "value", "textMode": "auto"}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "xray_uptime_seconds", + "legendFormat": "Xray uptime", + "instant": true, + "refId": "A" + } + ], + "title": "Xray uptime", + "type": "stat" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": {"h": 4, "w": 8, "x": 16, "y": 42}, + "id": 52, + "options": {"legend": {"displayMode": "list", "placement": "bottom"}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "xray_goroutines", + "legendFormat": "goroutines", + "range": true, + "refId": "A" + } + ], + "title": "Xray goroutines", + "type": "timeseries" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": {"mode": "absolute", "steps": [{"color": "blue", "value": null}]}, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": {"h": 4, "w": 4, "x": 12, "y": 46}, + "id": 53, + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}, "colorMode": "value", "textMode": "auto", "orientation": "auto"}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "xray_unique_users", + "legendFormat": "unique users", + "instant": true, + "refId": "A" + } + ], + "title": "Активных пользователей", + "type": "stat" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "thresholds"}, + "thresholds": {"mode": "absolute", "steps": [{"color": "blue", "value": null}]}, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": {"h": 4, "w": 4, "x": 16, "y": 46}, + "id": 54, + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}, "colorMode": "value", "textMode": "auto", "orientation": "auto"}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "xray_total_connections", + "legendFormat": "connections", + "instant": true, + "refId": "A" + } + ], + "title": "Соединений", + "type": "stat" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 50}, + "id": 55, + "options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"]}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "rate(xray_outbound_requests_total{outbound=\"freedom\"}[$__rate_interval])", + "legendFormat": "freedom (пропущено)", + "range": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "rate(xray_outbound_requests_total{outbound=\"blocked\"}[$__rate_interval])", + "legendFormat": "blocked (заблокировано)", + "range": true, + "refId": "B" + } + ], + "title": "Xray routing: запросы/с", + "type": "timeseries" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 50}, + "id": 56, + "options": {"legend": {"displayMode": "table", "placement": "bottom", "calcs": ["mean", "max"]}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "xray_memstats_alloc_bytes", + "legendFormat": "heap alloc", + "range": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "xray_memstats_sys_bytes", + "legendFormat": "sys", + "range": true, + "refId": "B" + } + ], + "title": "Xray память (heap)", + "type": "timeseries" + }, + + { + "collapsed": false, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 58}, + "id": 106, + "title": "Raven-subscribe", + "type": "row" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "mappings": [ + {"options": {"0": {"color": "red", "text": "DOWN"}, "1": {"color": "green", "text": "UP"}}, "type": "value"} + ], + "thresholds": {"mode": "absolute", "steps": [{"color": "red", "value": null}, {"color": "green", "value": 1}]}, + "color": {"mode": "thresholds"} + }, + "overrides": [] + }, + "gridPos": {"h": 4, "w": 4, "x": 0, "y": 59}, + "id": 60, + "options": {"reduceOptions": {"calcs": ["lastNotNull"]}, "colorMode": "background", "textMode": "auto"}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "up{job=\"raven-subscribe\"}", + "legendFormat": "Raven-subscribe", + "instant": true, + "refId": "A" + } + ], + "title": "Raven-subscribe", + "type": "stat" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": {"h": 4, "w": 8, "x": 4, "y": 59}, + "id": 61, + "options": {"legend": {"displayMode": "list", "placement": "bottom"}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "expr": "scrape_duration_seconds{job=\"raven-subscribe\"}", + "legendFormat": "scrape latency", + "range": true, + "refId": "A" + } + ], + "title": "Raven-subscribe latency (/health)", + "type": "timeseries" + } + + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["server", "node", "raven"], + "templating": {"list": []}, + "time": {"from": "now-3h", "to": "now"}, + "timepicker": {}, + "timezone": "browser", + "title": "Серверы EU / RU — состояние", + "uid": "raven-server-status", + "version": 1, + "weekStart": "" +} diff --git a/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 b/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 new file mode 100644 index 0000000..c8bd2c7 --- /dev/null +++ b/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 @@ -0,0 +1,241 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": {"type": "grafana", "uid": "-- Grafana --"}, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "liveNow": false, + "panels": [ + + { + "collapsed": false, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 0}, + "id": 10, + "title": "Пользователи — трафик", + "type": "row" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "showPoints": "never" + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 1}, + "id": 1, + "options": { + "legend": {"displayMode": "table", "placement": "right", "showLegend": true, "calcs": ["lastNotNull", "max"]} + }, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "editorMode": "code", + "expr": "rate(xray_user_uplink_bytes_total[$__rate_interval])", + "legendFormat": "{% raw %}{{email}}{% endraw %}", + "range": true, + "refId": "A" + } + ], + "title": "Upload (client → server), bytes/s by user", + "type": "timeseries" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "showPoints": "never" + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 11}, + "id": 2, + "options": { + "legend": {"displayMode": "table", "placement": "right", "showLegend": true, "calcs": ["lastNotNull", "max"]} + }, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "editorMode": "code", + "expr": "rate(xray_user_downlink_bytes_total[$__rate_interval])", + "legendFormat": "{% raw %}{{email}}{% endraw %}", + "range": true, + "refId": "A" + } + ], + "title": "Download (server → client), bytes/s by user", + "type": "timeseries" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "bytes", + "min": 0 + }, + "overrides": [] + }, + "gridPos": {"h": 10, "w": 12, "x": 0, "y": 21}, + "id": 3, + "options": {"orientation": "horizontal", "displayMode": "gradient", "reduceOptions": {"calcs": ["lastNotNull"]}, "valueMode": "color", "showUnfilled": true}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "editorMode": "code", + "expr": "sort_desc(increase(xray_user_downlink_bytes_total[$__range]))", + "instant": true, + "legendFormat": "{% raw %}{{email}}{% endraw %}", + "refId": "A" + } + ], + "title": "Download за период (топ пользователей)", + "type": "bargauge" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "bytes", + "min": 0 + }, + "overrides": [] + }, + "gridPos": {"h": 10, "w": 12, "x": 12, "y": 21}, + "id": 4, + "options": {"orientation": "horizontal", "displayMode": "gradient", "reduceOptions": {"calcs": ["lastNotNull"]}, "valueMode": "color", "showUnfilled": true}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "editorMode": "code", + "expr": "sort_desc(increase(xray_user_uplink_bytes_total[$__range]))", + "instant": true, + "legendFormat": "{% raw %}{{email}}{% endraw %}", + "refId": "A" + } + ], + "title": "Upload за период (топ пользователей)", + "type": "bargauge" + }, + + { + "collapsed": false, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 31}, + "id": 20, + "title": "Inbound — трафик по протоколам", + "type": "row" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": { + "axisBorderShow": false, + "drawStyle": "line", + "fillOpacity": 10, + "showPoints": "never" + }, + "unit": "Bps" + }, + "overrides": [] + }, + "gridPos": {"h": 10, "w": 12, "x": 0, "y": 32}, + "id": 21, + "options": { + "legend": {"displayMode": "table", "placement": "bottom", "showLegend": true, "calcs": ["lastNotNull", "max"]} + }, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "editorMode": "code", + "expr": "rate(xray_inbound_downlink_bytes_total[$__rate_interval])", + "legendFormat": "{% raw %}{{inbound}}{% endraw %} down", + "range": true, + "refId": "A" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "editorMode": "code", + "expr": "rate(xray_inbound_uplink_bytes_total[$__rate_interval])", + "legendFormat": "{% raw %}{{inbound}}{% endraw %} up", + "range": true, + "refId": "B" + } + ], + "title": "Трафик по inbound (bytes/s)", + "type": "timeseries" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "bytes", + "min": 0 + }, + "overrides": [] + }, + "gridPos": {"h": 10, "w": 12, "x": 12, "y": 32}, + "id": 22, + "options": {"orientation": "horizontal", "displayMode": "gradient", "reduceOptions": {"calcs": ["lastNotNull"]}, "valueMode": "color", "showUnfilled": true}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "editorMode": "code", + "expr": "sort_desc(increase(xray_inbound_downlink_bytes_total[$__range]))", + "instant": true, + "legendFormat": "{% raw %}{{inbound}}{% endraw %}", + "refId": "A" + } + ], + "title": "Inbound download за период", + "type": "bargauge" + } + + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["xray", "raven", "users"], + "templating": {"list": []}, + "time": {"from": "now-6h", "to": "now"}, + "timepicker": {}, + "timezone": "browser", + "title": "Xray — per-user upload / download", + "uid": "raven-xray-users-traffic", + "version": 2, + "weekStart": "" +} diff --git a/roles/monitoring/templates/grafana-alerting.yml.j2 b/roles/monitoring/templates/grafana-alerting.yml.j2 new file mode 100644 index 0000000..4e1f298 --- /dev/null +++ b/roles/monitoring/templates/grafana-alerting.yml.j2 @@ -0,0 +1,193 @@ +apiVersion: 1 + +groups: + - orgId: 1 + name: raven-critical + folder: Raven + interval: 1m + rules: + + - uid: xray-down + title: "Xray API недоступен" + condition: C + data: + - refId: A + relativeTimeRange: + from: 300 + to: 0 + datasourceUid: "{{ grafana_prometheus_datasource_uid }}" + model: + expr: "xray_up{job=\"xray-stats\"}" + instant: true + refId: A + - refId: C + datasourceUid: __expr__ + model: + type: threshold + refId: C + conditions: + - evaluator: + params: [1] + type: lt + operator: + type: and + query: + params: [A] + reducer: + type: last + noDataState: Alerting + execErrState: Alerting + for: 2m + annotations: + summary: "Xray API не отвечает на gRPC запросы" + description: "xray_up == 0 уже более 2 минут. Сервис Xray может быть упавшим." + labels: + severity: critical + + - uid: raven-subscribe-down + title: "Raven-subscribe недоступен" + condition: C + data: + - refId: A + relativeTimeRange: + from: 300 + to: 0 + datasourceUid: "{{ grafana_prometheus_datasource_uid }}" + model: + expr: "up{job=\"raven-subscribe\"}" + instant: true + refId: A + - refId: C + datasourceUid: __expr__ + model: + type: threshold + refId: C + conditions: + - evaluator: + params: [1] + type: lt + operator: + type: and + query: + params: [A] + reducer: + type: last + noDataState: Alerting + execErrState: Alerting + for: 2m + annotations: + summary: "Raven-subscribe не отвечает на /health" + description: "Сервис raven-subscribe недоступен более 2 минут. Подписки не работают." + labels: + severity: critical + + - uid: eu-server-down + title: "EU сервер недоступен" + condition: C + data: + - refId: A + relativeTimeRange: + from: 300 + to: 0 + datasourceUid: "{{ grafana_prometheus_datasource_uid }}" + model: + expr: "up{job=\"node\", server=\"eu\"}" + instant: true + refId: A + - refId: C + datasourceUid: __expr__ + model: + type: threshold + refId: C + conditions: + - evaluator: + params: [1] + type: lt + operator: + type: and + query: + params: [A] + reducer: + type: last + noDataState: Alerting + execErrState: Alerting + for: 3m + annotations: + summary: "EU сервер (64.226.79.239) недоступен" + description: "node_exporter на EU сервере не отвечает более 3 минут." + labels: + severity: critical + + - uid: ru-server-down + title: "RU сервер недоступен" + condition: C + data: + - refId: A + relativeTimeRange: + from: 300 + to: 0 + datasourceUid: "{{ grafana_prometheus_datasource_uid }}" + model: + expr: "up{job=\"node\", server=\"ru\"}" + instant: true + refId: A + - refId: C + datasourceUid: __expr__ + model: + type: threshold + refId: C + conditions: + - evaluator: + params: [1] + type: lt + operator: + type: and + query: + params: [A] + reducer: + type: last + noDataState: Alerting + execErrState: Alerting + for: 3m + annotations: + summary: "RU сервер (195.19.92.182) недоступен" + description: "node_exporter на RU сервере не отвечает более 3 минут." + labels: + severity: warning + + - uid: eu-disk-high + title: "EU диск заполнен >85%" + condition: C + data: + - refId: A + relativeTimeRange: + from: 300 + to: 0 + datasourceUid: "{{ grafana_prometheus_datasource_uid }}" + model: + expr: "1 - node_filesystem_avail_bytes{server=\"eu\", mountpoint=\"/\"} / node_filesystem_size_bytes{server=\"eu\", mountpoint=\"/\"}" + instant: true + refId: A + - refId: C + datasourceUid: __expr__ + model: + type: threshold + refId: C + conditions: + - evaluator: + params: [0.85] + type: gt + operator: + type: and + query: + params: [A] + reducer: + type: last + noDataState: NoData + execErrState: Error + for: 5m + annotations: + summary: "EU диск заполнен более чем на 85%" + description: "Дисковое пространство на EU сервере критически мало. Могут перестать писаться логи." + labels: + severity: warning diff --git a/roles/monitoring/templates/grafana-dashboards.yml.j2 b/roles/monitoring/templates/grafana-dashboards.yml.j2 new file mode 100644 index 0000000..6c2a6a2 --- /dev/null +++ b/roles/monitoring/templates/grafana-dashboards.yml.j2 @@ -0,0 +1,9 @@ +apiVersion: 1 + +providers: + - name: xray + type: file + disableDeletion: true + updateIntervalSeconds: 60 + options: + path: {{ grafana_data_dir }}/dashboards diff --git a/roles/monitoring/templates/grafana-datasource.yml.j2 b/roles/monitoring/templates/grafana-datasource.yml.j2 new file mode 100644 index 0000000..7d169d9 --- /dev/null +++ b/roles/monitoring/templates/grafana-datasource.yml.j2 @@ -0,0 +1,14 @@ +apiVersion: 1 + +datasources: + - name: "{{ grafana_prometheus_datasource_name }}" + uid: "{{ grafana_prometheus_datasource_uid }}" + type: prometheus + access: proxy + url: http://{{ victoriametrics_listen }} + isDefault: true + editable: false + basicAuth: true + basicAuthUser: {{ victoriametrics_auth_username }} + secureJsonData: + basicAuthPassword: {{ victoriametrics_auth_password }} diff --git a/roles/monitoring/templates/grafana.ini.j2 b/roles/monitoring/templates/grafana.ini.j2 new file mode 100644 index 0000000..b9c1750 --- /dev/null +++ b/roles/monitoring/templates/grafana.ini.j2 @@ -0,0 +1,35 @@ +[server] +http_addr = {{ grafana_listen.split(':')[0] }} +http_port = {{ grafana_listen.split(':')[1] }} +# SSH tunnel: ssh -L 13000:127.0.0.1:13000 ... then open http://localhost:13000 + +[security] +admin_user = admin +admin_password = {{ grafana_admin_password }} +disable_initial_admin_creation = false +cookie_secure = false # SSH tunnel is localhost, no HTTPS needed here +cookie_samesite = lax +secret_key = {{ grafana_admin_password | hash('sha256') }} + +[auth.anonymous] +enabled = false + +[auth] +disable_login_form = false + +[users] +allow_sign_up = false +allow_org_create = false + +[log] +mode = file +level = warn + +[paths] +data = {{ grafana_data_dir }} +logs = {{ grafana_log_dir }} +provisioning = {{ grafana_provisioning_dir }} + +[analytics] +reporting_enabled = false +check_for_updates = false diff --git a/roles/monitoring/templates/node_exporter.service.j2 b/roles/monitoring/templates/node_exporter.service.j2 new file mode 100644 index 0000000..a94d23e --- /dev/null +++ b/roles/monitoring/templates/node_exporter.service.j2 @@ -0,0 +1,27 @@ +[Unit] +Description=Prometheus Node Exporter +After=network.target + +[Service] +User=nobody +Group=nogroup +NoNewPrivileges=true +ExecStart={{ node_exporter_bin_dir }}/node_exporter \ + --web.listen-address={{ node_exporter_listen }} \ + --collector.disable-defaults \ + --collector.cpu \ + --collector.meminfo \ + --collector.filesystem \ + --collector.netdev \ + --collector.loadavg \ + --collector.time \ + --collector.uname \ + --collector.stat \ + --collector.diskstats \ + --collector.conntrack +Restart=on-failure +RestartSec=5s +TimeoutStopSec=10s + +[Install] +WantedBy=multi-user.target diff --git a/roles/monitoring/templates/scrape.yml.j2 b/roles/monitoring/templates/scrape.yml.j2 new file mode 100644 index 0000000..e0fac4c --- /dev/null +++ b/roles/monitoring/templates/scrape.yml.j2 @@ -0,0 +1,38 @@ +global: + scrape_interval: {{ victoriametrics_scrape_interval }} + scrape_timeout: {{ victoriametrics_scrape_timeout }} + +scrape_configs: + - job_name: xray + metrics_path: {{ xray_exporter_metrics_path }} + static_configs: + - targets: + - {{ xray_exporter_listen }} + + - job_name: xray-stats + metrics_path: {{ xray_stats_exporter_metrics_path }} + static_configs: + - targets: + - {{ xray_stats_exporter_listen }} + + - job_name: node + static_configs: + - targets: + - {{ node_exporter_listen }} + labels: + server: eu + instance: "{{ hostvars['vm_my_srv']['ansible_host'] }}" + - targets: + - {{ node_exporter_ru_scrape }} # WireGuard → RU:9100 + labels: + server: ru + instance: "{{ hostvars['vm_my_ru']['ansible_host'] }}" + + # Raven-subscribe health probe — up=1 if service responds to /health + # VictoriaMetrics records the scrape as up=0 when the endpoint is unreachable, + # regardless of whether the response body is valid Prometheus format. + - job_name: raven-subscribe + metrics_path: /health + static_configs: + - targets: + - {{ raven_subscribe_scrape_addr }} diff --git a/roles/monitoring/templates/ssh-tunnel-ru.service.j2 b/roles/monitoring/templates/ssh-tunnel-ru.service.j2 new file mode 100644 index 0000000..862e5aa --- /dev/null +++ b/roles/monitoring/templates/ssh-tunnel-ru.service.j2 @@ -0,0 +1,24 @@ +[Unit] +Description=SSH tunnel to RU node_exporter ({{ node_exporter_tunnel_listen }} -> RU:9100) +After=network.target +Wants=network-online.target + +[Service] +User=root +ExecStart=/usr/bin/ssh \ + -i {{ ssh_tunnel_ru_key }} \ + -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile={{ ssh_tunnel_ru_known_hosts }} \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=3 \ + -o ExitOnForwardFailure=yes \ + -o BatchMode=yes \ + -p {{ hostvars['vm_my_ru']['ansible_port'] }} \ + {{ ssh_tunnel_ru_user }}@{{ hostvars['vm_my_ru']['ansible_host'] }} \ + -L {{ node_exporter_tunnel_listen }}:127.0.0.1:9100 -N +Restart=on-failure +RestartSec=10s +TimeoutStopSec=5s + +[Install] +WantedBy=multi-user.target diff --git a/roles/monitoring/templates/victoriametrics.service.j2 b/roles/monitoring/templates/victoriametrics.service.j2 new file mode 100644 index 0000000..634f357 --- /dev/null +++ b/roles/monitoring/templates/victoriametrics.service.j2 @@ -0,0 +1,23 @@ +[Unit] +Description=VictoriaMetrics time series database +Documentation=https://docs.victoriametrics.com +After=network.target + +[Service] +User={{ victoriametrics_user }} +Group={{ victoriametrics_group }} +NoNewPrivileges=true +ExecStart={{ victoriametrics_bin_dir }}/victoria-metrics \ + -httpListenAddr={{ victoriametrics_listen }} \ + -storageDataPath={{ victoriametrics_data_dir }} \ + -retentionPeriod={{ victoriametrics_retention_months }}M \ + -promscrape.config={{ victoriametrics_scrape_config }} \ + -httpAuth.username={{ victoriametrics_auth_username }} \ + -httpAuth.password={{ victoriametrics_auth_password }} +Restart=on-failure +RestartSec=5s +TimeoutStopSec=30s +LimitNOFILE=262144 + +[Install] +WantedBy=multi-user.target diff --git a/roles/monitoring/templates/xray-exporter.service.j2 b/roles/monitoring/templates/xray-exporter.service.j2 new file mode 100644 index 0000000..354eeb2 --- /dev/null +++ b/roles/monitoring/templates/xray-exporter.service.j2 @@ -0,0 +1,22 @@ +[Unit] +Description=Xray Prometheus Exporter +Documentation=https://github.com/compassvpn/xray-exporter +After=network.target xray.service + +[Service] +User={{ xray_exporter_user }} +Group={{ xray_exporter_group }} +WorkingDirectory={{ xray_exporter_data_dir }} +NoNewPrivileges=true +ExecStart={{ xray_exporter_bin_dir }}/xray-exporter \ + --listen={{ xray_exporter_listen }} \ + --metrics-path={{ xray_exporter_metrics_path }} \ + --xray-endpoint={{ xray_exporter_xray_endpoint }} \ + --log-path={{ xray_exporter_log_path }} \ + --log-time-window={{ xray_exporter_log_time_window }} +Restart=on-failure +RestartSec=5s +TimeoutStopSec=10s + +[Install] +WantedBy=multi-user.target diff --git a/roles/monitoring/templates/xray-stats-exporter.service.j2 b/roles/monitoring/templates/xray-stats-exporter.service.j2 new file mode 100644 index 0000000..df9d86a --- /dev/null +++ b/roles/monitoring/templates/xray-stats-exporter.service.j2 @@ -0,0 +1,25 @@ +[Unit] +Description=Xray Per-User Stats Prometheus Exporter +After=network.target xray.service + +[Service] +User={{ xray_stats_exporter_user }} +Group={{ xray_stats_exporter_group }} +NoNewPrivileges=true +ProtectSystem=strict +PrivateTmp=yes +ProtectHome=yes +ReadOnlyPaths={{ xray_stats_exporter_log_path }} /var/lib/xray-exporter +ExecStart={{ xray_stats_exporter_bin_dir }}/xray-stats-exporter \ + --listen={{ xray_stats_exporter_listen }} \ + --metrics-path={{ xray_stats_exporter_metrics_path }} \ + --xray-endpoint={{ xray_stats_exporter_xray_endpoint }} \ + --log-path={{ xray_stats_exporter_log_path }} \ + --geo-city-db={{ xray_stats_exporter_geo_city_db }} \ + --geo-asn-db={{ xray_stats_exporter_geo_asn_db }} +Restart=on-failure +RestartSec=5s +TimeoutStopSec=10s + +[Install] +WantedBy=multi-user.target diff --git a/roles/nginx_frontend/defaults/main.yml b/roles/nginx_frontend/defaults/main.yml new file mode 100644 index 0000000..839d384 --- /dev/null +++ b/roles/nginx_frontend/defaults/main.yml @@ -0,0 +1,32 @@ +--- +# nginx_frontend role — TLS frontend + SNI stream routing for EU VPS +# +# Responsibilities: +# - Install nginx + certbot +# - Obtain Let's Encrypt certificate for nginx_frontend_domain +# - SNI routing on :443 → Xray XHTTP, Xray Reality, or nginx HTTPS +# - Proxy Raven-subscribe (/sub/, /c/, /api/, /health) on :8443 + +# ── Domain ──────────────────────────────────────────────────────────────────── +nginx_frontend_domain: "media.example.com" # Set to your EU VPS domain + +# ── Certbot ─────────────────────────────────────────────────────────────────── +nginx_frontend_certbot_email: "" # Set in secrets.yml + +# ── nginx HTTPS listen port ────────────────────────────────────────────────── +# Serves Raven-subscribe (subscriptions + admin API) and acts as Reality dest +# for XHTTP probe responses. NOT the main entry point — :443 stream is. +nginx_frontend_listen_port: 8443 + +# ── Raven-subscribe upstream ────────────────────────────────────────────────── +nginx_frontend_raven_port: 8080 # Must match raven_subscribe_listen_addr port + +# ── SNI stream routing on :443 ─────────────────────────────────────────────── +# nginx stream with ssl_preread reads SNI from TLS ClientHello and routes: +# SNI = xhttp_sni (e.g. www.adobe.com) → Xray XHTTP+Reality (xhttp_port) +# SNI = nginx_frontend_domain → nginx HTTPS (listen_port) for Raven +# default (e.g. askubuntu.com) → Xray Reality TCP (reality_port) +nginx_frontend_stream_enabled: true +nginx_frontend_stream_xhttp_sni: "www.adobe.com" # Must match xray_xhttp.reality.server_names[0] +nginx_frontend_stream_xhttp_port: 2053 # Must match xray_xhttp.port +nginx_frontend_stream_reality_port: 4443 # Must match xray_vless_port diff --git a/roles/nginx_frontend/defaults/secrets.yml.example b/roles/nginx_frontend/defaults/secrets.yml.example new file mode 100644 index 0000000..77b7f56 --- /dev/null +++ b/roles/nginx_frontend/defaults/secrets.yml.example @@ -0,0 +1,5 @@ +--- +# Copy to secrets.yml and encrypt with ansible-vault: +# ansible-vault encrypt roles/nginx_frontend/defaults/secrets.yml + +nginx_frontend_certbot_email: "admin@admin.com" diff --git a/roles/nginx_frontend/handlers/main.yml b/roles/nginx_frontend/handlers/main.yml new file mode 100644 index 0000000..fc320fa --- /dev/null +++ b/roles/nginx_frontend/handlers/main.yml @@ -0,0 +1,10 @@ +--- +- name: Reload nginx + ansible.builtin.service: + name: nginx + state: reloaded + +- name: Restart nginx + ansible.builtin.service: + name: nginx + state: restarted diff --git a/roles/nginx_frontend/inventory.ini b/roles/nginx_frontend/inventory.ini new file mode 100644 index 0000000..4891780 --- /dev/null +++ b/roles/nginx_frontend/inventory.ini @@ -0,0 +1,2 @@ +[eu] +vpn ansible_host=64.226.79.239 ansible_user=konkov ansible_port=2200 ansible_ssh_private_key_file=~/.ssh/id_ed25519 ansible_ssh_host_key_checking=false diff --git a/roles/nginx_frontend/tasks/certbot.yml b/roles/nginx_frontend/tasks/certbot.yml new file mode 100644 index 0000000..b609153 --- /dev/null +++ b/roles/nginx_frontend/tasks/certbot.yml @@ -0,0 +1,23 @@ +--- +- name: Nginx frontend | Check if certificate exists + ansible.builtin.stat: + path: "/etc/letsencrypt/live/{{ nginx_frontend_domain }}/fullchain.pem" + register: nginx_frontend_cert + +- name: Nginx frontend | Obtain Let's Encrypt certificate + ansible.builtin.command: + cmd: > + certbot certonly --webroot + --webroot-path /var/www/letsencrypt + --non-interactive + --agree-tos + --email {{ nginx_frontend_certbot_email }} + -d {{ nginx_frontend_domain }} + when: not nginx_frontend_cert.stat.exists + notify: Reload nginx + +- name: Nginx frontend | Ensure certbot renewal timer is enabled + ansible.builtin.service: + name: certbot.timer + enabled: true + state: started diff --git a/roles/nginx_frontend/tasks/install.yml b/roles/nginx_frontend/tasks/install.yml new file mode 100644 index 0000000..632858d --- /dev/null +++ b/roles/nginx_frontend/tasks/install.yml @@ -0,0 +1,15 @@ +--- +- name: Nginx frontend | Install nginx and certbot + ansible.builtin.apt: + name: + - nginx + - certbot + - python3-certbot-nginx + state: present + update_cache: true + +- name: Nginx frontend | Ensure nginx is enabled and started + ansible.builtin.service: + name: nginx + enabled: true + state: started diff --git a/roles/nginx_frontend/tasks/main.yml b/roles/nginx_frontend/tasks/main.yml new file mode 100644 index 0000000..0519d62 --- /dev/null +++ b/roles/nginx_frontend/tasks/main.yml @@ -0,0 +1,24 @@ +--- +- name: Nginx frontend | Validate + ansible.builtin.import_tasks: validate.yml + tags: always + +- name: Nginx frontend | Install packages + ansible.builtin.import_tasks: install.yml + tags: nginx_frontend_install + +- name: Nginx frontend | Configure HTTP (pre-certbot) + ansible.builtin.import_tasks: nginx.yml + tags: nginx_frontend_nginx + +- name: Nginx frontend | Obtain TLS certificate + ansible.builtin.import_tasks: certbot.yml + tags: nginx_frontend_certbot + +- name: Nginx frontend | Deploy HTTPS config + ansible.builtin.import_tasks: nginx_ssl.yml + tags: nginx_frontend_ssl + +- name: Nginx frontend | Configure TCP stream relay + ansible.builtin.import_tasks: stream.yml + tags: nginx_frontend_stream diff --git a/roles/nginx_frontend/tasks/nginx.yml b/roles/nginx_frontend/tasks/nginx.yml new file mode 100644 index 0000000..3778a36 --- /dev/null +++ b/roles/nginx_frontend/tasks/nginx.yml @@ -0,0 +1,20 @@ +--- +- name: Nginx frontend | Create letsencrypt webroot + ansible.builtin.file: + path: /var/www/letsencrypt + state: directory + owner: root + group: root + mode: "0755" + +- name: Nginx frontend | Deploy HTTP config (pre-certbot) + ansible.builtin.template: + src: nginx/http.conf.j2 + dest: "/etc/nginx/conf.d/{{ nginx_frontend_domain }}.conf" + owner: root + group: root + mode: "0644" + notify: Reload nginx + +- name: Nginx frontend | Reload nginx before certbot + ansible.builtin.meta: flush_handlers diff --git a/roles/nginx_frontend/tasks/nginx_ssl.yml b/roles/nginx_frontend/tasks/nginx_ssl.yml new file mode 100644 index 0000000..066c5b3 --- /dev/null +++ b/roles/nginx_frontend/tasks/nginx_ssl.yml @@ -0,0 +1,9 @@ +--- +- name: Nginx frontend | Deploy HTTPS config + ansible.builtin.template: + src: nginx/https.conf.j2 + dest: "/etc/nginx/sites-enabled/{{ nginx_frontend_domain }}.conf" + owner: root + group: root + mode: "0644" + notify: Reload nginx diff --git a/roles/nginx_frontend/tasks/stream.yml b/roles/nginx_frontend/tasks/stream.yml new file mode 100644 index 0000000..567cc0b --- /dev/null +++ b/roles/nginx_frontend/tasks/stream.yml @@ -0,0 +1,36 @@ +--- +- name: Nginx frontend | Ensure stream.d directory exists + ansible.builtin.file: + path: /etc/nginx/stream.d + state: directory + owner: root + group: root + mode: "0755" + +- name: Nginx frontend | Ensure stream block include in nginx.conf + ansible.builtin.blockinfile: + path: /etc/nginx/nginx.conf + marker: "# {mark} ANSIBLE MANAGED stream block" + block: | + stream { + include /etc/nginx/stream.d/*.conf; + } + insertafter: EOF + notify: Reload nginx + +- name: Nginx frontend | Deploy SNI stream routing config + ansible.builtin.template: + src: nginx/stream.conf.j2 + dest: "/etc/nginx/stream.d/{{ nginx_frontend_domain }}.conf" + owner: root + group: root + mode: "0644" + notify: Reload nginx + when: nginx_frontend_stream_enabled + +- name: Nginx frontend | Remove SNI stream config (disabled) + ansible.builtin.file: + path: "/etc/nginx/stream.d/{{ nginx_frontend_domain }}.conf" + state: absent + notify: Reload nginx + when: not nginx_frontend_stream_enabled diff --git a/roles/nginx_frontend/tasks/validate.yml b/roles/nginx_frontend/tasks/validate.yml new file mode 100644 index 0000000..4e5767d --- /dev/null +++ b/roles/nginx_frontend/tasks/validate.yml @@ -0,0 +1,11 @@ +--- +- name: Nginx frontend | Validate required vars + ansible.builtin.assert: + that: + - nginx_frontend_domain is defined + - nginx_frontend_domain != '' + - nginx_frontend_certbot_email is defined + - nginx_frontend_certbot_email != '' + fail_msg: >- + nginx_frontend_domain and nginx_frontend_certbot_email must be set in secrets.yml. + success_msg: "Nginx frontend vars are valid" diff --git a/roles/nginx_frontend/templates/nginx/http.conf.j2 b/roles/nginx_frontend/templates/nginx/http.conf.j2 new file mode 100644 index 0000000..7c8ba7a --- /dev/null +++ b/roles/nginx_frontend/templates/nginx/http.conf.j2 @@ -0,0 +1,20 @@ +# {{ nginx_frontend_domain }} — HTTP only (pre-certbot) +# Managed by Ansible nginx_frontend role +# +# This config is temporary and is replaced by https.conf.j2 after certbot obtains the certificate. +# It must allow certbot HTTP-01 validation for the Let's Encrypt certificate. + +server { + listen 80; + server_name {{ nginx_frontend_domain }}; + + # Allow certbot HTTP-01 challenge + location /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + } + + # Redirect all other traffic to HTTPS (when cert is available) + location / { + return 301 https://$host$request_uri; + } +} diff --git a/roles/nginx_frontend/templates/nginx/https.conf.j2 b/roles/nginx_frontend/templates/nginx/https.conf.j2 new file mode 100644 index 0000000..9fddad0 --- /dev/null +++ b/roles/nginx_frontend/templates/nginx/https.conf.j2 @@ -0,0 +1,64 @@ +# {{ nginx_frontend_domain }} — HTTPS frontend for EU server +# Managed by Ansible nginx_frontend role +# +# Serves Raven-subscribe and acts as fallback dest for XHTTP Reality probes. +# XHTTP VPN traffic goes through nginx stream SNI routing on :443 directly. +# +# Routes: +# /sub/, /c/ → Raven-subscribe (127.0.0.1:{{ nginx_frontend_raven_port }}) +# /api/ → Raven-subscribe admin API +# /health → Raven-subscribe health check +# / → Default page (landing / 404) + +# ── HTTPS ───────────────────────────────────────────────────────────────────── +server { + # proxy_protocol: accepts PROXY protocol v2 header sent by nginx stream on :443 + # This is required because stream block sends proxy_protocol on to all upstreams. + listen {{ nginx_frontend_listen_port }} ssl proxy_protocol; + http2 on; + server_name {{ nginx_frontend_domain }}; + + # Use real client IP from PROXY protocol header (set by nginx stream) + real_ip_header proxy_protocol; + set_real_ip_from 127.0.0.1; + + ssl_certificate /etc/letsencrypt/live/{{ nginx_frontend_domain }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ nginx_frontend_domain }}/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + # ── Raven-subscribe: subscriptions ────────────────────────────────────── + location ~ ^/(sub|c)/ { + proxy_pass http://127.0.0.1:{{ nginx_frontend_raven_port }}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $proxy_protocol_addr; + proxy_set_header X-Forwarded-For $proxy_protocol_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + proxy_connect_timeout 5s; + } + + # ── Raven-subscribe: admin API ─────────────────────────────────────────── + location /api/ { + proxy_pass http://127.0.0.1:{{ nginx_frontend_raven_port }}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $proxy_protocol_addr; + proxy_set_header X-Forwarded-For $proxy_protocol_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + proxy_connect_timeout 5s; + } + + # ── Raven-subscribe: health check ──────────────────────────────────────── + location /health { + proxy_pass http://127.0.0.1:{{ nginx_frontend_raven_port }}; + proxy_set_header Host $host; + proxy_read_timeout 5s; + proxy_connect_timeout 5s; + } + + # ── Everything else → 404 ──────────────────────────────────────────────── + location / { + return 404; + } +} diff --git a/roles/nginx_frontend/templates/nginx/stream.conf.j2 b/roles/nginx_frontend/templates/nginx/stream.conf.j2 new file mode 100644 index 0000000..e9496c2 --- /dev/null +++ b/roles/nginx_frontend/templates/nginx/stream.conf.j2 @@ -0,0 +1,40 @@ +# {{ nginx_frontend_domain }} — nginx stream SNI routing +# Managed by Ansible nginx_frontend role +# +# Reads SNI from TLS ClientHello (ssl_preread) and routes TCP: +# {{ nginx_frontend_stream_xhttp_sni }} → Xray XHTTP+Reality (127.0.0.1:{{ nginx_frontend_stream_xhttp_port }}) +# {{ nginx_frontend_domain }} → nginx HTTPS (127.0.0.1:{{ nginx_frontend_listen_port }}) +# default (other SNI) → Xray VLESS Reality (127.0.0.1:{{ nginx_frontend_stream_reality_port }}) +# +# This file is included inside stream {} block in nginx.conf + +map $ssl_preread_server_name $sni_backend { + {{ nginx_frontend_stream_xhttp_sni }} xhttp_reality; + {{ nginx_frontend_domain }} nginx_https; + default vless_reality; +} + +upstream xhttp_reality { + server 127.0.0.1:{{ nginx_frontend_stream_xhttp_port }}; +} + +upstream nginx_https { + server 127.0.0.1:{{ nginx_frontend_listen_port }}; +} + +upstream vless_reality { + server 127.0.0.1:{{ nginx_frontend_stream_reality_port }}; +} + +# Routes all SNI backends through one server block. +# PROXY protocol is sent to ALL upstreams so each backend knows the real client IP: +# - Xray inbounds: accept via "xver": 2 in realitySettings +# - nginx HTTPS: accept via "proxy_protocol on" in http server block +server { + listen 443 reuseport; + proxy_pass $sni_backend; + ssl_preread on; + proxy_protocol on; + proxy_connect_timeout 10s; + proxy_timeout 600s; +} diff --git a/roles/raven_subscribe/defaults/main.yml b/roles/raven_subscribe/defaults/main.yml new file mode 100644 index 0000000..93056ef --- /dev/null +++ b/roles/raven_subscribe/defaults/main.yml @@ -0,0 +1,57 @@ +--- + +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: "127.0.0.1:8080" +raven_subscribe_sync_interval_seconds: 60 +raven_subscribe_rate_limit_sub_per_min: 60 +raven_subscribe_rate_limit_admin_per_min: 30 + +# Balancer settings (used in generated client subscription configs) +# Strategy: random, roundRobin, leastPing, leastLoad +raven_subscribe_balancer_strategy: "leastPing" +raven_subscribe_balancer_probe_url: "https://www.gstatic.com/generate_204" +raven_subscribe_balancer_probe_interval: "30s" + +# The inbound tag Raven manages users in (must match an inbound in config.d) +raven_subscribe_api_inbound_tag: "vless-reality-in" + +# Xray API address for user sync via gRPC. Requires xray_api enabled. +raven_subscribe_xray_api_addr: "127.0.0.1:10085" + +# Xray config.d directory (must match xray role) +raven_subscribe_xray_config_dir: "/etc/xray/config.d" + +# Per-inbound host overrides. Falls back to raven_subscribe_server_host when tag not listed. +# Set in secrets.yml. With SNI routing both protocols are on :443 behind nginx stream. +# Example: {"vless-reality-in": "zirgate.com", "vless-xhttp-in": "zirgate.com"} +raven_subscribe_inbound_hosts: {} + +# Per-inbound port overrides. Falls back to inbound's own port when tag not listed. +# Set in secrets.yml. With SNI routing both protocols share port 443. +# Example: {"vless-reality-in": 443, "vless-xhttp-in": 443} +raven_subscribe_inbound_ports: {} + +# Path to sing-box config file. Leave empty to disable sing-box sync. +# Example: "/etc/sing-box/config.json" +raven_subscribe_singbox_config: "" + +# Enable/disable Xray and sing-box sync independently. +raven_subscribe_xray_enabled: true +raven_subscribe_singbox_enabled: false + +# System user/group (shared with xray role — do NOT change) +raven_subscribe_user: "xrayuser" +raven_subscribe_group: "xrayuser" + +# Xray service name (for systemd After= dependency) +raven_subscribe_xray_service_name: "xray" + +##### Set these in secrets.yml (ansible-vault encrypted) ##### +# raven_subscribe_server_host: "" # EU VPS public IP or domain +# raven_subscribe_base_url: "" # Public URL — must be relay domain, NOT direct EU IP +# raven_subscribe_admin_token: "" # Generate: openssl rand -hex 32 diff --git a/roles/raven_subscribe/defaults/secrets.yml.example b/roles/raven_subscribe/defaults/secrets.yml.example new file mode 100644 index 0000000..4fe1202 --- /dev/null +++ b/roles/raven_subscribe/defaults/secrets.yml.example @@ -0,0 +1,13 @@ +--- +# Raven-subscribe secrets — copy to secrets.yml and encrypt: +# ansible-vault encrypt roles/raven_subscribe/defaults/secrets.yml + +# Admin token for Raven-subscribe API (required) +# Generate: openssl rand -hex 32 +raven_subscribe_admin_token: "" + +# Public URL used in subscription links — must be the relay domain +raven_subscribe_base_url: "https://my.example.com" + +# EU VPS public IP or domain (used in generated client outbound addresses) +raven_subscribe_server_host: "" diff --git a/roles/raven_subscribe/handlers/main.yml b/roles/raven_subscribe/handlers/main.yml new file mode 100644 index 0000000..47e923f --- /dev/null +++ b/roles/raven_subscribe/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: Restart raven-subscribe + ansible.builtin.service: + name: "{{ raven_subscribe_service_name }}" + state: restarted diff --git a/roles/raven_subscribe/meta/main.yml b/roles/raven_subscribe/meta/main.yml new file mode 100644 index 0000000..e6cf843 --- /dev/null +++ b/roles/raven_subscribe/meta/main.yml @@ -0,0 +1,8 @@ +--- +galaxy_info: + role_name: raven_subscribe + author: findias + description: Raven-subscribe subscription server for Xray + min_ansible_version: "2.9" + +dependencies: [] diff --git a/roles/xray/tasks/raven_subscribe.yml b/roles/raven_subscribe/tasks/main.yml similarity index 93% rename from roles/xray/tasks/raven_subscribe.yml rename to roles/raven_subscribe/tasks/main.yml index 0801305..da92cc7 100644 --- a/roles/xray/tasks/raven_subscribe.yml +++ b/roles/raven_subscribe/tasks/main.yml @@ -50,29 +50,29 @@ path: "{{ raven_subscribe_config_dir }}" state: directory owner: root - group: "{{ xray_group }}" + group: "{{ raven_subscribe_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 }}" + owner: "{{ raven_subscribe_user }}" + group: "{{ raven_subscribe_group }}" mode: "0750" - name: Raven-subscribe | Deploy config ansible.builtin.template: - src: raven-subscribe/config.json.j2 + src: config.json.j2 dest: "{{ raven_subscribe_config_dir }}/config.json" owner: root - group: "{{ xray_group }}" + group: "{{ raven_subscribe_group }}" mode: "0640" notify: Restart raven-subscribe - name: Raven-subscribe | Deploy systemd service ansible.builtin.template: - src: raven-subscribe/xray-subscription.service.j2 + src: xray-subscription.service.j2 dest: "/etc/systemd/system/{{ raven_subscribe_service_name }}.service" owner: root group: root diff --git a/roles/raven_subscribe/templates/config.json.j2 b/roles/raven_subscribe/templates/config.json.j2 new file mode 100644 index 0000000..45b2b56 --- /dev/null +++ b/roles/raven_subscribe/templates/config.json.j2 @@ -0,0 +1,22 @@ +{ + "listen_addr": "{{ raven_subscribe_listen_addr }}", + "server_host": "{{ raven_subscribe_server_host }}", + "config_dir": "{{ raven_subscribe_xray_config_dir }}", + "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 }}", + "balancer_strategy": "{{ raven_subscribe_balancer_strategy }}", + "balancer_probe_url": "{{ raven_subscribe_balancer_probe_url }}", + "balancer_probe_interval": "{{ raven_subscribe_balancer_probe_interval }}", + "xray_enabled": {{ raven_subscribe_xray_enabled | lower }}, + "singbox_enabled": {{ raven_subscribe_singbox_enabled | lower }}{% if raven_subscribe_inbound_hosts | default({}) | length > 0 %}, + "inbound_hosts": {{ raven_subscribe_inbound_hosts | to_json }}{% endif %}{% if raven_subscribe_inbound_ports | default({}) | length > 0 %}, + "inbound_ports": {{ raven_subscribe_inbound_ports | to_json }}{% endif %}{% if raven_subscribe_vless_client_encryption | default({}) | length > 0 %}, + "vless_client_encryption": {{ raven_subscribe_vless_client_encryption | to_json }}{% endif %}{% if raven_subscribe_singbox_config | default('') != '' %}, + "singbox_config": "{{ raven_subscribe_singbox_config }}"{% endif %} +} diff --git a/roles/raven_subscribe/templates/xray-subscription.service.j2 b/roles/raven_subscribe/templates/xray-subscription.service.j2 new file mode 100644 index 0000000..5bdeaca --- /dev/null +++ b/roles/raven_subscribe/templates/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 {{ raven_subscribe_xray_service_name }}.service + +[Service] +Type=simple +User={{ raven_subscribe_user }} +Group={{ raven_subscribe_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 }} {{ raven_subscribe_xray_config_dir }} +ReadOnlyPaths={{ raven_subscribe_config_dir }} +PrivateTmp=yes +ProtectHome=yes + +[Install] +WantedBy=multi-user.target diff --git a/roles/relay/defaults/main.yml b/roles/relay/defaults/main.yml new file mode 100644 index 0000000..44cbdac --- /dev/null +++ b/roles/relay/defaults/main.yml @@ -0,0 +1,44 @@ +--- +# Relay role — nginx SNI routing + reverse proxy on RU VPS +# +# Port 443 is handled by nginx stream with ssl_preread (SNI routing): +# my.example.com / example.com → local nginx HTTPS (:8443) for subscriptions + stub +# everything else (VPN SNIs) → EU server :443 (SNI routing on EU side) +# +# Domain layout: +# example.com A → RU VPS IP (stub site, via SNI → local HTTPS) +# my.example.com A → RU VPS IP (Raven relay, via SNI → local HTTPS) +# media.example.com A → EU VPS IP (nginx_frontend, not this role) + +# ── Domain ─────────────────────────────────────────────────────────────────── +relay_domain: "example.com" # Set to your RU VPS domain +relay_sub_my: "my.{{ relay_domain }}" # Raven-subscribe relay (RU VPS) + +# ── Upstream EU server ──────────────────────────────────────────────────────── +# Set in secrets.yml +# relay_upstream_host: "1.2.3.4" # EU server IP address + +# Port where nginx_frontend SNI stream listens on EU server (both protocols) +relay_upstream_port: 443 + +# Port where nginx_frontend HTTPS listens on EU server (Raven-subscribe behind it) +relay_upstream_raven_port: 8443 + + +# ── SNI stream routing on :443 ─────────────────────────────────────────────── +# nginx stream with ssl_preread reads SNI from TLS ClientHello and routes: +# relay_domain / relay_sub_my → local nginx HTTPS (relay_https_port) +# everything else → EU server (relay_upstream_port) +relay_stream_enabled: true +relay_https_port: 8443 # Local HTTPS port (behind stream, stub + Raven relay) + +# ── nginx ───────────────────────────────────────────────────────────────────── +relay_nginx_user: "www-data" +relay_webroot: "/var/www/{{ relay_domain }}" + +# ── Certbot ─────────────────────────────────────────────────────────────────── +relay_certbot_email: "" # Set in secrets.yml: relay_certbot_email: "admin@example.com" + +# ── Stub site ───────────────────────────────────────────────────────────────── +relay_stub_title: "Welcome" +relay_stub_description: "Personal website" diff --git a/roles/relay/defaults/secrets.yml.example b/roles/relay/defaults/secrets.yml.example new file mode 100644 index 0000000..02edda0 --- /dev/null +++ b/roles/relay/defaults/secrets.yml.example @@ -0,0 +1,6 @@ +--- +# Copy to secrets.yml and encrypt with ansible-vault: +# ansible-vault encrypt roles/relay/defaults/secrets.yml + +relay_upstream_host: "1.2.3.4" # EU server IP address +relay_certbot_email: "admin@admin.com" diff --git a/roles/relay/handlers/main.yml b/roles/relay/handlers/main.yml new file mode 100644 index 0000000..fc320fa --- /dev/null +++ b/roles/relay/handlers/main.yml @@ -0,0 +1,10 @@ +--- +- name: Reload nginx + ansible.builtin.service: + name: nginx + state: reloaded + +- name: Restart nginx + ansible.builtin.service: + name: nginx + state: restarted diff --git a/roles/relay/inventory.ini b/roles/relay/inventory.ini new file mode 100644 index 0000000..cdaf016 --- /dev/null +++ b/roles/relay/inventory.ini @@ -0,0 +1,2 @@ +[relay] +relay ansible_host=195.19.92.182 ansible_user=deploy ansible_port=2200 ansible_ssh_private_key_file=~/.ssh/id_ed25519 ansible_ssh_host_key_checking=false diff --git a/roles/relay/tasks/certbot.yml b/roles/relay/tasks/certbot.yml new file mode 100644 index 0000000..4954eaf --- /dev/null +++ b/roles/relay/tasks/certbot.yml @@ -0,0 +1,24 @@ +--- +- name: Relay | Check if certificate already exists + ansible.builtin.stat: + path: "/etc/letsencrypt/live/{{ relay_domain }}/fullchain.pem" + register: relay_cert + +- name: Relay | Obtain Let's Encrypt certificate + ansible.builtin.command: + cmd: > + certbot certonly --webroot + --webroot-path {{ relay_webroot }} + --non-interactive + --agree-tos + --email {{ relay_certbot_email }} + -d {{ relay_domain }} + -d {{ relay_sub_my }} + when: not relay_cert.stat.exists + notify: Reload nginx + +- name: Relay | Ensure certbot renewal timer is enabled + ansible.builtin.service: + name: certbot.timer + enabled: true + state: started diff --git a/roles/relay/tasks/install.yml b/roles/relay/tasks/install.yml new file mode 100644 index 0000000..d3826fb --- /dev/null +++ b/roles/relay/tasks/install.yml @@ -0,0 +1,15 @@ +--- +- name: Relay | Install nginx and certbot + ansible.builtin.apt: + name: + - nginx + - certbot + - python3-certbot-nginx + state: present + update_cache: true + +- name: Relay | Ensure nginx is enabled + ansible.builtin.service: + name: nginx + enabled: true + state: started diff --git a/roles/relay/tasks/main.yml b/roles/relay/tasks/main.yml new file mode 100644 index 0000000..ff8e5db --- /dev/null +++ b/roles/relay/tasks/main.yml @@ -0,0 +1,28 @@ +--- +- name: Relay | Validate + ansible.builtin.import_tasks: validate.yml + tags: always + +- name: Relay | Install packages + ansible.builtin.import_tasks: install.yml + tags: relay_install + +- name: Relay | Configure stub site + ansible.builtin.import_tasks: stub.yml + tags: relay_stub + +- name: Relay | Configure nginx + ansible.builtin.import_tasks: nginx.yml + tags: relay_nginx + +- name: Relay | Obtain TLS certificates + ansible.builtin.import_tasks: certbot.yml + tags: relay_certbot + +- name: Relay | Deploy nginx HTTPS config + ansible.builtin.import_tasks: nginx_ssl.yml + tags: relay_nginx_ssl + +- name: Relay | Configure TCP stream relay + ansible.builtin.import_tasks: stream.yml + tags: relay_stream diff --git a/roles/relay/tasks/nginx.yml b/roles/relay/tasks/nginx.yml new file mode 100644 index 0000000..b7b31b1 --- /dev/null +++ b/roles/relay/tasks/nginx.yml @@ -0,0 +1,14 @@ +--- +# Initial HTTP-only config — needed for certbot HTTP-01 challenge + +- name: Relay | Deploy HTTP nginx config (pre-certbot) + ansible.builtin.template: + src: nginx/http.conf.j2 + dest: "/etc/nginx/conf.d/{{ relay_domain }}.conf" + owner: root + group: root + mode: "0644" + notify: Reload nginx + +- name: Relay | Reload nginx before certbot + ansible.builtin.meta: flush_handlers diff --git a/roles/relay/tasks/nginx_ssl.yml b/roles/relay/tasks/nginx_ssl.yml new file mode 100644 index 0000000..340e0c2 --- /dev/null +++ b/roles/relay/tasks/nginx_ssl.yml @@ -0,0 +1,11 @@ +--- +# Full HTTPS config with proxy_pass — deployed after certbot + +- name: Relay | Deploy HTTPS nginx config + ansible.builtin.template: + src: nginx/https.conf.j2 + dest: "/etc/nginx/conf.d/{{ relay_domain }}.conf" + owner: root + group: root + mode: "0644" + notify: Reload nginx diff --git a/roles/relay/tasks/stream.yml b/roles/relay/tasks/stream.yml new file mode 100644 index 0000000..f3d9241 --- /dev/null +++ b/roles/relay/tasks/stream.yml @@ -0,0 +1,36 @@ +--- +- name: Relay | Ensure stream.d directory exists + ansible.builtin.file: + path: /etc/nginx/stream.d + state: directory + owner: root + group: root + mode: "0755" + +- name: Relay | Ensure stream block include in nginx.conf + ansible.builtin.blockinfile: + path: /etc/nginx/nginx.conf + marker: "# {mark} ANSIBLE MANAGED stream block" + block: | + stream { + include /etc/nginx/stream.d/*.conf; + } + insertafter: EOF + notify: Reload nginx + +- name: Relay | Deploy nginx stream config + ansible.builtin.template: + src: nginx/stream.conf.j2 + dest: "/etc/nginx/stream.d/{{ relay_domain }}.conf" + owner: root + group: root + mode: "0644" + notify: Reload nginx + when: relay_stream_enabled + +- name: Relay | Remove nginx stream config (disabled) + ansible.builtin.file: + path: "/etc/nginx/stream.d/{{ relay_domain }}.conf" + state: absent + notify: Reload nginx + when: not relay_stream_enabled diff --git a/roles/relay/tasks/stub.yml b/roles/relay/tasks/stub.yml new file mode 100644 index 0000000..ae00c73 --- /dev/null +++ b/roles/relay/tasks/stub.yml @@ -0,0 +1,16 @@ +--- +- name: Relay | Create webroot directory + ansible.builtin.file: + path: "{{ relay_webroot }}" + state: directory + owner: "{{ relay_nginx_user }}" + group: "{{ relay_nginx_user }}" + mode: "0755" + +- name: Relay | Deploy stub site index.html + ansible.builtin.template: + src: stub/index.html.j2 + dest: "{{ relay_webroot }}/index.html" + owner: "{{ relay_nginx_user }}" + group: "{{ relay_nginx_user }}" + mode: "0644" diff --git a/roles/relay/tasks/validate.yml b/roles/relay/tasks/validate.yml new file mode 100644 index 0000000..1d08242 --- /dev/null +++ b/roles/relay/tasks/validate.yml @@ -0,0 +1,11 @@ +--- +- name: Relay | Validate required vars + ansible.builtin.assert: + that: + - relay_upstream_host is defined + - relay_upstream_host != '' + - relay_certbot_email is defined + - relay_certbot_email != '' + fail_msg: >- + relay_upstream_host and relay_certbot_email must be set in secrets.yml. + success_msg: "Relay vars are valid" diff --git a/roles/relay/templates/nginx/http.conf.j2 b/roles/relay/templates/nginx/http.conf.j2 new file mode 100644 index 0000000..0e8c7d7 --- /dev/null +++ b/roles/relay/templates/nginx/http.conf.j2 @@ -0,0 +1,14 @@ +# {{ relay_domain }} — HTTP only (pre-certbot) +# Managed by Ansible relay role + +server { + listen 80; + server_name {{ relay_domain }} {{ relay_sub_my }}; + + root {{ relay_webroot }}; + index index.html; + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/roles/relay/templates/nginx/https.conf.j2 b/roles/relay/templates/nginx/https.conf.j2 new file mode 100644 index 0000000..9932da9 --- /dev/null +++ b/roles/relay/templates/nginx/https.conf.j2 @@ -0,0 +1,47 @@ +# {{ relay_domain }} — HTTPS config (behind stream SNI routing) +# Managed by Ansible relay role +# +# Listens on {{ relay_https_port }} (NOT 443 — port 443 is used by nginx stream SNI routing). +# Stream routes {{ relay_domain }} and {{ relay_sub_my }} SNI here. + +# ── {{ relay_domain }} — stub site ────────────────────────────────────────────────── +server { + listen {{ relay_https_port }} ssl; + http2 on; + server_name {{ relay_domain }}; + + ssl_certificate /etc/letsencrypt/live/{{ relay_domain }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ relay_domain }}/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + root {{ relay_webroot }}; + index index.html; + + location / { + try_files $uri $uri/ =404; + } +} + +# ── {{ relay_sub_my }} — Raven-subscribe relay ─────────────────────────────────── +server { + listen {{ relay_https_port }} ssl; + http2 on; + server_name {{ relay_sub_my }}; + + ssl_certificate /etc/letsencrypt/live/{{ relay_domain }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ relay_domain }}/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass https://{{ relay_upstream_host }}:{{ relay_upstream_raven_port }}; + proxy_ssl_server_name on; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + proxy_connect_timeout 10s; + } +} diff --git a/roles/relay/templates/nginx/stream.conf.j2 b/roles/relay/templates/nginx/stream.conf.j2 new file mode 100644 index 0000000..08d8fae --- /dev/null +++ b/roles/relay/templates/nginx/stream.conf.j2 @@ -0,0 +1,33 @@ +# {{ relay_domain }} — nginx stream SNI routing +# Managed by Ansible relay role +# +# Reads SNI from TLS ClientHello (ssl_preread) and routes TCP: +# {{ relay_domain }} → local nginx HTTPS (127.0.0.1:{{ relay_https_port }}) for stub site +# {{ relay_sub_my }} → local nginx HTTPS (127.0.0.1:{{ relay_https_port }}) for Raven relay +# everything else (VPN) → EU server ({{ relay_upstream_host }}:{{ relay_upstream_port }}) +# +# This file is included inside stream {} block in nginx.conf + +{% if relay_stream_enabled %} +map $ssl_preread_server_name $relay_backend { + {{ relay_domain }} local_https; + {{ relay_sub_my }} local_https; + default eu_vpn; +} + +upstream local_https { + server 127.0.0.1:{{ relay_https_port }}; +} + +upstream eu_vpn { + server {{ relay_upstream_host }}:{{ relay_upstream_port }}; +} + +server { + listen 443 reuseport; + proxy_pass $relay_backend; + ssl_preread on; + proxy_connect_timeout 10s; + proxy_timeout 600s; +} +{% endif %} diff --git a/roles/relay/templates/stub/index.html.j2 b/roles/relay/templates/stub/index.html.j2 new file mode 100644 index 0000000..2904e26 --- /dev/null +++ b/roles/relay/templates/stub/index.html.j2 @@ -0,0 +1,53 @@ + + + + + + {{ relay_stub_title }} + + + +
+

{{ relay_domain }}

+
+
+

{{ relay_stub_title }}

+

{{ relay_stub_description }}

+

This site is currently under construction.

+
+ + + diff --git a/roles/role_monitoring.yml b/roles/role_monitoring.yml new file mode 100644 index 0000000..8eba6b9 --- /dev/null +++ b/roles/role_monitoring.yml @@ -0,0 +1,15 @@ +--- +# Deploy monitoring stack +# EU (vm_my_srv): node_exporter + xray-exporter + xray-stats-exporter + VictoriaMetrics + Grafana +# RU (vm_my_ru): node_exporter only (scraped by VictoriaMetrics via WireGuard 10.10.0.2:9100) +# +# Access Grafana: ssh -L 13000:127.0.0.1:13000 user@eu-server +# then open http://localhost:13000 + +- name: Deploy monitoring stack + hosts: + - vm_my_srv + - vm_my_ru + become: true + roles: + - role: monitoring diff --git a/roles/role_nginx_frontend.yml b/roles/role_nginx_frontend.yml new file mode 100644 index 0000000..9bf8cf4 --- /dev/null +++ b/roles/role_nginx_frontend.yml @@ -0,0 +1,23 @@ +--- +# nginx frontend playbook — EU VPS +# Usage: +# ansible-playbook roles/role_nginx_frontend.yml -i roles/nginx_frontend/inventory.ini \ +# --vault-password-file vault_password.txt +# +# Tags: +# nginx_frontend_install — install nginx + certbot +# nginx_frontend_nginx — deploy HTTP config +# nginx_frontend_certbot — obtain TLS certificate +# nginx_frontend_ssl — deploy HTTPS config with proxy_pass +# nginx_frontend_stream — deploy TCP stream relay for VLESS Reality + +- name: Configure nginx frontend + hosts: vm_my_srv + become: true + + vars_files: + - nginx_frontend/defaults/main.yml + - nginx_frontend/defaults/secrets.yml + + roles: + - role: nginx_frontend diff --git a/roles/role_raven_subscribe.yml b/roles/role_raven_subscribe.yml new file mode 100644 index 0000000..9b567bf --- /dev/null +++ b/roles/role_raven_subscribe.yml @@ -0,0 +1,11 @@ +--- +- name: Deploy Raven-subscribe + hosts: vm_my_srv + become: true + gather_facts: true + + vars_files: + - "raven_subscribe/defaults/secrets.yml" + + roles: + - role: raven_subscribe diff --git a/roles/role_relay.yml b/roles/role_relay.yml new file mode 100644 index 0000000..4e38dec --- /dev/null +++ b/roles/role_relay.yml @@ -0,0 +1,24 @@ +--- +# Relay playbook — RU VPS +# Usage: +# ansible-playbook roles/role_relay.yml -i roles/relay/inventory.ini \ +# --vault-password-file vault_password.txt +# +# Tags: +# relay_install — install nginx + certbot +# relay_stub — deploy stub site +# relay_nginx — deploy HTTP nginx config +# relay_certbot — obtain TLS certificates +# relay_nginx_ssl — deploy HTTPS nginx config with proxy_pass +# relay_stream — deploy TCP stream relay for VLESS Reality + +- name: Configure relay server + hosts: vm_my_ru + become: true + + vars_files: + - relay/defaults/main.yml + - relay/defaults/secrets.yml + + roles: + - role: relay diff --git a/roles/role_wireguard.yml b/roles/role_wireguard.yml new file mode 100644 index 0000000..6f43e2d --- /dev/null +++ b/roles/role_wireguard.yml @@ -0,0 +1,21 @@ +--- +# Deploy WireGuard mesh between EU and RU servers +# Usage: +# ansible-playbook roles/role_wireguard.yml -i roles/hosts.yml --vault-password-file vault_password.txt +# +# Tags: +# wireguard_eu — configure wg0 on EU (install, config) +# wireguard_ru — configure wg0 on RU (install, config) + +- name: Deploy WireGuard + hosts: + - vm_my_srv + - vm_my_ru + become: true + + vars_files: + - wireguard/defaults/main.yml + - wireguard/defaults/secrets.yml + + roles: + - role: wireguard diff --git a/roles/role_xray.yml b/roles/role_xray.yml index 946b363..83dd611 100644 --- a/roles/role_xray.yml +++ b/roles/role_xray.yml @@ -6,7 +6,7 @@ gather_facts: true vars_files: - - "xray/defaults/secrets.yml" # secrets для Xray (UUID, email и т.п.) + - "xray/defaults/secrets.yml" # Xray secrets (UUID, reality keys и т.п.) roles: - role: srv_prepare diff --git a/roles/sing-box-playbook/defaults/main.yml b/roles/sing-box-playbook/defaults/main.yml index bee8b0b..646eceb 100644 --- a/roles/sing-box-playbook/defaults/main.yml +++ b/roles/sing-box-playbook/defaults/main.yml @@ -2,7 +2,7 @@ # Default variables for the sing-box role with Hysteria2 # --- Sing-box Download Variables --- -singbox_version: "1.12.21" +singbox_version: "1.13.3" singbox_auto_update: true singbox_repo: "SagerNet/sing-box" singbox_architecture: "amd64" # Options: amd64, arm64, armv7 @@ -12,16 +12,9 @@ singbox_config_dir: "/etc/sing-box" singbox_user: "nobody" # --- DNS Settings --- -singbox_dns_servers: - - tag: "google" - address: "tls://8.8.8.8" - strategy: "prefer_ipv4" - - tag: "cloudflare" - address: "tls://1.1.1.1" - strategy: "prefer_ipv4" - - tag: "local" - address: "local" - detour: "direct" +# sing-box 1.12+ format: type + server fields (address: prefix is legacy) +singbox_dns_strategy: "prefer_ipv4" # global: prefer_ipv4, prefer_ipv6, ipv4_only, ipv6_only +singbox_dns_final: "local" # default server tag when no rule matches # --- Log Settings --- singbox_log_disabled: false @@ -33,7 +26,7 @@ singbox_log_timestamp: true singbox_hysteria2_enabled: true singbox_hysteria2_tag: "hysteria-in" singbox_hysteria2_listen: "::" # Replace with your server's public IP or " -singbox_hysteria2_listen_port: 8443 +singbox_hysteria2_listen_port: 8444 singbox_hysteria2_up_mbps: 100 singbox_hysteria2_down_mbps: 100 @@ -93,4 +86,15 @@ singbox_geosite_enabled: true singbox_geosite_url: "https://github.com/SagerNet/sing-geosite/releases/latest/download/geosite.db" singbox_geosite_path: "/etc/sing-box/geosite.db" +# --- Raven-subscribe integration --- +# When enabled, Raven-subscribe will sync Hysteria2 users from sing-box config. +# This variable is consumed by the xray role's raven-subscribe/config.json.j2 template. +# Set raven_subscribe_singbox_config to point to sing-box config file. +# Set raven_subscribe_singbox_enabled: true to activate sync. +# +# These variables are only effective when both roles (xray + sing-box) are deployed together. +# If deploying sing-box standalone, configure Raven manually. +raven_subscribe_singbox_config: "{{ singbox_config_dir }}/config.json" +raven_subscribe_singbox_enabled: true + # --- System Packages --- diff --git a/roles/sing-box-playbook/defaults/secrets.yml.example b/roles/sing-box-playbook/defaults/secrets.yml.example new file mode 100644 index 0000000..84f3d9e --- /dev/null +++ b/roles/sing-box-playbook/defaults/secrets.yml.example @@ -0,0 +1,21 @@ +--- +# secrets.yml — encrypt with ansible-vault: +# ansible-vault encrypt roles/sing-box-playbook/defaults/secrets.yml + +# --- Hysteria2 users --- +# At least one user required. +singbox_hysteria2_users: + - name: "user@example.com" + password: "change-me-strong-password" + +# --- Obfuscation (required when singbox_hysteria2_obfs_enabled: true) --- +singbox_hysteria2_obfs_password: "change-me-obfs-password" + +# --- TLS / ACME --- +singbox: + tls_enabled: true + tls_server_name: "your-server.com" # Public domain pointing to this server + tls_acme_domain: "your-server.com" # Domain for Let's Encrypt certificate + tls_acme_email: "admin@your-server.com" # Email for Let's Encrypt notifications + tls_acme_provider: "letsencrypt" # letsencrypt or zerossl + tls_acme_data_directory: "/etc/sing-box/acme" diff --git a/roles/sing-box-playbook/handlers/main.yml b/roles/sing-box-playbook/handlers/main.yml index 3be70b2..a2d2939 100644 --- a/roles/sing-box-playbook/handlers/main.yml +++ b/roles/sing-box-playbook/handlers/main.yml @@ -1,5 +1,14 @@ # handlers/main.yml -# Handlers for the sing-box role +# IMPORTANT: Ansible executes handlers in definition order, not notification order. +# Validate must come BEFORE Restart so invalid configs are caught before reload. + +- name: Validate sing-box config + ansible.builtin.command: + cmd: "{{ singbox_install_dir }}/sing-box check -c {{ singbox_config_dir }}/config.json" + register: singbox_validate_result + changed_when: false + failed_when: singbox_validate_result.rc != 0 + when: ansible_facts['service_mgr'] in ['systemd', 'openrc'] - name: Reload systemd and restart sing-box ansible.builtin.systemd: @@ -7,16 +16,20 @@ name: sing-box state: restarted enabled: true + when: ansible_facts['service_mgr'] == "systemd" listen: "Reload systemd and restart sing-box" - name: Restart sing-box service ansible.builtin.systemd: name: sing-box state: restarted + daemon_reload: true + when: ansible_facts['service_mgr'] == "systemd" listen: "Restart sing-box service" - name: Reload sing-box configuration ansible.builtin.systemd: name: sing-box state: reloaded + when: ansible_facts['service_mgr'] == "systemd" listen: "Reload sing-box configuration" diff --git a/roles/sing-box-playbook/tasks/config.yml b/roles/sing-box-playbook/tasks/config.yml new file mode 100644 index 0000000..5b49a64 --- /dev/null +++ b/roles/sing-box-playbook/tasks/config.yml @@ -0,0 +1,51 @@ +--- +- name: sing-box | Create configuration directory + ansible.builtin.file: + path: "{{ singbox_config_dir }}" + state: directory + owner: root + group: root + mode: "0755" + +- name: sing-box | Create ACME certificate directory + ansible.builtin.file: + path: "{{ singbox.tls_acme_data_directory }}" + state: directory + owner: root + group: root + mode: "0700" + when: singbox.tls_enabled | default(true) + +- name: sing-box | Create log directory + ansible.builtin.file: + path: "{{ singbox_log_output | dirname }}" + state: directory + owner: root + group: root + mode: "0755" + +- name: sing-box | Download GeoIP database + ansible.builtin.get_url: + url: "{{ singbox_geoip_url }}" + dest: "{{ singbox_geoip_path }}" + mode: "0644" + timeout: 300 + when: singbox_geoip_enabled + +- name: sing-box | Download GeoSite database + ansible.builtin.get_url: + url: "{{ singbox_geosite_url }}" + dest: "{{ singbox_geosite_path }}" + mode: "0644" + timeout: 300 + when: singbox_geosite_enabled + +- name: sing-box | Generate configuration from template + ansible.builtin.template: + src: config.json.j2 + dest: "{{ singbox_config_dir }}/config.json" + owner: root + group: root + mode: "0644" + validate: "{{ singbox_install_dir }}/sing-box check -c %s" + notify: Restart sing-box service diff --git a/roles/sing-box-playbook/tasks/install.yml b/roles/sing-box-playbook/tasks/install.yml new file mode 100644 index 0000000..efa14c4 --- /dev/null +++ b/roles/sing-box-playbook/tasks/install.yml @@ -0,0 +1,64 @@ +--- +- name: sing-box | Get latest release from GitHub + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ singbox_repo }}/releases/latest" + return_content: true + headers: + Accept: "application/vnd.github.v3+json" + register: singbox_latest_release + when: singbox_auto_update + ignore_errors: true + +- name: sing-box | Set version from latest release + ansible.builtin.set_fact: + singbox_version: "{{ singbox_latest_release.json.tag_name | default(singbox_version) | regex_replace('^v', '') }}" + when: singbox_auto_update and singbox_latest_release is succeeded + +- name: sing-box | Display version to be installed + ansible.builtin.debug: + msg: "Installing sing-box version: {{ singbox_version }}" + +- name: sing-box | Create installation directory + ansible.builtin.file: + path: "{{ singbox_install_dir }}" + state: directory + mode: "0755" + +- name: sing-box | Download archive + ansible.builtin.get_url: + url: "{{ singbox_url }}/v{{ singbox_version }}/sing-box-{{ singbox_version }}-linux-{{ singbox_architecture }}.tar.gz" + dest: "/tmp/sing-box-{{ singbox_version }}.tar.gz" + mode: "0644" + timeout: 300 + +- name: sing-box | Extract archive + ansible.builtin.unarchive: + src: "/tmp/sing-box-{{ singbox_version }}.tar.gz" + dest: "/tmp" + remote_src: true + +- name: sing-box | Install binary + ansible.builtin.copy: + src: "/tmp/sing-box-{{ singbox_version }}-linux-{{ singbox_architecture }}/sing-box" + dest: "{{ singbox_install_dir }}/sing-box" + remote_src: true + mode: "0755" + owner: root + group: root + +- name: sing-box | Clean up downloaded files + ansible.builtin.file: + path: "{{ item }}" + state: absent + loop: + - "/tmp/sing-box-{{ singbox_version }}.tar.gz" + - "/tmp/sing-box-{{ singbox_version }}-linux-{{ singbox_architecture }}" + +- name: sing-box | Verify installation + ansible.builtin.command: "{{ singbox_install_dir }}/sing-box version" + register: singbox_version_output + changed_when: false + +- name: sing-box | Display installed version + ansible.builtin.debug: + msg: "{{ singbox_version_output.stdout }}" diff --git a/roles/sing-box-playbook/tasks/main.yml b/roles/sing-box-playbook/tasks/main.yml index 3c46918..e8f748d 100644 --- a/roles/sing-box-playbook/tasks/main.yml +++ b/roles/sing-box-playbook/tasks/main.yml @@ -1,259 +1,25 @@ -# tasks/main.yml -# Main tasks for the sing-box role with Hysteria2 and auto-update - -- name: Display OS information +--- +- name: sing-box | Display OS information ansible.builtin.debug: msg: "Operating System: {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}" - tags: ["always"] - -# --- System Configuration --- -- name: Enable IPv4 forwarding - ansible.posix.sysctl: - name: net.ipv4.ip_forward - value: "1" - state: present - sysctl_set: true - reload: true - tags: ["full_reinstall"] - -- name: Disable ICMP echo (ping) - ansible.posix.sysctl: - name: net.ipv4.icmp_echo_ignore_all - value: "1" - state: present - sysctl_set: true - reload: true - tags: ["full_reinstall"] - -- name: Optimize network performance - increase TCP buffer sizes - ansible.posix.sysctl: - name: "{{ item.name }}" - value: "{{ item.value }}" - state: present - sysctl_set: true - reload: true - loop: - - { name: "net.core.rmem_max", value: "134217728" } - - { name: "net.core.wmem_max", value: "134217728" } - - { name: "net.ipv4.tcp_rmem", value: "4096 87380 67108864" } - - { name: "net.ipv4.tcp_wmem", value: "4096 65536 67108864" } - - { name: "net.ipv4.tcp_congestion_control", value: "bbr" } - tags: ["full_reinstall"] - -# --- Get Latest Sing-box Version --- -- name: Get latest sing-box release from GitHub - ansible.builtin.uri: - url: "https://api.github.com/repos/{{ singbox_repo }}/releases/latest" - return_content: true - headers: - Accept: "application/vnd.github.v3+json" - register: singbox_latest_release - when: singbox_auto_update - ignore_errors: true - tags: ["full_reinstall"] - -- name: Set singbox_version from latest release - ansible.builtin.set_fact: - singbox_version: "{{ singbox_latest_release.json.tag_name | default(singbox_version) | regex_replace('^v', '') }}" - when: singbox_auto_update and singbox_latest_release is succeeded - tags: ["full_reinstall"] - -- name: Display sing-box version to be installed - ansible.builtin.debug: - msg: "Installing sing-box version: {{ singbox_version }}" - tags: ["full_reinstall"] - -# --- Download and Install Sing-box --- -- name: Create installation directory - ansible.builtin.file: - path: "{{ singbox_install_dir }}" - state: directory - mode: "0755" - tags: ["full_reinstall"] - -- name: Download sing-box archive - ansible.builtin.get_url: - url: "{{ singbox_url }}/v{{ singbox_version }}/sing-box-{{ singbox_version }}-linux-{{ singbox_architecture }}.tar.gz" - dest: "/tmp/sing-box-{{ singbox_version }}.tar.gz" - mode: "0644" - timeout: 300 - tags: ["full_reinstall"] - -- name: Extract sing-box archive - ansible.builtin.unarchive: - src: "/tmp/sing-box-{{ singbox_version }}.tar.gz" - dest: "/tmp" - remote_src: true - tags: ["full_reinstall"] - -- name: Install sing-box binary - ansible.builtin.copy: - src: "/tmp/sing-box-{{ singbox_version }}-linux-{{ singbox_architecture }}/sing-box" - dest: "{{ singbox_install_dir }}/sing-box" - remote_src: true - mode: "0755" - owner: root - group: root - tags: ["full_reinstall"] - -- name: Clean up downloaded files - ansible.builtin.file: - path: "{{ item }}" - state: absent - loop: - - "/tmp/sing-box-{{ singbox_version }}.tar.gz" - - "/tmp/sing-box-{{ singbox_version }}-linux-{{ singbox_architecture }}" - tags: ["full_reinstall"] - -- name: Verify sing-box installation - ansible.builtin.command: "{{ singbox_install_dir }}/sing-box version" - register: singbox_version_output - changed_when: false - tags: ["full_reinstall"] + tags: always -- name: Display sing-box version - ansible.builtin.debug: - msg: "{{ singbox_version_output.stdout }}" - tags: ["full_reinstall"] - -# --- Configuration Setup --- -- name: Create sing-box configuration directory - ansible.builtin.file: - path: "{{ singbox_config_dir }}" - state: directory - owner: root - group: root - mode: "0755" - tags: - - full_reinstall - - config_update - -- name: Create ACME certificate directory - ansible.builtin.file: - path: "{{ singbox.tls_acme_data_directory }}" - state: directory - owner: root - group: root - mode: "0700" - when: singbox.tls_enabled - tags: - - full_reinstall - - config_update - -- name: Create log directory - ansible.builtin.file: - path: "{{ singbox_log_output | dirname }}" - state: directory - owner: root - group: root - mode: "0755" - tags: - - full_reinstall - - config_update - -- name: Download GeoIP database - ansible.builtin.get_url: - url: "{{ singbox_geoip_url }}" - dest: "{{ singbox_geoip_path }}" - mode: "0644" - timeout: 300 - when: singbox_geoip_enabled - tags: - - full_reinstall - - config_update - -- name: Download GeoSite database - ansible.builtin.get_url: - url: "{{ singbox_geosite_url }}" - dest: "{{ singbox_geosite_path }}" - mode: "0644" - timeout: 300 - when: singbox_geosite_enabled - tags: - - full_reinstall - - config_update - -- name: Generate sing-box configuration from template - ansible.builtin.template: - src: config.json.j2 - dest: "{{ singbox_config_dir }}/config.json" - owner: root - group: root - mode: "0644" - validate: "{{ singbox_install_dir }}/sing-box check -c %s" - notify: Restart sing-box service - tags: - - full_reinstall - - config_update +- name: sing-box | Validate + ansible.builtin.import_tasks: validate.yml + tags: always -# --- Systemd Service Setup --- -- name: Create systemd service file - ansible.builtin.template: - src: sing-box.service.j2 - dest: /etc/systemd/system/sing-box.service - owner: root - group: root - mode: "0644" - notify: Reload systemd and restart sing-box - tags: ["full_reinstall"] +- name: sing-box | System tuning + ansible.builtin.import_tasks: system.yml + tags: singbox_system -- name: Reload systemd daemon - ansible.builtin.systemd: - daemon_reload: true - tags: ["full_reinstall"] - -- name: Enable and start sing-box service - ansible.builtin.systemd: - name: sing-box - enabled: true - state: started - tags: ["full_reinstall"] - -# --- Final Status Check --- -- name: Wait for sing-box to start - ansible.builtin.wait_for: - timeout: 10 - tags: - - full_reinstall - - config_update - -- name: Check sing-box service status - ansible.builtin.systemd: - name: sing-box - register: singbox_service_status - tags: - - full_reinstall - - config_update - -- name: Display sing-box service status - ansible.builtin.debug: - msg: "sing-box service is {{ singbox_service_status.status.ActiveState }}" - tags: - - full_reinstall - - config_update - -- name: Display connection information - ansible.builtin.debug: - msg: | - ======================================== - Sing-box Hysteria2 Server Configuration - ======================================== - Server: {{ singbox.tls_server_name }} - Port: {{ singbox_hysteria2_listen_port }} - Protocol: Hysteria2 - TLS: Enabled (ACME) - Obfuscation: {{ singbox_hysteria2_obfs_type if singbox_hysteria2_obfs_enabled else 'Disabled' }} +- name: sing-box | Install + ansible.builtin.import_tasks: install.yml + tags: singbox_install - Client Configuration: - - Server: {{ singbox.tls_server_name }}:{{ singbox_hysteria2_listen_port }} - - Password: (check your secrets.yml) - - SNI: {{ singbox.tls_server_name }} - - ALPN: h3 - - Obfs Type: {{ singbox_hysteria2_obfs_type if singbox_hysteria2_obfs_enabled else 'none' }} +- name: sing-box | Configure + ansible.builtin.import_tasks: config.yml + tags: singbox_config - Logs: {{ singbox_log_output }} - Config: {{ singbox_config_dir }}/config.json - ======================================== - tags: - - full_reinstall - - config_update +- name: sing-box | Service + ansible.builtin.import_tasks: service.yml + tags: singbox_service diff --git a/roles/sing-box-playbook/tasks/service.yml b/roles/sing-box-playbook/tasks/service.yml new file mode 100644 index 0000000..66fe93b --- /dev/null +++ b/roles/sing-box-playbook/tasks/service.yml @@ -0,0 +1,55 @@ +--- +- name: sing-box | Deploy systemd service file + ansible.builtin.template: + src: sing-box.service.j2 + dest: /etc/systemd/system/sing-box.service + owner: root + group: root + mode: "0644" + notify: Reload systemd and restart sing-box + +- name: sing-box | Reload systemd daemon + ansible.builtin.systemd: + daemon_reload: true + +- name: sing-box | Enable and start service + ansible.builtin.systemd: + name: sing-box + enabled: true + state: started + +- name: sing-box | Wait for service to start + ansible.builtin.wait_for: + timeout: 10 + +- name: sing-box | Check service status + ansible.builtin.systemd: + name: sing-box + register: singbox_service_status + +- name: sing-box | Display service status + ansible.builtin.debug: + msg: "sing-box service is {{ singbox_service_status.status.ActiveState }}" + +- name: sing-box | Display connection information + ansible.builtin.debug: + msg: | + ======================================== + Sing-box Hysteria2 Server Configuration + ======================================== + Server: {{ singbox.tls_server_name }} + Port: {{ singbox_hysteria2_listen_port }} + Protocol: Hysteria2 + TLS: Enabled (ACME) + Obfuscation: {{ singbox_hysteria2_obfs_type if singbox_hysteria2_obfs_enabled else 'Disabled' }} + + Client Configuration: + - Server: {{ singbox.tls_server_name }}:{{ singbox_hysteria2_listen_port }} + - Password: (check your secrets.yml) + - SNI: {{ singbox.tls_server_name }} + - ALPN: h3 + - Obfs Type: {{ singbox_hysteria2_obfs_type if singbox_hysteria2_obfs_enabled else 'none' }} + + Logs: {{ singbox_log_output }} + Config: {{ singbox_config_dir }}/config.json + ======================================== diff --git a/roles/sing-box-playbook/tasks/system.yml b/roles/sing-box-playbook/tasks/system.yml new file mode 100644 index 0000000..dea5102 --- /dev/null +++ b/roles/sing-box-playbook/tasks/system.yml @@ -0,0 +1,30 @@ +--- +- name: sing-box | Enable IPv4 forwarding + ansible.posix.sysctl: + name: net.ipv4.ip_forward + value: "1" + state: present + sysctl_set: true + reload: true + +- name: sing-box | Disable ICMP echo (ping) + ansible.posix.sysctl: + name: net.ipv4.icmp_echo_ignore_all + value: "1" + state: present + sysctl_set: true + reload: true + +- name: sing-box | Optimize network — increase TCP buffer sizes and enable BBR + ansible.posix.sysctl: + name: "{{ item.name }}" + value: "{{ item.value }}" + state: present + sysctl_set: true + reload: true + loop: + - { name: "net.core.rmem_max", value: "134217728" } + - { name: "net.core.wmem_max", value: "134217728" } + - { name: "net.ipv4.tcp_rmem", value: "4096 87380 67108864" } + - { name: "net.ipv4.tcp_wmem", value: "4096 65536 67108864" } + - { name: "net.ipv4.tcp_congestion_control", value: "bbr" } diff --git a/roles/sing-box-playbook/tasks/validate.yml b/roles/sing-box-playbook/tasks/validate.yml new file mode 100644 index 0000000..49fe57d --- /dev/null +++ b/roles/sing-box-playbook/tasks/validate.yml @@ -0,0 +1,56 @@ +--- +- name: sing-box | Validate singbox_hysteria2_users is defined and non-empty + ansible.builtin.assert: + that: + - singbox_hysteria2_users is defined + - singbox_hysteria2_users | length > 0 + fail_msg: >- + singbox_hysteria2_users is not defined or empty. + Define at least one user in secrets.yml. + success_msg: "singbox_hysteria2_users is valid" + +- name: sing-box | Validate each user has name and password + ansible.builtin.assert: + that: + - item.name is defined and item.name != '' + - item.password is defined and item.password != '' + fail_msg: >- + User entry is missing required fields. + Each user must have 'name' and 'password'. + Offending entry: {{ item }} + success_msg: "User {{ item.name }} is valid" + loop: "{{ singbox_hysteria2_users }}" + +- name: sing-box | Validate TLS server_name is defined + ansible.builtin.assert: + that: + - singbox.tls_server_name is defined + - singbox.tls_server_name != '' + fail_msg: >- + singbox.tls_server_name is required. + Set it in secrets.yml (e.g. your-server.com). + success_msg: "singbox.tls_server_name is valid" + +- name: sing-box | Validate ACME domain and email are defined + ansible.builtin.assert: + that: + - singbox.tls_acme_domain is defined + - singbox.tls_acme_domain != '' + - singbox.tls_acme_email is defined + - singbox.tls_acme_email != '' + fail_msg: >- + singbox.tls_acme_domain and singbox.tls_acme_email are required for TLS/ACME. + Set them in secrets.yml. + success_msg: "ACME config is valid" + when: singbox.tls_enabled | default(true) + +- name: sing-box | Validate obfs password is defined when obfs is enabled + ansible.builtin.assert: + that: + - singbox_hysteria2_obfs_password is defined + - singbox_hysteria2_obfs_password != '' + fail_msg: >- + singbox_hysteria2_obfs_password is required when singbox_hysteria2_obfs_enabled is true. + Set it in secrets.yml. + success_msg: "Obfuscation password is valid" + when: singbox_hysteria2_obfs_enabled | default(false) diff --git a/roles/sing-box-playbook/templates/config.json.j2 b/roles/sing-box-playbook/templates/config.json.j2 index 4ab7e5a..79d4396 100644 --- a/roles/sing-box-playbook/templates/config.json.j2 +++ b/roles/sing-box-playbook/templates/config.json.j2 @@ -27,9 +27,8 @@ "detour": "direct" } ], - "strategy": "prefer_ipv4", - "disable_cache": false, - "disable_expire": false + "strategy": "{{ singbox_dns_strategy }}", + "final": "{{ singbox_dns_final }}" }, "inbounds": [ @@ -51,10 +50,12 @@ {% endfor %} ], {% endif %} + {% if singbox_hysteria2_obfs_enabled | default(false) and singbox_hysteria2_obfs_password | default('') != '' %} "obfs": { "type": "{{ singbox_hysteria2_obfs_type }}", "password": "{{ singbox_hysteria2_obfs_password }}" }, + {% endif %} "masquerade": "{{ singbox_hysteria2_masquerade }}", "tls": { "enabled": {{ singbox.tls_enabled | lower }}, diff --git a/roles/wireguard/defaults/main.yml b/roles/wireguard/defaults/main.yml new file mode 100644 index 0000000..ab0ff1a --- /dev/null +++ b/roles/wireguard/defaults/main.yml @@ -0,0 +1,13 @@ +--- +wg_interface: wg0 +wg_port: 51820 # UDP port on EU +wg_eu_ip: "10.10.0.1" # EU WireGuard IP +wg_ru_ip: "10.10.0.2" # RU WireGuard IP +wg_network: "10.10.0.0/24" +wg_config_dir: "/etc/wireguard" +wg_config_path: "/etc/wireguard/{{ wg_interface }}.conf" + +# wg_eu_private_key: set in secrets.yml (ansible-vault) +# wg_eu_public_key: set in secrets.yml (ansible-vault) +# wg_ru_private_key: set in secrets.yml (ansible-vault) +# wg_ru_public_key: set in secrets.yml (ansible-vault) diff --git a/roles/wireguard/defaults/secrets.yml.example b/roles/wireguard/defaults/secrets.yml.example new file mode 100644 index 0000000..630c783 --- /dev/null +++ b/roles/wireguard/defaults/secrets.yml.example @@ -0,0 +1,14 @@ +--- +# Copy to secrets.yml and encrypt: +# ansible-vault encrypt secrets.yml --vault-password-file vault_password.txt +# +# Generate key pair (run twice — for EU and RU): +# wg genkey | tee /tmp/wg_private.key | wg pubkey > /tmp/wg_public.key +# cat /tmp/wg_private.key # → wg_XX_private_key +# cat /tmp/wg_public.key # → wg_XX_public_key +# rm /tmp/wg_private.key /tmp/wg_public.key + +wg_eu_private_key: "CHANGE_ME" +wg_eu_public_key: "CHANGE_ME" +wg_ru_private_key: "CHANGE_ME" +wg_ru_public_key: "CHANGE_ME" diff --git a/roles/wireguard/handlers/main.yml b/roles/wireguard/handlers/main.yml new file mode 100644 index 0000000..770adc1 --- /dev/null +++ b/roles/wireguard/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Restart wg0 + ansible.builtin.systemd: + name: "wg-quick@wg0" + state: restarted + daemon_reload: true + when: ansible_facts['service_mgr'] == "systemd" diff --git a/roles/wireguard/tasks/eu.yml b/roles/wireguard/tasks/eu.yml new file mode 100644 index 0000000..0391974 --- /dev/null +++ b/roles/wireguard/tasks/eu.yml @@ -0,0 +1,32 @@ +--- +- name: Install wireguard-tools + ansible.builtin.apt: + name: wireguard-tools + state: present + update_cache: false + +- name: Ensure wireguard config directory exists + ansible.builtin.file: + path: "{{ wg_config_dir }}" + state: directory + owner: root + group: root + mode: "0700" + +- name: Deploy wg0 config on EU + ansible.builtin.template: + src: wg0-eu.conf.j2 + dest: "{{ wg_config_path }}" + owner: root + group: root + mode: "0600" + notify: Restart wg0 + +- name: Enable and start wg-quick@wg0 on EU + ansible.builtin.systemd: + name: "wg-quick@{{ wg_interface }}" + enabled: true + state: started + daemon_reload: true + +# No firewall configured on EU (iptables policy ACCEPT) — UDP {{ wg_port }} is open by default diff --git a/roles/wireguard/tasks/main.yml b/roles/wireguard/tasks/main.yml new file mode 100644 index 0000000..d296633 --- /dev/null +++ b/roles/wireguard/tasks/main.yml @@ -0,0 +1,15 @@ +--- +- name: Load wireguard secrets + ansible.builtin.include_vars: + file: "{{ role_path }}/defaults/secrets.yml" + tags: always + +- name: Configure WireGuard on EU + ansible.builtin.import_tasks: eu.yml + when: inventory_hostname == 'vm_my_srv' + tags: wireguard_eu + +- name: Configure WireGuard on RU + ansible.builtin.import_tasks: ru.yml + when: inventory_hostname == 'vm_my_ru' + tags: wireguard_ru diff --git a/roles/wireguard/tasks/ru.yml b/roles/wireguard/tasks/ru.yml new file mode 100644 index 0000000..900577e --- /dev/null +++ b/roles/wireguard/tasks/ru.yml @@ -0,0 +1,30 @@ +--- +- name: Install wireguard-tools + ansible.builtin.apt: + name: wireguard-tools + state: present + update_cache: false + +- name: Ensure wireguard config directory exists + ansible.builtin.file: + path: "{{ wg_config_dir }}" + state: directory + owner: root + group: root + mode: "0700" + +- name: Deploy wg0 config on RU + ansible.builtin.template: + src: wg0-ru.conf.j2 + dest: "{{ wg_config_path }}" + owner: root + group: root + mode: "0600" + notify: Restart wg0 + +- name: Enable and start wg-quick@wg0 on RU + ansible.builtin.systemd: + name: "wg-quick@{{ wg_interface }}" + enabled: true + state: started + daemon_reload: true diff --git a/roles/wireguard/templates/wg0-eu.conf.j2 b/roles/wireguard/templates/wg0-eu.conf.j2 new file mode 100644 index 0000000..3c31fac --- /dev/null +++ b/roles/wireguard/templates/wg0-eu.conf.j2 @@ -0,0 +1,10 @@ +[Interface] +Address = {{ wg_eu_ip }}/24 +PrivateKey = {{ wg_eu_private_key }} +ListenPort = {{ wg_port }} + +[Peer] +# RU server +PublicKey = {{ wg_ru_public_key }} +AllowedIPs = {{ wg_ru_ip }}/32 +PersistentKeepalive = 25 diff --git a/roles/wireguard/templates/wg0-ru.conf.j2 b/roles/wireguard/templates/wg0-ru.conf.j2 new file mode 100644 index 0000000..85e439e --- /dev/null +++ b/roles/wireguard/templates/wg0-ru.conf.j2 @@ -0,0 +1,10 @@ +[Interface] +Address = {{ wg_ru_ip }}/24 +PrivateKey = {{ wg_ru_private_key }} + +[Peer] +# EU server +PublicKey = {{ wg_eu_public_key }} +Endpoint = {{ hostvars['vm_my_srv']['ansible_host'] }}:{{ wg_port }} +AllowedIPs = {{ wg_eu_ip }}/32 +PersistentKeepalive = 25 diff --git a/roles/xray/README.md b/roles/xray/README.md index e55c6b9..5c3ad24 100644 --- a/roles/xray/README.md +++ b/roles/xray/README.md @@ -68,7 +68,6 @@ xray_vless_decryption: "mlkem768x25519plus.native.0rtt.100-111-1111-1111-1111-11 | `xray_reality_server_names` | `[askubuntu.com]` | SNI server names | | `xray_api.inbound.port` | `10085` | Xray gRPC API port (localhost only) | | `xray_vless_decryption` | `none` | VLESS payload decryption mode (`none` or postquantum cipher string) | -| `xray_vless_default_flow` | `xtls-rprx-vision` | `flow` для пользователя, если в `xray_users` не задан ([VLESS inbound](https://xtls.github.io/en/config/inbounds/vless.html)) | | `xray_reality.mldsa65_seed` | — | ML-DSA-65 server seed (secrets.yml only) | | `xray_reality.mldsa65_verify` | — | ML-DSA-65 public verification key (share with clients) | diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index bf6a17a..d104a65 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -17,12 +17,14 @@ 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) -# VLESS + XTLS-Reality Configuration +# VLESS + XTLS-Reality Configuration (TCP) +# Listens on localhost — nginx stream SNI routing on :443 forwards traffic here. xray_vless_tag: vless-reality-in # Tag for VLESS inbound -xray_vless_port: 443 # Port for incoming VLESS connections -xray_reality_dest: "askubuntu.com:443" # Destination for XTLS-Reality (usually a TLS website) -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 +xray_vless_port: 4443 # Local port (behind nginx stream SNI routing on :443) +xray_vless_listen: "127.0.0.1" # Bind to localhost only (nginx stream fronts :443) +xray_reality_dest: "askubuntu.com:443" # Destination for XTLS-Reality (camouflage domain) +xray_reality_server_names: # SNI names for Reality TCP (must match dest) + - "askubuntu.com" # VLESS Encryption — server inbound decryption string (Xray-core >= 25.x, PR #5067). # "none" — standard VLESS, no extra encryption layer, compatible with all clients. @@ -48,7 +50,7 @@ xray_dns_servers: # List of DNS servers for Xray # 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" +xray_dns_query_strategy: "UseIPv4" # DNS query strategy: "UseIP", "UseIPIfNonMatch", "UseIPv4", "UseIPv6" # Log vars xray_log_config: @@ -65,8 +67,6 @@ xray_blocked_domains: # List of domains to block - "geosite:category-ads-all" - "geosite:category-ads" - "geosite:category-public-tracker" - - "geosite:google-ads" - - "geosite:spotify-ads" # Xray Reality api configuration xray_api: @@ -82,15 +82,22 @@ xray_api: protocol: "dokodemo-door" # Protocol for API (dokodemo-door)" tag: "api-inbound" -# Xray XHTTP configuration +# Xray XHTTP + Reality configuration +# Listens on localhost — nginx stream SNI routing on :443 forwards traffic here. +# Uses separate Reality dest/serverNames from TCP Reality (different SNI for SNI-based routing). xray_xhttp: tag: "vless-xhttp-in" # Tag for XHTTP inbound - port: 2053 # Port for XHTTP inbound - network: "xhttp" # Network type for XHTTP (http or ws) + port: 2053 # Local port (behind nginx stream SNI routing on :443) + listen: "127.0.0.1" # Bind to localhost only (nginx stream fronts :443) + network: "xhttp" # Network type xhttpSettings: mode: "auto" path: "/api/v3/data-sync" - scMaxPacketSize: 50000 + scMaxPacketSize: 1048576 + reality: # XHTTP-specific Reality settings (separate from TCP Reality) + dest: "www.adobe.com:443" # Camouflage domain (TLS 1.3, H2, OCSP, Akamai CDN) + server_names: + - "www.adobe.com" ##### !!!!! This is a secret, do not share it publicly !!!!!#### @@ -108,37 +115,10 @@ xray_xhttp: # xray_users: # - id: "UUID_1" # Replace with your actual UUID # flow: "xtls-rprx-vision" -# email: "someEmailForIdentyfy" +# email: "someEmailForIdentyfy" # Non-empty recommended; if omitted, id is used (needed for per-user traffic stats) # - id: "UUID_2" # flow: "xtls-rprx-vision" # email: "someEmailForIdentyfy" # - 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/defaults/raven_subscribe_secrets.yml.example b/roles/xray/defaults/raven_subscribe_secrets.yml.example new file mode 100644 index 0000000..1f09578 --- /dev/null +++ b/roles/xray/defaults/raven_subscribe_secrets.yml.example @@ -0,0 +1,13 @@ +--- +# Raven-subscribe secrets — copy to raven_subscribe_secrets.yml and encrypt: +# ansible-vault encrypt roles/xray/defaults/raven_subscribe_secrets.yml + +# Admin token for Raven-subscribe API (required) +# Generate: openssl rand -hex 32 +raven_subscribe_admin_token: "" + +# Public URL used in subscription links — must be the relay domain +raven_subscribe_base_url: "https://my.example.com" + +# EU VPS public IP or domain (used in generated client outbound addresses) +raven_subscribe_server_host: "media.example.com" diff --git a/roles/xray/exampl/config.json.j2 b/roles/xray/exampl/config.json.j2 deleted file mode 100644 index ce234ec..0000000 --- a/roles/xray/exampl/config.json.j2 +++ /dev/null @@ -1,227 +0,0 @@ -{ - "log": { - "loglevel": "{{ xray_log_level }}", - "access": "{{ xray_access_log }}", - "error": "{{ xray_error_log }}", - "dnsLog": {{ xray_dns_log | to_json }} - }, - "dns": { - "servers": [ - {% for server in xray_dns_servers %} - "{{ server }}"{{ "," if not loop.last }} - {% endfor %} - ], - "disableFallback": {{ xray_dns_disable_fallback | to_json }}, - "queryStrategy": "{{ xray_dns_query_strategy }}" - }, - {% if xray_api.enable %} - "stats": {}, - "api": { - "tag": "{{ xray_api.tag }}", - "listen": "{{ xray_api.inbound.address }}:{{ xray_api.inbound.port }}", - "tag": "{{ xray_api.tag }}", - "services": [ - {% for service in xray_api.services %} - "{{ service }}"{{ "," if not loop.last }} - {% endfor %} - ] - }, - "policy": { - "levels": { - "0": { - "statsUserUplink": true, - "statsUserDownlink": true - } - }, - "system": { - "statsInboundUplink": true, - "statsInboundDownlink": true, - "statsOutboundUplink": true, - "statsOutboundDownlink": true - } - }, - {% endif %} - "inbounds": [ - {% if xray_api.enable %} - { - "listen": "{{ xray_api.inbound.address }}", - "port": {{ xray_api.inbound.port }}, - "protocol": "{{ xray_api.inbound.protocol }}", - "settings": { - "address": "{{ xray_api.inbound.address }}" - }, - "tag": "{{ xray_api.inbound.tag}}", - "sniffing": null - }, - {% endif %} - { - "port": {{ xray_vless_port }}, - "protocol": "vless", - "tag": "{{ xray_vless_tag }}", - "settings": { - "clients": [ - {% for user in xray_users %} - { - "id": "{{ user.id }}", - "flow": "{{ user.flow }}", - "email": "{{ user.email | default('') }}", - "level": 0 - }{{ "," if not loop.last }} - {% endfor %} - ], - "decryption": "none" - }, - "streamSettings": { - "network": "tcp", - "security": "reality", - "realitySettings": { - "show": false, - "dest": "{{ xray_reality_dest }}", - "spiderX": "{{ xray_reality.spiderX }}", - "xver": 0, - "serverNames": [ - {% for name in xray_reality_server_names %} - "{{ name }}"{{ "," if not loop.last }} - {% endfor %} - ], - "privateKey": "{{ xray_reality.private_key }}", - "shortIds": [ - {% for short_id in xray_reality.short_id %} - "{{ short_id }}"{{ "," if not loop.last }} - {% endfor %} - ] - } - }, - "sniffing": { - "enabled": true, - "destOverride": [ - "http", - "tls", - "quic" - ], - "routeOnly": true - } - }, - { - "port": 2053, - "protocol": "vless", - "settings": { - "clients": [ - {% for user in xray_users %} - { - "id": "{{ user.id }}", - "flow": "", - "email": "{{ user.email | default('') }}", - "level": 0 - }{{ "," if not loop.last }} - {% endfor %} - ], - "decryption": "none" - }, - "sniffing": { - "destOverride": [ - "http", - "tls", - "quic" - ], - "enabled": true - }, - "streamSettings": { - "network": "xhttp", - "realitySettings": { - "dest": "{{ xray_reality_dest }}", - "privateKey": "{{ xray_reality.private_key }}", - "serverNames": [ - {% for name in xray_reality_server_names %} - "{{ name }}"{{ "," if not loop.last }} - {% endfor %} - ], - "shortIds": [ - {% for short_id in xray_reality.short_id %} - "{{ short_id }}"{{ "," if not loop.last }} - {% endfor %} - ], - "show": false, - "xver": 0 - }, - "security": "reality", - "xhttpSettings": { - "mode": "{{ xray_xhttp.xhttpSettings.mode }}", - "path": "{{ xray_xhttp.xhttpSettings.path }}", - "scMaxPacketSize": "{{ xray_xhttp.xhttpSettings.scMaxPacketSize }}", - "xmux": { - "cids": [ - 1 - ], - "maxConcurrency": 16 - } - } - }, - "tag": "vless-xhttp-in" - } - ], - "outbounds": [ - { - "protocol": "freedom", - "settings": {}, - "tag": "freedom" - }, - { - "protocol": "blackhole", - "settings": {}, - "tag": "blocked" - } - ], - "routing": { - "domainStrategy": "IPIfNonMatch", - "rules": [ - {% if xray_api.enable %} - { - "type": "field", - "inboundTag": [ - "{{ xray_api.inbound.tag}}" - ], - "outboundTag": "{{ xray_api.tag }}" - }, - {% endif %} - { - "type": "field", - "inboundTag": [ - "{{ xray_vless_tag }}", - "{{ xray_xhttp.xray_xhttp_tag }}" - ], - "outboundTag": "freedom" - }, - { - "type": "field", - "domain": [ - "geosite:category-ads", - "geosite:category-public-tracker" - ], - "outboundTag": "blocked" - }, - { - "type": "field", - "domain": [ - {% for domain in xray_blocked_domains %} - "{{ domain }}"{{ "," if not loop.last }} - {% endfor %} - ], - "outboundTag": "blocked", - "settings": { - "response": { - "type": - "http" - } - } - }, - {% if xray_api.enable %} - { - "type": "field", - "inboundTag": ["${xray_api.inbound.tag}"], - "outboundTag": "api" - } - {% endif %} - ] - } -} \ No newline at end of file diff --git a/roles/xray/exampl/main.yml.bak b/roles/xray/exampl/main.yml.bak deleted file mode 100644 index afb6566..0000000 --- a/roles/xray/exampl/main.yml.bak +++ /dev/null @@ -1,192 +0,0 @@ -Л# File: roles/xray/tasks/main.yml - ---- -- name: "Ensure the {{ xray_group }} group exists" - ansible.builtin.group: - name: "{{ xray_group }}" - state: present - system: true - -- name: Set nologin shell path based on OS family - ansible.builtin.set_fact: - nologin_shell: | - {% if ansible_facts['os_family'] == 'Alpine' %} - /sbin/nologin - {% else %} - /usr/sbin/nologin - {% endif %} - -- name: Create a dedicated system user for Xray ({{ xray_user }}) - ansible.builtin.user: - name: "{{ xray_user }}" - state: present - system: true - shell: "{{ xray_nologin_shell }}" - group: "{{ xray_group }}" - -- name: Ensure Xray log directory exists and has correct permissions - ansible.builtin.file: - path: "{{ xray_log_dir }}" - state: directory - owner: "{{ xray_user }}" - group: "{{ xray_group }}" - mode: '0755' - recurse: true - -- name: Ensure Xray configuration directory exists and has correct permissions - ansible.builtin.file: - path: "{{ xray_config_dir }}" - state: directory - owner: "{{ xray_user }}" - group: "{{ xray_group }}" - mode: '0755' - recurse: true - -- name: "Correct ownership for Xray executable (if needed)" - ansible.builtin.file: - path: "{{ xray_bin_dir }}/xray" - owner: "{{ xray_user }}" - group: "{{ xray_group }}" - mode: '0755' - ignore_errors: true - when: xray_bin_dir is defined and xray_bin_dir != '' - -- name: Correct permissions for Xray service files (if necessary) - ansible.builtin.file: - path: "{{ xray_install_dir }}" - owner: "{{ xray_user }}" - group: "{{ xray_group }}" - -- name: Ensure Xray install directory exists - ansible.builtin.file: - path: "{{ xray_install_dir }}" - state: directory - mode: '0755' - -- name: Ensure Xray config directory exists - ansible.builtin.file: - path: "{{ xray_config_dir }}" - state: directory - mode: '0755' - -- name: Get latest Xray release version - ansible.builtin.uri: - url: "https://api.github.com/repos/{{ xray_github_repo }}/releases/latest" - method: GET - return_content: true - headers: - Accept: "application/vnd.github.v3+json" - register: xray_release_info - run_once: true - -- name: Set Xray version fact - ansible.builtin.set_fact: - xray_version: "{{ xray_release_info.json.tag_name }}" - ansible_version_full: "{{ ansible_version.full }}" - -- name: Download Xray using get_url (Ansible >= 2.16.0) - block: - - name: Download Xray archive (Ansible >= 2.16.0) - ansible.builtin.get_url: - url: "{{ xray_download_url }}" - dest: "/tmp/Xray-{{ xray_version }}-linux-64.zip" - mode: '0644' - register: download_xray - - name: Unarchive Xray - ansible.builtin.unarchive: - src: "{{ download_xray.dest }}" - dest: "{{ xray_install_dir }}" - remote_src: true - when: ansible_version_full is version('2.16.0', '>=') - notify: "Restart xray service" - -- name: Download Xray using curl (Ansible < 2.16.0) - block: - - name: Download Xray archive (Ansible < 2.16.0) - ansible.builtin.command: > - curl -L --output /tmp/Xray-{{ xray_version }}-linux-64.zip "{{ xray_download_url }}" - args: - creates: "/tmp/Xray-{{ xray_version }}-linux-64.zip" - register: download_xray - changed_when: download_xray.rc == 0 and not "already exists" in download_xray.stdout - - name: Unarchive Xray - ansible.builtin.unarchive: - src: "/tmp/Xray-{{ xray_version }}-linux-64.zip" - dest: "{{ xray_install_dir }}" - remote_src: true - when: ansible_version_full is version('2.16.0', '<') - notify: "Restart xray service" - -- name: Create symlink for Xray executable - ansible.builtin.file: - src: "{{ xray_install_dir }}/xray" - dest: /usr/local/bin/xray - state: link - force: true - -- name: Deploy Xray systemd service file - ansible.builtin.template: - src: xray.service.j2 - dest: /etc/systemd/system/{{ xray_service_name }}.service - mode: '0644' - when: ansible_facts['service_mgr'] == "systemd" - notify: - - Reload systemd - - Restart xray - -- name: Deploy Xray OpenRC service file - ansible.builtin.template: - src: xray.openrc.j2 - dest: /etc/init.d/{{ xray_service_name }} - mode: '0755' - when: ansible_facts['service_mgr'] == "openrc" - notify: - - Reload openrc - - Restart xray - -- name: Generate Xray configuration - ansible.builtin.template: - src: config.json.j2 - dest: "{{ xray_config_dir }}/config.json" - mode: '0644' - notify: "Restart xray service" - tags: - - xray_config - -- name: Ensure Xray service is started and enabled (systemd) - ansible.builtin.systemd_service: - name: "{{ xray_service_name }}" - state: started - enabled: true - register: xray_service_status_systemd - when: ansible_facts['service_mgr'] == "systemd" - tags: - - xray_config - -- name: Ensure Xray service is started and enabled (OpenRC) - ansible.builtin.service: - name: "{{ xray_service_name }}" - state: started - enabled: true - register: xray_service_status_openrc - when: ansible_facts['service_mgr'] == "openrc" - tags: - - xray_config - -- name: Fail if Xray service is not running (systemd) - ansible.builtin.fail: - msg: "Xray service failed to start! Check logs with 'journalctl -u {{ xray_service_name }}'" - when: - - ansible_facts['service_mgr'] == "systemd" - - not xray_service_status_systemd.status.ActiveState == "active" - tags: - - xray_config - -- name: Fail if Xray service is not running (OpenRC) - ansible.builtin.fail: - msg: "Xray service failed to start! Check logs with 'rc-service {{ xray_service_name }} status' or '/var/log/messages'" - when: - - ansible_facts['service_mgr'] == "openrc" - - not xray_service_status_openrc.status.active - tags: - - xray_config diff --git a/roles/xray/handlers/main.yml b/roles/xray/handlers/main.yml index b9d8cc9..a2dca5c 100644 --- a/roles/xray/handlers/main.yml +++ b/roles/xray/handlers/main.yml @@ -39,3 +39,4 @@ 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 f66373c..33d8f2a 100644 --- a/roles/xray/tasks/main.yml +++ b/roles/xray/tasks/main.yml @@ -39,6 +39,4 @@ 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/templates/conf/inbounds/200-in-vless-reality.json.j2 b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 index 3db78af..44c6438 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 @@ -1,6 +1,7 @@ { "inbounds": [ { + "listen": "{{ xray_vless_listen | default('0.0.0.0') }}", "port": {{ xray_vless_port }}, "protocol": "vless", "tag": "{{ xray_vless_tag }}", @@ -10,8 +11,8 @@ {% for user in xray_users %} { "id": "{{ user.id }}", - "flow": "{{ 'xtls-rprx-vision' if _pq else (user.flow | default(xray_vless_default_flow)) }}", - "email": "{{ user.email | default('') }}", + "flow": "{{ 'xtls-rprx-vision' if _pq else user.flow }}", + "email": "{{ (user.email | default('') | trim) or user.id }}", "level": 0 }{{ "," if not loop.last }} {% endfor %} @@ -44,7 +45,8 @@ {% for short_id in xray_reality.short_id %} "{{ short_id }}"{{ "," if not loop.last }} {% endfor %} - ] + ], + "xver": 2 } }, "sniffing": { 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 4212c84..01f6a24 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -1,18 +1,19 @@ { "inbounds": [ { + "listen": "{{ xray_xhttp.listen | default('0.0.0.0') }}", "port": {{ xray_xhttp.port }}, "protocol": "vless", - "tag": "{{ xray_common.inbound_tag.vless_xhttp_in }}", + "tag": "{{ xray_common.inbound_tag.vless_xhttp_in }}", "settings": { "clients": [ {% set _pq = xray_vless_decryption | default('none') != 'none' %} {% for user in xray_users %} - {% set _flow = user.flow | default(xray_vless_default_flow) %} + {% set _flow = user.flow | default('') %} { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}", - "email": "{{ user.email | default('') }}", + "email": "{{ (user.email | default('') | trim) or user.id }}", "level": 0 }{{ "," if not loop.last }} {% endfor %} @@ -31,7 +32,7 @@ "streamSettings": { "network": "{{ xray_xhttp.network }}", "realitySettings": { - "dest": "{{ xray_reality_dest }}", + "dest": "{{ xray_xhttp.reality.dest | default(xray_reality_dest) }}", "privateKey": "{{ xray_reality.private_key }}", "spiderX": "{{ xray_reality.spiderX }}", {% set _seed = xray_reality.mldsa65_seed | default('') %} @@ -43,7 +44,8 @@ {% endif %} {% endif %} "serverNames": [ - {% for name in xray_reality_server_names %} + {% set _xhttp_sni = xray_xhttp.reality.server_names | default(xray_reality_server_names) %} + {% for name in _xhttp_sni %} "{{ name }}"{{ "," if not loop.last }} {% endfor %} ], @@ -53,15 +55,18 @@ {% endfor %} ], "show": false, - "xver": 0 + "xver": 2 }, "security": "reality", "xhttpSettings": { "mode": "{{ xray_xhttp.xhttpSettings.mode }}", "path": "{{ xray_xhttp.xhttpSettings.path }}", - "scMaxPacketSize": {{ xray_xhttp.xhttpSettings.scMaxPacketSize }} + "scMaxPacketSize": {{ xray_xhttp.xhttpSettings.scMaxPacketSize }}, + "extra": { + "xPaddingBytes": "100-1000" + } } } } ] -} \ No newline at end of file +} diff --git a/roles/xray/templates/conf/routing/400-routing.json.j2 b/roles/xray/templates/conf/routing/400-routing.json.j2 index 774f09f..cada260 100644 --- a/roles/xray/templates/conf/routing/400-routing.json.j2 +++ b/roles/xray/templates/conf/routing/400-routing.json.j2 @@ -1,6 +1,6 @@ { "routing": { - "domainStrategy": "IPIfNonMatch", + "domainStrategy": "AsIs", "rules": [ {% if xray_blocked_domains | length > 0 %} { 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 aad54ec..0379cb3 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 @@ -10,7 +10,7 @@ { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}", - "email": "{{ user.email | default('') }}", + "email": "{{ (user.email | default('') | trim) or user.id }}", "level": 0 }{{ "," if not loop.last }} {% endfor %} 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 e327dd2..6f72c6d 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 @@ -10,7 +10,7 @@ { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else _flow }}", - "email": "{{ user.email | default('') }}", + "email": "{{ (user.email | default('') | trim) or user.id }}", "level": 0 }{{ "," if not loop.last }} {% endfor %} diff --git a/roles/xray/templates/raven-subscribe/config.json.j2 b/roles/xray/templates/raven-subscribe/config.json.j2 index ee168e3..90ec83e 100644 --- a/roles/xray/templates/raven-subscribe/config.json.j2 +++ b/roles/xray/templates/raven-subscribe/config.json.j2 @@ -9,9 +9,15 @@ "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' %}, + "xray_api_addr": "{{ raven_subscribe_xray_api_addr }}", + "balancer_strategy": "{{ raven_subscribe_balancer_strategy }}", + "balancer_probe_url": "{{ raven_subscribe_balancer_probe_url }}", + "balancer_probe_interval": "{{ raven_subscribe_balancer_probe_interval }}", + "xray_enabled": {{ raven_subscribe_xray_enabled | lower }}, + "singbox_enabled": {{ raven_subscribe_singbox_enabled | lower }}{% if xray_vless_client_encryption | default('') | string | trim not in ('', 'none', 'false', 'False') %}, "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 %} + }{% endif %}{% if raven_subscribe_singbox_config | default('') != '' %}, + "singbox_config": "{{ raven_subscribe_singbox_config }}"{% endif %} } diff --git a/roles/xray/templates/xray.service.j2 b/roles/xray/templates/xray.service.j2 index 65f8409..18028c9 100644 --- a/roles/xray/templates/xray.service.j2 +++ b/roles/xray/templates/xray.service.j2 @@ -11,8 +11,10 @@ AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE NoNewPrivileges=true ExecStart={{ xray_install_dir }}/xray run -confdir {{ xray_config_dir }}config.d Restart=on-failure +RestartSec=3s RestartPreventExitStatus=23 -LimitNPROC=10000 +TimeoutStopSec=30s +LimitNPROC=2000 LimitNOFILE=1000000 [Install]