From b148268d77d0d0203d76bee692b07bdeb0d480b4 Mon Sep 17 00:00:00 2001 From: findias Date: Sun, 22 Mar 2026 12:40:03 +0300 Subject: [PATCH 01/26] add sing-box --- .gitignore | 3 +- roles/sing-box-playbook/defaults/main.yml | 13 +- roles/sing-box-playbook/handlers/main.yml | 15 +- roles/sing-box-playbook/tasks/main.yml | 270 ++---------------- roles/xray/defaults/main.yml | 11 + .../templates/raven-subscribe/config.json.j2 | 7 +- 6 files changed, 61 insertions(+), 258 deletions(-) diff --git a/.gitignore b/.gitignore index 80a5655..7d83b39 100644 --- a/.gitignore +++ b/.gitignore @@ -14,5 +14,4 @@ tests/.output/ tests/fixtures/test_secrets.yml -CLAUDE.md - +CLAUDE.md \ No newline at end of file diff --git a/roles/sing-box-playbook/defaults/main.yml b/roles/sing-box-playbook/defaults/main.yml index bee8b0b..cdbf096 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 @@ -93,4 +93,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/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/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/xray/defaults/main.yml b/roles/xray/defaults/main.yml index bf6a17a..d48509e 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -138,6 +138,17 @@ raven_subscribe_api_inbound_tag: "{{ xray_common.inbound_tag.vless_reality_in }} # Requires xray_api.enable = true. Value: "127.0.0.1:" raven_subscribe_xray_api_addr: "127.0.0.1:{{ xray_api.inbound.port }}" +# Path to sing-box config file. When set, Raven additionally parses sing-box inbounds +# (currently hysteria2) and syncs their users to DB. Leave empty to disable. +# Example: "/etc/sing-box/config.json" +raven_subscribe_singbox_config: "" + +# Enable/disable Xray and sing-box sync independently. +# Set xray_enabled: false if Xray is not installed on this server. +# singbox_enabled defaults to true when singbox_config is set. +raven_subscribe_xray_enabled: true +raven_subscribe_singbox_enabled: false + ##### 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" diff --git a/roles/xray/templates/raven-subscribe/config.json.j2 b/roles/xray/templates/raven-subscribe/config.json.j2 index ee168e3..8ea9195 100644 --- a/roles/xray/templates/raven-subscribe/config.json.j2 +++ b/roles/xray/templates/raven-subscribe/config.json.j2 @@ -9,9 +9,12 @@ "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 }}", + "xray_enabled": {{ raven_subscribe_xray_enabled | lower }}, + "singbox_enabled": {{ raven_subscribe_singbox_enabled | lower }}{% if xray_vless_client_encryption | default('none') != 'none' %}, "vless_client_encryption": { "{{ xray_common.inbound_tag.vless_reality_in }}": "{{ xray_vless_client_encryption }}", "{{ xray_common.inbound_tag.vless_xhttp_in }}": "{{ xray_vless_client_encryption }}" - }{% endif %} + }{% endif %}{% if raven_subscribe_singbox_config | default('') != '' %}, + "singbox_config": "{{ raven_subscribe_singbox_config }}"{% endif %} } From 472e6e641399c75b7c7e8cd87e140738f806dc83 Mon Sep 17 00:00:00 2001 From: findias Date: Sun, 22 Mar 2026 12:58:27 +0300 Subject: [PATCH 02/26] chore: consolidate secrets rules in .gitignore --- .gitignore | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 7d83b39..b02fbf1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,13 @@ +# Secrets — never commit real credentials vault_password.txt +**/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/ From 68ef39813fb9580f328d8daae6efdfb52bd245ae Mon Sep 17 00:00:00 2001 From: findias Date: Sun, 22 Mar 2026 13:03:26 +0300 Subject: [PATCH 03/26] fix: skip vless_client_encryption block when value is false/none/empty --- roles/xray/templates/raven-subscribe/config.json.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/xray/templates/raven-subscribe/config.json.j2 b/roles/xray/templates/raven-subscribe/config.json.j2 index 8ea9195..96c65a8 100644 --- a/roles/xray/templates/raven-subscribe/config.json.j2 +++ b/roles/xray/templates/raven-subscribe/config.json.j2 @@ -11,7 +11,7 @@ "api_user_inbound_tag": "{{ raven_subscribe_api_inbound_tag }}", "xray_api_addr": "{{ raven_subscribe_xray_api_addr }}", "xray_enabled": {{ raven_subscribe_xray_enabled | lower }}, - "singbox_enabled": {{ raven_subscribe_singbox_enabled | lower }}{% if xray_vless_client_encryption | default('none') != 'none' %}, + "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 }}" From 2f9a777d3aca44f637e57df144f699c9d187a114 Mon Sep 17 00:00:00 2001 From: findias Date: Sun, 22 Mar 2026 13:34:50 +0300 Subject: [PATCH 04/26] fix(sing-box-playbook): update to sing-box 1.12+ DNS format and fix obfs rendering - Remove legacy `singbox_dns_servers` with `address:` prefix format (deprecated in 1.12) - Add `singbox_dns_strategy` and `singbox_dns_final` variables for new format - Update config.json.j2 to use variables instead of hardcoded DNS strategy - Wrap `obfs` block in conditional: only render when obfs_enabled and obfs_password set - Add tasks/*.yml and defaults/secrets.yml.example (new sing-box role files) --- roles/sing-box-playbook/defaults/main.yml | 13 +--- .../defaults/secrets.yml.example | 21 ++++++ roles/sing-box-playbook/tasks/config.yml | 51 +++++++++++++++ roles/sing-box-playbook/tasks/install.yml | 64 +++++++++++++++++++ roles/sing-box-playbook/tasks/service.yml | 55 ++++++++++++++++ roles/sing-box-playbook/tasks/system.yml | 30 +++++++++ roles/sing-box-playbook/tasks/validate.yml | 56 ++++++++++++++++ .../templates/config.json.j2 | 7 +- 8 files changed, 284 insertions(+), 13 deletions(-) create mode 100644 roles/sing-box-playbook/defaults/secrets.yml.example create mode 100644 roles/sing-box-playbook/tasks/config.yml create mode 100644 roles/sing-box-playbook/tasks/install.yml create mode 100644 roles/sing-box-playbook/tasks/service.yml create mode 100644 roles/sing-box-playbook/tasks/system.yml create mode 100644 roles/sing-box-playbook/tasks/validate.yml diff --git a/roles/sing-box-playbook/defaults/main.yml b/roles/sing-box-playbook/defaults/main.yml index cdbf096..eb9b2f9 100644 --- a/roles/sing-box-playbook/defaults/main.yml +++ b/roles/sing-box-playbook/defaults/main.yml @@ -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 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/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/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 }}, From 2d0032042b60071123486eda28bb6de38cc387a6 Mon Sep 17 00:00:00 2001 From: findias Date: Mon, 23 Mar 2026 09:14:30 +0300 Subject: [PATCH 05/26] fix(sing-box-playbook): enhance DNS configuration and obfuscation handling - Introduce `singbox_dns_strategy` and `singbox_dns_final` for improved DNS format compliance - Update `config.json.j2` to utilize new variables instead of deprecated formats - Conditional rendering of `obfs` block based on `obfs_enabled` and `obfs_password` settings - Add new role files in tasks/*.yml and defaults/secrets.yml.example --- README.md | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e852e0 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# Raven Server Install + +Ansible role that installs and configures [Xray-core](https://github.com/XTLS/Xray-core) with [Raven-subscribe](https://github.com/alchemylink/raven-subscribe) on a VPS. + +**What you get:** +- Xray with VLESS + XTLS-Reality and XHTTP inbounds +- Optional VLESS Encryption (post-quantum, mlkem768x25519plus) +- Raven-subscribe — subscription server for client config distribution +- Systemd services with auto-restart and config validation before reload +- Ad/tracker blocking via geosite routing rules +- BBR congestion control and kernel tuning via `srv_prepare` role + +## Requirements + +- Ansible >= 2.14 (ansible-core) +- Target: Debian/Ubuntu VPS with systemd +- `ansible-vault` for secrets management + +## Quick Start + +### 1. Inventory + +Edit `roles/hosts.yml` and point `vm_my_srv` at your server. + +### 2. Secrets + +Create and encrypt the secrets file: + +```bash +cp roles/xray/defaults/secrets.yml.example roles/xray/defaults/secrets.yml +# Fill in the values (see Secrets section below) +ansible-vault encrypt roles/xray/defaults/secrets.yml --vault-password-file vault_password.txt +``` + +To edit later: + +```bash +ansible-vault edit roles/xray/defaults/secrets.yml --vault-password-file vault_password.txt +``` + +### 3. Generate Reality keys + +```bash +# On any machine with Xray installed: +xray x25519 +# Output: PrivateKey + PublicKey — put both into secrets.yml +``` + +### 4. Deploy + +```bash +ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file vault_password.txt +``` + +Deploy only a specific component using tags: + +```bash +# Update subscription server config only +ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file vault_password.txt --tags raven_subscribe + +# Update inbound configs only +ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file vault_password.txt --tags xray_inbounds +``` + +## Secrets (`roles/xray/defaults/secrets.yml`) + +Ansible-vault encrypted. Required fields: + +```yaml +# Reality keys — generate with: xray x25519 +xray_reality: + private_key: "..." + 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: "user@example.com" + +# Raven-subscribe +raven_subscribe_admin_token: "" # openssl rand -hex 32 +raven_subscribe_server_host: "your-server.com" +raven_subscribe_base_url: "http://your-server.com:8080" +``` + +## Configuration + +Key variables in `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 | +| `xray_reality_server_names` | `["askubuntu.com"]` | SNI names for Reality | +| `xray_xhttp.port` | `2053` | XHTTP inbound port | +| `xray_dns_servers` | `tcp+local://8.8.8.8, ...` | DNS servers (no DoH — see note below) | +| `xray_vless_decryption` | `"none"` | VLESS Encryption (optional, see below) | +| `raven_subscribe_listen_addr` | `:8080` | Raven-subscribe listen address | +| `raven_subscribe_sync_interval_seconds` | `60` | User sync interval | + +> **DNS note:** Do not use `https://` (DoH) in `xray_dns_servers` — DoH queries route through the proxy and fail. Use `tcp+local://` instead. + +## Architecture + +``` +roles/role_xray.yml + └── srv_prepare — system packages, BBR, sysctl tuning + └── xray — Xray + Raven-subscribe + ├── validate.yml (always) — pre-flight assertions + ├── install.yml (xray_install) — download Xray binary + ├── base.yml (xray_base) — log + stats config + ├── api.yml (xray_api) — gRPC API on 127.0.0.1:10085 + ├── inbounds.yml (xray_inbounds) — VLESS+Reality, XHTTP + ├── dns.yml (xray_dns) — DNS config + ├── outbounds.yml (xray_outbounds) — direct + block outbounds + ├── routing.yml (xray_routing) — routing rules + ad blocking + ├── service.yml (xray_service) — systemd unit + ├── grpcurl.yml (grpcurl) — installs grpcurl tool + └── raven_subscribe.yml (raven_subscribe) — subscription server +``` + +Xray config is split across `/etc/xray/config.d/` — files are loaded in numeric order: + +| File | Content | +|------|---------| +| `000-log.json` | Logging | +| `010-stats.json` | Statistics | +| `050-api.json` | gRPC API | +| `100-dns.json` | DNS | +| `200-in-vless-reality.json` | VLESS + XTLS-Reality inbound | +| `210-in-xhttp.json` | VLESS + XHTTP inbound | +| `300-outbounds.json` | Outbounds | +| `400-routing.json` | Routing rules | + +**Handler safety:** `Validate xray` runs before `Restart xray` — invalid config never causes a service restart. + +## VLESS Encryption (optional) + +Xray-core >= 25.x supports post-quantum VLESS Encryption (PR #5067, mlkem768x25519plus). Disabled by default (`"none"`). + +To enable: + +```bash +# Generate key pair on the server +xray vlessenc +# Output: decryption string (private, for server) + encryption string (public, for clients) +``` + +Then in `secrets.yml`: + +```yaml +xray_vless_decryption: "mlkem768x25519plus...." # server private string +xray_vless_client_encryption: "mlkem768x25519plus...." # client public string +``` + +Both must be set together or both left as `"none"`. When enabled, all users are forced to `flow: xtls-rprx-vision`. + +## Testing + +Run the full test suite (Ansible render + `xray -test` via Docker): + +```bash +./tests/run.sh +``` + +Ansible-only (no Docker required): + +```bash +SKIP_XRAY_TEST=1 ./tests/run.sh +``` + +The pipeline: +1. Downloads Xray binary (cached in `tests/.cache/`) +2. Generates test Reality keys +3. Runs `validate.yml` assertions +4. Renders all `templates/conf/*.j2` to `tests/.output/conf.d/` +5. Runs `xray -test -confdir` in Docker + +CI runs automatically via `.github/workflows/xray-config-test.yml`. + +## Related Projects + +- [Raven-subscribe](https://github.com/alchemylink/raven-subscribe) — subscription server (Go) that syncs users via Xray gRPC API and serves client configs + +## License + +[Mozilla Public License 2.0](LICENSE) From f9e07103b9e20c1f1373243da1f95e4532e7f6bc Mon Sep 17 00:00:00 2001 From: findias Date: Tue, 24 Mar 2026 11:01:46 +0300 Subject: [PATCH 06/26] feat: extract raven_subscribe, nginx_frontend, relay into separate roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New role: raven_subscribe — standalone subscription server deployment, decoupled from xray/sing-box playbooks. Supports per-inbound host/port overrides (inbound_hosts, inbound_ports) for unified media.zirgate.com routing. - New role: nginx_frontend — TLS proxy on EU server (media.zirgate.com), listens on 8443 (not 443, reserved by Xray Reality). Adds nginx stream TCP relay on port 8445 → 127.0.0.1:443 for VLESS Reality passthrough. - New role: relay — nginx reverse proxy on RU VPS (zirgate.com/my.zirgate.com), TCP stream relay on 8444 → EU:8445 for VLESS Reality via RU server. - xray role: remove raven_subscribe vars/tasks (moved to raven_subscribe role), fix DNS query strategy UseIP → UseIPv4 to avoid IPv6 unreachable errors. - sing-box: update hysteria2 default port 8443 → 8444 (8443 now used by nginx_frontend). - raven-subscribe config.j2: add balancer_strategy/probe_url/probe_interval fields. - .gitignore: add **/*_secrets.yml pattern for raven_subscribe secrets files. --- .gitignore | 1 + roles/nginx_frontend/defaults/main.yml | 32 ++++++ .../defaults/secrets.yml.example | 5 + roles/nginx_frontend/handlers/main.yml | 10 ++ roles/nginx_frontend/inventory.ini | 2 + roles/nginx_frontend/tasks/certbot.yml | 23 ++++ roles/nginx_frontend/tasks/install.yml | 15 +++ roles/nginx_frontend/tasks/main.yml | 24 +++++ roles/nginx_frontend/tasks/nginx.yml | 20 ++++ roles/nginx_frontend/tasks/nginx_ssl.yml | 9 ++ roles/nginx_frontend/tasks/stream.yml | 36 +++++++ roles/nginx_frontend/tasks/validate.yml | 11 ++ .../templates/nginx/http.conf.j2 | 20 ++++ .../templates/nginx/https.conf.j2 | 76 +++++++++++++ .../templates/nginx/stream.conf.j2 | 15 +++ roles/raven_subscribe/defaults/main.yml | 55 ++++++++++ .../defaults/secrets.yml.example | 13 +++ roles/raven_subscribe/handlers/main.yml | 9 ++ roles/raven_subscribe/meta/main.yml | 8 ++ roles/raven_subscribe/tasks/main.yml | 100 ++++++++++++++++++ .../raven_subscribe/templates/config.json.j2 | 22 ++++ .../templates/xray-subscription.service.j2 | 23 ++++ roles/relay/defaults/main.yml | 38 +++++++ roles/relay/defaults/secrets.yml.example | 6 ++ roles/relay/handlers/main.yml | 10 ++ roles/relay/inventory.ini | 2 + roles/relay/tasks/certbot.yml | 24 +++++ roles/relay/tasks/install.yml | 15 +++ roles/relay/tasks/main.yml | 28 +++++ roles/relay/tasks/nginx.yml | 14 +++ roles/relay/tasks/nginx_ssl.yml | 11 ++ roles/relay/tasks/stream.yml | 36 +++++++ roles/relay/tasks/stub.yml | 16 +++ roles/relay/tasks/validate.yml | 11 ++ roles/relay/templates/nginx/http.conf.j2 | 14 +++ roles/relay/templates/nginx/https.conf.j2 | 52 +++++++++ roles/relay/templates/nginx/stream.conf.j2 | 15 +++ roles/relay/templates/stub/index.html.j2 | 53 ++++++++++ roles/role_nginx_frontend.yml | 22 ++++ roles/role_raven_subscribe.yml | 11 ++ roles/role_relay.yml | 23 ++++ roles/role_xray.yml | 2 +- roles/sing-box-playbook/defaults/main.yml | 2 +- roles/xray/defaults/main.yml | 40 +------ .../raven_subscribe_secrets.yml.example | 13 +++ roles/xray/handlers/main.yml | 1 + roles/xray/tasks/main.yml | 4 +- .../inbounds/200-in-vless-reality.json.j2 | 2 +- .../conf/inbounds/210-in-xhttp.json.j2 | 2 +- .../templates/raven-subscribe/config.json.j2 | 3 + 50 files changed, 953 insertions(+), 46 deletions(-) create mode 100644 roles/nginx_frontend/defaults/main.yml create mode 100644 roles/nginx_frontend/defaults/secrets.yml.example create mode 100644 roles/nginx_frontend/handlers/main.yml create mode 100644 roles/nginx_frontend/inventory.ini create mode 100644 roles/nginx_frontend/tasks/certbot.yml create mode 100644 roles/nginx_frontend/tasks/install.yml create mode 100644 roles/nginx_frontend/tasks/main.yml create mode 100644 roles/nginx_frontend/tasks/nginx.yml create mode 100644 roles/nginx_frontend/tasks/nginx_ssl.yml create mode 100644 roles/nginx_frontend/tasks/stream.yml create mode 100644 roles/nginx_frontend/tasks/validate.yml create mode 100644 roles/nginx_frontend/templates/nginx/http.conf.j2 create mode 100644 roles/nginx_frontend/templates/nginx/https.conf.j2 create mode 100644 roles/nginx_frontend/templates/nginx/stream.conf.j2 create mode 100644 roles/raven_subscribe/defaults/main.yml create mode 100644 roles/raven_subscribe/defaults/secrets.yml.example create mode 100644 roles/raven_subscribe/handlers/main.yml create mode 100644 roles/raven_subscribe/meta/main.yml create mode 100644 roles/raven_subscribe/tasks/main.yml create mode 100644 roles/raven_subscribe/templates/config.json.j2 create mode 100644 roles/raven_subscribe/templates/xray-subscription.service.j2 create mode 100644 roles/relay/defaults/main.yml create mode 100644 roles/relay/defaults/secrets.yml.example create mode 100644 roles/relay/handlers/main.yml create mode 100644 roles/relay/inventory.ini create mode 100644 roles/relay/tasks/certbot.yml create mode 100644 roles/relay/tasks/install.yml create mode 100644 roles/relay/tasks/main.yml create mode 100644 roles/relay/tasks/nginx.yml create mode 100644 roles/relay/tasks/nginx_ssl.yml create mode 100644 roles/relay/tasks/stream.yml create mode 100644 roles/relay/tasks/stub.yml create mode 100644 roles/relay/tasks/validate.yml create mode 100644 roles/relay/templates/nginx/http.conf.j2 create mode 100644 roles/relay/templates/nginx/https.conf.j2 create mode 100644 roles/relay/templates/nginx/stream.conf.j2 create mode 100644 roles/relay/templates/stub/index.html.j2 create mode 100644 roles/role_nginx_frontend.yml create mode 100644 roles/role_raven_subscribe.yml create mode 100644 roles/role_relay.yml create mode 100644 roles/xray/defaults/raven_subscribe_secrets.yml.example diff --git a/.gitignore b/.gitignore index b02fbf1..545906f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Secrets — never commit real credentials vault_password.txt **/secrets.yml +**/*_secrets.yml **/vault_password.txt **/*.secret **/*.vault diff --git a/roles/nginx_frontend/defaults/main.yml b/roles/nginx_frontend/defaults/main.yml new file mode 100644 index 0000000..36ae905 --- /dev/null +++ b/roles/nginx_frontend/defaults/main.yml @@ -0,0 +1,32 @@ +--- +# nginx_frontend role — TLS frontend for EU server (media.zirgate.com) +# +# Responsibilities: +# - Install nginx + certbot +# - Obtain Let's Encrypt certificate for nginx_frontend_domain +# - Proxy Xray XHTTP (nginx_frontend_xhttp_path) → 127.0.0.1:nginx_frontend_xhttp_port + +# ── Domain ──────────────────────────────────────────────────────────────────── +nginx_frontend_domain: "media.zirgate.com" + +# ── Certbot ─────────────────────────────────────────────────────────────────── +nginx_frontend_certbot_email: "" # Set in secrets.yml + +# ── nginx listen port ───────────────────────────────────────────────────────── +# IMPORTANT: Xray VLESS Reality already binds to 443 (TCP). +# nginx_frontend must listen on a different port (e.g., 8443, 9443). +# The relay role will proxy to this port over HTTPS with SNI. +nginx_frontend_listen_port: 8443 # Must NOT conflict with xray_vless_port (443) + +# ── Raven-subscribe upstream ────────────────────────────────────────────────── +nginx_frontend_raven_port: 8080 # Must match raven_subscribe_listen_addr port + +# ── Xray XHTTP upstream ─────────────────────────────────────────────────────── +nginx_frontend_xhttp_port: 2053 # Must match xray_xhttp.port +nginx_frontend_xhttp_path: "/api/v3/data-sync" # Must match xray_xhttp.xhttpSettings.path + +# ── TCP stream relay for Xray VLESS Reality ─────────────────────────────────── +# Stream proxy: nginx_frontend_reality_port → 127.0.0.1:443 (Xray) +# Allows clients to reach Reality via media.zirgate.com instead of direct EU IP. +nginx_frontend_reality_stream_enabled: true +nginx_frontend_reality_port: 8445 # External TCP port for Reality stream 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..763a413 --- /dev/null +++ b/roles/nginx_frontend/inventory.ini @@ -0,0 +1,2 @@ +[eu] +vpn ansible_host=EU_VPS_IP ansible_user=deploy 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..cedb92e --- /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/conf.d/{{ 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..7ea512c --- /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 Reality stream 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_reality_stream_enabled + +- name: Nginx frontend | Remove Reality stream config (disabled) + ansible.builtin.file: + path: "/etc/nginx/stream.d/{{ nginx_frontend_domain }}.conf" + state: absent + notify: Reload nginx + when: not nginx_frontend_reality_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..bed7ef5 --- /dev/null +++ b/roles/nginx_frontend/templates/nginx/https.conf.j2 @@ -0,0 +1,76 @@ +# {{ nginx_frontend_domain }} — HTTPS frontend for EU server +# Managed by Ansible nginx_frontend role +# +# Routes: +# /sub/, /c/ → Raven-subscribe (127.0.0.1:{{ nginx_frontend_raven_port }}) +# /api/ → Raven-subscribe admin API +# /health → Raven-subscribe health check +# {{ nginx_frontend_xhttp_path }} → Xray XHTTP (127.0.0.1:{{ nginx_frontend_xhttp_port }}) + +# ── Redirect HTTP → HTTPS ──────────────────────────────────────────────────── +server { + listen 80; + server_name {{ nginx_frontend_domain }}; + return 301 https://$host$request_uri; +} + +# ── HTTPS ───────────────────────────────────────────────────────────────────── +server { + listen {{ nginx_frontend_listen_port }} ssl; + http2 on; + server_name {{ nginx_frontend_domain }}; + + 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 $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + 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 $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + 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; + } + + # ── Xray XHTTP ─────────────────────────────────────────────────────────── + location {{ nginx_frontend_xhttp_path }} { + proxy_pass http://127.0.0.1:{{ nginx_frontend_xhttp_port }}; + 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_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection ""; + proxy_read_timeout 3600s; + proxy_connect_timeout 5s; + proxy_buffering off; + } + + # ── 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..c7de9a6 --- /dev/null +++ b/roles/nginx_frontend/templates/nginx/stream.conf.j2 @@ -0,0 +1,15 @@ +# {{ nginx_frontend_domain }} — nginx stream TCP relay for Xray VLESS Reality +# Managed by Ansible nginx_frontend role +# Proxies: external:{{ nginx_frontend_reality_port }} → 127.0.0.1:443 (Xray) +# This file is included inside stream {} block in nginx.conf + +upstream xray_reality_local { + server 127.0.0.1:443; +} + +server { + listen {{ nginx_frontend_reality_port }}; + proxy_pass xray_reality_local; + 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..aae8e04 --- /dev/null +++ b/roles/raven_subscribe/defaults/main.yml @@ -0,0 +1,55 @@ +--- + +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 + +# 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. Empty = all inbounds use server_host. +raven_subscribe_inbound_hosts: {} + +# Per-inbound port overrides. Falls back to inbound's own port when tag not listed. +# Set in secrets.yml. Example: {"vless-reality-in": 8444} for TCP relay on RU. +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..f87c35d --- /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.zirgate.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/raven_subscribe/tasks/main.yml b/roles/raven_subscribe/tasks/main.yml new file mode 100644 index 0000000..da92cc7 --- /dev/null +++ b/roles/raven_subscribe/tasks/main.yml @@ -0,0 +1,100 @@ +--- +- name: Raven-subscribe | Validate required vars + ansible.builtin.assert: + that: + - raven_subscribe_admin_token is defined + - raven_subscribe_admin_token != '' + - raven_subscribe_server_host is defined + - raven_subscribe_server_host != '' + fail_msg: >- + raven_subscribe_admin_token and raven_subscribe_server_host must be set in secrets.yml. + Generate a strong token: openssl rand -hex 32 + success_msg: "Raven-subscribe vars are valid" + +- name: Raven-subscribe | Get latest release info + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ raven_subscribe_github_repo }}/releases/latest" + method: GET + return_content: true + headers: + Accept: "application/vnd.github.v3+json" + status_code: 200 + register: raven_release_info + run_once: true + retries: 3 + delay: 3 + until: raven_release_info.status == 200 + +- name: Raven-subscribe | Set version and arch facts + ansible.builtin.set_fact: + raven_subscribe_version: "{{ raven_release_info.json.tag_name }}" + raven_subscribe_arch: >- + {{ + 'linux-amd64' if ansible_architecture in ['x86_64', 'amd64'] + else 'linux-arm64' if ansible_architecture in ['aarch64', 'arm64'] + else 'linux-arm' + }} + +- name: Raven-subscribe | Download binary + ansible.builtin.get_url: + url: "https://github.com/{{ raven_subscribe_github_repo }}/releases/download/\ + {{ raven_subscribe_version }}/xray-subscription-{{ raven_subscribe_arch }}" + dest: "{{ raven_subscribe_install_dir }}/xray-subscription" + mode: "0755" + owner: root + group: root + notify: Restart raven-subscribe + +- name: Raven-subscribe | Ensure config directory exists + ansible.builtin.file: + path: "{{ raven_subscribe_config_dir }}" + state: directory + owner: root + group: "{{ raven_subscribe_group }}" + mode: "0750" + +- name: Raven-subscribe | Ensure data directory exists + ansible.builtin.file: + path: "{{ raven_subscribe_db_dir }}" + state: directory + owner: "{{ raven_subscribe_user }}" + group: "{{ raven_subscribe_group }}" + mode: "0750" + +- name: Raven-subscribe | Deploy config + ansible.builtin.template: + src: config.json.j2 + dest: "{{ raven_subscribe_config_dir }}/config.json" + owner: root + group: "{{ raven_subscribe_group }}" + mode: "0640" + notify: Restart raven-subscribe + +- name: Raven-subscribe | Deploy systemd service + ansible.builtin.template: + src: xray-subscription.service.j2 + dest: "/etc/systemd/system/{{ raven_subscribe_service_name }}.service" + owner: root + group: root + mode: "0644" + when: ansible_facts.service_mgr == "systemd" + notify: + - Reload systemd + - Restart raven-subscribe + +- name: Raven-subscribe | Enable and start service + ansible.builtin.service: + name: "{{ raven_subscribe_service_name }}" + enabled: true + state: started + +- name: Raven-subscribe | Gather service facts + ansible.builtin.service_facts: + +- name: Raven-subscribe | Validate service is running + ansible.builtin.fail: + msg: "xray-subscription service is not running" + when: + - ansible_facts.services is defined + - ansible_facts.services[raven_subscribe_service_name + '.service'] is defined + - ansible_facts.services[raven_subscribe_service_name + '.service'].state != 'running' diff --git a/roles/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..574c931 --- /dev/null +++ b/roles/relay/defaults/main.yml @@ -0,0 +1,38 @@ +--- +# Relay role — nginx reverse proxy on RU VPS +# Domain: zirgate.com +# zirgate.com A → RU VPS IP (static stub site) +# my.zirgate.com A → RU VPS IP (relay → Raven subscriptions + API) +# +# EU server (managed by nginx_frontend role, not this role): +# media.zirgate.com A → EU VPS IP (nginx_frontend → Xray XHTTP) + +# ── Domain ─────────────────────────────────────────────────────────────────── +relay_domain: "zirgate.com" +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 listens on EU server (Raven-subscribe is behind it) +# Must match nginx_frontend_listen_port (default: 8443, NOT 443 which is taken by Xray) +relay_upstream_raven_port: 8443 + +# ── TCP stream relay (VLESS Reality) ───────────────────────────────────────── +# Proxies raw TCP on relay_stream_port → EU server:relay_upstream_xray_port +# Clients connect to zirgate.com:relay_stream_port instead of EU IP directly. +relay_stream_enabled: true +relay_stream_port: 8444 # Listening port on RU server (must be free) +relay_upstream_xray_port: 8445 # nginx_frontend Reality stream port on EU server + +# ── nginx ───────────────────────────────────────────────────────────────────── +relay_nginx_user: "www-data" +relay_webroot: "/var/www/{{ relay_domain }}" + +# ── Certbot ─────────────────────────────────────────────────────────────────── +relay_certbot_email: "" # Set in secrets.yml: relay_certbot_email: "admin@zirgate.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..b71a672 --- /dev/null +++ b/roles/relay/inventory.ini @@ -0,0 +1,2 @@ +[relay] +zirgate ansible_host=RU_VPS_IP ansible_user=deploy 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..adbe8eb --- /dev/null +++ b/roles/relay/templates/nginx/https.conf.j2 @@ -0,0 +1,52 @@ +# {{ relay_domain }} — HTTPS relay config +# Managed by Ansible relay role + +# ── Redirect HTTP → HTTPS ──────────────────────────────────────────────────── +server { + listen 80; + server_name {{ relay_domain }} {{ relay_sub_my }}; + return 301 https://$host$request_uri; +} + +# ── zirgate.com — stub site ────────────────────────────────────────────────── +server { + listen 443 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; + } +} + +# ── my.zirgate.com — Raven-subscribe relay ─────────────────────────────────── +server { + listen 443 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..4e7ba87 --- /dev/null +++ b/roles/relay/templates/nginx/stream.conf.j2 @@ -0,0 +1,15 @@ +# {{ relay_domain }} — nginx stream TCP relay +# Managed by Ansible relay role +# Proxies VLESS Reality TCP traffic: RU:{{ relay_stream_port }} → EU:{{ relay_upstream_xray_port }} +# This file is included inside stream {} block in nginx.conf + +upstream xray_reality { + server {{ relay_upstream_host }}:{{ relay_upstream_xray_port }}; +} + +server { + listen {{ relay_stream_port }}; + proxy_pass xray_reality; + proxy_connect_timeout 10s; + proxy_timeout 600s; +} 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.

+
+
+ © {{ relay_domain }} +
+ + diff --git a/roles/role_nginx_frontend.yml b/roles/role_nginx_frontend.yml new file mode 100644 index 0000000..1ac9e5f --- /dev/null +++ b/roles/role_nginx_frontend.yml @@ -0,0 +1,22 @@ +--- +# nginx frontend playbook — EU server (media.zirgate.com) +# 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 + +- name: Configure nginx frontend (vpn.zirgate.com) + 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..a731db4 --- /dev/null +++ b/roles/role_relay.yml @@ -0,0 +1,23 @@ +--- +# Relay playbook — RU VPS (zirgate.com) +# 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 + +- name: Configure relay server (zirgate.com) + hosts: vm_my_ru + become: true + + vars_files: + - relay/defaults/main.yml + - relay/defaults/secrets.yml + + roles: + - role: relay 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 eb9b2f9..646eceb 100644 --- a/roles/sing-box-playbook/defaults/main.yml +++ b/roles/sing-box-playbook/defaults/main.yml @@ -26,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 diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index d48509e..976396d 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -48,7 +48,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: @@ -115,41 +115,3 @@ xray_xhttp: # - id: UUID_3" # flow: "xtls-rprx-vision" # email: "someEmailForIdentyfy" - -# --------------------------------------------------------------------------- -# Raven-subscribe (subscription server) -# --------------------------------------------------------------------------- - -raven_subscribe_github_repo: "alchemylink/raven-subscribe" -raven_subscribe_install_dir: "/usr/local/bin" -raven_subscribe_config_dir: "/etc/xray-subscription" -raven_subscribe_db_dir: "/var/lib/xray-subscription" -raven_subscribe_service_name: "xray-subscription" - -raven_subscribe_listen_addr: ":8080" -raven_subscribe_sync_interval_seconds: 60 -raven_subscribe_rate_limit_sub_per_min: 60 -raven_subscribe_rate_limit_admin_per_min: 30 - -# The inbound tag Raven manages users in (must match an inbound in config.d) -raven_subscribe_api_inbound_tag: "{{ xray_common.inbound_tag.vless_reality_in }}" - -# Use Xray gRPC API for user sync instead of file writes. -# Requires xray_api.enable = true. Value: "127.0.0.1:" -raven_subscribe_xray_api_addr: "127.0.0.1:{{ xray_api.inbound.port }}" - -# Path to sing-box config file. When set, Raven additionally parses sing-box inbounds -# (currently hysteria2) and syncs their users to DB. Leave empty to disable. -# Example: "/etc/sing-box/config.json" -raven_subscribe_singbox_config: "" - -# Enable/disable Xray and sing-box sync independently. -# Set xray_enabled: false if Xray is not installed on this server. -# singbox_enabled defaults to true when singbox_config is set. -raven_subscribe_xray_enabled: true -raven_subscribe_singbox_enabled: false - -##### 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..9c2f1f1 --- /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.zirgate.com" + +# EU VPS public IP or domain (used in generated client outbound addresses) +raven_subscribe_server_host: "64.226.79.239" 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..1ec0d8d 100644 --- a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 +++ b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 @@ -10,7 +10,7 @@ {% for user in xray_users %} { "id": "{{ user.id }}", - "flow": "{{ 'xtls-rprx-vision' if _pq else (user.flow | default(xray_vless_default_flow)) }}", + "flow": "{{ 'xtls-rprx-vision' if _pq else user.flow }}", "email": "{{ user.email | default('') }}", "level": 0 }{{ "," if not loop.last }} diff --git a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 index 4212c84..76941f2 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -8,7 +8,7 @@ "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 }}", diff --git a/roles/xray/templates/raven-subscribe/config.json.j2 b/roles/xray/templates/raven-subscribe/config.json.j2 index 96c65a8..90ec83e 100644 --- a/roles/xray/templates/raven-subscribe/config.json.j2 +++ b/roles/xray/templates/raven-subscribe/config.json.j2 @@ -10,6 +10,9 @@ "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 xray_vless_client_encryption | default('') | string | trim not in ('', 'none', 'false', 'False') %}, "vless_client_encryption": { From c51e6c7ecf066204aee967b01263ba1c7055a493 Mon Sep 17 00:00:00 2001 From: findias Date: Tue, 24 Mar 2026 11:32:04 +0300 Subject: [PATCH 07/26] docs: update README for multi-role architecture and per-inbound overrides - Add nginx_frontend and relay roles to What you get list - Update Quick Start: separate secrets files per role, separate deploy commands - Update Secrets section: document raven_subscribe/defaults/secrets.yml with inbound_hosts/inbound_ports example - Update Configuration: add xray_dns_query_strategy, raven_subscribe_inbound_hosts/ports - Update Architecture: show full EU+RU server topology and client connection flow - roles/xray/README.md: remove xray_vless_default_flow (removed from templates) --- README.md | 124 ++++++++++++++++++++++++++++++++++--------- roles/xray/README.md | 1 - 2 files changed, 99 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 4e852e0..beaf54e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Ansible role that installs and configures [Xray-core](https://github.com/XTLS/Xr - Xray with VLESS + XTLS-Reality and XHTTP inbounds - Optional VLESS Encryption (post-quantum, mlkem768x25519plus) - Raven-subscribe — subscription server for client config distribution +- nginx TLS frontend on EU server (`nginx_frontend` role) +- nginx relay on RU server with TCP stream proxy for VLESS Reality (`relay` role) - Systemd services with auto-restart and config validation before reload - Ad/tracker blocking via geosite routing rules - BBR congestion control and kernel tuning via `srv_prepare` role @@ -24,12 +26,24 @@ Edit `roles/hosts.yml` and point `vm_my_srv` at your server. ### 2. Secrets -Create and encrypt the secrets file: +Create and encrypt secrets files for each role you deploy: ```bash +# Xray role (Reality keys, users) cp roles/xray/defaults/secrets.yml.example roles/xray/defaults/secrets.yml -# Fill in the values (see Secrets section below) ansible-vault encrypt roles/xray/defaults/secrets.yml --vault-password-file vault_password.txt + +# Raven-subscribe role (admin token, server host, per-inbound overrides) +cp roles/raven_subscribe/defaults/secrets.yml.example roles/raven_subscribe/defaults/secrets.yml +ansible-vault encrypt roles/raven_subscribe/defaults/secrets.yml --vault-password-file vault_password.txt + +# nginx_frontend role (certbot email) — EU server +cp roles/nginx_frontend/defaults/secrets.yml.example roles/nginx_frontend/defaults/secrets.yml +ansible-vault encrypt roles/nginx_frontend/defaults/secrets.yml --vault-password-file vault_password.txt + +# relay role (upstream EU IP, certbot email) — RU server +cp roles/relay/defaults/secrets.yml.example roles/relay/defaults/secrets.yml +ansible-vault encrypt roles/relay/defaults/secrets.yml --vault-password-file vault_password.txt ``` To edit later: @@ -49,22 +63,31 @@ xray x25519 ### 4. Deploy ```bash +# EU server: Xray ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file vault_password.txt + +# EU server: nginx TLS frontend +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 ``` Deploy only a specific component using tags: ```bash -# Update subscription server config only -ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file vault_password.txt --tags raven_subscribe - # Update inbound configs only ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file vault_password.txt --tags xray_inbounds ``` -## Secrets (`roles/xray/defaults/secrets.yml`) +## Secrets + +Each role has its own `defaults/secrets.yml` (ansible-vault encrypted). -Ansible-vault encrypted. Required fields: +**`roles/xray/defaults/secrets.yml`** — Reality keys and VLESS users: ```yaml # Reality keys — generate with: xray x25519 @@ -80,11 +103,29 @@ xray_users: - id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # UUID — generate: uuidgen flow: "xtls-rprx-vision" email: "user@example.com" +``` + +**`roles/raven_subscribe/defaults/secrets.yml`** — Raven-subscribe settings: + +```yaml +raven_subscribe_admin_token: "" # openssl rand -hex 32 +raven_subscribe_server_host: "media.zirgate.com" +raven_subscribe_base_url: "https://media.zirgate.com" + +# Per-inbound host/port overrides (optional) +# Useful when clients should connect through a relay instead of the EU server directly +raven_subscribe_inbound_hosts: + vless-reality-in: "media.zirgate.com" + vless-xhttp-in: "media.zirgate.com" +raven_subscribe_inbound_ports: + vless-reality-in: 8445 # nginx stream relay port on EU server +``` + +**`roles/relay/defaults/secrets.yml`** — RU server relay: -# Raven-subscribe -raven_subscribe_admin_token: "" # openssl rand -hex 32 -raven_subscribe_server_host: "your-server.com" -raven_subscribe_base_url: "http://your-server.com:8080" +```yaml +relay_upstream_host: "1.2.3.4" # EU server IP +relay_certbot_email: "admin@example.com" ``` ## Configuration @@ -98,29 +139,62 @@ Key variables in `roles/xray/defaults/main.yml`: | `xray_reality_server_names` | `["askubuntu.com"]` | SNI names for Reality | | `xray_xhttp.port` | `2053` | XHTTP inbound port | | `xray_dns_servers` | `tcp+local://8.8.8.8, ...` | DNS servers (no DoH — see note below) | +| `xray_dns_query_strategy` | `UseIPv4` | DNS query strategy — use `UseIPv4` if the server has no IPv6 | | `xray_vless_decryption` | `"none"` | VLESS Encryption (optional, see below) | + +Key variables in `roles/raven_subscribe/defaults/main.yml`: + +| Variable | Default | Description | +|----------|---------|-------------| | `raven_subscribe_listen_addr` | `:8080` | Raven-subscribe listen address | | `raven_subscribe_sync_interval_seconds` | `60` | User sync interval | +| `raven_subscribe_inbound_hosts` | `{}` | Per-inbound host overrides (set in secrets.yml) | +| `raven_subscribe_inbound_ports` | `{}` | Per-inbound port overrides (set in secrets.yml) | > **DNS note:** Do not use `https://` (DoH) in `xray_dns_servers` — DoH queries route through the proxy and fail. Use `tcp+local://` instead. ## Architecture ``` -roles/role_xray.yml - └── srv_prepare — system packages, BBR, sysctl tuning - └── xray — Xray + Raven-subscribe - ├── validate.yml (always) — pre-flight assertions - ├── install.yml (xray_install) — download Xray binary - ├── base.yml (xray_base) — log + stats config - ├── api.yml (xray_api) — gRPC API on 127.0.0.1:10085 - ├── inbounds.yml (xray_inbounds) — VLESS+Reality, XHTTP - ├── dns.yml (xray_dns) — DNS config - ├── outbounds.yml (xray_outbounds) — direct + block outbounds - ├── routing.yml (xray_routing) — routing rules + ad blocking - ├── service.yml (xray_service) — systemd unit - ├── grpcurl.yml (grpcurl) — installs grpcurl tool - └── raven_subscribe.yml (raven_subscribe) — subscription server +EU server + role_xray.yml + └── srv_prepare — system packages, BBR, sysctl tuning + └── xray — Xray binary + config + ├── validate.yml (always) — pre-flight assertions + ├── install.yml (xray_install) — download Xray binary + ├── base.yml (xray_base) — log + stats config + ├── api.yml (xray_api) — gRPC API on 127.0.0.1:10085 + ├── inbounds.yml (xray_inbounds) — VLESS+Reality, XHTTP + ├── dns.yml (xray_dns) — DNS config + ├── outbounds.yml (xray_outbounds) — direct + block outbounds + ├── routing.yml (xray_routing) — routing rules + ad blocking + ├── service.yml (xray_service) — systemd unit + └── grpcurl.yml (grpcurl) — installs grpcurl tool + + role_nginx_frontend.yml + └── nginx_frontend — nginx TLS proxy on media.zirgate.com + ├── listens on port 8443 (not 443, reserved by Xray Reality) + ├── proxies /sub/* → Raven-subscribe :8080 + └── stream TCP relay: port 8445 → 127.0.0.1:443 (Xray Reality) + + role_raven_subscribe.yml + └── raven_subscribe — subscription server + ├── listens on 127.0.0.1:8080 + ├── syncs users to Xray via gRPC API + └── serves client configs with per-inbound host/port overrides + +RU server + role_relay.yml + └── relay — nginx reverse proxy on zirgate.com + ├── my.zirgate.com → https://media.zirgate.com:8443 (Raven) + └── stream TCP relay: port 8444 → media.zirgate.com:8445 (Reality) +``` + +Client connection flow: +``` +VLESS Reality: client → zirgate.com:8444 (RU TCP relay) → media.zirgate.com:8445 (EU nginx stream) → 127.0.0.1:443 (Xray) +VLESS XHTTP: client → media.zirgate.com:443/path → nginx_frontend:8443 → Xray :2053 +Subscription: client → my.zirgate.com (RU relay) → media.zirgate.com:8443 → Raven-subscribe :8080 ``` Xray config is split across `/etc/xray/config.d/` — files are loaded in numeric order: 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) | From 4c3114d45b468ede1ad483fc0810d94539cff7a1 Mon Sep 17 00:00:00 2001 From: findias Date: Tue, 24 Mar 2026 13:13:17 +0300 Subject: [PATCH 08/26] docs: prepare repo for public release - Rewrite README.md with full architecture diagrams, role reference, secrets docs, DNS setup, VLESS Encryption and Hysteria2 sections - Add README.ru.md (Russian translation, full parity with EN) - Add roles/hosts.yml.example (safe template, no real IPs/usernames) - Replace all personal domains (zirgate.com) with example.com in defaults, templates, inventory files, and playbook comments - Remove leftover draft files: roles/xray/exampl/ directory - Remove unused roles/xray/tasks/raven_subscribe.yml (moved to separate role) - Fix role_nginx_frontend.yml and role_relay.yml: use inventory group names (eu/relay) instead of personal host names (vm_my_srv/vm_my_ru) --- README.md | 486 ++++++++++++----- README.ru.md | 507 ++++++++++++++++++ roles/hosts.yml.example | 13 + roles/nginx_frontend/defaults/main.yml | 6 +- .../defaults/secrets.yml.example | 2 +- roles/relay/defaults/main.yml | 14 +- roles/relay/inventory.ini | 2 +- roles/relay/templates/nginx/https.conf.j2 | 4 +- roles/role_nginx_frontend.yml | 7 +- roles/role_relay.yml | 7 +- .../raven_subscribe_secrets.yml.example | 4 +- roles/xray/exampl/config.json.j2 | 227 -------- roles/xray/exampl/main.yml.bak | 192 ------- roles/xray/tasks/raven_subscribe.yml | 100 ---- 14 files changed, 908 insertions(+), 663 deletions(-) create mode 100644 README.ru.md create mode 100644 roles/hosts.yml.example delete mode 100644 roles/xray/exampl/config.json.j2 delete mode 100644 roles/xray/exampl/main.yml.bak delete mode 100644 roles/xray/tasks/raven_subscribe.yml diff --git a/README.md b/README.md index beaf54e..0648f3e 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,192 @@ # Raven Server Install -Ansible role that installs and configures [Xray-core](https://github.com/XTLS/Xray-core) with [Raven-subscribe](https://github.com/alchemylink/raven-subscribe) on a VPS. +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 self-hosted VPN server stack based on [Xray-core](https://github.com/XTLS/Xray-core) and [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe). **What you get:** -- Xray with VLESS + XTLS-Reality and XHTTP inbounds -- Optional VLESS Encryption (post-quantum, mlkem768x25519plus) -- Raven-subscribe — subscription server for client config distribution -- nginx TLS frontend on EU server (`nginx_frontend` role) -- nginx relay on RU server with TCP stream proxy for VLESS Reality (`relay` role) -- Systemd services with auto-restart and config validation before reload -- Ad/tracker blocking via geosite routing rules -- BBR congestion control and kernel tuning via `srv_prepare` role + +- Xray-core with VLESS + XTLS-Reality and VLESS + XHTTP inbounds +- Optional post-quantum VLESS Encryption (mlkem768x25519plus) +- 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 +- nginx TLS frontend on EU VPS (`nginx_frontend` role) +- nginx relay + TCP stream proxy on RU VPS for routing through a second server (`relay` role) +- Systemd services with config validation before every reload +- Ad and tracker blocking via geosite routing rules +- 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. + +``` +Client ──VLESS+Reality──► VPS:443 (Xray) +Client ──VLESS+XHTTP────► VPS:443 (nginx) ──► VPS:2053 (Xray) +Client ──subscription───► VPS:443 (nginx) ──► VPS:8080 (Raven) +``` + +### Dual-server with RU relay (recommended for CIS users) + +EU VPS runs Xray + nginx_frontend + Raven-subscribe. +RU VPS runs a relay that hides the EU IP from clients. + +``` +EU VPS (media.example.com) RU VPS (example.com) +┌───────────────────────────┐ ┌─────────────────────────────┐ +│ Xray :443 TCP │ │ nginx relay │ +│ nginx XHTTP :443 HTTPS │◄─────│ my.example.com → EU:8443 │ +│ nginx stream:8445 TCP │◄─────│ :8444 TCP → EU:8445 TCP │ +│ Raven :8080 local │ └─────────────────────────────┘ +│ nginx front :8443 HTTPS │ ▲ +└───────────────────────────┘ │ + clients +``` + +**Client connection flow:** +``` +VLESS Reality: client → RU:8444 (TCP relay) → EU:8445 (nginx stream) → Xray:443 +VLESS XHTTP: client → EU:443 (nginx HTTPS) → Xray:2053 +Subscription: client → my.example.com (RU relay) → EU:8443 → Raven:8080 +``` + +### Role map + +| Role | VPS | Playbook | What it does | +|------|-----|----------|--------------| +| `srv_prepare` | EU | `role_xray.yml` | BBR, sysctl, system user | +| `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 TLS proxy + TCP stream relay (port 8443/8445) | +| `sing-box-playbook` | EU | `role_sing-box.yml` | sing-box + Hysteria2 (optional) | +| `relay` | RU | `role_relay.yml` | nginx reverse proxy + TCP stream relay (port 8444) | + +--- ## Requirements -- Ansible >= 2.14 (ansible-core) -- Target: Debian/Ubuntu VPS with systemd -- `ansible-vault` for secrets management +- **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. Inventory +### 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`): -Edit `roles/hosts.yml` and point `vm_my_srv` at your server. +```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 +``` -### 2. Secrets +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 +``` -Create and encrypt secrets files for each role you deploy: +### 3. Create secrets files + +Each role has a `defaults/secrets.yml.example`. Copy and fill in the values, then encrypt: ```bash -# Xray role (Reality keys, users) +# 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 role (admin token, server host, per-inbound overrides) +# 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 role (certbot email) — EU server +# 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 role (upstream EU IP, certbot email) — RU server +# 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 later: +To edit an encrypted file later: ```bash ansible-vault edit roles/xray/defaults/secrets.yml --vault-password-file vault_password.txt ``` -### 3. Generate Reality keys +### 4. Generate Reality keys ```bash # On any machine with Xray installed: xray x25519 -# Output: PrivateKey + PublicKey — put both into secrets.yml +# Output: PrivateKey + PublicKey — put both into roles/xray/defaults/secrets.yml + +openssl rand -hex 8 # generates a short_id ``` -### 4. Deploy +### 5. Deploy ```bash -# EU server: Xray +# 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 +# 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 @@ -76,24 +196,101 @@ ansible-playbook roles/role_raven_subscribe.yml -i roles/hosts.yml --vault-passw ansible-playbook roles/role_relay.yml -i roles/relay/inventory.ini --vault-password-file vault_password.txt ``` -Deploy only a specific component using tags: +Use `--tags` to deploy only a specific part: ```bash -# Update inbound configs only -ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file vault_password.txt --tags xray_inbounds +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 reverse proxy. Responsibilities: + +- Obtains Let's Encrypt certificate for `nginx_frontend_domain` +- Listens on port **8443** (port 443 is taken by Xray VLESS Reality) +- Proxies XHTTP path → Xray `:2053` +- Proxies subscription/API paths → Raven-subscribe `:8080` +- **TCP stream relay**: port 8445 → `127.0.0.1:443` (passes VLESS Reality through nginx) + +--- + +### `relay` role + +Deploys nginx on the RU VPS as a relay. Responsibilities: + +- Obtains Let's Encrypt certificates for `relay_domain` and `relay_sub_my` +- Serves a static stub site on `relay_domain` (camouflage) +- Proxies `my.relay_domain` → EU VPS nginx_frontend `:8443` (Raven-subscribe) +- **TCP stream relay**: port 8444 → EU VPS `:8445` (VLESS Reality passthrough) + +--- + +### `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 has its own `defaults/secrets.yml` (ansible-vault encrypted). +Each role keeps secrets in `defaults/secrets.yml` (ansible-vault encrypted, not committed). Copy from the `.example` file. -**`roles/xray/defaults/secrets.yml`** — Reality keys and VLESS users: +### `roles/xray/defaults/secrets.yml` ```yaml # Reality keys — generate with: xray x25519 xray_reality: - private_key: "..." - public_key: "..." + private_key: "YOUR_PRIVATE_KEY" + public_key: "YOUR_PUBLIC_KEY" spiderX: "/" short_id: - "a1b2c3d4e5f67890" # 8-byte hex — generate: openssl rand -hex 8 @@ -102,163 +299,208 @@ xray_reality: xray_users: - id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" # UUID — generate: uuidgen flow: "xtls-rprx-vision" - email: "user@example.com" + email: "alice@example.com" ``` -**`roles/raven_subscribe/defaults/secrets.yml`** — Raven-subscribe settings: +### `roles/raven_subscribe/defaults/secrets.yml` ```yaml -raven_subscribe_admin_token: "" # openssl rand -hex 32 -raven_subscribe_server_host: "media.zirgate.com" -raven_subscribe_base_url: "https://media.zirgate.com" +# 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) -# Useful when clients should connect through a relay instead of the EU server directly +# 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: "media.zirgate.com" - vless-xhttp-in: "media.zirgate.com" + vless-reality-in: "example.com" # RU relay domain for Reality + vless-xhttp-in: "media.example.com" raven_subscribe_inbound_ports: - vless-reality-in: 8445 # nginx stream relay port on EU server + 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`** — RU server relay: +### `roles/relay/defaults/secrets.yml` ```yaml -relay_upstream_host: "1.2.3.4" # EU server IP +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 -Key variables in `roles/xray/defaults/main.yml`: +### 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 | -| `xray_reality_server_names` | `["askubuntu.com"]` | SNI names for Reality | +| `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_dns_servers` | `tcp+local://8.8.8.8, ...` | DNS servers (no DoH — see note below) | -| `xray_dns_query_strategy` | `UseIPv4` | DNS query strategy — use `UseIPv4` if the server has no IPv6 | -| `xray_vless_decryption` | `"none"` | VLESS Encryption (optional, see below) | +| `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 | -Key variables in `roles/raven_subscribe/defaults/main.yml`: +### Raven-subscribe (`roles/raven_subscribe/defaults/main.yml`) | Variable | Default | Description | |----------|---------|-------------| -| `raven_subscribe_listen_addr` | `:8080` | Raven-subscribe listen address | -| `raven_subscribe_sync_interval_seconds` | `60` | User sync interval | +| `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 | -> **DNS note:** Do not use `https://` (DoH) in `xray_dns_servers` — DoH queries route through the proxy and fail. Use `tcp+local://` instead. +### nginx_frontend (`roles/nginx_frontend/defaults/main.yml`) -## Architecture +| Variable | Default | Description | +|----------|---------|-------------| +| `nginx_frontend_domain` | `media.example.com` | EU VPS domain — set to your domain | +| `nginx_frontend_listen_port` | `8443` | nginx HTTPS listen port (not 443 — taken by Xray) | +| `nginx_frontend_xhttp_port` | `2053` | Xray XHTTP upstream port | +| `nginx_frontend_xhttp_path` | `/api/v3/data-sync` | XHTTP path (must match xray config) | +| `nginx_frontend_reality_port` | `8445` | TCP stream relay port for Reality | -``` -EU server - role_xray.yml - └── srv_prepare — system packages, BBR, sysctl tuning - └── xray — Xray binary + config - ├── validate.yml (always) — pre-flight assertions - ├── install.yml (xray_install) — download Xray binary - ├── base.yml (xray_base) — log + stats config - ├── api.yml (xray_api) — gRPC API on 127.0.0.1:10085 - ├── inbounds.yml (xray_inbounds) — VLESS+Reality, XHTTP - ├── dns.yml (xray_dns) — DNS config - ├── outbounds.yml (xray_outbounds) — direct + block outbounds - ├── routing.yml (xray_routing) — routing rules + ad blocking - ├── service.yml (xray_service) — systemd unit - └── grpcurl.yml (grpcurl) — installs grpcurl tool - - role_nginx_frontend.yml - └── nginx_frontend — nginx TLS proxy on media.zirgate.com - ├── listens on port 8443 (not 443, reserved by Xray Reality) - ├── proxies /sub/* → Raven-subscribe :8080 - └── stream TCP relay: port 8445 → 127.0.0.1:443 (Xray Reality) - - role_raven_subscribe.yml - └── raven_subscribe — subscription server - ├── listens on 127.0.0.1:8080 - ├── syncs users to Xray via gRPC API - └── serves client configs with per-inbound host/port overrides - -RU server - role_relay.yml - └── relay — nginx reverse proxy on zirgate.com - ├── my.zirgate.com → https://media.zirgate.com:8443 (Raven) - └── stream TCP relay: port 8444 → media.zirgate.com:8445 (Reality) -``` +### relay (`roles/relay/defaults/main.yml`) -Client connection flow: -``` -VLESS Reality: client → zirgate.com:8444 (RU TCP relay) → media.zirgate.com:8445 (EU nginx stream) → 127.0.0.1:443 (Xray) -VLESS XHTTP: client → media.zirgate.com:443/path → nginx_frontend:8443 → Xray :2053 -Subscription: client → my.zirgate.com (RU relay) → media.zirgate.com:8443 → Raven-subscribe :8080 -``` +| Variable | Default | Description | +|----------|---------|-------------| +| `relay_domain` | `example.com` | RU VPS domain — set to your domain | +| `relay_upstream_raven_port` | `8443` | EU nginx_frontend port (must match `nginx_frontend_listen_port`) | +| `relay_stream_port` | `8444` | RU relay TCP port for Reality (exposed to clients) | +| `relay_upstream_xray_port` | `8445` | EU nginx stream port (must match `nginx_frontend_reality_port`) | +| `relay_stub_title` | `Welcome` | Stub site page title | +| `relay_stub_description` | `Personal website` | Stub site meta description | -Xray config is split across `/etc/xray/config.d/` — files are loaded in numeric order: +--- -| File | Content | -|------|---------| -| `000-log.json` | Logging | -| `010-stats.json` | Statistics | -| `050-api.json` | gRPC API | -| `100-dns.json` | DNS | -| `200-in-vless-reality.json` | VLESS + XTLS-Reality inbound | -| `210-in-xhttp.json` | VLESS + XHTTP inbound | -| `300-outbounds.json` | Outbounds | -| `400-routing.json` | Routing rules | +## DNS Setup + +Point the following DNS A records to the correct servers: -**Handler safety:** `Validate xray` runs before `Restart xray` — invalid config never causes a service restart. +| Domain | → | Server | Purpose | +|--------|---|--------|---------| +| `media.example.com` | → | EU VPS IP | nginx_frontend (XHTTP, Raven) | +| `example.com` | → | RU VPS IP | Relay stub site | +| `my.example.com` | → | RU VPS IP | Relay → Raven-subscribe | + +The RU VPS TCP relay for Reality (port 8444) works by IP — no DNS record needed. + +--- ## VLESS Encryption (optional) -Xray-core >= 25.x supports post-quantum VLESS Encryption (PR #5067, mlkem768x25519plus). Disabled by default (`"none"`). +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. -To enable: +**Generate keys:** ```bash -# Generate key pair on the server xray vlessenc -# Output: decryption string (private, for server) + encryption string (public, for clients) +# Output: decryption string (server private) + encryption string (client public) ``` -Then in `secrets.yml`: +**Add to `roles/xray/defaults/secrets.yml`:** ```yaml -xray_vless_decryption: "mlkem768x25519plus...." # server private string -xray_vless_client_encryption: "mlkem768x25519plus...." # client public string +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 ``` -Both must be set together or both left as `"none"`. When enabled, all users are forced to `flow: xtls-rprx-vision`. +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 (Ansible render + `xray -test` via Docker): +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 required): +Ansible-only (no Docker needed): ```bash SKIP_XRAY_TEST=1 ./tests/run.sh ``` -The pipeline: +**Pipeline steps:** 1. Downloads Xray binary (cached in `tests/.cache/`) -2. Generates test Reality keys +2. Generates ephemeral Reality keys → `tests/fixtures/test_secrets.yml` 3. Runs `validate.yml` assertions -4. Renders all `templates/conf/*.j2` to `tests/.output/conf.d/` +4. Renders all `templates/conf/*.j2` → `tests/.output/conf.d/` 5. Runs `xray -test -confdir` in Docker -CI runs automatically via `.github/workflows/xray-config-test.yml`. +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) that syncs users via Xray gRPC API and serves client configs +- [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-core](https://github.com/XTLS/Xray-core) — the VPN core +- [sing-box](https://github.com/SagerNet/sing-box) — alternative VPN core (Hysteria2) + +--- ## License diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..3bcfe11 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,507 @@ +# 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-плейбуки для развёртывания самохостинг VPN-стека на основе [Xray-core](https://github.com/XTLS/Xray-core) и [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe). + +**Что вы получаете:** + +- Xray-core с inbound'ами VLESS + XTLS-Reality и VLESS + XHTTP +- Опциональное пост-квантовое VLESS Encryption (mlkem768x25519plus) +- Опциональный Hysteria2 через [sing-box](https://github.com/SagerNet/sing-box) +- [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe) — сервер подписок: автоматически находит пользователей, раздаёт клиентские конфиги по персональным ссылкам +- nginx TLS frontend на EU VPS (роль `nginx_frontend`) +- nginx relay + TCP stream proxy на RU VPS для маршрутизации через второй сервер (роль `relay`) +- systemd-сервисы с валидацией конфига перед каждым перезапуском +- Блокировка рекламы и публичных трекеров через правила маршрутизации geosite +- BBR и тюнинг sysctl (роль `srv_prepare`) + +--- + +## Содержание + +- [Архитектура](#архитектура) +- [Требования](#требования) +- [Быстрый старт](#быстрый-старт) +- [Описание ролей](#описание-ролей) +- [Секреты](#секреты) +- [Конфигурация](#конфигурация) +- [DNS-записи](#dns-записи) +- [VLESS Encryption (опционально)](#vless-encryption-опционально) +- [Hysteria2 / sing-box (опционально)](#hysteria2--sing-box-опционально) +- [Тестирование](#тестирование) +- [Связанные проекты](#связанные-проекты) +- [Лицензия](#лицензия) + +--- + +## Архитектура + +Поддерживаются две топологии деплоя. + +### Один сервер (минимальный вариант) + +Один VPS с Xray + Raven-subscribe + nginx. + +``` +Клиент ──VLESS+Reality──► VPS:443 (Xray) +Клиент ──VLESS+XHTTP────► VPS:443 (nginx) ──► VPS:2053 (Xray) +Клиент ──подписка───────► VPS:443 (nginx) ──► VPS:8080 (Raven) +``` + +### Два сервера с RU-relay (рекомендуется для пользователей из СНГ) + +EU VPS: Xray + nginx_frontend + Raven-subscribe. +RU VPS: relay — скрывает EU IP от клиентов. + +``` +EU VPS (media.example.com) RU VPS (example.com) +┌───────────────────────────┐ ┌─────────────────────────────┐ +│ Xray :443 TCP │ │ nginx relay │ +│ nginx XHTTP :443 HTTPS │◄─────│ my.example.com → EU:8443 │ +│ nginx stream:8445 TCP │◄─────│ :8444 TCP → EU:8445 TCP │ +│ Raven :8080 local │ └─────────────────────────────┘ +│ nginx front :8443 HTTPS │ ▲ +└───────────────────────────┘ │ + клиенты +``` + +**Маршруты подключения клиентов:** +``` +VLESS Reality: клиент → RU:8444 (TCP relay) → EU:8445 (nginx stream) → Xray:443 +VLESS XHTTP: клиент → EU:443 (nginx HTTPS) → Xray:2053 +Подписка: клиент → my.example.com (RU relay) → EU:8443 → Raven:8080 +``` + +### Карта ролей + +| Роль | VPS | Плейбук | Что делает | +|------|-----|---------|-----------| +| `srv_prepare` | EU | `role_xray.yml` | BBR, sysctl, системный пользователь | +| `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 TLS proxy + TCP stream relay (порты 8443/8445) | +| `sing-box-playbook` | EU | `role_sing-box.yml` | sing-box + Hysteria2 (опционально) | +| `relay` | RU | `role_relay.yml` | nginx reverse proxy + TCP stream relay (порт 8444) | + +--- + +## Требования + +- **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 reverse proxy. Функции: + +- Получает Let's Encrypt сертификат для `nginx_frontend_domain` +- Слушает на порту **8443** (порт 443 занят Xray VLESS Reality) +- Проксирует XHTTP path → Xray `:2053` +- Проксирует пути подписки/API → Raven-subscribe `:8080` +- **TCP stream relay**: порт 8445 → `127.0.0.1:443` (проброс VLESS Reality через nginx) + +--- + +### Роль `relay` + +Деплоит nginx на RU VPS как relay. Функции: + +- Получает Let's Encrypt сертификаты для `relay_domain` и `relay_sub_my` +- Отдаёт статический stub-сайт на `relay_domain` (маскировка) +- Проксирует `my.relay_domain` → EU VPS nginx_frontend `:8443` (Raven-subscribe) +- **TCP stream relay**: порт 8444 → EU VPS `:8445` (проброс VLESS Reality) + +--- + +### Роль `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 — заменить на свой | +| `nginx_frontend_listen_port` | `8443` | Порт HTTPS nginx (не 443 — занят Xray) | +| `nginx_frontend_xhttp_port` | `2053` | Порт upstream Xray XHTTP | +| `nginx_frontend_xhttp_path` | `/api/v3/data-sync` | Путь XHTTP (должен совпадать с конфигом Xray) | +| `nginx_frontend_reality_port` | `8445` | Порт TCP stream relay для Reality | + +### relay (`roles/relay/defaults/main.yml`) + +| Переменная | По умолчанию | Описание | +|-----------|--------------|---------| +| `relay_domain` | `example.com` | Домен RU VPS — заменить на свой | +| `relay_upstream_raven_port` | `8443` | Порт nginx_frontend на EU (должен совпадать с `nginx_frontend_listen_port`) | +| `relay_stream_port` | `8444` | TCP порт RU relay для Reality (открытый для клиентов) | +| `relay_upstream_xray_port` | `8445` | Порт nginx stream на EU (должен совпадать с `nginx_frontend_reality_port`) | +| `relay_stub_title` | `Welcome` | Заголовок страницы stub-сайта | +| `relay_stub_description` | `Personal website` | Мета-описание stub-сайта | + +--- + +## DNS-записи + +Направьте следующие DNS A-записи на нужные серверы: + +| Домен | → | Сервер | Назначение | +|-------|---|--------|-----------| +| `media.example.com` | → | IP EU VPS | nginx_frontend (XHTTP, Raven) | +| `example.com` | → | IP RU VPS | Stub-сайт relay | +| `my.example.com` | → | IP RU VPS | Relay → Raven-subscribe | + +TCP relay Reality (порт 8444 на RU VPS) работает по IP — DNS-запись не нужна. + +--- + +## 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 +``` + +--- + +## Связанные проекты + +- [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe) — сервер подписок (Go): автоматически находит пользователей из конфигов Xray, синхронизирует через gRPC API, раздаёт персональные ссылки подписки в форматах Xray JSON / sing-box JSON / share-ссылки +- [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/nginx_frontend/defaults/main.yml b/roles/nginx_frontend/defaults/main.yml index 36ae905..dccf8ee 100644 --- a/roles/nginx_frontend/defaults/main.yml +++ b/roles/nginx_frontend/defaults/main.yml @@ -1,5 +1,5 @@ --- -# nginx_frontend role — TLS frontend for EU server (media.zirgate.com) +# nginx_frontend role — TLS frontend for EU VPS # # Responsibilities: # - Install nginx + certbot @@ -7,7 +7,7 @@ # - Proxy Xray XHTTP (nginx_frontend_xhttp_path) → 127.0.0.1:nginx_frontend_xhttp_port # ── Domain ──────────────────────────────────────────────────────────────────── -nginx_frontend_domain: "media.zirgate.com" +nginx_frontend_domain: "media.example.com" # Set to your EU VPS domain # ── Certbot ─────────────────────────────────────────────────────────────────── nginx_frontend_certbot_email: "" # Set in secrets.yml @@ -27,6 +27,6 @@ nginx_frontend_xhttp_path: "/api/v3/data-sync" # Must match xray_xhttp.xhttpSe # ── TCP stream relay for Xray VLESS Reality ─────────────────────────────────── # Stream proxy: nginx_frontend_reality_port → 127.0.0.1:443 (Xray) -# Allows clients to reach Reality via media.zirgate.com instead of direct EU IP. +# Allows clients to reach Reality via media.example.com instead of direct EU IP. nginx_frontend_reality_stream_enabled: true nginx_frontend_reality_port: 8445 # External TCP port for Reality stream diff --git a/roles/raven_subscribe/defaults/secrets.yml.example b/roles/raven_subscribe/defaults/secrets.yml.example index f87c35d..4fe1202 100644 --- a/roles/raven_subscribe/defaults/secrets.yml.example +++ b/roles/raven_subscribe/defaults/secrets.yml.example @@ -7,7 +7,7 @@ raven_subscribe_admin_token: "" # Public URL used in subscription links — must be the relay domain -raven_subscribe_base_url: "https://my.zirgate.com" +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/relay/defaults/main.yml b/roles/relay/defaults/main.yml index 574c931..3a8d131 100644 --- a/roles/relay/defaults/main.yml +++ b/roles/relay/defaults/main.yml @@ -1,14 +1,14 @@ --- # Relay role — nginx reverse proxy on RU VPS -# Domain: zirgate.com -# zirgate.com A → RU VPS IP (static stub site) -# my.zirgate.com A → RU VPS IP (relay → Raven subscriptions + API) +# Domain layout example: +# example.com A → RU VPS IP (static stub site) +# my.example.com A → RU VPS IP (relay → Raven subscriptions + API) # # EU server (managed by nginx_frontend role, not this role): -# media.zirgate.com A → EU VPS IP (nginx_frontend → Xray XHTTP) +# media.example.com A → EU VPS IP (nginx_frontend → Xray XHTTP) # ── Domain ─────────────────────────────────────────────────────────────────── -relay_domain: "zirgate.com" +relay_domain: "example.com" # Set to your RU VPS domain relay_sub_my: "my.{{ relay_domain }}" # Raven-subscribe relay (RU VPS) # ── Upstream EU server ──────────────────────────────────────────────────────── @@ -21,7 +21,7 @@ relay_upstream_raven_port: 8443 # ── TCP stream relay (VLESS Reality) ───────────────────────────────────────── # Proxies raw TCP on relay_stream_port → EU server:relay_upstream_xray_port -# Clients connect to zirgate.com:relay_stream_port instead of EU IP directly. +# Clients connect to example.com:relay_stream_port instead of EU IP directly. relay_stream_enabled: true relay_stream_port: 8444 # Listening port on RU server (must be free) relay_upstream_xray_port: 8445 # nginx_frontend Reality stream port on EU server @@ -31,7 +31,7 @@ relay_nginx_user: "www-data" relay_webroot: "/var/www/{{ relay_domain }}" # ── Certbot ─────────────────────────────────────────────────────────────────── -relay_certbot_email: "" # Set in secrets.yml: relay_certbot_email: "admin@zirgate.com" +relay_certbot_email: "" # Set in secrets.yml: relay_certbot_email: "admin@example.com" # ── Stub site ───────────────────────────────────────────────────────────────── relay_stub_title: "Welcome" diff --git a/roles/relay/inventory.ini b/roles/relay/inventory.ini index b71a672..af0c8cf 100644 --- a/roles/relay/inventory.ini +++ b/roles/relay/inventory.ini @@ -1,2 +1,2 @@ [relay] -zirgate ansible_host=RU_VPS_IP ansible_user=deploy +relay ansible_host=RU_VPS_IP ansible_user=deploy diff --git a/roles/relay/templates/nginx/https.conf.j2 b/roles/relay/templates/nginx/https.conf.j2 index adbe8eb..2905c55 100644 --- a/roles/relay/templates/nginx/https.conf.j2 +++ b/roles/relay/templates/nginx/https.conf.j2 @@ -8,7 +8,7 @@ server { return 301 https://$host$request_uri; } -# ── zirgate.com — stub site ────────────────────────────────────────────────── +# ── {{ relay_domain }} — stub site ────────────────────────────────────────────────── server { listen 443 ssl; http2 on; @@ -27,7 +27,7 @@ server { } } -# ── my.zirgate.com — Raven-subscribe relay ─────────────────────────────────── +# ── {{ relay_sub_my }} — Raven-subscribe relay ─────────────────────────────────── server { listen 443 ssl; http2 on; diff --git a/roles/role_nginx_frontend.yml b/roles/role_nginx_frontend.yml index 1ac9e5f..8be1ff0 100644 --- a/roles/role_nginx_frontend.yml +++ b/roles/role_nginx_frontend.yml @@ -1,5 +1,5 @@ --- -# nginx frontend playbook — EU server (media.zirgate.com) +# 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 @@ -9,9 +9,10 @@ # 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 (vpn.zirgate.com) - hosts: vm_my_srv +- name: Configure nginx frontend + hosts: eu become: true vars_files: diff --git a/roles/role_relay.yml b/roles/role_relay.yml index a731db4..96d8890 100644 --- a/roles/role_relay.yml +++ b/roles/role_relay.yml @@ -1,5 +1,5 @@ --- -# Relay playbook — RU VPS (zirgate.com) +# Relay playbook — RU VPS # Usage: # ansible-playbook roles/role_relay.yml -i roles/relay/inventory.ini \ # --vault-password-file vault_password.txt @@ -10,9 +10,10 @@ # 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 (zirgate.com) - hosts: vm_my_ru +- name: Configure relay server + hosts: relay become: true vars_files: diff --git a/roles/xray/defaults/raven_subscribe_secrets.yml.example b/roles/xray/defaults/raven_subscribe_secrets.yml.example index 9c2f1f1..1f09578 100644 --- a/roles/xray/defaults/raven_subscribe_secrets.yml.example +++ b/roles/xray/defaults/raven_subscribe_secrets.yml.example @@ -7,7 +7,7 @@ raven_subscribe_admin_token: "" # Public URL used in subscription links — must be the relay domain -raven_subscribe_base_url: "https://my.zirgate.com" +raven_subscribe_base_url: "https://my.example.com" # EU VPS public IP or domain (used in generated client outbound addresses) -raven_subscribe_server_host: "64.226.79.239" +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/tasks/raven_subscribe.yml b/roles/xray/tasks/raven_subscribe.yml deleted file mode 100644 index 0801305..0000000 --- a/roles/xray/tasks/raven_subscribe.yml +++ /dev/null @@ -1,100 +0,0 @@ ---- -- name: Raven-subscribe | Validate required vars - ansible.builtin.assert: - that: - - raven_subscribe_admin_token is defined - - raven_subscribe_admin_token != '' - - raven_subscribe_server_host is defined - - raven_subscribe_server_host != '' - fail_msg: >- - raven_subscribe_admin_token and raven_subscribe_server_host must be set in secrets.yml. - Generate a strong token: openssl rand -hex 32 - success_msg: "Raven-subscribe vars are valid" - -- name: Raven-subscribe | Get latest release info - ansible.builtin.uri: - url: "https://api.github.com/repos/{{ raven_subscribe_github_repo }}/releases/latest" - method: GET - return_content: true - headers: - Accept: "application/vnd.github.v3+json" - status_code: 200 - register: raven_release_info - run_once: true - retries: 3 - delay: 3 - until: raven_release_info.status == 200 - -- name: Raven-subscribe | Set version and arch facts - ansible.builtin.set_fact: - raven_subscribe_version: "{{ raven_release_info.json.tag_name }}" - raven_subscribe_arch: >- - {{ - 'linux-amd64' if ansible_architecture in ['x86_64', 'amd64'] - else 'linux-arm64' if ansible_architecture in ['aarch64', 'arm64'] - else 'linux-arm' - }} - -- name: Raven-subscribe | Download binary - ansible.builtin.get_url: - url: "https://github.com/{{ raven_subscribe_github_repo }}/releases/download/\ - {{ raven_subscribe_version }}/xray-subscription-{{ raven_subscribe_arch }}" - dest: "{{ raven_subscribe_install_dir }}/xray-subscription" - mode: "0755" - owner: root - group: root - notify: Restart raven-subscribe - -- name: Raven-subscribe | Ensure config directory exists - ansible.builtin.file: - path: "{{ raven_subscribe_config_dir }}" - state: directory - owner: root - group: "{{ xray_group }}" - mode: "0750" - -- name: Raven-subscribe | Ensure data directory exists - ansible.builtin.file: - path: "{{ raven_subscribe_db_dir }}" - state: directory - owner: "{{ xray_user }}" - group: "{{ xray_group }}" - mode: "0750" - -- name: Raven-subscribe | Deploy config - ansible.builtin.template: - src: raven-subscribe/config.json.j2 - dest: "{{ raven_subscribe_config_dir }}/config.json" - owner: root - group: "{{ xray_group }}" - mode: "0640" - notify: Restart raven-subscribe - -- name: Raven-subscribe | Deploy systemd service - ansible.builtin.template: - src: raven-subscribe/xray-subscription.service.j2 - dest: "/etc/systemd/system/{{ raven_subscribe_service_name }}.service" - owner: root - group: root - mode: "0644" - when: ansible_facts.service_mgr == "systemd" - notify: - - Reload systemd - - Restart raven-subscribe - -- name: Raven-subscribe | Enable and start service - ansible.builtin.service: - name: "{{ raven_subscribe_service_name }}" - enabled: true - state: started - -- name: Raven-subscribe | Gather service facts - ansible.builtin.service_facts: - -- name: Raven-subscribe | Validate service is running - ansible.builtin.fail: - msg: "xray-subscription service is not running" - when: - - ansible_facts.services is defined - - ansible_facts.services[raven_subscribe_service_name + '.service'] is defined - - ansible_facts.services[raven_subscribe_service_name + '.service'].state != 'running' From c18f1de4cd49fb29dcfb62e6d69f2157dad4ee16 Mon Sep 17 00:00:00 2001 From: findias Date: Tue, 24 Mar 2026 22:24:46 +0300 Subject: [PATCH 09/26] feat: migrate to nginx stream SNI routing on port 443 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both VPN protocols (VLESS+Reality TCP and VLESS+XHTTP+Reality) now share port 443 via nginx stream ssl_preread SNI routing on both EU and RU servers. EU server: - nginx stream :443 routes by SNI: www.adobe.com → Xray XHTTP (:2053), media.zirgate.com → nginx HTTPS (:8443), default → Xray Reality (:4443) - Xray inbounds bind 127.0.0.1 (no longer exposed directly) - XHTTP Reality dest changed to www.adobe.com with xPaddingBytes RU relay: - Single stream :443 routes by SNI: zirgate.com/my.zirgate.com → local nginx HTTPS (:8443), everything else → EU:443 - Removed separate per-protocol stream ports (8444, 2053) nginx_frontend HTTPS: removed XHTTP proxy location (now handled by stream) Raven-subscribe: updated inbound_ports comments for port 443 --- roles/nginx_frontend/defaults/main.yml | 32 +++++++++---------- roles/nginx_frontend/inventory.ini | 2 +- roles/nginx_frontend/tasks/stream.yml | 8 ++--- .../templates/nginx/https.conf.j2 | 26 +++------------ .../templates/nginx/stream.conf.j2 | 32 +++++++++++++++---- roles/raven_subscribe/defaults/main.yml | 6 ++-- roles/relay/defaults/main.yml | 31 ++++++++++-------- roles/relay/inventory.ini | 2 +- roles/relay/templates/nginx/https.conf.j2 | 17 ++++------ roles/relay/templates/nginx/stream.conf.j2 | 30 +++++++++++++---- roles/xray/defaults/main.yml | 25 ++++++++++----- .../inbounds/200-in-vless-reality.json.j2 | 1 + .../conf/inbounds/210-in-xhttp.json.j2 | 15 ++++++--- .../conf/routing/400-routing.json.j2 | 2 +- 14 files changed, 133 insertions(+), 96 deletions(-) diff --git a/roles/nginx_frontend/defaults/main.yml b/roles/nginx_frontend/defaults/main.yml index dccf8ee..839d384 100644 --- a/roles/nginx_frontend/defaults/main.yml +++ b/roles/nginx_frontend/defaults/main.yml @@ -1,10 +1,11 @@ --- -# nginx_frontend role — TLS frontend for EU VPS +# nginx_frontend role — TLS frontend + SNI stream routing for EU VPS # # Responsibilities: # - Install nginx + certbot # - Obtain Let's Encrypt certificate for nginx_frontend_domain -# - Proxy Xray XHTTP (nginx_frontend_xhttp_path) → 127.0.0.1:nginx_frontend_xhttp_port +# - 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 @@ -12,21 +13,20 @@ nginx_frontend_domain: "media.example.com" # Set to your EU VPS domain # ── Certbot ─────────────────────────────────────────────────────────────────── nginx_frontend_certbot_email: "" # Set in secrets.yml -# ── nginx listen port ───────────────────────────────────────────────────────── -# IMPORTANT: Xray VLESS Reality already binds to 443 (TCP). -# nginx_frontend must listen on a different port (e.g., 8443, 9443). -# The relay role will proxy to this port over HTTPS with SNI. -nginx_frontend_listen_port: 8443 # Must NOT conflict with xray_vless_port (443) +# ── 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 -# ── Xray XHTTP upstream ─────────────────────────────────────────────────────── -nginx_frontend_xhttp_port: 2053 # Must match xray_xhttp.port -nginx_frontend_xhttp_path: "/api/v3/data-sync" # Must match xray_xhttp.xhttpSettings.path - -# ── TCP stream relay for Xray VLESS Reality ─────────────────────────────────── -# Stream proxy: nginx_frontend_reality_port → 127.0.0.1:443 (Xray) -# Allows clients to reach Reality via media.example.com instead of direct EU IP. -nginx_frontend_reality_stream_enabled: true -nginx_frontend_reality_port: 8445 # External TCP port for Reality stream +# ── 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/inventory.ini b/roles/nginx_frontend/inventory.ini index 763a413..4891780 100644 --- a/roles/nginx_frontend/inventory.ini +++ b/roles/nginx_frontend/inventory.ini @@ -1,2 +1,2 @@ [eu] -vpn ansible_host=EU_VPS_IP ansible_user=deploy +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/stream.yml b/roles/nginx_frontend/tasks/stream.yml index 7ea512c..567cc0b 100644 --- a/roles/nginx_frontend/tasks/stream.yml +++ b/roles/nginx_frontend/tasks/stream.yml @@ -18,7 +18,7 @@ insertafter: EOF notify: Reload nginx -- name: Nginx frontend | Deploy Reality stream config +- 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" @@ -26,11 +26,11 @@ group: root mode: "0644" notify: Reload nginx - when: nginx_frontend_reality_stream_enabled + when: nginx_frontend_stream_enabled -- name: Nginx frontend | Remove Reality stream config (disabled) +- 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_reality_stream_enabled + when: not nginx_frontend_stream_enabled diff --git a/roles/nginx_frontend/templates/nginx/https.conf.j2 b/roles/nginx_frontend/templates/nginx/https.conf.j2 index bed7ef5..7ec6b20 100644 --- a/roles/nginx_frontend/templates/nginx/https.conf.j2 +++ b/roles/nginx_frontend/templates/nginx/https.conf.j2 @@ -1,18 +1,14 @@ # {{ 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 -# {{ nginx_frontend_xhttp_path }} → Xray XHTTP (127.0.0.1:{{ nginx_frontend_xhttp_port }}) - -# ── Redirect HTTP → HTTPS ──────────────────────────────────────────────────── -server { - listen 80; - server_name {{ nginx_frontend_domain }}; - return 301 https://$host$request_uri; -} +# / → Default page (landing / 404) # ── HTTPS ───────────────────────────────────────────────────────────────────── server { @@ -55,20 +51,6 @@ server { proxy_connect_timeout 5s; } - # ── Xray XHTTP ─────────────────────────────────────────────────────────── - location {{ nginx_frontend_xhttp_path }} { - proxy_pass http://127.0.0.1:{{ nginx_frontend_xhttp_port }}; - 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_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection ""; - proxy_read_timeout 3600s; - proxy_connect_timeout 5s; - proxy_buffering off; - } - # ── 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 index c7de9a6..46f2c7d 100644 --- a/roles/nginx_frontend/templates/nginx/stream.conf.j2 +++ b/roles/nginx_frontend/templates/nginx/stream.conf.j2 @@ -1,15 +1,35 @@ -# {{ nginx_frontend_domain }} — nginx stream TCP relay for Xray VLESS Reality +# {{ nginx_frontend_domain }} — nginx stream SNI routing # Managed by Ansible nginx_frontend role -# Proxies: external:{{ nginx_frontend_reality_port }} → 127.0.0.1:443 (Xray) +# +# 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 -upstream xray_reality_local { - server 127.0.0.1:443; +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 }}; } server { - listen {{ nginx_frontend_reality_port }}; - proxy_pass xray_reality_local; + listen 443 reuseport; + proxy_pass $sni_backend; + ssl_preread on; proxy_connect_timeout 10s; proxy_timeout 600s; } diff --git a/roles/raven_subscribe/defaults/main.yml b/roles/raven_subscribe/defaults/main.yml index aae8e04..f392dcb 100644 --- a/roles/raven_subscribe/defaults/main.yml +++ b/roles/raven_subscribe/defaults/main.yml @@ -27,11 +27,13 @@ raven_subscribe_xray_api_addr: "127.0.0.1:10085" 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. Empty = all inbounds use server_host. +# 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. Example: {"vless-reality-in": 8444} for TCP relay on RU. +# 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. diff --git a/roles/relay/defaults/main.yml b/roles/relay/defaults/main.yml index 3a8d131..efc8404 100644 --- a/roles/relay/defaults/main.yml +++ b/roles/relay/defaults/main.yml @@ -1,11 +1,14 @@ --- -# Relay role — nginx reverse proxy on RU VPS -# Domain layout example: -# example.com A → RU VPS IP (static stub site) -# my.example.com A → RU VPS IP (relay → Raven subscriptions + API) +# Relay role — nginx SNI routing + reverse proxy on RU VPS # -# EU server (managed by nginx_frontend role, not this role): -# media.example.com A → EU VPS IP (nginx_frontend → Xray XHTTP) +# 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 @@ -15,16 +18,18 @@ relay_sub_my: "my.{{ relay_domain }}" # Raven-subscribe relay (RU VPS) # Set in secrets.yml # relay_upstream_host: "1.2.3.4" # EU server IP address -# Port where nginx_frontend listens on EU server (Raven-subscribe is behind it) -# Must match nginx_frontend_listen_port (default: 8443, NOT 443 which is taken by Xray) +# 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 -# ── TCP stream relay (VLESS Reality) ───────────────────────────────────────── -# Proxies raw TCP on relay_stream_port → EU server:relay_upstream_xray_port -# Clients connect to example.com:relay_stream_port instead of EU IP directly. +# ── 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_stream_port: 8444 # Listening port on RU server (must be free) -relay_upstream_xray_port: 8445 # nginx_frontend Reality stream port on EU server +relay_https_port: 8443 # Local HTTPS port (behind stream, stub + Raven relay) # ── nginx ───────────────────────────────────────────────────────────────────── relay_nginx_user: "www-data" diff --git a/roles/relay/inventory.ini b/roles/relay/inventory.ini index af0c8cf..cdaf016 100644 --- a/roles/relay/inventory.ini +++ b/roles/relay/inventory.ini @@ -1,2 +1,2 @@ [relay] -relay ansible_host=RU_VPS_IP ansible_user=deploy +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/templates/nginx/https.conf.j2 b/roles/relay/templates/nginx/https.conf.j2 index 2905c55..9932da9 100644 --- a/roles/relay/templates/nginx/https.conf.j2 +++ b/roles/relay/templates/nginx/https.conf.j2 @@ -1,16 +1,12 @@ -# {{ relay_domain }} — HTTPS relay config +# {{ relay_domain }} — HTTPS config (behind stream SNI routing) # Managed by Ansible relay role - -# ── Redirect HTTP → HTTPS ──────────────────────────────────────────────────── -server { - listen 80; - server_name {{ relay_domain }} {{ relay_sub_my }}; - return 301 https://$host$request_uri; -} +# +# 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 443 ssl; + listen {{ relay_https_port }} ssl; http2 on; server_name {{ relay_domain }}; @@ -29,7 +25,7 @@ server { # ── {{ relay_sub_my }} — Raven-subscribe relay ─────────────────────────────────── server { - listen 443 ssl; + listen {{ relay_https_port }} ssl; http2 on; server_name {{ relay_sub_my }}; @@ -49,4 +45,3 @@ server { proxy_connect_timeout 10s; } } - diff --git a/roles/relay/templates/nginx/stream.conf.j2 b/roles/relay/templates/nginx/stream.conf.j2 index 4e7ba87..08d8fae 100644 --- a/roles/relay/templates/nginx/stream.conf.j2 +++ b/roles/relay/templates/nginx/stream.conf.j2 @@ -1,15 +1,33 @@ -# {{ relay_domain }} — nginx stream TCP relay +# {{ relay_domain }} — nginx stream SNI routing # Managed by Ansible relay role -# Proxies VLESS Reality TCP traffic: RU:{{ relay_stream_port }} → EU:{{ relay_upstream_xray_port }} +# +# 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 -upstream xray_reality { - server {{ relay_upstream_host }}:{{ relay_upstream_xray_port }}; +{% 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 {{ relay_stream_port }}; - proxy_pass xray_reality; + listen 443 reuseport; + proxy_pass $relay_backend; + ssl_preread on; proxy_connect_timeout 10s; proxy_timeout 600s; } +{% endif %} diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index 976396d..896c171 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. @@ -82,15 +84,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 + 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 !!!!!#### diff --git a/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 b/roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 index 1ec0d8d..72ad1af 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 }}", diff --git a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 index 76941f2..1be9a11 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -1,9 +1,10 @@ { "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' %} @@ -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 %} ], @@ -59,9 +61,12 @@ "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 %} { From c21aa8d8b59d358b24868d6fb818cb27f396ffc5 Mon Sep 17 00:00:00 2001 From: findias Date: Wed, 25 Mar 2026 02:08:50 +0300 Subject: [PATCH 10/26] 1 --- roles/role_nginx_frontend.yml | 2 +- roles/role_relay.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/roles/role_nginx_frontend.yml b/roles/role_nginx_frontend.yml index 8be1ff0..9bf8cf4 100644 --- a/roles/role_nginx_frontend.yml +++ b/roles/role_nginx_frontend.yml @@ -12,7 +12,7 @@ # nginx_frontend_stream — deploy TCP stream relay for VLESS Reality - name: Configure nginx frontend - hosts: eu + hosts: vm_my_srv become: true vars_files: diff --git a/roles/role_relay.yml b/roles/role_relay.yml index 96d8890..4e38dec 100644 --- a/roles/role_relay.yml +++ b/roles/role_relay.yml @@ -13,7 +13,7 @@ # relay_stream — deploy TCP stream relay for VLESS Reality - name: Configure relay server - hosts: relay + hosts: vm_my_ru become: true vars_files: From 8677c871537eb1f8b99d3906f439f4eb60c9cb56 Mon Sep 17 00:00:00 2001 From: findias Date: Wed, 25 Mar 2026 10:37:23 +0300 Subject: [PATCH 11/26] perf: increase scMaxPacketSize to 1MB, add eu_https SNI route for extra domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - xray XHTTP scMaxPacketSize: 50000 → 1048576 (1MB) — reduces HTTP round-trips - relay stream: add relay_extra_eu_https_domains for SNI routing to EU:8443 (used when a domain's DNS is moved to RU but cert is on EU nginx) --- roles/relay/defaults/main.yml | 6 ++++++ roles/relay/templates/nginx/stream.conf.j2 | 7 +++++++ roles/xray/defaults/main.yml | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/roles/relay/defaults/main.yml b/roles/relay/defaults/main.yml index efc8404..d884b90 100644 --- a/roles/relay/defaults/main.yml +++ b/roles/relay/defaults/main.yml @@ -24,6 +24,12 @@ relay_upstream_port: 443 # Port where nginx_frontend HTTPS listens on EU server (Raven-subscribe behind it) relay_upstream_raven_port: 8443 +# Extra domains to route directly to EU HTTPS (relay_upstream_raven_port) via SNI. +# Use for domains that have TLS certs on EU (e.g. media.example.com moved to RU DNS). +relay_extra_eu_https_domains: [] +# relay_extra_eu_https_domains: +# - "media.example.com" + # ── 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) diff --git a/roles/relay/templates/nginx/stream.conf.j2 b/roles/relay/templates/nginx/stream.conf.j2 index 08d8fae..e6e4d9e 100644 --- a/roles/relay/templates/nginx/stream.conf.j2 +++ b/roles/relay/templates/nginx/stream.conf.j2 @@ -12,6 +12,9 @@ map $ssl_preread_server_name $relay_backend { {{ relay_domain }} local_https; {{ relay_sub_my }} local_https; +{% for domain in relay_extra_eu_https_domains | default([]) %} + {{ domain }} eu_https; +{% endfor %} default eu_vpn; } @@ -19,6 +22,10 @@ upstream local_https { server 127.0.0.1:{{ relay_https_port }}; } +upstream eu_https { + server {{ relay_upstream_host }}:{{ relay_upstream_raven_port }}; +} + upstream eu_vpn { server {{ relay_upstream_host }}:{{ relay_upstream_port }}; } diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index 896c171..5efd6f2 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -95,7 +95,7 @@ xray_xhttp: 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: From 1e6670399fd5480dbbc6af25696b3f24502a78ac Mon Sep 17 00:00:00 2001 From: findias Date: Wed, 25 Mar 2026 10:38:06 +0300 Subject: [PATCH 12/26] perf: systemd RestartSec=3s, TimeoutStopSec=30s, LimitNPROC=2000 --- roles/xray/templates/xray.service.j2 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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] From 16892ed0bf0cecee3dea4a5298b1fb9eb28a590c Mon Sep 17 00:00:00 2001 From: findias Date: Wed, 25 Mar 2026 10:45:39 +0300 Subject: [PATCH 13/26] cleanup: remove media.zirgate.com, fix nginx_frontend deploy path to sites-enabled - relay stream: remove relay_extra_eu_https_domains (no longer needed) - nginx_frontend: deploy HTTPS config to sites-enabled/ instead of conf.d/ (nginx.conf on EU only includes sites-enabled/) --- roles/nginx_frontend/tasks/nginx_ssl.yml | 2 +- roles/relay/defaults/main.yml | 5 ----- roles/relay/templates/nginx/stream.conf.j2 | 7 ------- 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/roles/nginx_frontend/tasks/nginx_ssl.yml b/roles/nginx_frontend/tasks/nginx_ssl.yml index cedb92e..066c5b3 100644 --- a/roles/nginx_frontend/tasks/nginx_ssl.yml +++ b/roles/nginx_frontend/tasks/nginx_ssl.yml @@ -2,7 +2,7 @@ - name: Nginx frontend | Deploy HTTPS config ansible.builtin.template: src: nginx/https.conf.j2 - dest: "/etc/nginx/conf.d/{{ nginx_frontend_domain }}.conf" + dest: "/etc/nginx/sites-enabled/{{ nginx_frontend_domain }}.conf" owner: root group: root mode: "0644" diff --git a/roles/relay/defaults/main.yml b/roles/relay/defaults/main.yml index d884b90..44cbdac 100644 --- a/roles/relay/defaults/main.yml +++ b/roles/relay/defaults/main.yml @@ -24,11 +24,6 @@ relay_upstream_port: 443 # Port where nginx_frontend HTTPS listens on EU server (Raven-subscribe behind it) relay_upstream_raven_port: 8443 -# Extra domains to route directly to EU HTTPS (relay_upstream_raven_port) via SNI. -# Use for domains that have TLS certs on EU (e.g. media.example.com moved to RU DNS). -relay_extra_eu_https_domains: [] -# relay_extra_eu_https_domains: -# - "media.example.com" # ── SNI stream routing on :443 ─────────────────────────────────────────────── # nginx stream with ssl_preread reads SNI from TLS ClientHello and routes: diff --git a/roles/relay/templates/nginx/stream.conf.j2 b/roles/relay/templates/nginx/stream.conf.j2 index e6e4d9e..08d8fae 100644 --- a/roles/relay/templates/nginx/stream.conf.j2 +++ b/roles/relay/templates/nginx/stream.conf.j2 @@ -12,9 +12,6 @@ map $ssl_preread_server_name $relay_backend { {{ relay_domain }} local_https; {{ relay_sub_my }} local_https; -{% for domain in relay_extra_eu_https_domains | default([]) %} - {{ domain }} eu_https; -{% endfor %} default eu_vpn; } @@ -22,10 +19,6 @@ upstream local_https { server 127.0.0.1:{{ relay_https_port }}; } -upstream eu_https { - server {{ relay_upstream_host }}:{{ relay_upstream_raven_port }}; -} - upstream eu_vpn { server {{ relay_upstream_host }}:{{ relay_upstream_port }}; } From 322a57c7bbbc28bdb20739fbdc76d757f0299697 Mon Sep 17 00:00:00 2001 From: findias Date: Wed, 25 Mar 2026 10:55:21 +0300 Subject: [PATCH 14/26] security: bind raven-subscribe to 127.0.0.1 instead of 0.0.0.0 --- roles/raven_subscribe/defaults/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roles/raven_subscribe/defaults/main.yml b/roles/raven_subscribe/defaults/main.yml index f392dcb..93056ef 100644 --- a/roles/raven_subscribe/defaults/main.yml +++ b/roles/raven_subscribe/defaults/main.yml @@ -6,7 +6,7 @@ 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_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 From fa8e1aac0a88346be57cac01cf6a98a175a56bee Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 00:30:35 +0300 Subject: [PATCH 15/26] feat: add WireGuard mesh, replace SSH tunnel for monitoring - New role roles/wireguard: installs wireguard-tools, deploys wg0 on EU (10.10.0.1) and RU (10.10.0.2), PersistentKeepalive=25s - role_wireguard.yml playbook applies to both vm_my_srv and vm_my_ru - monitoring: remove ssh_tunnel_ru task/handler/vars; node_exporter on RU now binds to 10.10.0.2:9100 (WireGuard iface); VictoriaMetrics scrapes RU via 10.10.0.2:9100 instead of SSH tunnel 127.0.0.1:19100 - monitoring/node_exporter: add ufw allow from 10.10.0.0/24 for RU - role_monitoring.yml: apply role to both EU and RU hosts; EU-only components (VictoriaMetrics, Grafana, exporters) guarded by when --- roles/monitoring/defaults/main.yml | 63 ++ roles/monitoring/defaults/secrets.yml.example | 7 + roles/monitoring/handlers/main.yml | 35 ++ roles/monitoring/tasks/grafana.yml | 127 ++++ roles/monitoring/tasks/main.yml | 29 + roles/monitoring/tasks/node_exporter.yml | 101 +++ roles/monitoring/tasks/ssh_tunnel_ru.yml | 38 ++ roles/monitoring/tasks/victoriametrics.yml | 121 ++++ roles/monitoring/tasks/xray_exporter.yml | 67 ++ .../monitoring/tasks/xray_stats_exporter.yml | 25 + .../dashboards/server-status.json.j2 | 583 ++++++++++++++++++ .../dashboards/xray-users-traffic.json.j2 | 157 +++++ .../templates/grafana-dashboards.yml.j2 | 9 + .../templates/grafana-datasource.yml.j2 | 14 + roles/monitoring/templates/grafana.ini.j2 | 35 ++ .../templates/node_exporter.service.j2 | 27 + roles/monitoring/templates/scrape.yml.j2 | 29 + .../templates/ssh-tunnel-ru.service.j2 | 24 + .../templates/victoriametrics.service.j2 | 23 + .../templates/xray-exporter.service.j2 | 22 + .../templates/xray-stats-exporter.service.j2 | 18 + roles/role_monitoring.yml | 15 + roles/role_wireguard.yml | 21 + roles/wireguard/defaults/main.yml | 13 + roles/wireguard/defaults/secrets.yml.example | 14 + roles/wireguard/handlers/main.yml | 7 + roles/wireguard/tasks/eu.yml | 32 + roles/wireguard/tasks/main.yml | 15 + roles/wireguard/tasks/ru.yml | 30 + roles/wireguard/templates/wg0-eu.conf.j2 | 10 + roles/wireguard/templates/wg0-ru.conf.j2 | 10 + 31 files changed, 1721 insertions(+) create mode 100644 roles/monitoring/defaults/main.yml create mode 100644 roles/monitoring/defaults/secrets.yml.example create mode 100644 roles/monitoring/handlers/main.yml create mode 100644 roles/monitoring/tasks/grafana.yml create mode 100644 roles/monitoring/tasks/main.yml create mode 100644 roles/monitoring/tasks/node_exporter.yml create mode 100644 roles/monitoring/tasks/ssh_tunnel_ru.yml create mode 100644 roles/monitoring/tasks/victoriametrics.yml create mode 100644 roles/monitoring/tasks/xray_exporter.yml create mode 100644 roles/monitoring/tasks/xray_stats_exporter.yml create mode 100644 roles/monitoring/templates/dashboards/server-status.json.j2 create mode 100644 roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 create mode 100644 roles/monitoring/templates/grafana-dashboards.yml.j2 create mode 100644 roles/monitoring/templates/grafana-datasource.yml.j2 create mode 100644 roles/monitoring/templates/grafana.ini.j2 create mode 100644 roles/monitoring/templates/node_exporter.service.j2 create mode 100644 roles/monitoring/templates/scrape.yml.j2 create mode 100644 roles/monitoring/templates/ssh-tunnel-ru.service.j2 create mode 100644 roles/monitoring/templates/victoriametrics.service.j2 create mode 100644 roles/monitoring/templates/xray-exporter.service.j2 create mode 100644 roles/monitoring/templates/xray-stats-exporter.service.j2 create mode 100644 roles/role_monitoring.yml create mode 100644 roles/role_wireguard.yml create mode 100644 roles/wireguard/defaults/main.yml create mode 100644 roles/wireguard/defaults/secrets.yml.example create mode 100644 roles/wireguard/handlers/main.yml create mode 100644 roles/wireguard/tasks/eu.yml create mode 100644 roles/wireguard/tasks/main.yml create mode 100644 roles/wireguard/tasks/ru.yml create mode 100644 roles/wireguard/templates/wg0-eu.conf.j2 create mode 100644 roles/wireguard/templates/wg0-ru.conf.j2 diff --git a/roles/monitoring/defaults/main.yml b/roles/monitoring/defaults/main.yml new file mode 100644 index 0000000..c7bf278 --- /dev/null +++ b/roles/monitoring/defaults/main.yml @@ -0,0 +1,63 @@ +--- + +# 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" + +# 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..64ccbab --- /dev/null +++ b/roles/monitoring/tasks/grafana.yml @@ -0,0 +1,127 @@ +--- +- 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_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: 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..6b75fb3 --- /dev/null +++ b/roles/monitoring/tasks/victoriametrics.yml @@ -0,0 +1,121 @@ +--- +- 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: Get latest VictoriaMetrics release version + ansible.builtin.uri: + url: "https://api.github.com/repos/{{ victoriametrics_github_repo }}/releases/latest" + method: GET + return_content: true + headers: + Accept: "application/vnd.github.v3+json" + status_code: 200 + register: vm_release_info + run_once: true + retries: 3 + delay: 3 + until: vm_release_info.status == 200 + when: victoriametrics_version == "latest" + +- name: Set VictoriaMetrics version fact + ansible.builtin.set_fact: + vm_resolved_version: >- + {{ + vm_release_info.json.tag_name + if victoriametrics_version == "latest" + else victoriametrics_version + }} + +- 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: 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..961cae9 --- /dev/null +++ b/roles/monitoring/tasks/xray_stats_exporter.yml @@ -0,0 +1,25 @@ +--- +- 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" + 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..adcd9b4 --- /dev/null +++ b/roles/monitoring/templates/dashboards/server-status.json.j2 @@ -0,0 +1,583 @@ +{ + "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" + } + + ], + "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..757fbc6 --- /dev/null +++ b/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 @@ -0,0 +1,157 @@ +{ + "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": [ + { + "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": 0}, + "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": 10}, + "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": { + "custom": {"align": "auto", "inspect": false}, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 20}, + "id": 3, + "options": {"showHeader": true, "sortBy": [{"displayName": "Value", "desc": true}]}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "editorMode": "code", + "expr": "xray_user_uplink_bytes_total", + "format": "table", + "instant": true, + "legendFormat": "{% raw %}{{email}}{% endraw %}", + "refId": "A" + } + ], + "transformations": [ + {"id": "filterFieldsByName", "options": {"include": {"names": ["email", "Value"]}}} + ], + "title": "Cumulative upload by user (counter, since Xray start)", + "type": "table" + }, + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "custom": {"align": "auto", "inspect": false}, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 20}, + "id": 4, + "options": {"showHeader": true, "sortBy": [{"displayName": "Value", "desc": true}]}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "editorMode": "code", + "expr": "xray_user_downlink_bytes_total", + "format": "table", + "instant": true, + "legendFormat": "{% raw %}{{email}}{% endraw %}", + "refId": "A" + } + ], + "transformations": [ + {"id": "filterFieldsByName", "options": {"include": {"names": ["email", "Value"]}}} + ], + "title": "Cumulative download by user (counter, since Xray start)", + "type": "table" + } + ], + "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": 1, + "weekStart": "" +} 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..162cbf1 --- /dev/null +++ b/roles/monitoring/templates/scrape.yml.j2 @@ -0,0 +1,29 @@ +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'] }}" 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..8eda67a --- /dev/null +++ b/roles/monitoring/templates/xray-stats-exporter.service.j2 @@ -0,0 +1,18 @@ +[Unit] +Description=Xray Per-User Stats Prometheus Exporter +After=network.target xray.service + +[Service] +User={{ xray_exporter_user }} +Group={{ xray_exporter_group }} +NoNewPrivileges=true +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 }} +Restart=on-failure +RestartSec=5s +TimeoutStopSec=10s + +[Install] +WantedBy=multi-user.target 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_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/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 From f67715939165d5d1b026aa0129429b3c9a5d6aee Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 00:30:40 +0300 Subject: [PATCH 16/26] fix: fallback user email to id when empty for per-user traffic stats Xray requires non-empty email for StatsService per-user counters. If user.email is missing or blank, use user.id instead. Applies to 200-in-vless-reality, 210-in-xhttp, 230/240-in-*-users. --- roles/xray/defaults/main.yml | 2 +- roles/xray/templates/conf/inbounds/200-in-vless-reality.json.j2 | 2 +- roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 | 2 +- roles/xray/templates/conf/users/230-in-xhttp-users.json.j2 | 2 +- roles/xray/templates/conf/users/240-in-vless-users.json.j2 | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/roles/xray/defaults/main.yml b/roles/xray/defaults/main.yml index 5efd6f2..c8d6a51 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -117,7 +117,7 @@ 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" 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 72ad1af..be5c948 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 @@ -12,7 +12,7 @@ { "id": "{{ user.id }}", "flow": "{{ 'xtls-rprx-vision' if _pq else user.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/inbounds/210-in-xhttp.json.j2 b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 index 1be9a11..4d17f35 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -13,7 +13,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/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 %} From eddfa682d83a1cd8042451c71bd598835e380fe3 Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 00:33:12 +0300 Subject: [PATCH 17/26] fix: VictoriaMetrics version detection skips enterprise-only releases GitHub /releases/latest returns enterprise release (no single-node tarball). Switch to /releases list and find first release that has victoria-metrics-linux-{arch}-vX.Y.Z.tar.gz (non-enterprise, non-cluster). Also fix task order: detect arch before fetching releases list. --- roles/monitoring/tasks/victoriametrics.yml | 47 +++++++++++++--------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/roles/monitoring/tasks/victoriametrics.yml b/roles/monitoring/tasks/victoriametrics.yml index 6b75fb3..2fb6094 100644 --- a/roles/monitoring/tasks/victoriametrics.yml +++ b/roles/monitoring/tasks/victoriametrics.yml @@ -29,38 +29,49 @@ group: root mode: "0755" -- name: Get latest VictoriaMetrics release version +- 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/latest" + 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_info + register: vm_release_list run_once: true retries: 3 delay: 3 - until: vm_release_info.status == 200 + until: vm_release_list.status == 200 when: victoriametrics_version == "latest" - name: Set VictoriaMetrics version fact ansible.builtin.set_fact: vm_resolved_version: >- - {{ - vm_release_info.json.tag_name - if victoriametrics_version == "latest" - else victoriametrics_version - }} - -- 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 - }} + {%- 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: From 51defff70bf0082c3ab9b35bcf0930792f76ed4e Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 00:40:19 +0300 Subject: [PATCH 18/26] feat: extend Grafana dashboards with Xray activity metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server-status: add unique_users (stat), total_connections (stat), routing requests rate (freedom vs blocked), Xray heap memory timeseries. xray-users-traffic: replace cumulative counter tables with bar gauges using increase($__range) — shows traffic per user for selected period. --- .../dashboards/server-status.json.j2 | 118 ++++++++++++++++++ .../dashboards/xray-users-traffic.json.j2 | 38 +++--- 2 files changed, 134 insertions(+), 22 deletions(-) diff --git a/roles/monitoring/templates/dashboards/server-status.json.j2 b/roles/monitoring/templates/dashboards/server-status.json.j2 index adcd9b4..1b3de68 100644 --- a/roles/monitoring/templates/dashboards/server-status.json.j2 +++ b/roles/monitoring/templates/dashboards/server-status.json.j2 @@ -566,6 +566,124 @@ ], "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" } ], diff --git a/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 b/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 index 757fbc6..0f4d354 100644 --- a/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 +++ b/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 @@ -88,59 +88,53 @@ "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, "fieldConfig": { "defaults": { - "custom": {"align": "auto", "inspect": false}, - "unit": "bytes" + "color": {"mode": "palette-classic"}, + "unit": "bytes", + "min": 0 }, "overrides": [] }, - "gridPos": {"h": 8, "w": 12, "x": 0, "y": 20}, + "gridPos": {"h": 10, "w": 12, "x": 0, "y": 20}, "id": 3, - "options": {"showHeader": true, "sortBy": [{"displayName": "Value", "desc": true}]}, + "options": {"orientation": "horizontal", "displayMode": "gradient", "reduceOptions": {"calcs": ["lastNotNull"]}, "valueMode": "color", "showUnfilled": true}, "targets": [ { "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, "editorMode": "code", - "expr": "xray_user_uplink_bytes_total", - "format": "table", + "expr": "increase(xray_user_downlink_bytes_total[$__range])", "instant": true, "legendFormat": "{% raw %}{{email}}{% endraw %}", "refId": "A" } ], - "transformations": [ - {"id": "filterFieldsByName", "options": {"include": {"names": ["email", "Value"]}}} - ], - "title": "Cumulative upload by user (counter, since Xray start)", - "type": "table" + "title": "Download за период", + "type": "bargauge" }, { "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, "fieldConfig": { "defaults": { - "custom": {"align": "auto", "inspect": false}, - "unit": "bytes" + "color": {"mode": "palette-classic"}, + "unit": "bytes", + "min": 0 }, "overrides": [] }, - "gridPos": {"h": 8, "w": 12, "x": 12, "y": 20}, + "gridPos": {"h": 10, "w": 12, "x": 12, "y": 20}, "id": 4, - "options": {"showHeader": true, "sortBy": [{"displayName": "Value", "desc": true}]}, + "options": {"orientation": "horizontal", "displayMode": "gradient", "reduceOptions": {"calcs": ["lastNotNull"]}, "valueMode": "color", "showUnfilled": true}, "targets": [ { "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, "editorMode": "code", - "expr": "xray_user_downlink_bytes_total", - "format": "table", + "expr": "increase(xray_user_uplink_bytes_total[$__range])", "instant": true, "legendFormat": "{% raw %}{{email}}{% endraw %}", "refId": "A" } ], - "transformations": [ - {"id": "filterFieldsByName", "options": {"include": {"names": ["email", "Value"]}}} - ], - "title": "Cumulative download by user (counter, since Xray start)", - "type": "table" + "title": "Upload за период", + "type": "bargauge" } ], "refresh": "30s", From 90b37edfac70b123807c2bbcf7ac8c2ab284df05 Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 10:20:48 +0300 Subject: [PATCH 19/26] feat: add per-inbound metrics, inbound dashboard panels, Raven-subscribe monitoring, Grafana alerts - xray-stats-exporter: add xray_inbound_uplink/downlink_bytes_total metrics by querying inbound>>> pattern alongside user>>> in StatsService - scrape.yml: add raven-subscribe job scraping /health (up=0 on failure) - dashboards: add inbound traffic panels and sort_desc on user top charts - server-status: add Raven-subscribe UP/DOWN status and latency panels - grafana-alerting.yml: provision 5 alert rules (xray down, raven-subscribe down, EU/RU server down, EU disk >85%) --- roles/monitoring/defaults/main.yml | 3 + roles/monitoring/tasks/grafana.yml | 10 + .../dashboards/server-status.json.j2 | 62 ++++++ .../dashboards/xray-users-traffic.json.j2 | 108 +++++++++- .../templates/grafana-alerting.yml.j2 | 193 ++++++++++++++++++ roles/monitoring/templates/scrape.yml.j2 | 9 + 6 files changed, 376 insertions(+), 9 deletions(-) create mode 100644 roles/monitoring/templates/grafana-alerting.yml.j2 diff --git a/roles/monitoring/defaults/main.yml b/roles/monitoring/defaults/main.yml index c7bf278..1fc8530 100644 --- a/roles/monitoring/defaults/main.yml +++ b/roles/monitoring/defaults/main.yml @@ -46,6 +46,9 @@ 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" +# 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" diff --git a/roles/monitoring/tasks/grafana.yml b/roles/monitoring/tasks/grafana.yml index 64ccbab..a1a1d1f 100644 --- a/roles/monitoring/tasks/grafana.yml +++ b/roles/monitoring/tasks/grafana.yml @@ -55,6 +55,7 @@ loop: - "{{ grafana_provisioning_dir }}/datasources" - "{{ grafana_provisioning_dir }}/dashboards" + - "{{ grafana_provisioning_dir }}/alerting" - "{{ grafana_data_dir }}/dashboards" - name: Deploy grafana.ini @@ -119,6 +120,15 @@ 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 }}" diff --git a/roles/monitoring/templates/dashboards/server-status.json.j2 b/roles/monitoring/templates/dashboards/server-status.json.j2 index 1b3de68..55ef41c 100644 --- a/roles/monitoring/templates/dashboards/server-status.json.j2 +++ b/roles/monitoring/templates/dashboards/server-status.json.j2 @@ -684,6 +684,68 @@ ], "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" } ], diff --git a/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 b/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 index 0f4d354..c8bd2c7 100644 --- a/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 +++ b/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 @@ -18,6 +18,15 @@ "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": { @@ -33,7 +42,7 @@ }, "overrides": [] }, - "gridPos": {"h": 10, "w": 24, "x": 0, "y": 0}, + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 1}, "id": 1, "options": { "legend": {"displayMode": "table", "placement": "right", "showLegend": true, "calcs": ["lastNotNull", "max"]} @@ -51,6 +60,7 @@ "title": "Upload (client → server), bytes/s by user", "type": "timeseries" }, + { "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, "fieldConfig": { @@ -66,7 +76,7 @@ }, "overrides": [] }, - "gridPos": {"h": 10, "w": 24, "x": 0, "y": 10}, + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 11}, "id": 2, "options": { "legend": {"displayMode": "table", "placement": "right", "showLegend": true, "calcs": ["lastNotNull", "max"]} @@ -84,6 +94,7 @@ "title": "Download (server → client), bytes/s by user", "type": "timeseries" }, + { "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, "fieldConfig": { @@ -94,22 +105,23 @@ }, "overrides": [] }, - "gridPos": {"h": 10, "w": 12, "x": 0, "y": 20}, + "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": "increase(xray_user_downlink_bytes_total[$__range])", + "expr": "sort_desc(increase(xray_user_downlink_bytes_total[$__range]))", "instant": true, "legendFormat": "{% raw %}{{email}}{% endraw %}", "refId": "A" } ], - "title": "Download за период", + "title": "Download за период (топ пользователей)", "type": "bargauge" }, + { "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, "fieldConfig": { @@ -120,22 +132,100 @@ }, "overrides": [] }, - "gridPos": {"h": 10, "w": 12, "x": 12, "y": 20}, + "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": "increase(xray_user_uplink_bytes_total[$__range])", + "expr": "sort_desc(increase(xray_user_uplink_bytes_total[$__range]))", "instant": true, "legendFormat": "{% raw %}{{email}}{% endraw %}", "refId": "A" } ], - "title": "Upload за период", + "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, @@ -146,6 +236,6 @@ "timezone": "browser", "title": "Xray — per-user upload / download", "uid": "raven-xray-users-traffic", - "version": 1, + "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/scrape.yml.j2 b/roles/monitoring/templates/scrape.yml.j2 index 162cbf1..e0fac4c 100644 --- a/roles/monitoring/templates/scrape.yml.j2 +++ b/roles/monitoring/templates/scrape.yml.j2 @@ -27,3 +27,12 @@ scrape_configs: 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 }} From d6823dbdb22ce507e55941ad6997b6c558421eb9 Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 10:27:01 +0300 Subject: [PATCH 20/26] fix: xray-stats-exporter service uses own user/group vars, not xray-exporter's --- roles/monitoring/defaults/main.yml | 2 ++ roles/monitoring/templates/xray-stats-exporter.service.j2 | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/roles/monitoring/defaults/main.yml b/roles/monitoring/defaults/main.yml index 1fc8530..b82cd35 100644 --- a/roles/monitoring/defaults/main.yml +++ b/roles/monitoring/defaults/main.yml @@ -45,6 +45,8 @@ 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 (no filesystem access required) +xray_stats_exporter_group: "xrayuser" # raven-subscribe health probe (VictoriaMetrics scrapes /health → up metric) raven_subscribe_scrape_addr: "127.0.0.1:8080" diff --git a/roles/monitoring/templates/xray-stats-exporter.service.j2 b/roles/monitoring/templates/xray-stats-exporter.service.j2 index 8eda67a..1d7cf23 100644 --- a/roles/monitoring/templates/xray-stats-exporter.service.j2 +++ b/roles/monitoring/templates/xray-stats-exporter.service.j2 @@ -3,8 +3,8 @@ Description=Xray Per-User Stats Prometheus Exporter After=network.target xray.service [Service] -User={{ xray_exporter_user }} -Group={{ xray_exporter_group }} +User={{ xray_stats_exporter_user }} +Group={{ xray_stats_exporter_group }} NoNewPrivileges=true ExecStart={{ xray_stats_exporter_bin_dir }}/xray-stats-exporter \ --listen={{ xray_stats_exporter_listen }} \ From 11eb8ae8026cf3ae623145e1f4c5af3b9456d9e8 Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 10:30:39 +0300 Subject: [PATCH 21/26] fix: skip binary copy if xray_stats_exporter_local_binary not provided --- roles/monitoring/tasks/xray_stats_exporter.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/roles/monitoring/tasks/xray_stats_exporter.yml b/roles/monitoring/tasks/xray_stats_exporter.yml index 961cae9..50fd341 100644 --- a/roles/monitoring/tasks/xray_stats_exporter.yml +++ b/roles/monitoring/tasks/xray_stats_exporter.yml @@ -6,6 +6,7 @@ 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 From 29e00d793af3e480c4a44e65b86254bbb585c414 Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 11:55:41 +0300 Subject: [PATCH 22/26] refactor --- roles/monitoring/defaults/main.yml | 5 +- .../dashboards/xray-users-traffic.json.j2 | 94 ++++++++++++++++++- .../templates/xray-stats-exporter.service.j2 | 9 +- .../templates/nginx/https.conf.j2 | 16 +++- .../templates/nginx/stream.conf.j2 | 5 + roles/xray/defaults/main.yml | 2 - .../inbounds/200-in-vless-reality.json.j2 | 3 +- .../conf/inbounds/210-in-xhttp.json.j2 | 2 +- 8 files changed, 123 insertions(+), 13 deletions(-) diff --git a/roles/monitoring/defaults/main.yml b/roles/monitoring/defaults/main.yml index b82cd35..a239fbb 100644 --- a/roles/monitoring/defaults/main.yml +++ b/roles/monitoring/defaults/main.yml @@ -45,8 +45,11 @@ 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 (no filesystem access required) +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" diff --git a/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 b/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 index c8bd2c7..9ea47f9 100644 --- a/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 +++ b/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 @@ -152,6 +152,96 @@ { "collapsed": false, "gridPos": {"h": 1, "w": 24, "x": 0, "y": 31}, + "id": 30, + "title": "Геолокация пользователей", + "type": "row" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "short", + "min": 0 + }, + "overrides": [] + }, + "gridPos": {"h": 10, "w": 12, "x": 0, "y": 32}, + "id": 31, + "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(sum by (country) (xray_user_connections_total))", + "instant": true, + "legendFormat": "{% raw %}{{country}}{% endraw %}", + "refId": "A" + } + ], + "title": "Соединения по странам", + "type": "bargauge" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "unit": "short", + "min": 0 + }, + "overrides": [] + }, + "gridPos": {"h": 10, "w": 12, "x": 12, "y": 32}, + "id": 32, + "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(sum by (city) (xray_user_connections_total))", + "instant": true, + "legendFormat": "{% raw %}{{city}}{% endraw %}", + "refId": "A" + } + ], + "title": "Соединения по городам", + "type": "bargauge" + }, + + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "fieldConfig": { + "defaults": { + "color": {"mode": "palette-classic"}, + "custom": {"axisBorderShow": false, "drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": {"h": 10, "w": 24, "x": 0, "y": 42}, + "id": 33, + "options": {"legend": {"displayMode": "table", "placement": "right", "showLegend": true, "calcs": ["lastNotNull", "max"]}}, + "targets": [ + { + "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, + "editorMode": "code", + "expr": "sum by (email, country, city) (xray_user_last_country)", + "instant": false, + "legendFormat": "{% raw %}{{email}} ({{city}}, {{country}}){% endraw %}", + "range": true, + "refId": "A" + } + ], + "title": "Последнее местоположение пользователей", + "type": "timeseries" + }, + + { + "collapsed": false, + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 52}, "id": 20, "title": "Inbound — трафик по протоколам", "type": "row" @@ -172,7 +262,7 @@ }, "overrides": [] }, - "gridPos": {"h": 10, "w": 12, "x": 0, "y": 32}, + "gridPos": {"h": 10, "w": 12, "x": 0, "y": 53}, "id": 21, "options": { "legend": {"displayMode": "table", "placement": "bottom", "showLegend": true, "calcs": ["lastNotNull", "max"]} @@ -209,7 +299,7 @@ }, "overrides": [] }, - "gridPos": {"h": 10, "w": 12, "x": 12, "y": 32}, + "gridPos": {"h": 10, "w": 12, "x": 12, "y": 53}, "id": 22, "options": {"orientation": "horizontal", "displayMode": "gradient", "reduceOptions": {"calcs": ["lastNotNull"]}, "valueMode": "color", "showUnfilled": true}, "targets": [ diff --git a/roles/monitoring/templates/xray-stats-exporter.service.j2 b/roles/monitoring/templates/xray-stats-exporter.service.j2 index 1d7cf23..df9d86a 100644 --- a/roles/monitoring/templates/xray-stats-exporter.service.j2 +++ b/roles/monitoring/templates/xray-stats-exporter.service.j2 @@ -6,10 +6,17 @@ After=network.target xray.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 }} + --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 diff --git a/roles/nginx_frontend/templates/nginx/https.conf.j2 b/roles/nginx_frontend/templates/nginx/https.conf.j2 index 7ec6b20..9fddad0 100644 --- a/roles/nginx_frontend/templates/nginx/https.conf.j2 +++ b/roles/nginx_frontend/templates/nginx/https.conf.j2 @@ -12,10 +12,16 @@ # ── HTTPS ───────────────────────────────────────────────────────────────────── server { - listen {{ nginx_frontend_listen_port }} ssl; + # 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; @@ -25,8 +31,8 @@ server { 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 $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + 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; @@ -36,8 +42,8 @@ server { location /api/ { proxy_pass http://127.0.0.1:{{ nginx_frontend_raven_port }}; 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-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; diff --git a/roles/nginx_frontend/templates/nginx/stream.conf.j2 b/roles/nginx_frontend/templates/nginx/stream.conf.j2 index 46f2c7d..e9496c2 100644 --- a/roles/nginx_frontend/templates/nginx/stream.conf.j2 +++ b/roles/nginx_frontend/templates/nginx/stream.conf.j2 @@ -26,10 +26,15 @@ 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/xray/defaults/main.yml b/roles/xray/defaults/main.yml index c8d6a51..d104a65 100644 --- a/roles/xray/defaults/main.yml +++ b/roles/xray/defaults/main.yml @@ -67,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: 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 be5c948..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 @@ -45,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 4d17f35..01f6a24 100644 --- a/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 +++ b/roles/xray/templates/conf/inbounds/210-in-xhttp.json.j2 @@ -55,7 +55,7 @@ {% endfor %} ], "show": false, - "xver": 0 + "xver": 2 }, "security": "reality", "xhttpSettings": { From fbc1065235b58cf48f27bbab0deda19e54b9ecf4 Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 13:28:56 +0300 Subject: [PATCH 23/26] cleanup: remove geo panels from xray-users-traffic dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geo metrics (country/city) not available — Xray access.log shows 127.0.0.1 after nginx proxy_protocol was added. Removed geo row and shifted inbound panels up. --- .../dashboards/xray-users-traffic.json.j2 | 94 +------------------ 1 file changed, 2 insertions(+), 92 deletions(-) diff --git a/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 b/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 index 9ea47f9..c8bd2c7 100644 --- a/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 +++ b/roles/monitoring/templates/dashboards/xray-users-traffic.json.j2 @@ -152,96 +152,6 @@ { "collapsed": false, "gridPos": {"h": 1, "w": 24, "x": 0, "y": 31}, - "id": 30, - "title": "Геолокация пользователей", - "type": "row" - }, - - { - "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, - "fieldConfig": { - "defaults": { - "color": {"mode": "palette-classic"}, - "unit": "short", - "min": 0 - }, - "overrides": [] - }, - "gridPos": {"h": 10, "w": 12, "x": 0, "y": 32}, - "id": 31, - "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(sum by (country) (xray_user_connections_total))", - "instant": true, - "legendFormat": "{% raw %}{{country}}{% endraw %}", - "refId": "A" - } - ], - "title": "Соединения по странам", - "type": "bargauge" - }, - - { - "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, - "fieldConfig": { - "defaults": { - "color": {"mode": "palette-classic"}, - "unit": "short", - "min": 0 - }, - "overrides": [] - }, - "gridPos": {"h": 10, "w": 12, "x": 12, "y": 32}, - "id": 32, - "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(sum by (city) (xray_user_connections_total))", - "instant": true, - "legendFormat": "{% raw %}{{city}}{% endraw %}", - "refId": "A" - } - ], - "title": "Соединения по городам", - "type": "bargauge" - }, - - { - "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, - "fieldConfig": { - "defaults": { - "color": {"mode": "palette-classic"}, - "custom": {"axisBorderShow": false, "drawStyle": "line", "fillOpacity": 10, "showPoints": "never"}, - "unit": "short" - }, - "overrides": [] - }, - "gridPos": {"h": 10, "w": 24, "x": 0, "y": 42}, - "id": 33, - "options": {"legend": {"displayMode": "table", "placement": "right", "showLegend": true, "calcs": ["lastNotNull", "max"]}}, - "targets": [ - { - "datasource": {"type": "prometheus", "uid": "{{ grafana_prometheus_datasource_uid }}"}, - "editorMode": "code", - "expr": "sum by (email, country, city) (xray_user_last_country)", - "instant": false, - "legendFormat": "{% raw %}{{email}} ({{city}}, {{country}}){% endraw %}", - "range": true, - "refId": "A" - } - ], - "title": "Последнее местоположение пользователей", - "type": "timeseries" - }, - - { - "collapsed": false, - "gridPos": {"h": 1, "w": 24, "x": 0, "y": 52}, "id": 20, "title": "Inbound — трафик по протоколам", "type": "row" @@ -262,7 +172,7 @@ }, "overrides": [] }, - "gridPos": {"h": 10, "w": 12, "x": 0, "y": 53}, + "gridPos": {"h": 10, "w": 12, "x": 0, "y": 32}, "id": 21, "options": { "legend": {"displayMode": "table", "placement": "bottom", "showLegend": true, "calcs": ["lastNotNull", "max"]} @@ -299,7 +209,7 @@ }, "overrides": [] }, - "gridPos": {"h": 10, "w": 12, "x": 12, "y": 53}, + "gridPos": {"h": 10, "w": 12, "x": 12, "y": 32}, "id": 22, "options": {"orientation": "horizontal", "displayMode": "gradient", "reduceOptions": {"calcs": ["lastNotNull"]}, "valueMode": "color", "showUnfilled": true}, "targets": [ From 1406fbaaec3b03e08460312afda66c94e9013dec Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 13:38:11 +0300 Subject: [PATCH 24/26] docs: update README with SNI routing architecture, monitoring, correct ports - Fix architecture diagrams: SNI routing on :443, ports 4443/2053/8443 - Add nginx_frontend PROXY protocol notes and deploy order warning - Add monitoring role description (xray-stats-exporter, VictoriaMetrics, Grafana) - Add Monitoring section with Grafana dashboard and alerting description - Update nginx_frontend and relay config variable tables - Add xray-stats-exporter to Related Projects - Sync README.ru.md with EN changes --- README.md | 143 ++++++++++++++++++++++++++++++++++----------------- README.ru.md | 135 +++++++++++++++++++++++++++++++----------------- 2 files changed, 182 insertions(+), 96 deletions(-) diff --git a/README.md b/README.md index 0648f3e..fdf11ce 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,20 @@ 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 self-hosted VPN server stack based on [Xray-core](https://github.com/XTLS/Xray-core) and [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe). +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 and VLESS + XHTTP inbounds -- Optional post-quantum VLESS Encryption (mlkem768x25519plus) +- 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 -- nginx TLS frontend on EU VPS (`nginx_frontend` role) -- nginx relay + TCP stream proxy on RU VPS for routing through a second server (`relay` role) +- [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 +- Ad and tracker blocking via geosite routing rules (`geosite:category-ads-all`) - BBR congestion control and sysctl tuning (`srv_prepare` role) --- @@ -44,48 +46,50 @@ This repo supports two deployment topologies: ### Single-server (minimal) -One VPS running Xray + Raven-subscribe + nginx frontend. +One VPS running Xray + Raven-subscribe + nginx frontend. All traffic enters on port 443 — nginx routes by SNI. ``` -Client ──VLESS+Reality──► VPS:443 (Xray) -Client ──VLESS+XHTTP────► VPS:443 (nginx) ──► VPS:2053 (Xray) -Client ──subscription───► VPS:443 (nginx) ──► VPS:8080 (Raven) +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 a relay that hides the EU IP from clients. +RU VPS runs an SNI relay that hides the EU IP from clients and passes traffic through. ``` -EU VPS (media.example.com) RU VPS (example.com) -┌───────────────────────────┐ ┌─────────────────────────────┐ -│ Xray :443 TCP │ │ nginx relay │ -│ nginx XHTTP :443 HTTPS │◄─────│ my.example.com → EU:8443 │ -│ nginx stream:8445 TCP │◄─────│ :8444 TCP → EU:8445 TCP │ -│ Raven :8080 local │ └─────────────────────────────┘ -│ nginx front :8443 HTTPS │ ▲ -└───────────────────────────┘ │ - clients +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:8444 (TCP relay) → EU:8445 (nginx stream) → Xray:443 -VLESS XHTTP: client → EU:443 (nginx HTTPS) → Xray:2053 -Subscription: client → my.example.com (RU relay) → EU:8443 → Raven:8080 +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, system user | +| `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 TLS proxy + TCP stream relay (port 8443/8445) | +| `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 reverse proxy + TCP stream relay (port 8444) | +| `relay` | RU | `role_relay.yml` | nginx SNI relay on :443 — forwards all VPN traffic to EU | --- @@ -253,24 +257,37 @@ Listens on `127.0.0.1:8080`, proxied by nginx_frontend. ### `nginx_frontend` role -Deploys nginx on the EU VPS as a TLS reverse proxy. Responsibilities: +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` -- Listens on port **8443** (port 443 is taken by Xray VLESS Reality) -- Proxies XHTTP path → Xray `:2053` -- Proxies subscription/API paths → Raven-subscribe `:8080` -- **TCP stream relay**: port 8445 → `127.0.0.1:443` (passes VLESS Reality through nginx) + +**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 a relay. Responsibilities: +Deploys nginx on the RU VPS as an SNI relay. Responsibilities: -- Obtains Let's Encrypt certificates for `relay_domain` and `relay_sub_my` -- Serves a static stub site on `relay_domain` (camouflage) +- **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) -- **TCP stream relay**: port 8444 → EU VPS `:8445` (VLESS Reality passthrough) + +--- + +### `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 --- @@ -384,20 +401,21 @@ singbox: | Variable | Default | Description | |----------|---------|-------------| -| `nginx_frontend_domain` | `media.example.com` | EU VPS domain — set to your domain | -| `nginx_frontend_listen_port` | `8443` | nginx HTTPS listen port (not 443 — taken by Xray) | -| `nginx_frontend_xhttp_port` | `2053` | Xray XHTTP upstream port | -| `nginx_frontend_xhttp_path` | `/api/v3/data-sync` | XHTTP path (must match xray config) | -| `nginx_frontend_reality_port` | `8445` | TCP stream relay port for Reality | +| `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 — set to your domain | -| `relay_upstream_raven_port` | `8443` | EU nginx_frontend port (must match `nginx_frontend_listen_port`) | -| `relay_stream_port` | `8444` | RU relay TCP port for Reality (exposed to clients) | -| `relay_upstream_xray_port` | `8445` | EU nginx stream port (must match `nginx_frontend_reality_port`) | +| `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 | @@ -409,11 +427,39 @@ Point the following DNS A records to the correct servers: | Domain | → | Server | Purpose | |--------|---|--------|---------| -| `media.example.com` | → | EU VPS IP | nginx_frontend (XHTTP, Raven) | -| `example.com` | → | RU VPS IP | Relay stub site | -| `my.example.com` | → | RU VPS IP | Relay → Raven-subscribe | +| `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 RU VPS TCP relay for Reality (port 8444) works by IP — no DNS record needed. +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" +``` --- @@ -497,6 +543,7 @@ 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) diff --git a/README.ru.md b/README.ru.md index 3bcfe11..5b9a211 100644 --- a/README.ru.md +++ b/README.ru.md @@ -5,18 +5,20 @@ [![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-плейбуки для развёртывания самохостинг VPN-стека на основе [Xray-core](https://github.com/XTLS/Xray-core) и [Raven-subscribe](https://github.com/AlchemyLink/Raven-subscribe). +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 и VLESS + XHTTP -- Опциональное пост-квантовое VLESS Encryption (mlkem768x25519plus) +- 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) — сервер подписок: автоматически находит пользователей, раздаёт клиентские конфиги по персональным ссылкам -- nginx TLS frontend на EU VPS (роль `nginx_frontend`) -- nginx relay + TCP stream proxy на RU VPS для маршрутизации через второй сервер (роль `relay`) +- [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 +- Блокировка рекламы и публичных трекеров (`geosite:category-ads-all`) - BBR и тюнинг sysctl (роль `srv_prepare`) --- @@ -44,48 +46,50 @@ Ansible-плейбуки для развёртывания самохостин ### Один сервер (минимальный вариант) -Один VPS с Xray + Raven-subscribe + nginx. +Один VPS с Xray + Raven-subscribe + nginx. Весь трафик через порт 443 — nginx маршрутизирует по SNI. ``` -Клиент ──VLESS+Reality──► VPS:443 (Xray) -Клиент ──VLESS+XHTTP────► VPS:443 (nginx) ──► VPS:2053 (Xray) -Клиент ──подписка───────► VPS:443 (nginx) ──► VPS:8080 (Raven) +Клиент ──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: relay — скрывает EU IP от клиентов. +RU VPS: SNI relay — скрывает EU IP от клиентов, пробрасывает трафик насквозь. ``` -EU VPS (media.example.com) RU VPS (example.com) -┌───────────────────────────┐ ┌─────────────────────────────┐ -│ Xray :443 TCP │ │ nginx relay │ -│ nginx XHTTP :443 HTTPS │◄─────│ my.example.com → EU:8443 │ -│ nginx stream:8445 TCP │◄─────│ :8444 TCP → EU:8445 TCP │ -│ Raven :8080 local │ └─────────────────────────────┘ -│ nginx front :8443 HTTPS │ ▲ -└───────────────────────────┘ │ - клиенты +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:8444 (TCP relay) → EU:8445 (nginx stream) → Xray:443 -VLESS XHTTP: клиент → EU:443 (nginx HTTPS) → Xray:2053 -Подписка: клиент → my.example.com (RU relay) → EU:8443 → Raven: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, системный пользователь | +| `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 TLS proxy + TCP stream relay (порты 8443/8445) | +| `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 reverse proxy + TCP stream relay (порт 8444) | +| `relay` | RU | `role_relay.yml` | nginx SNI relay на :443 — весь VPN-трафик на EU | --- @@ -253,24 +257,37 @@ ansible-playbook roles/role_xray.yml -i roles/hosts.yml --vault-password-file va ### Роль `nginx_frontend` -Деплоит nginx на EU VPS как TLS reverse proxy. Функции: +Деплоит 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` -- Слушает на порту **8443** (порт 443 занят Xray VLESS Reality) -- Проксирует XHTTP path → Xray `:2053` -- Проксирует пути подписки/API → Raven-subscribe `:8080` -- **TCP stream relay**: порт 8445 → `127.0.0.1:443` (проброс VLESS Reality через nginx) + +**Важно:** При одновременном деплое nginx_frontend и Xray inbounds — сначала деплоить **Xray** (`--tags xray_inbounds`), потом nginx. nginx сразу начинает отправлять PROXY protocol заголовки — Xray должен быть готов их принять. --- ### Роль `relay` -Деплоит nginx на RU VPS как relay. Функции: +Деплоит nginx на RU VPS как SNI relay. Функции: -- Получает Let's Encrypt сертификаты для `relay_domain` и `relay_sub_my` -- Отдаёт статический stub-сайт на `relay_domain` (маскировка) +- **Stream SNI routing на :443** — по умолчанию весь VPN-трафик → EU VPS:443 +- Отдаёт статический stub-сайт на `relay_domain` (маскировка, сертификат Let's Encrypt) - Проксирует `my.relay_domain` → EU VPS nginx_frontend `:8443` (Raven-subscribe) -- **TCP stream relay**: порт 8444 → EU VPS `:8445` (проброс VLESS Reality) + +--- + +### Роль `monitoring` + +Деплоит полный стек мониторинга на EU VPS: + +- **[xray-stats-exporter](https://github.com/AlchemyLink/xray-stats-exporter)** — Prometheus exporter для метрик трафика по пользователям и inbound'ам +- **VictoriaMetrics** — Prometheus-совместимая TSDB +- **Grafana** — дашборды трафика, состояния серверов, Raven-subscribe, и правила алертинга --- @@ -384,20 +401,21 @@ singbox: | Переменная | По умолчанию | Описание | |-----------|--------------|---------| -| `nginx_frontend_domain` | `media.example.com` | Домен EU VPS — заменить на свой | -| `nginx_frontend_listen_port` | `8443` | Порт HTTPS nginx (не 443 — занят Xray) | -| `nginx_frontend_xhttp_port` | `2053` | Порт upstream Xray XHTTP | -| `nginx_frontend_xhttp_path` | `/api/v3/data-sync` | Путь XHTTP (должен совпадать с конфигом Xray) | -| `nginx_frontend_reality_port` | `8445` | Порт TCP stream relay для Reality | +| `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 — заменить на свой | -| `relay_upstream_raven_port` | `8443` | Порт nginx_frontend на EU (должен совпадать с `nginx_frontend_listen_port`) | -| `relay_stream_port` | `8444` | TCP порт RU relay для Reality (открытый для клиентов) | -| `relay_upstream_xray_port` | `8445` | Порт nginx stream на EU (должен совпадать с `nginx_frontend_reality_port`) | +| `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-сайта | @@ -409,11 +427,11 @@ singbox: | Домен | → | Сервер | Назначение | |-------|---|--------|-----------| -| `media.example.com` | → | IP EU VPS | nginx_frontend (XHTTP, Raven) | -| `example.com` | → | IP RU VPS | Stub-сайт relay | -| `my.example.com` | → | IP RU VPS | Relay → Raven-subscribe | +| `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 (ссылки подписки) | -TCP relay Reality (порт 8444 на RU VPS) работает по IP — DNS-запись не нужна. +Клиенты подключаются к RU VPS на порт 443 для всех протоколов — дополнительные DNS-записи для VPN-трафика не нужны. --- @@ -494,9 +512,30 @@ 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) From 362dafdfa11a983a7544298055f515fab522edae Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 13:41:57 +0300 Subject: [PATCH 25/26] ci: trigger tests From 46d1ea03a1550e0cc019a37f6c6ed4dad29833ec Mon Sep 17 00:00:00 2001 From: findias Date: Sat, 28 Mar 2026 13:43:06 +0300 Subject: [PATCH 26/26] ci: run tests on develop branch and PRs targeting develop --- .github/workflows/xray-config-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: