feat: переход с использования connection.py на outbound.py

This commit is contained in:
2026-07-04 18:04:32 +03:00
parent 7e35cbabd1
commit 3c420018ad
4 changed files with 125 additions and 116 deletions
+12 -13
View File
@@ -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
+33 -22
View File
@@ -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
+34 -47
View File
@@ -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:
+46 -34
View File
@@ -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, **_):