23 Commits
Author SHA1 Message Date
vladimir ae73c0bce5 refactor: перенос логики работы с конфигурациями Xray из класса XrayConfig в классы Outbound и OutboundFactory 2026-07-06 02:59:49 +03:00
vladimir 3f2ccc6f04 refactor: перенос логики работы с конфигурациями Xray из класса XrayConfig в классы User и UserFactory 2026-07-06 02:58:08 +03:00
vladimir 40875b1ff1 refactor: новый класс XrayStorage без методов требующих классов User и Outbound 2026-07-06 02:56:33 +03:00
vladimir 3c420018ad feat: переход с использования connection.py на outbound.py 2026-07-04 18:04:32 +03:00
vladimir 7e35cbabd1 refactor: удаление неиспользуемых импортов 2026-07-04 18:03:37 +03:00
vladimir ecb669a5a1 feat: удаление connection.py из-за перехода на outbound.py 2026-07-04 18:03:02 +03:00
vladimir 904f9cd973 chore: создание каталога sandbox для хранения проверочных скриптов 2026-07-04 05:13:24 +03:00
vladimir 7ac981e055 test: добавление файла для проверки работоспособности VlessOutbound 2026-07-04 05:11:56 +03:00
vladimir d8c33a7e69 feat: добавление импорта из файлов outbound.py и target.py 2026-07-04 05:11:05 +03:00
vladimir cf00ddee07 feat: завершение работы над классом VlessOutbound 2026-07-04 05:10:11 +03:00
vladimir 8d6b23e5ac feat: добавление port override для vless 2026-07-04 04:28:59 +03:00
vladimir cce8431c40 fix: исправление опечатки 2026-07-04 04:06:11 +03:00
vladimir 44ede92073 feat: добавление примера секции "override" для переопределения параметров соединений пользователя 2026-07-04 04:05:13 +03:00
vladimir a344b1802b docs: изменение описания группы команд user outbound 2026-07-04 03:58:26 +03:00
vladimir 1fe07b0dd1 refactor: удаление методов targets, get_targets_list 2026-07-04 03:26:25 +03:00
vladimir 99e95d0624 refactor: удаление метода find_target_by_pretty_name из класса Outbound 2026-07-04 03:24:32 +03:00
vladimir d2e4537d15 test: небольшое изменение кода для проверки работоспособности Outbound с использованием Target 2026-07-04 03:21:12 +03:00
vladimir c637f944f9 refactor: изменение классов OutboundSpec, OutboundFactory и Outbound для использования класса Target 2026-07-04 03:19:37 +03:00
vladimir d0c3d5d50e refactor: замена полей target: str и target_pretty_name: str на target: Target 2026-07-04 03:17:14 +03:00
vladimir 669737e918 style: black formatter 2026-07-04 02:44:21 +03:00
vladimir cb7f25918b test: добавление файлы для проверки работоспособности TargetStorage 2026-07-03 18:07:16 +03:00
vladimir 3b2ae419b6 feat: добавление методов для TargetStorage 2026-07-03 18:06:26 +03:00
vladimir a3b3fdf33b feat: добавление классов для работы с target 2026-07-03 18:04:30 +03:00
15 changed files with 735 additions and 823 deletions
+3 -4
View File
@@ -7,10 +7,9 @@ user show (<username> | -a)
user modify <username> [-a <protocol>:<target> [<protocol>:<target> ...]] [-d <protocol>:<target> [<protocol>:<target> ...]] user modify <username> [-a <protocol>:<target> [<protocol>:<target> ...]] [-d <protocol>:<target> [<protocol>:<target> ...]]
user delete (<username> | -a) user delete (<username> | -a)
user outbound add <username> -p <protocol> -t <target> [--<param> <value> ...] user outbound set <username> -protocol [--target <target_id>] [--<param> <value> ...]
user outbound show <username> (-a | -p <protocol> | -t <target>) user outbound show <username> (-a | -p <protocol> [--target <target_id>])
user outbound modify <username> -p <protocol> -t <target> [--<param> <value> ...] user outbound delete <username> (-a | -p <protocol> | --target <target_id>)
user outbound delete <username> (-a | -p <protocol> | -t <target>)
user profile build (<username> | -a) user profile build (<username> | -a)
user profile show (<username> | -a) user profile show (<username> | -a)
+9 -1
View File
@@ -1,5 +1,13 @@
from .connection import Connection, ConnectionFactory, ConnectionKey from .outbound import (
OUTBOUND_FIELDS,
Outbound,
OutboundFactory,
OutboundSpec,
ShadowsocksOutbound,
VlessOutbound,
)
from .profile import Profile, ProfileFactory, ProfileStorage from .profile import Profile, ProfileFactory, ProfileStorage
from .target import Target, TargetStorage
from .user import User, UserFactory from .user import User, UserFactory
from .xray_config import XrayConfig from .xray_config import XrayConfig
from .xray_manager_config import XrayManagerConfig from .xray_manager_config import XrayManagerConfig
-625
View File
@@ -1,625 +0,0 @@
from __future__ import annotations
import secrets
import uuid
from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, TypeVar
from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
from ..utils import private_to_public
if TYPE_CHECKING:
from .xray_config import XrayConfig
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
)
+341 -53
View File
@@ -8,17 +8,18 @@ from dataclasses import dataclass, make_dataclass
from typing import TYPE_CHECKING, TypeVar from typing import TYPE_CHECKING, TypeVar
from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
from ..utils import private_to_public
from .outbound_fields import OUTBOUND_FIELDS from .outbound_fields import OUTBOUND_FIELDS
if TYPE_CHECKING: if TYPE_CHECKING:
from .target import Target, TargetStorage
from .xray_config import XrayConfig from .xray_config import XrayConfig
from .xray_manager_config import XrayManagerConfig
@dataclass(frozen=True) @dataclass(frozen=True)
class OutboundSpec: class OutboundSpec:
protocol: str protocol: str
target: str target_id: str
O = TypeVar("O", bound="Outbound") O = TypeVar("O", bound="Outbound")
@@ -50,22 +51,20 @@ class OutboundFactory:
return list(cls._registry) return list(cls._registry)
@classmethod @classmethod
def from_link( def from_link(cls, target_storage: TargetStorage, link: str) -> Outbound:
cls, xray_manager_config: XrayManagerConfig, link: str
) -> Outbound:
scheme = link.split("://", 1)[0] scheme = link.split("://", 1)[0]
if scheme not in cls._scheme_registry: if scheme not in cls._scheme_registry:
raise ValueError(f"Unsupported link scheme: {scheme}") raise ValueError(f"Unsupported link scheme: {scheme}")
outbound_cls = cls._scheme_registry[scheme] outbound_cls = cls._scheme_registry[scheme]
targets = xray_manager_config.targets return outbound_cls.from_link(target_storage, link)
return outbound_cls.from_link(targets, link)
# устаревший метод, удалить после рефакторинга
@classmethod @classmethod
def from_inbound( def from_inbound(
cls, cls,
xray_manager_config: XrayManagerConfig, target_storage: TargetStorage,
host: str, host: str,
client: dict, client: dict,
inbound: dict, inbound: dict,
@@ -76,62 +75,76 @@ class OutboundFactory:
raise ValueError(f"Unsupported protocol: {protocol}") raise ValueError(f"Unsupported protocol: {protocol}")
outbound_cls = cls._registry[protocol] outbound_cls = cls._registry[protocol]
targets = xray_manager_config.targets
username, outbound = outbound_cls.from_inbound( username, outbound = outbound_cls.from_inbound(
targets, host, client, inbound target_storage, host, client, inbound
) )
return username, outbound return username, outbound
@classmethod
def from_xray_inbound(
cls, ts: TargetStorage, client: dict, inbound: dict
) -> Outbound:
protocol = inbound.get("protocol")
if protocol not in cls._registry:
raise ValueError(f"Unsupported protocol: {protocol}")
outbound_cls = cls._registry[protocol]
outbound = outbound_cls.from_xray_inbound(ts, client, inbound)
return outbound
@classmethod @classmethod
def from_spec( def from_spec(
cls, cls,
xray_config: XrayConfig, xray_config: XrayConfig,
xray_manager_config: XrayManagerConfig, target_storage: TargetStorage,
spec: OutboundSpec, spec: OutboundSpec,
) -> Outbound: ) -> Outbound:
if spec.protocol not in cls._registry: if spec.protocol not in cls._registry:
raise ValueError(f"Unsupported protocol: {spec.protocol}") raise ValueError(f"Unsupported protocol: {spec.protocol}")
if spec.target not in xray_manager_config.get_targets_list(): target = target_storage.load_by_id(spec.target_id)
raise ValueError(f"Target {spec.target} not found")
inbound = xray_config.find_managed_inbound_by_protocol(spec.protocol) inbound = xray_config.find_managed_inbound_by_protocol(spec.protocol)
outbound_cls = cls._registry[spec.protocol] outbound_cls = cls._registry[spec.protocol]
targets = xray_manager_config.targets return outbound_cls.from_scratch(target, xray_config.host, inbound)
target_pretty_name = targets.get(spec.target, {}).get(
"pretty_name", spec.target
)
return outbound_cls.from_scratch(
spec.target, target_pretty_name, xray_config.host, inbound
)
class Outbound(ABC): class Outbound(ABC):
PROTOCOL: str PROTOCOL: str
LINK_SCHEME: str LINK_SCHEME: str
host: str
port: int
target: Target
@classmethod @classmethod
@abstractmethod @abstractmethod
def from_link( def from_link(
cls, targets: dict[str, dict[str, str]], link: str cls, target_storage: TargetStorage, link: str
) -> Outbound: ... ) -> Outbound: ...
@classmethod @classmethod
@abstractmethod @abstractmethod
def from_inbound( def from_inbound(
cls, cls,
targets: dict[str, dict[str, str]], target_storage: TargetStorage,
host: str, host: str,
client: dict, client: dict,
inbound: dict, inbound: dict,
) -> tuple[str, Outbound]: ... ) -> tuple[str, Outbound]: ...
@classmethod
@abstractmethod
def from_xray_inbound(
cls, ts: TargetStorage, client: dict, inbound: dict
) -> Outbound: ...
@classmethod @classmethod
@abstractmethod @abstractmethod
def from_scratch( def from_scratch(
cls, target: str, target_pretty_name: str, host: str, inbound: dict cls, target: Target, host: str, inbound: dict
) -> Outbound: ... ) -> Outbound: ...
@abstractmethod @abstractmethod
@@ -152,18 +165,8 @@ class Outbound(ABC):
@staticmethod @staticmethod
def split_client_email(email: str) -> tuple[str, str]: def split_client_email(email: str) -> tuple[str, str]:
username, target = email.rsplit("-", 1) username, target_id = email.rsplit("-", 1)
return username, target return username, target_id
@staticmethod
def find_target_by_pretty_name(
targets: dict[str, dict[str, str]], pretty_name: str
) -> str | None:
search_name = pretty_name.strip()
for target, target_data in targets.items():
if target_data.get("pretty_name", "").strip() == search_name:
return target
return None
@abstractmethod @abstractmethod
def __hash__(self) -> int: ... def __hash__(self) -> int: ...
@@ -184,7 +187,7 @@ class ShadowsocksOutbound(_ShadowsocksFields, Outbound):
@classmethod @classmethod
def from_link( def from_link(
cls, targets: dict[str, dict[str, str]], link: str cls, target_storage: TargetStorage, link: str
) -> ShadowsocksOutbound: ) -> ShadowsocksOutbound:
import base64 import base64
@@ -198,15 +201,12 @@ class ShadowsocksOutbound(_ShadowsocksFields, Outbound):
method, server_password, client_password = prefix_str.split(":") method, server_password, client_password = prefix_str.split(":")
target_pretty_name = unquote(quoted_target_pretty_name) target_pretty_name = unquote(quoted_target_pretty_name)
target = cls.find_target_by_pretty_name(targets, target_pretty_name) target = target_storage.load_by_pretty(target_pretty_name)
if target is None:
target = "unknown"
return cls( return cls(
host=host, host=host,
port=port, port=port,
target=target, target=target,
target_pretty_name=target_pretty_name,
method=method, method=method,
server_password=server_password, server_password=server_password,
client_password=client_password, client_password=client_password,
@@ -215,7 +215,7 @@ class ShadowsocksOutbound(_ShadowsocksFields, Outbound):
@classmethod @classmethod
def from_inbound( def from_inbound(
cls, cls,
targets: dict[str, dict[str, str]], target_storage: TargetStorage,
host: str, host: str,
client: dict, client: dict,
inbound: dict, inbound: dict,
@@ -225,15 +225,15 @@ class ShadowsocksOutbound(_ShadowsocksFields, Outbound):
server_password = inbound["settings"]["password"] server_password = inbound["settings"]["password"]
client_password = client["password"] client_password = client["password"]
email = client["email"] email = client["email"]
username, target = cls.split_client_email(email)
target_pretty_name = targets.get(target, {}).get("pretty_name", target) username, target_id = cls.split_client_email(email)
target = target_storage.load_by_id(target_id)
outbound = cls( outbound = cls(
host=host, host=host,
port=port, port=port,
target=target, target=target,
target_pretty_name=target_pretty_name,
method=method, method=method,
server_password=server_password, server_password=server_password,
client_password=client_password, client_password=client_password,
@@ -242,7 +242,7 @@ class ShadowsocksOutbound(_ShadowsocksFields, Outbound):
@classmethod @classmethod
def from_scratch( def from_scratch(
cls, target: str, target_pretty_name: str, host: str, inbound: dict cls, target: Target, host: str, inbound: dict
) -> ShadowsocksOutbound: ) -> ShadowsocksOutbound:
port = inbound["port"] port = inbound["port"]
method = inbound["settings"]["method"] method = inbound["settings"]["method"]
@@ -253,7 +253,6 @@ class ShadowsocksOutbound(_ShadowsocksFields, Outbound):
host=host, host=host,
port=port, port=port,
target=target, target=target,
target_pretty_name=target_pretty_name,
method=method, method=method,
server_password=server_password, server_password=server_password,
client_password=client_password, client_password=client_password,
@@ -264,7 +263,7 @@ class ShadowsocksOutbound(_ShadowsocksFields, Outbound):
prefix = f"{self.method}:{self.server_password}:{self.client_password}" prefix = f"{self.method}:{self.server_password}:{self.client_password}"
prefix_b64 = base64.urlsafe_b64encode(prefix.encode()).decode() prefix_b64 = base64.urlsafe_b64encode(prefix.encode()).decode()
tag = quote(self.target_pretty_name) tag = quote(self.target.pretty)
link = f"ss://{prefix_b64}@{self.host}:{self.port}#{tag}" link = f"ss://{prefix_b64}@{self.host}:{self.port}#{tag}"
return link return link
@@ -307,7 +306,7 @@ class ShadowsocksOutbound(_ShadowsocksFields, Outbound):
def add_to_inbound(self, inbound: dict, username: str) -> None: def add_to_inbound(self, inbound: dict, username: str) -> None:
client = { client = {
"email": f"{username}-{self.target}", "email": f"{username}-{self.target.id}",
"password": self.client_password, "password": self.client_password,
} }
@@ -322,8 +321,8 @@ class ShadowsocksOutbound(_ShadowsocksFields, Outbound):
priority = {"default": 0, "managed": 1, "relay": 2} priority = {"default": 0, "managed": 1, "relay": 2}
def sort_key(c): def sort_key(c):
_, target = self.split_client_email(c["email"]) _, target_id = self.split_client_email(c["email"])
return (priority.get(target, 100), c["email"]) return (priority.get(target_id, 100), c["email"])
for user_clients in clients_by_user.values(): for user_clients in clients_by_user.values():
user_clients.sort(key=sort_key) user_clients.sort(key=sort_key)
@@ -335,7 +334,7 @@ class ShadowsocksOutbound(_ShadowsocksFields, Outbound):
inbound["settings"]["clients"] = sorted_clients inbound["settings"]["clients"] = sorted_clients
def delete_from_inbound(self, inbound: dict, username: str) -> None: def delete_from_inbound(self, inbound: dict, username: str) -> None:
target_email = f"{username}-{self.target}" target_email = f"{username}-{self.target.id}"
settings = inbound["settings"] settings = inbound["settings"]
clients = settings["clients"] clients = settings["clients"]
@@ -376,10 +375,299 @@ class ShadowsocksOutbound(_ShadowsocksFields, Outbound):
) )
_VlessFields = make_dataclass("_VlessFieldss", fields=OUTBOUND_FIELDS["vless"]) _VlessFields = make_dataclass("_VlessFields", fields=OUTBOUND_FIELDS["vless"])
@OutboundFactory.register @OutboundFactory.register
class VlessOutbound(_VlessFields, Outbound): class VlessOutbound(_VlessFields, Outbound):
PROTOCOL = "vless" PROTOCOL = "vless"
LINK_SCHEME = "vless" LINK_SCHEME = "vless"
@classmethod
def from_link(
cls, target_storage: TargetStorage, link: str
) -> VlessOutbound:
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"),
target=target_storage.load_by_pretty(
require_str(parsed.fragment, "target_pretty_name")
),
id=require_str(parsed.username, "id"),
security=get_required("security"),
encryption=get_required("encryption"),
public_key=get_required("pbk"),
network_type=get_required("type"),
sni=get_required("sni"),
short_id=get_required("sid"),
flow=get_required("flow"),
header_type=get_required("headerType"),
finger_print=get_required("fp"),
)
@classmethod
def from_inbound(
cls,
target_storage: TargetStorage,
host: str,
client: dict,
inbound: dict,
) -> tuple[str, VlessOutbound]:
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, target_id = cls.split_client_email(email)
connection = cls(
host=host,
port=port,
target=target_storage.load_by_id(target_id),
id=id,
security=security,
encryption=encryption,
public_key=public_key,
network_type=network_type,
sni=sni,
short_id=short_id,
flow=flow,
)
return username, connection
@classmethod
def from_scratch(
cls, target: Target, host: str, inbound: dict
) -> VlessOutbound:
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,
target=target,
id=id,
security=security,
encryption=encryption,
public_key=public_key,
network_type=network_type,
sni=sni,
short_id=short_id,
)
def to_link(self) -> str:
netloc = f"{self.id}@{self.host}:{self.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.target.pretty)
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.target.id}",
"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
_, target_id = self.split_client_email(client["email"])
return (priority.get(target_id, 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.target.id}"
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.target,
self.id,
self.security,
self.encryption,
self.public_key,
self.network_type,
self.sni,
self.short_id,
self.flow,
self.header_type,
self.finger_print,
)
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, VlessOutbound):
return NotImplemented
return (
self.host == other.host
and self.port == other.port
and self.target == other.target
and self.id == other.id
and self.security == other.security
and self.encryption == other.encryption
and self.public_key == other.public_key
and self.network_type == other.network_type
and self.sni == other.sni
and self.short_id == other.short_id
and self.flow == other.flow
and self.header_type == other.header_type
and self.finger_print == other.finger_print
)
+3 -6
View File
@@ -1,11 +1,8 @@
from dataclasses import field from dataclasses import field
_BASE_FIELDS = [ from .target import Target
("host", str),
("port", str), _BASE_FIELDS = [("host", str), ("port", str), ("target", Target)]
("target", str),
("target_pretty_name", str),
]
OUTBOUND_FIELDS = { OUTBOUND_FIELDS = {
+12 -13
View File
@@ -1,16 +1,11 @@
from __future__ import annotations from __future__ import annotations
import json from dataclasses import dataclass
import secrets
import uuid
from abc import ABC, abstractmethod
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, TypeVar from typing import TYPE_CHECKING
from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
from .connection import ConnectionFactory from .outbound import OutboundFactory
from .target import TargetStorage
from .user import User from .user import User
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -23,7 +18,7 @@ class Profile:
user: User user: User
def to_text(self): def to_text(self):
links = [connection.to_link() for connection in self.user.connections] links = [outbound.to_link() for outbound in self.user.outbounds]
text = "\n".join(links) text = "\n".join(links)
return text return text
@@ -94,12 +89,16 @@ class ProfileStorage:
if line.strip() if line.strip()
] ]
connections = [ConnectionFactory.from_link(link) for link in links] target_storage = TargetStorage(self.xrmc)
user = User(username, connections)
outbounds = [
OutboundFactory.from_link(target_storage, link) for link in links
]
user = User(username, outbounds)
profile = Profile(folder_name=path.parent.name, user=user) profile = Profile(folder_name=path.parent.name, user=user)
print( print(
f"[DEBUG] Loaded profile: folder='{profile.folder_name}', connections={len(connections)}" f"[DEBUG] Loaded profile: folder='{profile.folder_name}', outbounds={len(outbounds)}"
) )
return profile return profile
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .xray_manager_config import XrayManagerConfig
@dataclass(frozen=True)
class Target:
id: str
pretty: str
route: str
class TargetStorage:
def __init__(self, xray_manager_config: XrayManagerConfig):
self.xrmc = xray_manager_config
@property
def target_dict(self) -> dict[str, dict[str, str]]:
return self.xrmc.config.get("target", {})
def load_by_id(self, target_id: str) -> Target:
if target_id not in self.target_dict:
raise ValueError(f"Target not found: {target_id}")
target_data = self.target_dict[target_id]
if "route" not in target_data:
raise ValueError(
f"Missing required 'route' field for target: {target_id}"
)
return Target(
id=target_id,
pretty=target_data.get("pretty_name", target_id),
route=target_data["route"],
)
def load_by_pretty(self, target_pretty_name: str) -> Target:
for target_id, target_data in self.target_dict.items():
if target_data.get("pretty_name", target_id) == target_pretty_name:
return self.load_by_id(target_id)
raise ValueError(f"Target pretty name not found: {target_pretty_name}")
def load_all_targets(self) -> list[Target]:
targets = [
self.load_by_id(target_id) for target_id in self.target_dict.keys()
]
return targets
def save_target(self, target: Target):
self.xrmc.set_target(target.id, target.pretty, target.route)
def delete_target(self, target: Target):
if target not in self.load_all_targets():
raise ValueError(f"Target not found: {target.id}")
self.xrmc.delete_target(target.id)
+69 -24
View File
@@ -3,53 +3,98 @@ from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .connection import ConnectionFactory, ConnectionKey from .outbound import Outbound, OutboundFactory, OutboundSpec
if TYPE_CHECKING: if TYPE_CHECKING:
from .connection import Connection from .target import TargetStorage
from .xray_config import XrayConfig from .xray_config import XrayConfig, XrayStorage
@dataclass @dataclass
class User: class User:
username: str username: str
connections: list[Connection] = field(default_factory=list) outbounds: list[Outbound] = field(default_factory=list)
def modify_connection_by_key( def modify_outbounds(
self, self,
xray_config: "XrayConfig", xray_config: XrayConfig,
add: list[ConnectionKey] = [], target_storage: TargetStorage,
delete: list[ConnectionKey] = [], to_add: list[OutboundSpec] | None = None,
to_delete: list[OutboundSpec] | None = None,
): ):
key_map = { to_add = to_add or []
ConnectionKey(c.protocol, c.exit_point): c for c in self.connections to_delete = to_delete or []
user_specs = {
OutboundSpec(o.protocol, o.target.id): o for o in self.outbounds
} }
for key in delete: for spec in to_delete:
key_map.pop(key, None) user_specs.pop(spec, None)
for key in add: for spec in to_add:
if key not in key_map: if spec not in user_specs:
connection = ConnectionFactory.from_spec(key, xray_config) outbound = OutboundFactory.from_spec(
key_map[key] = connection xray_config, target_storage, spec
)
user_specs[spec] = outbound
self.connections = list(key_map.values()) self.outbounds = list(user_specs.values())
def add_to_xray_inbounds(self, xrs: XrayStorage):
added = False
xray_inbounds = xrs.load_inbounds()
for outbound in self.outbounds:
for xray_inbound in xray_inbounds:
if outbound.matches_inbound(xray_inbound):
outbound.add_to_inbound(xray_inbound, self.username)
added = True
if not added:
raise RuntimeError(f"Inbound not found for {outbound}")
class UserFactory: class UserFactory:
@staticmethod @staticmethod
def from_spec( def from_spec(
username: str, keys: list[ConnectionKey], xray_config: "XrayConfig" username: str,
specs: list[OutboundSpec],
xray_config: XrayConfig,
target_storage: TargetStorage,
) -> User: ) -> User:
connections = [] outbounds = []
for key in keys: for spec in specs:
conn = ConnectionFactory.from_spec(key, xray_config) outbound = OutboundFactory.from_spec(
connections.append(conn) xray_config, target_storage, spec
)
outbounds.append(outbound)
user = User(username=username, connections=connections) user = User(username=username, outbounds=outbounds)
return user return user
@staticmethod @staticmethod
def empty(username: str): def empty(username: str) -> User:
return User(username, []) return User(username, [])
@staticmethod
def from_xray_inbound(username: str, ts: TargetStorage, xrs: XrayStorage):
user_outbounds: list[Outbound] = []
inbounds: list[dict] = xrs.load_inbounds()
for inbound in inbounds:
protocol = inbound.get("protocol")
if protocol in OutboundFactory._registry:
settings = inbound.get("settings", {})
clients = settings.get("clients", [])
for client in clients:
email = client.get("email", "")
inbound_username, _ = Outbound.split_client_email(email)
if inbound_username == username:
user_outbound = OutboundFactory.from_xray_inbound(
ts, client, inbound
)
user_outbounds.append(user_outbound)
+50 -47
View File
@@ -5,17 +5,21 @@ from collections import defaultdict
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .connection import ConnectionFactory from .outbound import OutboundFactory
from .target import TargetStorage
from .user import User from .user import User
if TYPE_CHECKING: if TYPE_CHECKING:
from .connection import Connection from .outbound import Outbound
class XrayConfig: class XrayConfig:
def __init__(self, config_folder: Path, host: str): def __init__(
self, config_folder: Path, host: str, target_storage: TargetStorage
):
self.path = config_folder self.path = config_folder
self.host = host self.host = host
self.ts = target_storage
self.inbounds_files = list(self.path.glob("*-in-*.json")) self.inbounds_files = list(self.path.glob("*-in-*.json"))
self.outbounds_files = list(self.path.glob("*-out-*.json")) self.outbounds_files = list(self.path.glob("*-out-*.json"))
@@ -37,14 +41,14 @@ class XrayConfig:
encoding="utf-8", encoding="utf-8",
) )
def _find_inbound(self, connection: Connection) -> tuple[str, dict]: def _find_inbound(self, outbound: Outbound) -> tuple[str, dict]:
for filename, data in self.inbounds_data.items(): for filename, data in self.inbounds_data.items():
inbounds = data.get("inbounds", []) inbounds = data.get("inbounds", [])
for inbound in inbounds: for inbound in inbounds:
if connection.matches_inbound(inbound): if outbound.matches_inbound(inbound):
return filename, inbound return filename, inbound
raise RuntimeError(f"Inbound not found for {connection}") raise RuntimeError(f"Inbound not found for {outbound}")
def find_managed_inbound_by_protocol(self, protocol: str) -> dict: def find_managed_inbound_by_protocol(self, protocol: str) -> dict:
for data in self.inbounds_data.values(): for data in self.inbounds_data.values():
@@ -62,40 +66,27 @@ class XrayConfig:
f"Managed inbound not found for protocol: {protocol}" f"Managed inbound not found for protocol: {protocol}"
) )
# Устаревший метод. Перейти на использование xray_config.get_targets_list()
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]: def get_users(self) -> list[User]:
connection_map: defaultdict[str, list[Connection]] = defaultdict(list) outbound_map: defaultdict[str, list[Outbound]] = defaultdict(list)
for data in self.inbounds_data.values(): for data in self.inbounds_data.values():
inbounds = data.get("inbounds", []) inbounds = data.get("inbounds", [])
for inbound in inbounds: for inbound in inbounds:
protocol = inbound.get("protocol") protocol = inbound.get("protocol")
if protocol in ConnectionFactory._registry: if protocol in OutboundFactory._registry:
settings = inbound.get("settings", {}) settings = inbound.get("settings", {})
clients = settings.get("clients", []) clients = settings.get("clients", [])
for client in clients: for client in clients:
username, connection = ConnectionFactory.from_inbound( username, outbound = OutboundFactory.from_inbound(
self.host, client, inbound self.ts, self.host, client, inbound
) )
connection_map[username].append(connection) outbound_map[username].append(outbound)
users = [] users = []
for username, connections in connection_map.items(): for username, outbounds in outbound_map.items():
users.append(User(username, connections)) users.append(User(username, outbounds))
return users return users
@@ -108,30 +99,26 @@ class XrayConfig:
def add_user(self, user: User) -> None: def add_user(self, user: User) -> None:
modified_files = set() modified_files = set()
for connection in user.connections: for outbound in user.outbounds:
filename, inbound = self._find_inbound(connection) filename, inbound = self._find_inbound(outbound)
connection.add_to_inbound(inbound, user.username) outbound.add_to_inbound(inbound, user.username)
modified_files.add(filename) modified_files.add(filename)
for filename in modified_files: for filename in modified_files:
path = self.path / filename path = self.path / filename
self._save_json(self.inbounds_data[filename], path) self._save_json(self.inbounds_data[filename], path)
def delete_user(self, username: str) -> bool: def delete_user(self, user: User) -> bool:
user = self.get_user(username)
if not user:
return False
deleted = False deleted = False
modified_files = set() modified_files = set()
for connection in user.connections: for outbound in user.outbounds:
try: try:
filename, inbound = self._find_inbound(connection) filename, inbound = self._find_inbound(outbound)
except RuntimeError: except RuntimeError:
continue continue
connection.delete_from_inbound(inbound, username) outbound.delete_from_inbound(inbound, user.username)
modified_files.add(filename) modified_files.add(filename)
deleted = True deleted = True
@@ -147,22 +134,22 @@ class XrayConfig:
if current_user is None: if current_user is None:
return False return False
old_connections = set(current_user.connections) old_outbounds = set(current_user.outbounds)
new_connections = set(user.connections) new_outbounds = set(user.outbounds)
to_add = new_connections - old_connections to_add = new_outbounds - old_outbounds
to_remove = old_connections - new_connections to_remove = old_outbounds - new_outbounds
modified_files = set() modified_files = set()
for connection in to_remove: for outbound in to_remove:
filename, inbound = self._find_inbound(connection) filename, inbound = self._find_inbound(outbound)
connection.delete_from_inbound(inbound, user.username) outbound.delete_from_inbound(inbound, user.username)
modified_files.add(filename) modified_files.add(filename)
for connection in to_add: for outbound in to_add:
filename, inbound = self._find_inbound(connection) filename, inbound = self._find_inbound(outbound)
connection.add_to_inbound(inbound, user.username) outbound.add_to_inbound(inbound, user.username)
modified_files.add(filename) modified_files.add(filename)
for filename in modified_files: for filename in modified_files:
@@ -170,3 +157,19 @@ class XrayConfig:
self._save_json(self.inbounds_data[filename], path) self._save_json(self.inbounds_data[filename], path)
return True return True
class XrayStorage:
def __init__(self, config_path: Path):
self.path = config_path
self.inbounds_files: list
self.outbound_files: list
self.routings_files: list
self.inbounds: list[dict]
self.outbouns: list[dict]
self.routings: list[dict]
def load_inbounds(self) -> list[dict]:
return self.inbounds
+15 -7
View File
@@ -73,13 +73,6 @@ class XrayManagerConfig:
for username, folder_name in self.profile_folder_mapping.items() for username, folder_name in self.profile_folder_mapping.items()
} }
@property
def targets(self) -> dict[str, dict[str, str]]:
return self.config.get("target", {})
def get_targets_list(self) -> list[str]:
return list(self.targets.keys())
def set_profile_folder(self, username: str, folder_name: str): def set_profile_folder(self, username: str, folder_name: str):
self.config["profiles"]["folder_mapping"][username] = folder_name self.config["profiles"]["folder_mapping"][username] = folder_name
self._save_json() self._save_json()
@@ -87,3 +80,18 @@ class XrayManagerConfig:
def delete_profile_folder(self, username: str): def delete_profile_folder(self, username: str):
self.config["profiles"]["folder_mapping"].pop(username, None) self.config["profiles"]["folder_mapping"].pop(username, None)
self._save_json() self._save_json()
def set_target(
self, target_id: str, target_pretty_name: str, target_route: str
):
self.config["target"][target_id] = {
"pretty_name": target_pretty_name,
"route": target_route,
}
self.config["target"][target_id]["pretty_name"] = target_pretty_name
self.config["target"][target_id]["route"] = target_route
self._save_json()
def delete_target(self, target_id: str):
self.config["target"].pop(target_id, None)
self._save_json()
+46 -34
View File
@@ -2,11 +2,12 @@ import os
from pathlib import Path from pathlib import Path
from .core import ( from .core import (
ConnectionFactory, OutboundFactory,
ConnectionKey, OutboundSpec,
Profile, Profile,
ProfileFactory, ProfileFactory,
ProfileStorage, ProfileStorage,
TargetStorage,
User, User,
UserFactory, UserFactory,
XrayConfig, XrayConfig,
@@ -34,11 +35,15 @@ def _resolve_config_path() -> str:
xray_manager_config = XrayManagerConfig(_resolve_config_path()) xray_manager_config = XrayManagerConfig(_resolve_config_path())
xray_config = XrayConfig( profile_storage = ProfileStorage(xray_manager_config)
xray_manager_config.xray_config_folder, xray_manager_config.host
)
storage = ProfileStorage(xray_manager_config) target_storage = TargetStorage(xray_manager_config)
xray_config = XrayConfig(
xray_manager_config.xray_config_folder,
xray_manager_config.host,
target_storage,
)
def _resolve_users(username: str | None, all: bool) -> list[User]: def _resolve_users(username: str | None, all: bool) -> list[User]:
@@ -54,35 +59,37 @@ def _resolve_users(username: str | None, all: bool) -> list[User]:
def _resolve_profiles(username: str | None, all: bool) -> list[Profile]: def _resolve_profiles(username: str | None, all: bool) -> list[Profile]:
if all: if all:
return storage.load_profiles() return profile_storage.load_profiles()
if username: if username:
try: try:
profile = storage.load_profile(username) profile = profile_storage.load_profile(username)
except (ValueError, FileNotFoundError) as e: except (ValueError, FileNotFoundError) as e:
raise ValueError(f"Cannot load profile for {username}: {e}") raise ValueError(f"Cannot load profile for {username}: {e}")
return [profile] return [profile]
raise ValueError("Either username or --all must be specified") raise ValueError("Either username or --all must be specified")
def _parse_connection_spec(spec: str) -> ConnectionKey: def _parse_outbound_spec(spec: str) -> OutboundSpec:
protocol, exit_point = spec.split(":", 1) protocol, target_id = spec.split(":", 1)
if protocol not in ConnectionFactory.get_protocols(): if protocol not in OutboundFactory.get_protocols():
raise ValueError(f"Unsupported protocol: {protocol}") raise ValueError(f"Unsupported protocol: {protocol}")
if exit_point not in xray_config.get_exit_points(): if target_id not in {
raise ValueError(f"Unsupported exit point: {exit_point}") target.id for target in target_storage.load_all_targets()
return ConnectionKey(protocol, exit_point) }:
raise ValueError(f"Target ID not found: {target_id}")
return OutboundSpec(protocol, target_id)
def _build_user_rows(users: list[User]) -> list[list[str]]: def _build_user_rows(users: list[User]) -> list[list[str]]:
rows = [] rows = []
for index, user in enumerate(users, start=1): for index, user in enumerate(users, start=1):
first = True first = True
for conn in user.connections: for out in user.outbounds:
if first: if first:
row = [index, user.username, conn.exit_point, conn.protocol] row = [index, user.username, out.target.id, out.protocol]
first = False first = False
else: else:
row = ["", "", conn.exit_point, conn.protocol] row = ["", "", out.target.id, out.protocol]
rows.append(row) rows.append(row)
return rows return rows
@@ -91,18 +98,18 @@ def _build_profile_rows(profiles: list[Profile]) -> list[list[str]]:
rows = [] rows = []
for index, profile in enumerate(profiles, start=1): for index, profile in enumerate(profiles, start=1):
first = True first = True
for conn in profile.user.connections: for out in profile.user.outbounds:
if first: if first:
row = [ row = [
index, index,
profile.user.username, profile.user.username,
profile.folder_name, profile.folder_name,
conn.exit_point, out.target.id,
conn.protocol, out.protocol,
] ]
first = False first = False
else: else:
row = ["", "", "", conn.exit_point, conn.protocol] row = ["", "", "", out.target.id, out.protocol]
rows.append(row) rows.append(row)
return rows return rows
@@ -113,12 +120,15 @@ def user_add(username: str, connection: list[str], **_):
if isinstance(user, User): if isinstance(user, User):
raise ValueError(f"User already exists: {username}") raise ValueError(f"User already exists: {username}")
keys: list[ConnectionKey] = [ specs: list[OutboundSpec] = [
_parse_connection_spec(spec) for spec in connection _parse_outbound_spec(raw_spec) for raw_spec in connection
] ]
user = UserFactory.from_spec( user = UserFactory.from_spec(
username=username, keys=keys, xray_config=xray_config username=username,
specs=specs,
xray_config=xray_config,
target_storage=target_storage,
) )
xray_config.add_user(user) xray_config.add_user(user)
@@ -127,7 +137,7 @@ def user_add(username: str, connection: list[str], **_):
def user_show(username: str | None, all: bool, **_): def user_show(username: str | None, all: bool, **_):
users = _resolve_users(username, all) users = _resolve_users(username, all)
headers = ["#", "Username", "Exit Point", "Protocol"] headers = ["#", "Username", "Target ID", "Protocol"]
rows = _build_user_rows(users) rows = _build_user_rows(users)
print_table(headers, rows) print_table(headers, rows)
@@ -139,10 +149,12 @@ def user_modify(username: str, to_add: list[str], to_delete: list[str], **_):
if not isinstance(user, User): if not isinstance(user, User):
raise ValueError(f"User not found: {username}") raise ValueError(f"User not found: {username}")
add_key = [_parse_connection_spec(spec) for spec in to_add] to_add_specs = [_parse_outbound_spec(spec) for spec in to_add]
del_key = [_parse_connection_spec(spec) for spec in to_delete] to_del_specs = [_parse_outbound_spec(spec) for spec in to_delete]
user.modify_connection_by_key(xray_config, add_key, del_key) user.modify_outbounds(
xray_config, target_storage, to_add_specs, to_del_specs
)
xray_config.modify_user(user) xray_config.modify_user(user)
@@ -150,7 +162,7 @@ def user_delete(username: str | None, all: bool, **_):
users = _resolve_users(username, all) users = _resolve_users(username, all)
for user in users: for user in users:
xray_config.delete_user(user.username) xray_config.delete_user(user)
def profile_build(username: str | None, all: bool, **_): def profile_build(username: str | None, all: bool, **_):
@@ -158,19 +170,19 @@ def profile_build(username: str | None, all: bool, **_):
for user in users: for user in users:
try: try:
profile = storage.load_profile(user.username) profile = profile_storage.load_profile(user.username)
profile = ProfileFactory.from_user(user, profile.folder_name) profile = ProfileFactory.from_user(user, profile.folder_name)
except (ValueError, FileNotFoundError) as e: except (ValueError, FileNotFoundError) as e:
print(f"Cannot load profile for {user.username}: {e}") print(f"Cannot load profile for {user.username}: {e}")
profile = ProfileFactory.from_user(user) profile = ProfileFactory.from_user(user)
storage.save_profile(profile) profile_storage.save_profile(profile)
def profile_show(username: str | None, all: bool, **_): def profile_show(username: str | None, all: bool, **_):
profiles = _resolve_profiles(username, all) profiles = _resolve_profiles(username, all)
headers = ["#", "Username", "Profile", "Exit Point", "Protocol"] headers = ["#", "Username", "Profile", "Target ID", "Protocol"]
rows = _build_profile_rows(profiles) rows = _build_profile_rows(profiles)
print_table(headers, rows) print_table(headers, rows)
@@ -180,14 +192,14 @@ def profile_clear(username: str | None, all: bool, **_):
profiles = _resolve_profiles(username, all) profiles = _resolve_profiles(username, all)
for profile in profiles: for profile in profiles:
storage.clear_profile(profile.user.username) profile_storage.clear_profile(profile.user.username)
def profile_delete(username: str | None, all: bool, **_): def profile_delete(username: str | None, all: bool, **_):
profiles = _resolve_profiles(username, all) profiles = _resolve_profiles(username, all)
for profile in profiles: for profile in profiles:
storage.delete_profile(profile.user.username) profile_storage.delete_profile(profile.user.username)
def profile_qrcode(username: str | None, all: bool, **_): def profile_qrcode(username: str | None, all: bool, **_):
@@ -27,5 +27,16 @@
"route": "out-us2", "route": "out-us2",
"pretty_name": "🇺🇸 USA #2" "pretty_name": "🇺🇸 USA #2"
} }
},
"override": {
"test": {
"vless": {
"port": 443,
"finger_print": "qq",
"by_target_id": {
"us2": "chrome"
}
}
}
} }
} }
@@ -3,7 +3,7 @@ from xray_manager.core.outbound import (
OutboundSpec, OutboundSpec,
ShadowsocksOutbound, ShadowsocksOutbound,
) )
from xray_manager.core.xray_config import XrayConfig from xray_manager.core.target import Target, TargetStorage
from xray_manager.core.xray_manager_config import XrayManagerConfig from xray_manager.core.xray_manager_config import XrayManagerConfig
inbound = { inbound = {
@@ -32,16 +32,13 @@ inbound = {
if __name__ == "__main__": if __name__ == "__main__":
DEV_CONFIG_PATH = "tests/mock_data/etc/xray-manager/config.json" DEV_CONFIG_PATH = "tests/mock_data/etc/xray-manager/config.json"
xray_manager_config = XrayManagerConfig(DEV_CONFIG_PATH) xray_manager_config = XrayManagerConfig(DEV_CONFIG_PATH)
target_storage = TargetStorage(xray_manager_config)
target = "us1" target = target_storage.load_by_id("default")
target_pretty_name = xray_manager_config.targets.get(target, {}).get(
"pretty_name", target
)
outbound = ShadowsocksOutbound.from_scratch( outbound = ShadowsocksOutbound.from_scratch(target, "127.0.0.1", inbound)
target, target_pretty_name, "127.0.0.1", inbound
)
link = outbound.to_link() link = outbound.to_link()
o = ShadowsocksOutbound.from_link(xray_manager_config.targets, link) print(link)
o = ShadowsocksOutbound.from_link(target_storage, link)
print(o) print(o)
+13
View File
@@ -0,0 +1,13 @@
from xray_manager.core.target import Target, TargetStorage
from xray_manager.core.xray_manager_config import XrayManagerConfig
DEV_CONFIG_PATH = "tests/mock_data/etc/xray-manager/config.json"
if __name__ == "__main__":
xray_manager_config = XrayManagerConfig(DEV_CONFIG_PATH)
storage = TargetStorage(xray_manager_config)
target = Target("de1", "pretty", "out-de1")
storage.delete_target(target)
+95
View File
@@ -0,0 +1,95 @@
from xray_manager.core import (
OutboundFactory,
ProfileStorage,
Target,
TargetStorage,
VlessOutbound,
XrayManagerConfig,
)
inbound = {
"tag": "in-vless",
"listen": "127.0.0.1",
"port": 443,
"protocol": "vless",
"settings": {
"clients": [
{
"email": "test-fr1",
"id": "0eb7a1a8-c24f-4eb2-9f89-8023a10ed070",
"flow": "xtls-rprx-vision",
},
{
"email": "test2-us2",
"id": "7b6da4ce-bb10-4127-a0c6-243618c62edd",
"flow": "xtls-rprx-vision",
},
],
"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": ["91e7c34ea2c07723", "0419a64d06467571"],
},
},
}
def test_from_scratch(target_storage: TargetStorage) -> VlessOutbound:
target = target_storage.load_by_id("fr1")
outbound = VlessOutbound.from_scratch(target, "localhost", inbound)
print("Test from scratch")
print(outbound)
return outbound
def test_to_link(outbound: VlessOutbound) -> str:
link = outbound.to_link()
print("Test to link")
print(link)
return link
def test_from_link(target_storage: TargetStorage, link: str):
outbound = VlessOutbound.from_link(target_storage, link)
print("Test from link")
print(outbound)
def test_from_inbound(target_storage: TargetStorage):
client = {
"email": "test-fr1",
"id": "0eb7a1a8-c24f-4eb2-9f89-8023a10ed070",
"flow": "xtls-rprx-vision",
}
username, outbound = VlessOutbound.from_inbound(
target_storage, "localhost", client, inbound
)
print("Test from inbound")
print(username)
print(outbound)
if __name__ == "__main__":
DEV_CONFIG_PATH = "tests/mock_data/etc/xray-manager/config.json"
xray_manager_config = XrayManagerConfig(DEV_CONFIG_PATH)
profile_storage = ProfileStorage(xray_manager_config)
target_storage = TargetStorage(xray_manager_config)
outbound = test_from_scratch(target_storage)
link = test_to_link(outbound)
test_from_link(target_storage, link)
test_from_inbound(target_storage)