Initial commit: xray-manager v0.2 with Debian 13 support

This commit is contained in:
2026-06-28 22:19:53 +03:00
commit 884a55de14
23 changed files with 1890 additions and 0 deletions
+221
View File
@@ -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
View File
+10
View File
@@ -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": {}
}
}
+5
View File
@@ -0,0 +1,5 @@
python3-xray-manager (0.2-1) stable; urgency=medium
* Initial release.
-- Vladimir Khvan <thisisnotablownfuse@gmail.com> Thu, 28 Jun 2026 20:00:00 +0300
+16
View File
@@ -0,0 +1,16 @@
Source: python3-xray-manager
Section: python
Priority: optional
Maintainer: vkh <vkh@casket.ddns.net>
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
+1
View File
@@ -0,0 +1 @@
data/config.json etc/xray-manager/
Vendored Executable
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/make -f
%:
dh $@ --with python3 --buildsystem=pybuild
+16
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
cryptography==43.0.0
qrcode==8.2
tabulate==0.9.0
View File
+123
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+202
View File
@@ -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)
+44
View File
@@ -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)
@@ -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": {}
}
}
@@ -0,0 +1,5 @@
{
"log": {
"loglevel": "info"
}
}
@@ -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"
}
}
]
}
@@ -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": []
}
}
}
]
}
@@ -0,0 +1,8 @@
{
"outbounds": [
{
"tag": "out-relay",
"protocol": "freedom"
}
]
}
@@ -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": "-"
}
}
}
]
}
@@ -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": "-"
}
}
}
]
}
@@ -0,0 +1,12 @@
{
"outbounds": [
{
"tag": "out-fr1",
"protocol": "socks",
"settings": {
"address": "127.0.0.1",
"port": 1080
}
}
]
}
@@ -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"
}
]
}
}