From 884a55de14f217a9976e112bc94aae141ca64523 Mon Sep 17 00:00:00 2001 From: vladimir Date: Sun, 28 Jun 2026 22:19:53 +0300 Subject: [PATCH] Initial commit: xray-manager v0.2 with Debian 13 support --- .gitignore | 221 ++++ README.md | 0 data/config.json | 10 + debian/changelog | 5 + debian/control | 16 + debian/install | 1 + debian/rules | 4 + pyproject.toml | 16 + requirements.txt | 3 + src/xray_manager/__init__.py | 0 src/xray_manager/cli.py | 123 ++ src/xray_manager/core.py | 1062 +++++++++++++++++ src/xray_manager/handlers.py | 202 ++++ src/xray_manager/utils.py | 44 + tests/mock_data/etc/xray-manager/config.json | 10 + .../usr/local/etc/xray/default/000-log.json | 5 + .../etc/xray/default/203-in-shadowsocks.json | 17 + .../local/etc/xray/default/204-in-vless.json | 32 + .../local/etc/xray/default/301-out-relay.json | 8 + .../local/etc/xray/default/302-out-us1.json | 34 + .../local/etc/xray/default/303-out-us2.json | 34 + .../local/etc/xray/default/304-out-fr1.json | 12 + .../local/etc/xray/default/400-routing.json | 31 + 23 files changed, 1890 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 data/config.json create mode 100644 debian/changelog create mode 100644 debian/control create mode 100644 debian/install create mode 100755 debian/rules create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 src/xray_manager/__init__.py create mode 100644 src/xray_manager/cli.py create mode 100644 src/xray_manager/core.py create mode 100644 src/xray_manager/handlers.py create mode 100644 src/xray_manager/utils.py create mode 100644 tests/mock_data/etc/xray-manager/config.json create mode 100644 tests/mock_data/usr/local/etc/xray/default/000-log.json create mode 100644 tests/mock_data/usr/local/etc/xray/default/203-in-shadowsocks.json create mode 100644 tests/mock_data/usr/local/etc/xray/default/204-in-vless.json create mode 100644 tests/mock_data/usr/local/etc/xray/default/301-out-relay.json create mode 100644 tests/mock_data/usr/local/etc/xray/default/302-out-us1.json create mode 100644 tests/mock_data/usr/local/etc/xray/default/303-out-us2.json create mode 100644 tests/mock_data/usr/local/etc/xray/default/304-out-fr1.json create mode 100644 tests/mock_data/usr/local/etc/xray/default/400-routing.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dadb42e --- /dev/null +++ b/.gitignore @@ -0,0 +1,221 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.pybuild + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/data/config.json b/data/config.json new file mode 100644 index 0000000..59c596e --- /dev/null +++ b/data/config.json @@ -0,0 +1,10 @@ +{ + "host": "127.0.0.1", + "xray_config_folder": "/usr/local/etc/xray/default", + "profiles": { + "host": "profiles-site-dns.com", + "base_path": "/var/lib/xray-manager/profiles", + "file_name": "list.txt", + "folder_mapping": {} + } +} diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..88a339f --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +python3-xray-manager (0.2-1) stable; urgency=medium + + * Initial release. + + -- Vladimir Khvan Thu, 28 Jun 2026 20:00:00 +0300 \ No newline at end of file diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..fc290ff --- /dev/null +++ b/debian/control @@ -0,0 +1,16 @@ +Source: python3-xray-manager +Section: python +Priority: optional +Maintainer: vkh +Build-Depends: debhelper-compat (=13), + dh-python, + pybuild-plugin-pyproject, + python3-all, + python3-setuptools, + python3-build +Standards-Version: 4.6.2 + +Package: python3-xray-manager +Architecture: all +Depends: ${python3:Depends}, ${misc:Depends} +Description: Utility for managing Xray configuration and user profiles \ No newline at end of file diff --git a/debian/install b/debian/install new file mode 100644 index 0000000..07b10dd --- /dev/null +++ b/debian/install @@ -0,0 +1 @@ +data/config.json etc/xray-manager/ \ No newline at end of file diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..eb1ae1c --- /dev/null +++ b/debian/rules @@ -0,0 +1,4 @@ +#!/usr/bin/make -f + +%: + dh $@ --with python3 --buildsystem=pybuild \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9631f79 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=61.0.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "xray-manager" +version = "0.2" +description = "Utility for managing Xray configuration and user profiles" +dependencies = [ + "cryptography>=43.0.0", + "qrcode>=8.2", + "tabulate>=0.9.0", +] + +[project.scripts] +xray-manager = "xray_manager.cli:main" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..569b296 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +cryptography==43.0.0 +qrcode==8.2 +tabulate==0.9.0 diff --git a/src/xray_manager/__init__.py b/src/xray_manager/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/xray_manager/cli.py b/src/xray_manager/cli.py new file mode 100644 index 0000000..9bae3f9 --- /dev/null +++ b/src/xray_manager/cli.py @@ -0,0 +1,123 @@ +import argparse +import sys + +from . import handlers + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="xray-manager") + + subparsers = parser.add_subparsers(dest="group", required=True) + + # ---------- user ---------- + user_parser = subparsers.add_parser("user") + user_subparser = user_parser.add_subparsers(dest="command", required=True) + + # user add + user_add_parser = user_subparser.add_parser("add") + user_add_parser.add_argument("username") + user_add_parser.add_argument( + "-c", + "--connection", + nargs="+", + default=[], + help="Connections to add, e.g. shadowsocks:ru1 vless:us1", + ) + user_add_parser.set_defaults(handler=handlers.user_add) + + # user show + user_show_parser = user_subparser.add_parser("show") + user_show_parser.add_argument("username", nargs="?") + user_show_parser.add_argument("-a", "--all", action="store_true") + user_show_parser.set_defaults(handler=handlers.user_show) + + # user modify + user_modify_parser = user_subparser.add_parser("modify") + user_modify_parser.add_argument("username") + user_modify_parser.add_argument( + "-a", + "--add", + dest="to_add", + nargs="+", + default=[], + metavar="SPEC", + help="Connections to add", + ) + user_modify_parser.add_argument( + "-d", + "--delete", + dest="to_delete", + nargs="+", + default=[], + metavar="SPEC", + help="Connections to delete", + ) + user_modify_parser.set_defaults(handler=handlers.user_modify) + + # user delete + user_delete_parser = user_subparser.add_parser("delete") + group = user_delete_parser.add_mutually_exclusive_group(required=True) + group.add_argument("username", nargs="?") + group.add_argument("-a", "--all", action="store_true") + user_delete_parser.set_defaults(handler=handlers.user_delete) + + # -------- profile --------- + profile_parser = subparsers.add_parser("profile") + profile_subparser = profile_parser.add_subparsers( + dest="command", required=True + ) + + # profile build + profile_build_parser = profile_subparser.add_parser("build") + group = profile_build_parser.add_mutually_exclusive_group(required=True) + group.add_argument("username", nargs="?") + group.add_argument("-a", "--all", action="store_true") + profile_build_parser.set_defaults(handler=handlers.profile_build) + + # profile show + profile_show_parser = profile_subparser.add_parser("show") + group = profile_show_parser.add_mutually_exclusive_group(required=True) + group.add_argument("username", nargs="?") + group.add_argument("-a", "--all", action="store_true") + profile_show_parser.set_defaults(handler=handlers.profile_show) + + # profile clear + profile_clear_parser = profile_subparser.add_parser("clear") + group = profile_clear_parser.add_mutually_exclusive_group(required=True) + group.add_argument("username", nargs="?") + group.add_argument("-a", "--all", action="store_true") + profile_clear_parser.set_defaults(handler=handlers.profile_clear) + + # profile delete + profile_delete_parser = profile_subparser.add_parser("delete") + group = profile_delete_parser.add_mutually_exclusive_group(required=True) + group.add_argument("username", nargs="?") + group.add_argument("-a", "--all", action="store_true") + profile_delete_parser.set_defaults(handler=handlers.profile_delete) + + # profile qrcode + profile_qrcode_parser = profile_subparser.add_parser("qrcode") + group = profile_qrcode_parser.add_mutually_exclusive_group(required=True) + group.add_argument("username", nargs="?") + group.add_argument("-a", "--all", action="store_true") + profile_qrcode_parser.set_defaults(handler=handlers.profile_qrcode) + + return parser + + +def main(): + parser = build_parser() + + try: + args = parser.parse_args() + func = args.handler + kwargs = vars(args) + kwargs.pop("handler") + func(**kwargs) + except ValueError as e: + print(e, file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/xray_manager/core.py b/src/xray_manager/core.py new file mode 100644 index 0000000..4ee81d6 --- /dev/null +++ b/src/xray_manager/core.py @@ -0,0 +1,1062 @@ +import json +import secrets +import uuid +from abc import ABC, abstractmethod +from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import TypeVar +from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse + +from .utils import private_to_public + +C = TypeVar("C", bound="Connection") +T = TypeVar("T") + + +class ConnectionFactory: + _registry: dict[str, type["Connection"]] = {} + _scheme_registry: dict[str, type["Connection"]] = {} + + @classmethod + def register(cls, conn_cls: type[C]) -> type[C]: + protocol = conn_cls.PROTOCOL + scheme = conn_cls.LINK_SCHEME + + if protocol in cls._registry: + raise RuntimeError(f"Protocol already registered: {protocol}") + + if scheme in cls._scheme_registry: + raise RuntimeError(f"Scheme already registered: {scheme}") + + cls._registry[protocol] = conn_cls + cls._scheme_registry[scheme] = conn_cls + + return conn_cls + + @classmethod + def get_protocols(cls) -> list[str]: + return list(cls._registry) + + @classmethod + def from_link(cls, link: str) -> "Connection": + scheme = link.split("://", 1)[0] + + if scheme not in cls._scheme_registry: + raise ValueError(f"Unsupported link scheme: {scheme}") + + conn_cls = cls._scheme_registry[scheme] + return conn_cls.from_link(link) + + @classmethod + def from_inbound(cls, host: str, client: dict, inbound: dict): + protocol = inbound.get("protocol") + + if protocol not in cls._registry: + raise ValueError(f"Unsupported protocol: {protocol}") + + conn_cls = cls._registry[protocol] + username, connection = conn_cls.from_inbound(host, client, inbound) + return username, connection + + @classmethod + def from_spec( + cls, key: "ConnectionKey", xray_config: "XrayConfig" + ) -> "Connection": + if key.protocol not in cls._registry: + raise ValueError(f"Unsupported protocol: {key.protocol}") + + if key.exit_point not in xray_config.get_exit_points(): + raise ValueError(f"Exit point {key.exit_point} not found") + + inbound = xray_config.find_managed_inbound_by_protocol(key.protocol) + conn_cls = cls._registry[key.protocol] + return conn_cls.from_spec(xray_config.host, inbound, key.exit_point) + + +class Connection(ABC): + PROTOCOL: str + LINK_SCHEME: str + exit_point: str + + @classmethod + @abstractmethod + def from_link(cls, link: str) -> "Connection": ... + + @classmethod + @abstractmethod + def from_inbound( + cls, host: str, client: dict, inbound: dict + ) -> tuple[str, "Connection"]: ... + + @classmethod + @abstractmethod + def from_spec( + cls, host: str, inbound: dict, exit_point: str + ) -> "Connection": ... + + @abstractmethod + def to_link(self) -> str: ... + + @abstractmethod + def matches_inbound(self, inbound: dict) -> bool: ... + + @abstractmethod + def add_to_inbound(self, inbound: dict, username: str) -> None: ... + + @abstractmethod + def delete_from_inbound(self, inbound: dict, username: str) -> None: ... + + @property + @abstractmethod + def protocol(self) -> str: ... + + @staticmethod + def split_client_email(email: str) -> tuple[str, str]: + username, exit_point = email.rsplit("-", 1) + return username, exit_point + + @abstractmethod + def __hash__(self) -> int: ... + + @abstractmethod + def __eq__(self, other: object) -> bool: ... + + +@dataclass(frozen=True) +class ConnectionKey: + protocol: str + exit_point: str + + +@ConnectionFactory.register +@dataclass(eq=False) +class ShadowsocksConnection(Connection): + PROTOCOL = "shadowsocks" + LINK_SCHEME = "ss" + + host: str + port: int + method: str + server_password: str + client_password: str + exit_point: str + + @classmethod + def from_link(cls, link: str) -> "ShadowsocksConnection": + import base64 + + prefix_b64, rest = link[5:].split("@", 1) + host_port, exit_point = rest.split("#", 1) + host, port_str = host_port.split(":") + port = int(port_str) + + prefix_bytes = base64.urlsafe_b64decode(prefix_b64) + prefix_str = prefix_bytes.decode() + method, server_password, client_password = prefix_str.split(":") + + return cls( + host=host, + port=port, + method=method, + server_password=server_password, + client_password=client_password, + exit_point=exit_point, + ) + + @classmethod + def from_inbound( + cls, host: str, client: dict, inbound: dict + ) -> tuple[str, "ShadowsocksConnection"]: + port = inbound["port"] + method = inbound["settings"]["method"] + server_password = inbound["settings"]["password"] + client_password = client["password"] + email = client["email"] + username, exit_point = cls.split_client_email(email) + connection = cls( + host=host, + port=port, + method=method, + server_password=server_password, + client_password=client_password, + exit_point=exit_point, + ) + return username, connection + + @classmethod + def from_spec( + cls, host: str, inbound: dict, exit_point: str + ) -> "ShadowsocksConnection": + port = inbound["port"] + method = inbound["settings"]["method"] + server_password = inbound["settings"]["password"] + client_password = cls.generate_password(method) + + return cls( + host=host, + port=port, + method=method, + server_password=server_password, + client_password=client_password, + exit_point=exit_point, + ) + + def to_link(self) -> str: + import base64 + + prefix = f"{self.method}:{self.server_password}:{self.client_password}" + prefix_b64 = base64.urlsafe_b64encode(prefix.encode()).decode() + tag = self.exit_point + link = f"ss://{prefix_b64}@{self.host}:{self.port}#{tag}" + return link + + @staticmethod + def generate_password(method: str) -> str: + import base64 + import secrets + + key_lengths = { + "2022-blake3-aes-128-gcm": 16, + "2022-blake3-aes-256-gcm": 32, + "2022-blake3-chacha20-poly1305": 32, + } + + try: + length = key_lengths[method] + except KeyError: + raise ValueError(f"Unsupported shadowsocks method: {method}") + + key = secrets.token_bytes(length) + + return base64.b64encode(key).decode() + + def matches_inbound(self, inbound: dict): + if inbound.get("protocol") != self.PROTOCOL: + return False + + if inbound.get("port") != self.port: + return False + + settings = inbound.get("settings", {}) + + if settings.get("method") != self.method: + return False + + if settings.get("password") != self.server_password: + return False + + return True + + def add_to_inbound(self, inbound: dict, username: str) -> None: + client = { + "email": f"{username}-{self.exit_point}", + "password": self.client_password, + } + + clients = inbound["settings"].setdefault("clients", []) + clients.append(client) + + clients_by_user = defaultdict(list) + for client in clients: + username, _ = self.split_client_email(client["email"]) + clients_by_user[username].append(client) + + priority = {"default": 0, "managed": 1, "relay": 2} + + def sort_key(c): + _, exit_point = self.split_client_email(c["email"]) + return (priority.get(exit_point, 100), c["email"]) + + for user_clients in clients_by_user.values(): + user_clients.sort(key=sort_key) + + sorted_clients = [] + for username in sorted(clients_by_user): + sorted_clients.extend(clients_by_user[username]) + + inbound["settings"]["clients"] = sorted_clients + + def delete_from_inbound(self, inbound: dict, username: str) -> None: + target_email = f"{username}-{self.exit_point}" + settings = inbound["settings"] + clients = settings["clients"] + + filtered_clients = [] + for client in clients: + if client["email"] != target_email: + filtered_clients.append(client) + + inbound["settings"]["clients"] = filtered_clients + + @property + def protocol(self) -> str: + return self.PROTOCOL + + def __hash__(self) -> int: + return hash( + ( + self.host, + self.port, + self.method, + self.server_password, + self.client_password, + self.exit_point, + ) + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ShadowsocksConnection): + return NotImplemented + + return ( + self.host == other.host + and self.port == other.port + and self.method == other.method + and self.server_password == other.server_password + and self.client_password == other.client_password + and self.exit_point == other.exit_point + ) + + +@ConnectionFactory.register +@dataclass(eq=False) +class VlessConnection(Connection): + PROTOCOL = "vless" + LINK_SCHEME = "vless" + + host: str + port: int + id: str + security: str + encryption: str + public_key: str + network_type: str + sni: str + short_id: str + exit_point: str + + flow: str = "xtls-rprx-vision" + header_type: str = "none" + finger_print: str = "chrome" + + @classmethod + def from_link(cls, link: str) -> "VlessConnection": + parsed = urlparse(link) + query = parse_qs(parsed.query) + + def require(value: T | None, name: str) -> T: + if value is None: + raise ValueError(f"Missing required field: {name}") + return value + + def require_str(value: str | None, name: str) -> str: + value = require(value, name) + if value == "": + raise ValueError(f"Empty value for required field: {name}") + return unquote(value) + + def get_required(key: str) -> str: + values = query.get(key) + if not values: + raise ValueError(f"Missing required query param: {key}") + raw = values[0] + if raw is None or raw == "": + raise ValueError(f"Empty value for required query param: {key}") + return unquote(raw) + + return cls( + host=require_str(parsed.hostname, "host"), + port=require(parsed.port, "port"), + id=require_str(parsed.username, "id"), + security=get_required("security"), + encryption=get_required("encryption"), + public_key=get_required("pbk"), + header_type=get_required("headerType"), + finger_print=get_required("fp"), + network_type=get_required("type"), + flow=get_required("flow"), + sni=get_required("sni"), + short_id=get_required("sid"), + exit_point=require_str(parsed.fragment, "exit_point"), + ) + + @classmethod + def from_inbound( + cls, host: str, client: dict, inbound: dict + ) -> tuple[str, "VlessConnection"]: + settings = inbound["settings"] + stream = inbound["streamSettings"] + reality = stream["realitySettings"] + + port = inbound["port"] + id = client["id"] + flow = client["flow"] + encryption = settings["decryption"] + network_type = stream["network"] + security = stream["security"] + sni = reality["serverNames"][0] + public_key = private_to_public(reality["privateKey"]) + + clients = settings["clients"] + short_ids = reality["shortIds"] + + try: + index = clients.index(client) + except ValueError: + raise RuntimeError(f"Client not found in inbound: {client}") + + try: + short_id = short_ids[index] + except IndexError: + raise RuntimeError(f"No shortId for client at index {index}") + + email = client["email"] + username, exit_point = cls.split_client_email(email) + connection = cls( + host=host, + port=port, + id=id, + security=security, + encryption=encryption, + public_key=public_key, + network_type=network_type, + flow=flow, + sni=sni, + short_id=short_id, + exit_point=exit_point, + ) + return username, connection + + @classmethod + def from_spec( + cls, host: str, inbound: dict, exit_point: str + ) -> "VlessConnection": + settings = inbound["settings"] + stream = inbound["streamSettings"] + reality = stream["realitySettings"] + + port = inbound["port"] + encryption = settings["decryption"] + network_type = stream["network"] + security = stream["security"] + sni = reality["serverNames"][0] + public_key = private_to_public(reality["privateKey"]) + + id = cls.generate_id() + short_id = cls.generate_short_id() + + return cls( + host=host, + port=port, + id=id, + security=security, + encryption=encryption, + public_key=public_key, + network_type=network_type, + sni=sni, + short_id=short_id, + exit_point=exit_point, + ) + + def to_link(self) -> str: + port = 443 + netloc = f"{self.id}@{self.host}:{port}" + params = { + "security": self.security, + "encryption": self.encryption, + "pbk": self.public_key, + "headerType": self.header_type, + "fp": self.finger_print, + "type": self.network_type, + "flow": self.flow, + "sni": self.sni, + "sid": self.short_id, + } + query = urlencode(params, quote_via=quote) + fragment = quote(self.exit_point) + return f"vless://{netloc}?{query}#{fragment}" + + @staticmethod + def generate_id() -> str: + return str(uuid.uuid4()) + + @staticmethod + def generate_short_id() -> str: + return secrets.token_hex(8) + + def matches_inbound(self, inbound: dict) -> bool: + if inbound.get("protocol") != self.PROTOCOL: + return False + + if inbound.get("port") != self.port: + return False + + settings = inbound.get("settings", {}) + if settings.get("decryption") != self.encryption: + return False + + stream_settings = inbound.get("streamSettings", {}) + if stream_settings.get("network") != self.network_type: + return False + if stream_settings.get("security") != self.security: + return False + + reality_settings = stream_settings.get("realitySettings", {}) + if self.sni not in reality_settings.get("serverNames", []): + return False + + inbound_private_key = reality_settings.get("privateKey") + if not inbound_private_key: + raise ValueError("Reality Settings do not have private key") + inbound_public_key = private_to_public(inbound_private_key) + if inbound_public_key != self.public_key: + return False + + return True + + def add_to_inbound(self, inbound: dict, username: str) -> None: + client = { + "email": f"{username}-{self.exit_point}", + "id": self.id, + "flow": self.flow, + } + + clients = inbound["settings"].setdefault("clients", []) + reality = inbound["streamSettings"].setdefault("realitySettings", {}) + short_ids = reality.setdefault("shortIds", []) + + clients.append(client) + short_ids.append(self.short_id) + + clients_and_sids_by_user = defaultdict(list) + for client, sid in zip(clients, short_ids): + username, _ = self.split_client_email(client["email"]) + clients_and_sids_by_user[username].append((client, sid)) + + priority = {"default": 0, "managed": 1, "relay": 2} + + def sort_key(pair): + client, _ = pair + _, exit_point = self.split_client_email(client["email"]) + return (priority.get(exit_point, 100), client["email"]) + + for pair in clients_and_sids_by_user.values(): + pair.sort(key=sort_key) + + sorted_clients = [] + sorted_short_ids = [] + for username in sorted(clients_and_sids_by_user): + for client, sid in clients_and_sids_by_user[username]: + sorted_clients.append(client) + sorted_short_ids.append(sid) + + inbound["settings"]["clients"] = sorted_clients + reality["shortIds"] = sorted_short_ids + + def delete_from_inbound(self, inbound: dict, username: str) -> None: + target_email = f"{username}-{self.exit_point}" + + clients = inbound["settings"].get("clients", []) + reality = inbound["streamSettings"].get("realitySettings", {}) + short_ids = reality.get("shortIds", []) + + if not clients or not short_ids: + return + + filtered_pairs = [ + (client, sid) + for client, sid in zip(clients, short_ids) + if client["email"] != target_email + ] + + inbound["settings"]["clients"] = [ + client for client, _ in filtered_pairs + ] + + inbound["streamSettings"]["realitySettings"]["shortIds"] = [ + sid for _, sid in filtered_pairs + ] + + @property + def protocol(self) -> str: + return self.PROTOCOL + + def __hash__(self) -> int: + return hash( + ( + self.host, + self.port, + self.id, + self.security, + self.encryption, + self.public_key, + self.header_type, + self.finger_print, + self.network_type, + self.flow, + self.sni, + self.short_id, + self.exit_point, + ) + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, VlessConnection): + return NotImplemented + + return ( + self.host == other.host + and self.port == other.port + and self.id == other.id + and self.security == other.security + and self.encryption == other.encryption + and self.public_key == other.public_key + and self.header_type == other.header_type + and self.finger_print == other.finger_print + and self.network_type == other.network_type + and self.flow == other.flow + and self.sni == other.sni + and self.short_id == other.short_id + and self.exit_point == other.exit_point + ) + + +@dataclass +class User: + username: str + connections: list[Connection] = field(default_factory=list) + + def modify_connection_by_key( + self, + xray_config: "XrayConfig", + add: list[ConnectionKey] = [], + delete: list[ConnectionKey] = [], + ): + key_map = { + ConnectionKey(c.protocol, c.exit_point): c for c in self.connections + } + + for key in delete: + key_map.pop(key, None) + + for key in add: + if key not in key_map: + connection = ConnectionFactory.from_spec(key, xray_config) + key_map[key] = connection + + self.connections = list(key_map.values()) + + +class UserFactory: + @staticmethod + def from_spec( + username: str, keys: list[ConnectionKey], xray_config: "XrayConfig" + ) -> User: + connections = [] + + for key in keys: + conn = ConnectionFactory.from_spec(key, xray_config) + connections.append(conn) + + user = User(username=username, connections=connections) + return user + + @staticmethod + def empty(username: str): + return User(username, []) + + +class XrayManagerConfig: + def __init__(self, config_path: str): + self.path = Path(config_path) + self.config = json.loads(self.path.read_text(encoding="utf-8")) + + def _save_json(self): + self.path.write_text( + json.dumps(self.config, indent=4, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + def _update(self, path: list[str], value): + ref = self.config + for key in path[:-1]: + ref = ref[key] + + ref[path[-1]] = value + + self._save_json() + + @property + def host(self) -> str: + return self.config["host"] + + @host.setter + def host(self, value: str): + self._update(["host"], value) + + @property + def xray_config_folder(self) -> Path: + return Path(self.config["xray_config_folder"]) + + @xray_config_folder.setter + def xray_config_folder(self, value: str): + self._update(["xray_config_folder"], value) + + @property + def profile_host(self) -> str: + return self.config["profiles"]["host"] + + @profile_host.setter + def profile_host(self, value: str): + self._update(["profiles", "host"], value) + + @property + def profile_base_path(self) -> Path: + return Path(self.config["profiles"]["base_path"]) + + @profile_base_path.setter + def profile_base_path(self, value: str): + self._update(["profiles", "base_path"], value) + + @property + def profile_file_name(self) -> str: + return self.config["profiles"]["file_name"] + + @profile_file_name.setter + def profile_file_name(self, value: str): + self._update(["profiles", "file_name"], value) + + @property + def profile_folder_mapping(self) -> dict[str, str]: + return self.config["profiles"]["folder_mapping"] + + @property + def folder_profile_mapping(self) -> dict[str, str]: + return { + folder_name: username + for username, folder_name in self.profile_folder_mapping.items() + } + + def set_profile_folder(self, username: str, folder_name: str): + self.config["profiles"]["folder_mapping"][username] = folder_name + self._save_json() + + def delete_profile_folder(self, username: str): + self.config["profiles"]["folder_mapping"].pop(username, None) + self._save_json() + + +class XrayConfig: + def __init__(self, config_folder: Path, host: str): + self.path = config_folder + self.host = host + + self.inbounds_files = list(self.path.glob("*-in-*.json")) + self.outbounds_files = list(self.path.glob("*-out-*.json")) + + self.inbounds_data = { + f.name: self._load_json(f) for f in self.inbounds_files + } + + self.outbounds_data = { + f.name: self._load_json(f) for f in self.outbounds_files + } + + def _load_json(self, path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + def _save_json(self, data: dict, path: Path): + path.write_text( + json.dumps(data, indent=4, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + def _find_inbound(self, connection: Connection) -> tuple[str, dict]: + for filename, data in self.inbounds_data.items(): + inbounds = data.get("inbounds", []) + for inbound in inbounds: + if connection.matches_inbound(inbound): + return filename, inbound + + raise RuntimeError(f"Inbound not found for {connection}") + + def find_managed_inbound_by_protocol(self, protocol: str) -> dict: + for data in self.inbounds_data.values(): + inbounds = data.get("inbounds", []) + for inbound in inbounds: + if not inbound.get("xrm"): + continue + + if inbound.get("protocol") != protocol: + continue + + return inbound + + raise RuntimeError( + f"Managed inbound not found for protocol: {protocol}" + ) + + def get_exit_points(self) -> set[str]: + exit_points = set() + for data in self.outbounds_data.values(): + outbounds = data.get("outbounds", []) + for outbound in outbounds: + tag: str = outbound.get("tag") + if tag: + exit_point = tag.rsplit("-", 1)[1] + exit_points.add(exit_point) + exit_points.add("default") + return exit_points + + def get_users(self) -> list[User]: + connection_map: defaultdict[str, list[Connection]] = defaultdict(list) + + for data in self.inbounds_data.values(): + inbounds = data.get("inbounds", []) + for inbound in inbounds: + protocol = inbound.get("protocol") + + if protocol in ConnectionFactory._registry: + settings = inbound.get("settings", {}) + clients = settings.get("clients", []) + for client in clients: + username, connection = ConnectionFactory.from_inbound( + self.host, client, inbound + ) + connection_map[username].append(connection) + + users = [] + + for username, connections in connection_map.items(): + users.append(User(username, connections)) + + return users + + def get_user(self, username: str) -> User | None: + users = self.get_users() + for user in users: + if user.username == username: + return user + + def add_user(self, user: User) -> None: + modified_files = set() + + for connection in user.connections: + filename, inbound = self._find_inbound(connection) + connection.add_to_inbound(inbound, user.username) + modified_files.add(filename) + + for filename in modified_files: + path = self.path / filename + self._save_json(self.inbounds_data[filename], path) + + def delete_user(self, username: str) -> bool: + user = self.get_user(username) + if not user: + return False + + deleted = False + modified_files = set() + + for connection in user.connections: + try: + filename, inbound = self._find_inbound(connection) + except RuntimeError: + continue + + connection.delete_from_inbound(inbound, username) + modified_files.add(filename) + deleted = True + + for filename in modified_files: + path = self.path / filename + self._save_json(self.inbounds_data[filename], path) + + return deleted + + def modify_user(self, user: User) -> bool: + current_user = self.get_user(user.username) + + if current_user is None: + return False + + old_connections = set(current_user.connections) + new_connections = set(user.connections) + + to_add = new_connections - old_connections + to_remove = old_connections - new_connections + + modified_files = set() + + for connection in to_remove: + filename, inbound = self._find_inbound(connection) + connection.delete_from_inbound(inbound, user.username) + modified_files.add(filename) + + for connection in to_add: + filename, inbound = self._find_inbound(connection) + connection.add_to_inbound(inbound, user.username) + modified_files.add(filename) + + for filename in modified_files: + path = self.path / filename + self._save_json(self.inbounds_data[filename], path) + + return True + + +@dataclass +class Profile: + folder_name: str + user: User + + def to_text(self): + links = [connection.to_link() for connection in self.user.connections] + text = "\n".join(links) + return text + + +class ProfileFactory: + @classmethod + def from_user(cls, user: User, folder_name: str | None = None): + if not folder_name: + folder_name = cls.generate_folder_name() + + return Profile(folder_name=folder_name, user=user) + + @staticmethod + def generate_folder_name() -> str: + import secrets + import string + + folder_name = "".join( + secrets.choice(string.ascii_letters + string.digits) + for _ in range(12) + ) + + return folder_name + + +class ProfileStorage: + def __init__(self, xray_manager_config: XrayManagerConfig): + self.xrmc = xray_manager_config + + def _get_profile_path(self, username: str) -> Path: + folder_name = self.xrmc.profile_folder_mapping.get(username) + + if not folder_name: + raise ValueError(f"No folder found for username: {username}") + + path = ( + self.xrmc.profile_base_path + / folder_name + / self.xrmc.profile_file_name + ) + + if not path.exists(): + raise FileNotFoundError(f"Profile path does not exist: {path}") + + print(f"[DEBUG] Found profile path for user '{username}': {path}") + return path + + def _create_profile_path(self, username: str, folder_name: str) -> Path: + path = ( + self.xrmc.profile_base_path + / folder_name + / self.xrmc.profile_file_name + ) + path.parent.mkdir(parents=True, exist_ok=False) + path.touch(exist_ok=False) + + self.xrmc.set_profile_folder(username, folder_name) + + print(f"[DEBUG] Created profile path for user '{username}': {path}") + return path + + def _load_profile(self, path: Path, username: str) -> Profile: + print(f"[DEBUG] Loading profile for user '{username}' from {path}") + + links = [ + line + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + connections = [ConnectionFactory.from_link(link) for link in links] + user = User(username, connections) + + profile = Profile(folder_name=path.parent.name, user=user) + print( + f"[DEBUG] Loaded profile: folder='{profile.folder_name}', connections={len(connections)}" + ) + return profile + + def load_profile(self, username: str) -> Profile: + path = self._get_profile_path(username) + profile = self._load_profile(path, username) + return profile + + def load_profiles(self) -> list[Profile]: + storage_path = self.xrmc.profile_base_path + + profiles = [] + for profile_path in storage_path.iterdir(): + if not profile_path.is_dir(): + continue + + folder_name = profile_path.name + + username = self.xrmc.folder_profile_mapping.get(folder_name) + if not username: + print( + f"[WARN] Unknown profile folder: {profile_path.name}, skipping" + ) + continue + + profile_file = profile_path / self.xrmc.profile_file_name + if not profile_file.exists(): + print( + f"[WARN] Unknown profile folder: {profile_path.name}, skipping" + ) + continue + + profile = self._load_profile(profile_file, username) + profiles.append(profile) + + print(f"[DEBUG] Loaded total {len(profiles)} profiles") + return profiles + + def save_profile(self, profile: Profile) -> Path: + try: + path = self._create_profile_path( + profile.user.username, profile.folder_name + ) + except FileExistsError: + print( + f"[INFO] Profile already exists for user '{profile.user.username}', overwriting" + ) + path = self._get_profile_path(profile.user.username) + + path.write_text(profile.to_text(), encoding="utf-8") + print( + f"[DEBUG] Saved profile for user '{profile.user.username}' at '{path}'" + ) + return path + + def clear_profile(self, username: str) -> None: + path = self._get_profile_path(username) + path.write_text("", encoding="utf-8") + print(f"[INFO] Cleared profile for user '{username}'") + + def delete_profile(self, username: str) -> None: + path = self._get_profile_path(username) + + path.unlink() + print(f"[INFO] Deleted profile file for user '{username}'") + + path.parent.rmdir() + print(f"[INFO] Deleted profile folder for user '{username}'") + + self.xrmc.delete_profile_folder(username) + print(f"[INFO] Removed folder mapping for user '{username}'") diff --git a/src/xray_manager/handlers.py b/src/xray_manager/handlers.py new file mode 100644 index 0000000..f5f97b1 --- /dev/null +++ b/src/xray_manager/handlers.py @@ -0,0 +1,202 @@ +import os +from pathlib import Path + +from .core import ( + ConnectionFactory, + ConnectionKey, + Profile, + ProfileFactory, + ProfileStorage, + User, + UserFactory, + XrayConfig, + XrayManagerConfig, +) +from .utils import print_qrcode, print_table + +DEFAULT_CONFIG_PATH = "/etc/xray-manager/config.json" +DEV_CONFIG_PATH = "tests/mock_data/etc/xray-manager/config.json" + + +def _resolve_config_path() -> str: + env_path = os.getenv("XRAY_MANAGER_CONFIG") + if env_path: + return env_path + + if os.getenv("XRAY_ENV") == "development": + return DEV_CONFIG_PATH + + if os.path.exists(DEFAULT_CONFIG_PATH): + return DEFAULT_CONFIG_PATH + + return DEV_CONFIG_PATH + + +xray_manager_config = XrayManagerConfig(_resolve_config_path()) + +xray_config = XrayConfig( + xray_manager_config.xray_config_folder, xray_manager_config.host +) + +storage = ProfileStorage(xray_manager_config) + + +def _resolve_users(username: str | None, all: bool) -> list[User]: + if all: + return xray_config.get_users() + if username: + user = xray_config.get_user(username) + if not isinstance(user, User): + raise ValueError(f"User not found: {username}") + return [user] + raise ValueError("Either username or --all must be specified") + + +def _resolve_profiles(username: str | None, all: bool) -> list[Profile]: + if all: + return storage.load_profiles() + if username: + try: + profile = storage.load_profile(username) + except (ValueError, FileNotFoundError) as e: + raise ValueError(f"Cannot load profile for {username}: {e}") + return [profile] + raise ValueError("Either username or --all must be specified") + + +def _parse_connection_spec(spec: str) -> ConnectionKey: + protocol, exit_point = spec.split(":", 1) + if protocol not in ConnectionFactory.get_protocols(): + raise ValueError(f"Unsupported protocol: {protocol}") + if exit_point not in xray_config.get_exit_points(): + raise ValueError(f"Unsupported exit point: {exit_point}") + return ConnectionKey(protocol, exit_point) + + +def _build_user_rows(users: list[User]) -> list[list[str]]: + rows = [] + for index, user in enumerate(users, start=1): + first = True + for conn in user.connections: + if first: + row = [index, user.username, conn.exit_point, conn.protocol] + first = False + else: + row = ["", "", conn.exit_point, conn.protocol] + rows.append(row) + return rows + + +def _build_profile_rows(profiles: list[Profile]) -> list[list[str]]: + rows = [] + for index, profile in enumerate(profiles, start=1): + first = True + for conn in profile.user.connections: + if first: + row = [ + index, + profile.user.username, + profile.folder_name, + conn.exit_point, + conn.protocol, + ] + first = False + else: + row = ["", "", "", conn.exit_point, conn.protocol] + rows.append(row) + return rows + + +def user_add(username: str, connection: list[str], **_): + user = xray_config.get_user(username) + + if isinstance(user, User): + raise ValueError(f"User already exists: {username}") + + keys: list[ConnectionKey] = [ + _parse_connection_spec(spec) for spec in connection + ] + + user = UserFactory.from_spec( + username=username, keys=keys, xray_config=xray_config + ) + + xray_config.add_user(user) + + +def user_show(username: str | None, all: bool, **_): + users = _resolve_users(username, all) + + headers = ["#", "Username", "Exit Point", "Protocol"] + rows = _build_user_rows(users) + + print_table(headers, rows) + + +def user_modify(username: str, to_add: list[str], to_delete: list[str], **_): + user = xray_config.get_user(username) + + if not isinstance(user, User): + raise ValueError(f"User not found: {username}") + + add_key = [_parse_connection_spec(spec) for spec in to_add] + del_key = [_parse_connection_spec(spec) for spec in to_delete] + + user.modify_connection_by_key(xray_config, add_key, del_key) + xray_config.modify_user(user) + + +def user_delete(username: str | None, all: bool, **_): + users = _resolve_users(username, all) + + for user in users: + xray_config.delete_user(user.username) + + +def profile_build(username: str | None, all: bool, **_): + users = _resolve_users(username, all) + + for user in users: + try: + profile = storage.load_profile(user.username) + profile = ProfileFactory.from_user(user, profile.folder_name) + except (ValueError, FileNotFoundError) as e: + print(f"Cannot load profile for {user.username}: {e}") + profile = ProfileFactory.from_user(user) + + storage.save_profile(profile) + + +def profile_show(username: str | None, all: bool, **_): + profiles = _resolve_profiles(username, all) + + headers = ["#", "Username", "Profile", "Exit Point", "Protocol"] + rows = _build_profile_rows(profiles) + + print_table(headers, rows) + + +def profile_clear(username: str | None, all: bool, **_): + profiles = _resolve_profiles(username, all) + + for profile in profiles: + storage.clear_profile(profile.user.username) + + +def profile_delete(username: str | None, all: bool, **_): + profiles = _resolve_profiles(username, all) + + for profile in profiles: + storage.delete_profile(profile.user.username) + + +def profile_qrcode(username: str | None, all: bool, **_): + profiles = _resolve_profiles(username, all) + + for profile in profiles: + host = xray_manager_config.profile_host + folder_name = profile.folder_name + filename = xray_manager_config.profile_file_name + + url = f"https://{host}/{folder_name}/{filename}" + print_qrcode(url) diff --git a/src/xray_manager/utils.py b/src/xray_manager/utils.py new file mode 100644 index 0000000..1235858 --- /dev/null +++ b/src/xray_manager/utils.py @@ -0,0 +1,44 @@ +import base64 + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import x25519 +from qrcode import ERROR_CORRECT_L, QRCode +from tabulate import tabulate + + +def print_table(headers: list[str], rows: list[list[str]]) -> None: + table = tabulate(rows, headers=headers, tablefmt="rounded_grid") + print(table) + + +def print_qrcode(url: str) -> None: + qr = QRCode(version=1, error_correction=ERROR_CORRECT_L) + qr.add_data(url) + qr.make() + + print(f"{url}\n") + qr.print_ascii() + + +def private_to_public(private_key_b64: str) -> str: + def b64decode(s: str) -> bytes: + padding = "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s + padding) + + def b64encode(b: bytes) -> str: + return base64.urlsafe_b64encode(b).decode().rstrip("=") + + private_bytes = b64decode(private_key_b64) + + if len(private_bytes) != 32: + raise ValueError("Invalid private key length (expected 32 bytes)") + + private_key = x25519.X25519PrivateKey.from_private_bytes(private_bytes) + public_key = private_key.public_key() + + public_bytes = public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + + return b64encode(public_bytes) diff --git a/tests/mock_data/etc/xray-manager/config.json b/tests/mock_data/etc/xray-manager/config.json new file mode 100644 index 0000000..43afbe5 --- /dev/null +++ b/tests/mock_data/etc/xray-manager/config.json @@ -0,0 +1,10 @@ +{ + "host": "127.0.0.1", + "xray_config_folder": "tests/mock_data/usr/local/etc/xray/default", + "profiles": { + "host": "localhost.internal", + "base_path": "tests/mock_data/var/lib/xray-manager/profiles", + "file_name": "list.txt", + "folder_mapping": {} + } +} diff --git a/tests/mock_data/usr/local/etc/xray/default/000-log.json b/tests/mock_data/usr/local/etc/xray/default/000-log.json new file mode 100644 index 0000000..5d7a27c --- /dev/null +++ b/tests/mock_data/usr/local/etc/xray/default/000-log.json @@ -0,0 +1,5 @@ +{ + "log": { + "loglevel": "info" + } +} diff --git a/tests/mock_data/usr/local/etc/xray/default/203-in-shadowsocks.json b/tests/mock_data/usr/local/etc/xray/default/203-in-shadowsocks.json new file mode 100644 index 0000000..f66b1ee --- /dev/null +++ b/tests/mock_data/usr/local/etc/xray/default/203-in-shadowsocks.json @@ -0,0 +1,17 @@ +{ + "inbounds": [ + { + "tag": "shadowsocks", + "xrm": true, + "listen": "0.0.0.0", + "port": 8443, + "protocol": "shadowsocks", + "settings": { + "method": "2022-blake3-aes-256-gcm", + "password": "Z8WtMeWzZVh1F6/5URGrs8vWdB3CLN5y7A9D1U0Q65E=", + "clients": [], + "network": "tcp,udp" + } + } + ] +} diff --git a/tests/mock_data/usr/local/etc/xray/default/204-in-vless.json b/tests/mock_data/usr/local/etc/xray/default/204-in-vless.json new file mode 100644 index 0000000..705ecb8 --- /dev/null +++ b/tests/mock_data/usr/local/etc/xray/default/204-in-vless.json @@ -0,0 +1,32 @@ +{ + "inbounds": [ + { + "tag": "in-vless", + "xrm": true, + "listen": "127.0.0.1", + "port": 443, + "protocol": "vless", + "settings": { + "clients": [], + "decryption": "none" + }, + "streamSettings": { + "network": "tcp", + "security": "reality", + "realitySettings": { + "show": false, + "dest": "google.com:443", + "xver": 0, + "serverNames": [ + "google.com" + ], + "privateKey": "yHJQq57MNRi-0UQm7ymiy2DI17iO_Ovu2HF5EV1ViGg", + "minClientVer": "", + "maxClientVer": "", + "maxTimeDiff": 0, + "shortIds": [] + } + } + } + ] +} diff --git a/tests/mock_data/usr/local/etc/xray/default/301-out-relay.json b/tests/mock_data/usr/local/etc/xray/default/301-out-relay.json new file mode 100644 index 0000000..c78ef6a --- /dev/null +++ b/tests/mock_data/usr/local/etc/xray/default/301-out-relay.json @@ -0,0 +1,8 @@ +{ + "outbounds": [ + { + "tag": "out-relay", + "protocol": "freedom" + } + ] +} diff --git a/tests/mock_data/usr/local/etc/xray/default/302-out-us1.json b/tests/mock_data/usr/local/etc/xray/default/302-out-us1.json new file mode 100644 index 0000000..54c1918 --- /dev/null +++ b/tests/mock_data/usr/local/etc/xray/default/302-out-us1.json @@ -0,0 +1,34 @@ +{ + "outbounds": [ + { + "tag": "out-us1", + "protocol": "vless", + "settings": { + "vnext": [ + { + "address": "127.0.0.1", + "port": 443, + "users": [ + { + "id": "-", + "encryption": "none", + "flow": "xtls-rprx-vision" + } + ] + } + ] + }, + "streamSettings": { + "network": "tcp", + "security": "reality", + "realitySettings": { + "fingerprint": "chrome", + "serverName": "google.com", + "publicKey": "-", + "spiderX": "", + "shortId": "-" + } + } + } + ] +} diff --git a/tests/mock_data/usr/local/etc/xray/default/303-out-us2.json b/tests/mock_data/usr/local/etc/xray/default/303-out-us2.json new file mode 100644 index 0000000..a70e3f5 --- /dev/null +++ b/tests/mock_data/usr/local/etc/xray/default/303-out-us2.json @@ -0,0 +1,34 @@ +{ + "outbounds": [ + { + "tag": "out-us2", + "protocol": "vless", + "settings": { + "vnext": [ + { + "address": "127.0.0.1", + "port": 443, + "users": [ + { + "id": "-", + "encryption": "none", + "flow": "xtls-rprx-vision" + } + ] + } + ] + }, + "streamSettings": { + "network": "tcp", + "security": "reality", + "realitySettings": { + "fingerprint": "chrome", + "serverName": "google.com", + "publicKey": "-", + "spiderX": "", + "shortId": "-" + } + } + } + ] +} diff --git a/tests/mock_data/usr/local/etc/xray/default/304-out-fr1.json b/tests/mock_data/usr/local/etc/xray/default/304-out-fr1.json new file mode 100644 index 0000000..80a7e62 --- /dev/null +++ b/tests/mock_data/usr/local/etc/xray/default/304-out-fr1.json @@ -0,0 +1,12 @@ +{ + "outbounds": [ + { + "tag": "out-fr1", + "protocol": "socks", + "settings": { + "address": "127.0.0.1", + "port": 1080 + } + } + ] +} diff --git a/tests/mock_data/usr/local/etc/xray/default/400-routing.json b/tests/mock_data/usr/local/etc/xray/default/400-routing.json new file mode 100644 index 0000000..0ff6026 --- /dev/null +++ b/tests/mock_data/usr/local/etc/xray/default/400-routing.json @@ -0,0 +1,31 @@ +{ + "routing": { + "domainStrategy": "AsIs", + "rules": [ + { + "type": "field", + "user": [ + ], + "outboundTag": "out-fr1" + }, + { + "type": "field", + "user": [ + ], + "outboundTag": "out-us1" + }, + { + "type": "field", + "user": [ + ], + "outboundTag": "out-us2" + }, + { + "type": "field", + "user": [ + ], + "outboundTag": "out-relay" + } + ] + } +}