diff --git a/src/xray_manager/core/profile.py b/src/xray_manager/core/profile.py index b7582fc..51364ce 100644 --- a/src/xray_manager/core/profile.py +++ b/src/xray_manager/core/profile.py @@ -1,16 +1,11 @@ from __future__ import annotations -import json -import secrets -import uuid -from abc import ABC, abstractmethod -from collections import defaultdict -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, TypeVar -from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse +from typing import TYPE_CHECKING -from .connection import ConnectionFactory +from .outbound import OutboundFactory +from .target import TargetStorage from .user import User if TYPE_CHECKING: @@ -23,7 +18,7 @@ class Profile: user: User 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) return text @@ -94,12 +89,16 @@ class ProfileStorage: if line.strip() ] - connections = [ConnectionFactory.from_link(link) for link in links] - user = User(username, connections) + target_storage = TargetStorage(self.xrmc) + + outbounds = [ + OutboundFactory.from_link(target_storage, link) for link in links + ] + user = User(username, outbounds) profile = Profile(folder_name=path.parent.name, user=user) 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 diff --git a/src/xray_manager/core/user.py b/src/xray_manager/core/user.py index 6fc9091..7bcb6a2 100644 --- a/src/xray_manager/core/user.py +++ b/src/xray_manager/core/user.py @@ -3,51 +3,62 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import TYPE_CHECKING -from .connection import ConnectionFactory, ConnectionKey +from .outbound import Outbound, OutboundFactory, OutboundSpec if TYPE_CHECKING: - from .connection import Connection + from .target import TargetStorage from .xray_config import XrayConfig @dataclass class User: 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, - xray_config: "XrayConfig", - add: list[ConnectionKey] = [], - delete: list[ConnectionKey] = [], + xray_config: XrayConfig, + target_storage: TargetStorage, + to_add: list[OutboundSpec] | None = None, + to_delete: list[OutboundSpec] | None = None, ): - key_map = { - ConnectionKey(c.protocol, c.exit_point): c for c in self.connections + to_add = to_add or [] + to_delete = to_delete or [] + + user_specs = { + OutboundSpec(o.protocol, o.target.id): o for o in self.outbounds } - for key in delete: - key_map.pop(key, None) + for spec in to_delete: + user_specs.pop(spec, None) - for key in add: - if key not in key_map: - connection = ConnectionFactory.from_spec(key, xray_config) - key_map[key] = connection + for spec in to_add: + if spec not in user_specs: + outbound = OutboundFactory.from_spec( + xray_config, target_storage, spec + ) + user_specs[spec] = outbound - self.connections = list(key_map.values()) + self.outbounds = list(user_specs.values()) class UserFactory: @staticmethod def from_spec( - username: str, keys: list[ConnectionKey], xray_config: "XrayConfig" + username: str, + specs: list[OutboundSpec], + xray_config: XrayConfig, + target_storage: TargetStorage, ) -> User: - connections = [] + outbounds = [] - for key in keys: - conn = ConnectionFactory.from_spec(key, xray_config) - connections.append(conn) + for spec in specs: + outbound = OutboundFactory.from_spec( + xray_config, target_storage, spec + ) + outbounds.append(outbound) - user = User(username=username, connections=connections) + user = User(username=username, outbounds=outbounds) return user @staticmethod diff --git a/src/xray_manager/core/xray_config.py b/src/xray_manager/core/xray_config.py index 018d68e..393d3ba 100644 --- a/src/xray_manager/core/xray_config.py +++ b/src/xray_manager/core/xray_config.py @@ -5,17 +5,21 @@ from collections import defaultdict from pathlib import Path from typing import TYPE_CHECKING -from .connection import ConnectionFactory +from .outbound import OutboundFactory +from .target import TargetStorage from .user import User if TYPE_CHECKING: - from .connection import Connection + from .outbound import Outbound 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.host = host + self.ts = target_storage self.inbounds_files = list(self.path.glob("*-in-*.json")) self.outbounds_files = list(self.path.glob("*-out-*.json")) @@ -37,14 +41,14 @@ class XrayConfig: 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(): inbounds = data.get("inbounds", []) for inbound in inbounds: - if connection.matches_inbound(inbound): + if outbound.matches_inbound(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: for data in self.inbounds_data.values(): @@ -62,40 +66,27 @@ class XrayConfig: 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]: - connection_map: defaultdict[str, list[Connection]] = defaultdict(list) + outbound_map: defaultdict[str, list[Outbound]] = 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: + if protocol in OutboundFactory._registry: settings = inbound.get("settings", {}) clients = settings.get("clients", []) for client in clients: - username, connection = ConnectionFactory.from_inbound( - self.host, client, inbound + username, outbound = OutboundFactory.from_inbound( + self.ts, self.host, client, inbound ) - connection_map[username].append(connection) + outbound_map[username].append(outbound) users = [] - for username, connections in connection_map.items(): - users.append(User(username, connections)) + for username, outbounds in outbound_map.items(): + users.append(User(username, outbounds)) return users @@ -108,30 +99,26 @@ class XrayConfig: 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) + for outbound in user.outbounds: + filename, inbound = self._find_inbound(outbound) + outbound.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 - + def delete_user(self, user: User) -> bool: deleted = False modified_files = set() - for connection in user.connections: + for outbound in user.outbounds: try: - filename, inbound = self._find_inbound(connection) + filename, inbound = self._find_inbound(outbound) except RuntimeError: continue - connection.delete_from_inbound(inbound, username) + outbound.delete_from_inbound(inbound, user.username) modified_files.add(filename) deleted = True @@ -147,22 +134,22 @@ class XrayConfig: if current_user is None: return False - old_connections = set(current_user.connections) - new_connections = set(user.connections) + old_outbounds = set(current_user.outbounds) + new_outbounds = set(user.outbounds) - to_add = new_connections - old_connections - to_remove = old_connections - new_connections + to_add = new_outbounds - old_outbounds + to_remove = old_outbounds - new_outbounds modified_files = set() - for connection in to_remove: - filename, inbound = self._find_inbound(connection) - connection.delete_from_inbound(inbound, user.username) + for outbound in to_remove: + filename, inbound = self._find_inbound(outbound) + outbound.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) + for outbound in to_add: + filename, inbound = self._find_inbound(outbound) + outbound.add_to_inbound(inbound, user.username) modified_files.add(filename) for filename in modified_files: diff --git a/src/xray_manager/handlers.py b/src/xray_manager/handlers.py index f5f97b1..0dbb6c9 100644 --- a/src/xray_manager/handlers.py +++ b/src/xray_manager/handlers.py @@ -2,11 +2,12 @@ import os from pathlib import Path from .core import ( - ConnectionFactory, - ConnectionKey, + OutboundFactory, + OutboundSpec, Profile, ProfileFactory, ProfileStorage, + TargetStorage, User, UserFactory, XrayConfig, @@ -34,11 +35,15 @@ def _resolve_config_path() -> str: xray_manager_config = XrayManagerConfig(_resolve_config_path()) -xray_config = XrayConfig( - xray_manager_config.xray_config_folder, xray_manager_config.host -) +profile_storage = ProfileStorage(xray_manager_config) -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]: @@ -54,35 +59,37 @@ def _resolve_users(username: str | None, all: bool) -> list[User]: def _resolve_profiles(username: str | None, all: bool) -> list[Profile]: if all: - return storage.load_profiles() + return profile_storage.load_profiles() if username: try: - profile = storage.load_profile(username) + profile = 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(): +def _parse_outbound_spec(spec: str) -> OutboundSpec: + protocol, target_id = spec.split(":", 1) + if protocol not in OutboundFactory.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) + if target_id not in { + target.id for target in target_storage.load_all_targets() + }: + raise ValueError(f"Target ID not found: {target_id}") + return OutboundSpec(protocol, target_id) 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: + for out in user.outbounds: if first: - row = [index, user.username, conn.exit_point, conn.protocol] + row = [index, user.username, out.target.id, out.protocol] first = False else: - row = ["", "", conn.exit_point, conn.protocol] + row = ["", "", out.target.id, out.protocol] rows.append(row) return rows @@ -91,18 +98,18 @@ 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: + for out in profile.user.outbounds: if first: row = [ index, profile.user.username, profile.folder_name, - conn.exit_point, - conn.protocol, + out.target.id, + out.protocol, ] first = False else: - row = ["", "", "", conn.exit_point, conn.protocol] + row = ["", "", "", out.target.id, out.protocol] rows.append(row) return rows @@ -113,12 +120,15 @@ def user_add(username: str, connection: list[str], **_): if isinstance(user, User): raise ValueError(f"User already exists: {username}") - keys: list[ConnectionKey] = [ - _parse_connection_spec(spec) for spec in connection + specs: list[OutboundSpec] = [ + _parse_outbound_spec(raw_spec) for raw_spec in connection ] 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) @@ -127,7 +137,7 @@ def user_add(username: str, connection: list[str], **_): def user_show(username: str | None, all: bool, **_): users = _resolve_users(username, all) - headers = ["#", "Username", "Exit Point", "Protocol"] + headers = ["#", "Username", "Target ID", "Protocol"] rows = _build_user_rows(users) 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): 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] + to_add_specs = [_parse_outbound_spec(spec) for spec in to_add] + 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) @@ -150,7 +162,7 @@ def user_delete(username: str | None, all: bool, **_): users = _resolve_users(username, all) for user in users: - xray_config.delete_user(user.username) + xray_config.delete_user(user) def profile_build(username: str | None, all: bool, **_): @@ -158,19 +170,19 @@ def profile_build(username: str | None, all: bool, **_): for user in users: try: - profile = storage.load_profile(user.username) + profile = 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) + profile_storage.save_profile(profile) def profile_show(username: str | None, all: bool, **_): profiles = _resolve_profiles(username, all) - headers = ["#", "Username", "Profile", "Exit Point", "Protocol"] + headers = ["#", "Username", "Profile", "Target ID", "Protocol"] rows = _build_profile_rows(profiles) print_table(headers, rows) @@ -180,14 +192,14 @@ def profile_clear(username: str | None, all: bool, **_): profiles = _resolve_profiles(username, all) 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, **_): profiles = _resolve_profiles(username, all) 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, **_):