mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Xray works
This commit is contained in:
62
vpn/xray_api_v2/__init__.py
Normal file
62
vpn/xray_api_v2/__init__.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Xray API Python Library"""
|
||||
from .client import XrayClient
|
||||
from .stats import StatsManager, StatItem, SystemStats
|
||||
from .subscription import SubscriptionLinkGenerator
|
||||
from .exceptions import (
|
||||
XrayAPIError, XrayConnectionError, XrayCommandError,
|
||||
XrayConfigError, XrayNotFoundError, XrayValidationError
|
||||
)
|
||||
from .models import (
|
||||
# Base
|
||||
XrayProtocol, TransportProtocol, SecurityType,
|
||||
|
||||
# Protocols
|
||||
VLESSClient, VMeSSUser, TrojanUser, TrojanFallback,
|
||||
VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig,
|
||||
generate_uuid, validate_uuid,
|
||||
|
||||
# Transports
|
||||
StreamSettings, create_tcp_stream, create_ws_stream,
|
||||
create_grpc_stream, create_http_stream, create_xhttp_stream,
|
||||
|
||||
# Security
|
||||
TLSConfig, REALITYConfig,
|
||||
create_tls_config, create_reality_config,
|
||||
generate_self_signed_certificate, create_tls_config_with_self_signed,
|
||||
|
||||
# Inbound
|
||||
InboundConfig, InboundBuilder, SniffingConfig
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
# Client
|
||||
'XrayClient',
|
||||
|
||||
# Stats
|
||||
'StatsManager', 'StatItem', 'SystemStats',
|
||||
|
||||
# Subscription
|
||||
'SubscriptionLinkGenerator',
|
||||
|
||||
# Exceptions
|
||||
'XrayAPIError', 'XrayConnectionError', 'XrayCommandError',
|
||||
'XrayConfigError', 'XrayNotFoundError', 'XrayValidationError',
|
||||
|
||||
# Enums
|
||||
'XrayProtocol', 'TransportProtocol', 'SecurityType',
|
||||
|
||||
# Models
|
||||
'VLESSClient', 'VMeSSUser', 'TrojanUser', 'TrojanFallback',
|
||||
'VLESSInboundConfig', 'VMeSSInboundConfig', 'TrojanServerConfig',
|
||||
'StreamSettings', 'TLSConfig', 'REALITYConfig',
|
||||
'InboundConfig', 'InboundBuilder', 'SniffingConfig',
|
||||
|
||||
# Utils
|
||||
'generate_uuid', 'validate_uuid',
|
||||
'create_tcp_stream', 'create_ws_stream',
|
||||
'create_grpc_stream', 'create_http_stream', 'create_xhttp_stream',
|
||||
'create_tls_config', 'create_reality_config',
|
||||
'generate_self_signed_certificate', 'create_tls_config_with_self_signed',
|
||||
]
|
235
vpn/xray_api_v2/client.py
Normal file
235
vpn/xray_api_v2/client.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Xray API client implementation"""
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
from .exceptions import (
|
||||
XrayConnectionError,
|
||||
XrayCommandError,
|
||||
XrayConfigError
|
||||
)
|
||||
from .models.base import BaseXrayModel
|
||||
|
||||
|
||||
class XrayClient:
|
||||
"""Client for interacting with Xray API via CLI commands"""
|
||||
|
||||
def __init__(self, server: str = "127.0.0.1:8080", timeout: int = 3):
|
||||
"""
|
||||
Initialize Xray client
|
||||
|
||||
Args:
|
||||
server: API server address (host:port)
|
||||
timeout: Command timeout in seconds
|
||||
"""
|
||||
self.server = server
|
||||
self.timeout = timeout
|
||||
self._xray_binary = "xray"
|
||||
|
||||
def execute_command(self,
|
||||
command: str,
|
||||
args: List[str] = None,
|
||||
json_files: List[Union[str, Dict, BaseXrayModel]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute xray API command
|
||||
|
||||
Args:
|
||||
command: API command (e.g., 'adi', 'adu', 'lsi')
|
||||
args: Additional command arguments
|
||||
json_files: JSON configurations (paths, dicts, or models)
|
||||
|
||||
Returns:
|
||||
Command output as dictionary
|
||||
"""
|
||||
cmd = [self._xray_binary, "api", command]
|
||||
cmd.extend([f"--server={self.server}", f"--timeout={self.timeout}"])
|
||||
|
||||
if args:
|
||||
cmd.extend(args)
|
||||
|
||||
temp_files = []
|
||||
try:
|
||||
# Handle JSON configurations
|
||||
if json_files:
|
||||
for config in json_files:
|
||||
if isinstance(config, str):
|
||||
# File path provided
|
||||
cmd.append(config)
|
||||
else:
|
||||
# Create temporary file for dict or model
|
||||
temp_file = self._create_temp_json(config)
|
||||
temp_files.append(temp_file)
|
||||
cmd.append(temp_file.name)
|
||||
|
||||
# Execute command
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=self.timeout + 5 # Add buffer to subprocess timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise XrayCommandError(f"Command failed: {result.stderr}")
|
||||
|
||||
# Parse output
|
||||
output = result.stdout.strip()
|
||||
if not output:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
# Some commands return plain text
|
||||
return {"output": output}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
raise XrayConnectionError(f"Command timed out after {self.timeout} seconds")
|
||||
except Exception as e:
|
||||
raise XrayCommandError(f"Command execution failed: {str(e)}")
|
||||
finally:
|
||||
# Cleanup temporary files
|
||||
for temp_file in temp_files:
|
||||
try:
|
||||
os.unlink(temp_file.name)
|
||||
except:
|
||||
pass
|
||||
|
||||
def _create_temp_json(self, config: Union[Dict, BaseXrayModel]) -> tempfile.NamedTemporaryFile:
|
||||
"""Create temporary JSON file from config"""
|
||||
if isinstance(config, BaseXrayModel):
|
||||
data = config.to_xray_json()
|
||||
else:
|
||||
data = config
|
||||
|
||||
temp_file = tempfile.NamedTemporaryFile(
|
||||
mode='w',
|
||||
suffix='.json',
|
||||
delete=False
|
||||
)
|
||||
json.dump(data, temp_file, indent=2)
|
||||
temp_file.close()
|
||||
return temp_file
|
||||
|
||||
# Inbound management
|
||||
def add_inbound(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add one or more inbounds"""
|
||||
# Wrap inbound configs in the required format
|
||||
wrapped_configs = []
|
||||
for config in configs:
|
||||
if isinstance(config, BaseXrayModel):
|
||||
config_dict = config.to_xray_json()
|
||||
else:
|
||||
config_dict = config
|
||||
|
||||
# Wrap in inbounds array if not already wrapped
|
||||
if "inbounds" not in config_dict:
|
||||
wrapped_config = {"inbounds": [config_dict]}
|
||||
else:
|
||||
wrapped_config = config_dict
|
||||
|
||||
wrapped_configs.append(wrapped_config)
|
||||
|
||||
return self.execute_command("adi", json_files=wrapped_configs)
|
||||
|
||||
def remove_inbound(self, tag: str) -> Dict[str, Any]:
|
||||
"""Remove inbound by tag"""
|
||||
return self.execute_command("rmi", args=[tag])
|
||||
|
||||
def list_inbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List all inbounds"""
|
||||
result = self.execute_command("lsi")
|
||||
return result.get("inbounds", [])
|
||||
|
||||
# Outbound management
|
||||
def add_outbound(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add one or more outbounds"""
|
||||
return self.execute_command("ado", json_files=list(configs))
|
||||
|
||||
def remove_outbound(self, tag: str) -> Dict[str, Any]:
|
||||
"""Remove outbound by tag"""
|
||||
return self.execute_command("rmo", args=[tag])
|
||||
|
||||
def list_outbounds(self) -> List[Dict[str, Any]]:
|
||||
"""List all outbounds"""
|
||||
result = self.execute_command("lso")
|
||||
return result.get("outbounds", [])
|
||||
|
||||
# User management
|
||||
def add_users(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add users to inbounds using JSON files"""
|
||||
return self.execute_command("adu", json_files=list(configs))
|
||||
|
||||
def remove_users(self, tag: str, *emails: str) -> Dict[str, Any]:
|
||||
"""Remove users from inbounds using tag and email list"""
|
||||
args = [f"-tag={tag}"] + list(emails)
|
||||
return self.execute_command("rmu", args=args)
|
||||
|
||||
def get_inbound_user(self, tag: str, email: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get inbound user(s) information using -tag flag"""
|
||||
args = [f"-tag={tag}"]
|
||||
if email:
|
||||
args.append(f"-email={email}")
|
||||
return self.execute_command("inbounduser", args=args)
|
||||
|
||||
def get_inbound_user_count(self, tag: str) -> int:
|
||||
"""Get inbound user count using -tag flag"""
|
||||
args = [f"-tag={tag}"]
|
||||
result = self.execute_command("inboundusercount", args=args)
|
||||
# Parse the result - might be in output field or direct number
|
||||
if isinstance(result, dict):
|
||||
return result.get("count", 0)
|
||||
return 0
|
||||
|
||||
# Statistics
|
||||
def get_stats(self, pattern: str = "", reset: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Get statistics"""
|
||||
args = [pattern]
|
||||
if reset:
|
||||
args.append("-reset")
|
||||
if pattern:
|
||||
args.extend(["-json"])
|
||||
result = self.execute_command("statsquery", args=args)
|
||||
return result.get("stat", [])
|
||||
|
||||
def get_system_stats(self) -> Dict[str, Any]:
|
||||
"""Get system statistics"""
|
||||
return self.execute_command("statssys")
|
||||
|
||||
def get_online_stats(self, email: str) -> Dict[str, Any]:
|
||||
"""Get online session count for user"""
|
||||
return self.execute_command("statsonline", args=[email])
|
||||
|
||||
def get_online_ips(self, email: str) -> List[Dict[str, Any]]:
|
||||
"""Get user's online IP addresses"""
|
||||
result = self.execute_command("statsonlineiplist", args=[email])
|
||||
return result.get("ips", [])
|
||||
|
||||
# Routing rules
|
||||
def add_routing_rules(self, *configs: Union[Dict, BaseXrayModel]) -> Dict[str, Any]:
|
||||
"""Add routing rules"""
|
||||
return self.execute_command("adrules", json_files=list(configs))
|
||||
|
||||
def remove_routing_rules(self, *tags: str) -> Dict[str, Any]:
|
||||
"""Remove routing rules by tags"""
|
||||
return self.execute_command("rmrules", args=list(tags))
|
||||
|
||||
# Other operations
|
||||
def restart_logger(self) -> Dict[str, Any]:
|
||||
"""Restart the logger"""
|
||||
return self.execute_command("restartlogger")
|
||||
|
||||
def get_balancer_info(self, tag: str) -> Dict[str, Any]:
|
||||
"""Get balancer information"""
|
||||
return self.execute_command("bi", args=[tag])
|
||||
|
||||
def override_balancer(self, tag: str, selectors: List[str]) -> Dict[str, Any]:
|
||||
"""Override balancer selection"""
|
||||
return self.execute_command("bo", args=[tag] + selectors)
|
||||
|
||||
def block_connection(self, source_ip: str, seconds: int) -> Dict[str, Any]:
|
||||
"""Block connections from source IP"""
|
||||
return self.execute_command("sib", args=[source_ip, str(seconds)])
|
0
vpn/xray_api_v2/commands/__init__.py
Normal file
0
vpn/xray_api_v2/commands/__init__.py
Normal file
33
vpn/xray_api_v2/commands/user.py
Normal file
33
vpn/xray_api_v2/commands/user.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""User management command wrappers"""
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from ..models.base import BaseXrayModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class AddUserRequest(BaseXrayModel):
|
||||
"""Request to add user to inbound"""
|
||||
inboundTag: str
|
||||
user: Dict[str, Any] # Protocol-specific user config
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoveUserRequest(BaseXrayModel):
|
||||
"""Request to remove user from inbound"""
|
||||
inboundTag: str
|
||||
email: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserStats(BaseXrayModel):
|
||||
"""User statistics data"""
|
||||
email: str
|
||||
uplink: int = 0
|
||||
downlink: int = 0
|
||||
online: bool = False
|
||||
ips: List[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.ips is None:
|
||||
self.ips = []
|
31
vpn/xray_api_v2/exceptions.py
Normal file
31
vpn/xray_api_v2/exceptions.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Exceptions for xray-api library"""
|
||||
|
||||
|
||||
class XrayAPIError(Exception):
|
||||
"""Base exception for all xray-api errors"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayConnectionError(XrayAPIError):
|
||||
"""Connection to Xray API server failed"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayCommandError(XrayAPIError):
|
||||
"""Xray command execution failed"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayConfigError(XrayAPIError):
|
||||
"""Invalid configuration"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayNotFoundError(XrayAPIError):
|
||||
"""Resource not found"""
|
||||
pass
|
||||
|
||||
|
||||
class XrayValidationError(XrayAPIError):
|
||||
"""Validation error"""
|
||||
pass
|
55
vpn/xray_api_v2/models/__init__.py
Normal file
55
vpn/xray_api_v2/models/__init__.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Xray API models"""
|
||||
from .base import (
|
||||
BaseXrayModel, XrayConfig, XrayProtocol,
|
||||
TransportProtocol, SecurityType
|
||||
)
|
||||
from .protocols import (
|
||||
# VLESS
|
||||
VLESSAccount, VLESSClient, VLESSInboundConfig,
|
||||
# VMess
|
||||
VMeSSAccount, VMeSSUser, VMeSSInboundConfig, VMeSSSecurityConfig,
|
||||
# Trojan
|
||||
TrojanAccount, TrojanUser, TrojanServerConfig, TrojanFallback,
|
||||
# Shadowsocks
|
||||
ShadowsocksAccount, ShadowsocksUser, ShadowsocksServerConfig,
|
||||
# Utils
|
||||
generate_uuid, validate_uuid, create_protocol_config
|
||||
)
|
||||
from .transports import (
|
||||
StreamSettings, TCPSettings, WebSocketSettings, GRPCSettings,
|
||||
HTTPSettings, XHTTPSettings, KCPSettings, QUICSettings, DomainSocketSettings,
|
||||
create_tcp_stream, create_ws_stream, create_grpc_stream, create_http_stream, create_xhttp_stream
|
||||
)
|
||||
from .security import (
|
||||
TLSConfig, REALITYConfig, XTLSConfig, Certificate,
|
||||
create_tls_config, create_reality_config, create_reality_client_config,
|
||||
generate_self_signed_certificate, create_tls_config_with_self_signed
|
||||
)
|
||||
from .inbound import (
|
||||
InboundConfig, ReceiverConfig, SniffingConfig, InboundBuilder
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
'BaseXrayModel', 'XrayConfig', 'XrayProtocol', 'TransportProtocol', 'SecurityType',
|
||||
|
||||
# Protocols
|
||||
'VLESSAccount', 'VLESSClient', 'VLESSInboundConfig',
|
||||
'VMeSSAccount', 'VMeSSUser', 'VMeSSInboundConfig', 'VMeSSSecurityConfig',
|
||||
'TrojanAccount', 'TrojanUser', 'TrojanServerConfig', 'TrojanFallback',
|
||||
'ShadowsocksAccount', 'ShadowsocksUser', 'ShadowsocksServerConfig',
|
||||
'generate_uuid', 'validate_uuid', 'create_protocol_config',
|
||||
|
||||
# Transports
|
||||
'StreamSettings', 'TCPSettings', 'WebSocketSettings', 'GRPCSettings',
|
||||
'HTTPSettings', 'XHTTPSettings', 'KCPSettings', 'QUICSettings', 'DomainSocketSettings',
|
||||
'create_tcp_stream', 'create_ws_stream', 'create_grpc_stream', 'create_http_stream', 'create_xhttp_stream',
|
||||
|
||||
# Security
|
||||
'TLSConfig', 'REALITYConfig', 'XTLSConfig', 'Certificate',
|
||||
'create_tls_config', 'create_reality_config', 'create_reality_client_config',
|
||||
'generate_self_signed_certificate', 'create_tls_config_with_self_signed',
|
||||
|
||||
# Inbound
|
||||
'InboundConfig', 'ReceiverConfig', 'SniffingConfig', 'InboundBuilder',
|
||||
]
|
97
vpn/xray_api_v2/models/base.py
Normal file
97
vpn/xray_api_v2/models/base.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Base models for xray-api library"""
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from typing import Dict, Any, Optional, Type, TypeVar
|
||||
import json
|
||||
from enum import Enum
|
||||
|
||||
T = TypeVar('T', bound='BaseXrayModel')
|
||||
|
||||
|
||||
class BaseXrayModel(ABC):
|
||||
"""Base class for all Xray configuration models"""
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert model to dictionary for storage"""
|
||||
if hasattr(self, '__dataclass_fields__'):
|
||||
return self._clean_dict(asdict(self))
|
||||
return self._clean_dict(self.__dict__.copy())
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert model to Xray API format"""
|
||||
return self.to_dict()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
|
||||
"""Create model instance from dictionary"""
|
||||
return cls(**data)
|
||||
|
||||
def _clean_dict(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Remove None values and empty collections"""
|
||||
cleaned = {}
|
||||
for key, value in data.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, (list, dict)) and not value:
|
||||
continue
|
||||
if isinstance(value, BaseXrayModel):
|
||||
cleaned[key] = value.to_dict()
|
||||
elif isinstance(value, list):
|
||||
cleaned[key] = [
|
||||
item.to_dict() if isinstance(item, BaseXrayModel) else item
|
||||
for item in value
|
||||
]
|
||||
elif isinstance(value, Enum):
|
||||
cleaned[key] = value.value
|
||||
else:
|
||||
cleaned[key] = value
|
||||
return cleaned
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Convert to JSON string"""
|
||||
return json.dumps(self.to_dict(), indent=2)
|
||||
|
||||
|
||||
@dataclass
|
||||
class XrayConfig(BaseXrayModel):
|
||||
"""Base configuration class"""
|
||||
_TypedMessage_: Optional[str] = field(default=None, init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Set TypedMessage after initialization"""
|
||||
if self._TypedMessage_ is None and hasattr(self, '__xray_type__'):
|
||||
self._TypedMessage_ = self.__xray_type__
|
||||
|
||||
|
||||
class XrayProtocol(str, Enum):
|
||||
"""Supported Xray protocols"""
|
||||
VLESS = "vless"
|
||||
VMESS = "vmess"
|
||||
TROJAN = "trojan"
|
||||
SHADOWSOCKS = "shadowsocks"
|
||||
DOKODEMO = "dokodemo-door"
|
||||
FREEDOM = "freedom"
|
||||
BLACKHOLE = "blackhole"
|
||||
DNS = "dns"
|
||||
HTTP = "http"
|
||||
SOCKS = "socks"
|
||||
|
||||
|
||||
class TransportProtocol(str, Enum):
|
||||
"""Transport protocols"""
|
||||
TCP = "tcp"
|
||||
KCP = "kcp"
|
||||
WS = "ws"
|
||||
HTTP = "http"
|
||||
XHTTP = "xhttp"
|
||||
DOMAINSOCKET = "domainsocket"
|
||||
QUIC = "quic"
|
||||
GRPC = "grpc"
|
||||
|
||||
|
||||
class SecurityType(str, Enum):
|
||||
"""Security types"""
|
||||
NONE = "none"
|
||||
TLS = "tls"
|
||||
REALITY = "reality"
|
||||
XTLS = "xtls"
|
176
vpn/xray_api_v2/models/inbound.py
Normal file
176
vpn/xray_api_v2/models/inbound.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Inbound configuration models"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, XrayProtocol
|
||||
from .protocols import (
|
||||
VLESSInboundConfig, VMeSSInboundConfig,
|
||||
TrojanServerConfig, ShadowsocksServerConfig
|
||||
)
|
||||
from .transports import StreamSettings
|
||||
|
||||
|
||||
@dataclass
|
||||
class SniffingConfig(BaseXrayModel):
|
||||
"""Traffic sniffing configuration"""
|
||||
enabled: bool = True
|
||||
destOverride: Optional[List[str]] = None
|
||||
metadataOnly: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
if self.destOverride is None:
|
||||
self.destOverride = ["http", "tls"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReceiverConfig(XrayConfig):
|
||||
"""Receiver configuration for inbound"""
|
||||
__xray_type__ = "xray.app.proxyman.ReceiverConfig"
|
||||
|
||||
listen: str = "0.0.0.0"
|
||||
port: Optional[int] = None
|
||||
portList: Optional[Union[int, str]] = None # Can be int or range like "10000-20000"
|
||||
streamSettings: Optional[StreamSettings] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format"""
|
||||
config = {"listen": self.listen}
|
||||
|
||||
# Either port or portList must be set
|
||||
if self.port is not None:
|
||||
config["port"] = self.port
|
||||
elif self.portList is not None:
|
||||
config["portList"] = self.portList
|
||||
else:
|
||||
raise ValueError("Either port or portList must be specified")
|
||||
|
||||
if self.streamSettings:
|
||||
config["streamSettings"] = self.streamSettings.to_xray_json()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@dataclass
|
||||
class InboundConfig(BaseXrayModel):
|
||||
"""Complete inbound configuration"""
|
||||
tag: str
|
||||
protocol: XrayProtocol
|
||||
settings: Union[VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig, ShadowsocksServerConfig]
|
||||
listen: str = "0.0.0.0"
|
||||
port: Optional[int] = None
|
||||
portList: Optional[Union[int, str]] = None
|
||||
streamSettings: Optional[StreamSettings] = None
|
||||
sniffing: Optional[SniffingConfig] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper field order and structure"""
|
||||
config = {
|
||||
"listen": self.listen,
|
||||
"tag": self.tag,
|
||||
"protocol": self.protocol.value if hasattr(self.protocol, 'value') else str(self.protocol),
|
||||
}
|
||||
|
||||
# Add port or portList (port comes before protocol in working format)
|
||||
if self.port is not None:
|
||||
config["port"] = self.port
|
||||
elif self.portList is not None:
|
||||
config["portList"] = self.portList
|
||||
else:
|
||||
raise ValueError("Either port or portList must be specified")
|
||||
|
||||
# Add protocol settings with _TypedMessage_
|
||||
settings = self.settings.to_xray_json()
|
||||
if "_TypedMessage_" not in settings:
|
||||
# Add _TypedMessage_ based on protocol
|
||||
protocol_type_map = {
|
||||
"vless": "xray.proxy.vless.inbound.Config",
|
||||
"vmess": "xray.proxy.vmess.inbound.Config",
|
||||
"trojan": "xray.proxy.trojan.inbound.Config",
|
||||
"shadowsocks": "xray.proxy.shadowsocks.inbound.Config"
|
||||
}
|
||||
protocol_name = self.protocol.value if hasattr(self.protocol, 'value') else str(self.protocol)
|
||||
if protocol_name in protocol_type_map:
|
||||
settings["_TypedMessage_"] = protocol_type_map[protocol_name]
|
||||
|
||||
config["settings"] = settings
|
||||
|
||||
# Add stream settings
|
||||
if self.streamSettings:
|
||||
config["streamSettings"] = self.streamSettings.to_xray_json()
|
||||
else:
|
||||
config["streamSettings"] = {"network": "tcp"} # Default TCP
|
||||
|
||||
# Add sniffing
|
||||
if self.sniffing:
|
||||
config["sniffing"] = self.sniffing.to_dict()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Builder for easier configuration
|
||||
class InboundBuilder:
|
||||
"""Builder for creating inbound configurations"""
|
||||
|
||||
def __init__(self, tag: str, protocol: XrayProtocol):
|
||||
self.tag = tag
|
||||
self.protocol = protocol
|
||||
self._settings = None
|
||||
self._listen = "0.0.0.0"
|
||||
self._port = None
|
||||
self._port_list = None
|
||||
self._stream_settings = None
|
||||
self._sniffing = None
|
||||
|
||||
def listen(self, address: str) -> 'InboundBuilder':
|
||||
"""Set listen address"""
|
||||
self._listen = address
|
||||
return self
|
||||
|
||||
def port(self, port: int) -> 'InboundBuilder':
|
||||
"""Set single port"""
|
||||
self._port = port
|
||||
self._port_list = None
|
||||
return self
|
||||
|
||||
def port_range(self, start: int, end: int) -> 'InboundBuilder':
|
||||
"""Set port range"""
|
||||
self._port_list = f"{start}-{end}"
|
||||
self._port = None
|
||||
return self
|
||||
|
||||
def stream_settings(self, settings: StreamSettings) -> 'InboundBuilder':
|
||||
"""Set stream settings"""
|
||||
self._stream_settings = settings
|
||||
return self
|
||||
|
||||
def sniffing(self, enabled: bool = True, dest_override: Optional[List[str]] = None) -> 'InboundBuilder':
|
||||
"""Configure sniffing"""
|
||||
self._sniffing = SniffingConfig(
|
||||
enabled=enabled,
|
||||
destOverride=dest_override
|
||||
)
|
||||
return self
|
||||
|
||||
def protocol_settings(self, settings: Union[VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig, ShadowsocksServerConfig]) -> 'InboundBuilder':
|
||||
"""Set protocol-specific settings"""
|
||||
self._settings = settings
|
||||
return self
|
||||
|
||||
def build(self) -> InboundConfig:
|
||||
"""Build the inbound configuration"""
|
||||
if not self._settings:
|
||||
raise ValueError("Protocol settings must be specified")
|
||||
|
||||
if not self._port and not self._port_list:
|
||||
raise ValueError("Either port or port range must be specified")
|
||||
|
||||
return InboundConfig(
|
||||
tag=self.tag,
|
||||
protocol=self.protocol,
|
||||
settings=self._settings,
|
||||
listen=self._listen,
|
||||
port=self._port,
|
||||
portList=self._port_list,
|
||||
streamSettings=self._stream_settings,
|
||||
sniffing=self._sniffing
|
||||
)
|
266
vpn/xray_api_v2/models/protocols.py
Normal file
266
vpn/xray_api_v2/models/protocols.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""Protocol models for Xray"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from uuid import uuid4
|
||||
import re
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, XrayProtocol
|
||||
|
||||
|
||||
def validate_uuid(uuid_str: str) -> bool:
|
||||
"""Validate UUID format"""
|
||||
pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I)
|
||||
return bool(pattern.match(uuid_str))
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
"""Generate new UUID"""
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
# VLESS Protocol
|
||||
@dataclass
|
||||
class VLESSAccount(XrayConfig):
|
||||
"""VLESS account configuration"""
|
||||
__xray_type__ = "xray.proxy.vless.Account"
|
||||
|
||||
id: str
|
||||
flow: Optional[str] = None
|
||||
encryption: str = "none"
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
if not validate_uuid(self.id):
|
||||
raise ValueError(f"Invalid UUID: {self.id}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class VLESSClient(BaseXrayModel):
|
||||
"""VLESS client configuration"""
|
||||
email: str
|
||||
account: VLESSAccount
|
||||
level: int = 0
|
||||
|
||||
@classmethod
|
||||
def create(cls, email: str, uuid: Optional[str] = None, flow: Optional[str] = None) -> 'VLESSClient':
|
||||
"""Create VLESS client with optional UUID generation"""
|
||||
if uuid is None:
|
||||
uuid = generate_uuid()
|
||||
account = VLESSAccount(id=uuid, flow=flow)
|
||||
return cls(email=email, account=account)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VLESSInboundConfig(XrayConfig):
|
||||
"""VLESS inbound configuration"""
|
||||
__xray_type__ = "xray.proxy.vless.inbound.Config"
|
||||
|
||||
clients: List[VLESSClient] = field(default_factory=list)
|
||||
decryption: str = "none"
|
||||
fallbacks: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper structure"""
|
||||
config = {
|
||||
"_TypedMessage_": self.__xray_type__,
|
||||
"clients": [],
|
||||
"decryption": self.decryption
|
||||
}
|
||||
|
||||
# Convert clients to proper format
|
||||
for client in self.clients:
|
||||
client_data = {
|
||||
"id": client.account.id,
|
||||
"level": client.level,
|
||||
"email": client.email
|
||||
}
|
||||
if client.account.flow:
|
||||
client_data["flow"] = client.account.flow
|
||||
config["clients"].append(client_data)
|
||||
|
||||
if self.fallbacks:
|
||||
config["fallbacks"] = self.fallbacks
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# VMess Protocol
|
||||
@dataclass
|
||||
class VMeSSSecurityConfig(BaseXrayModel):
|
||||
"""VMess security configuration"""
|
||||
type: str = "AUTO" # AUTO, AES-128-GCM, CHACHA20-POLY1305, NONE
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMeSSAccount(XrayConfig):
|
||||
"""VMess account configuration"""
|
||||
__xray_type__ = "xray.proxy.vmess.Account"
|
||||
|
||||
id: str
|
||||
securitySettings: Optional[VMeSSSecurityConfig] = None
|
||||
|
||||
def __post_init__(self):
|
||||
super().__post_init__()
|
||||
if not validate_uuid(self.id):
|
||||
raise ValueError(f"Invalid UUID: {self.id}")
|
||||
if self.securitySettings is None:
|
||||
self.securitySettings = VMeSSSecurityConfig()
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMeSSUser(BaseXrayModel):
|
||||
"""VMess user configuration"""
|
||||
email: str
|
||||
account: VMeSSAccount
|
||||
level: int = 0
|
||||
|
||||
@classmethod
|
||||
def create(cls, email: str, uuid: Optional[str] = None, security: str = "AUTO") -> 'VMeSSUser':
|
||||
"""Create VMess user with optional UUID generation"""
|
||||
if uuid is None:
|
||||
uuid = generate_uuid()
|
||||
account = VMeSSAccount(
|
||||
id=uuid,
|
||||
securitySettings=VMeSSSecurityConfig(type=security)
|
||||
)
|
||||
return cls(email=email, account=account)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VMeSSInboundConfig(XrayConfig):
|
||||
"""VMess inbound configuration"""
|
||||
__xray_type__ = "xray.proxy.vmess.inbound.Config"
|
||||
|
||||
user: List[VMeSSUser] = field(default_factory=list)
|
||||
disableInsecureEncryption: bool = False
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper structure"""
|
||||
config = {
|
||||
"_TypedMessage_": self.__xray_type__,
|
||||
"clients": []
|
||||
}
|
||||
|
||||
# Convert users to proper format
|
||||
for user in self.user:
|
||||
client_data = {
|
||||
"id": user.account.id,
|
||||
"level": user.level,
|
||||
"email": user.email,
|
||||
"alterId": 0 # VMess specific
|
||||
}
|
||||
config["clients"].append(client_data)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Trojan Protocol
|
||||
@dataclass
|
||||
class TrojanAccount(XrayConfig):
|
||||
"""Trojan account configuration"""
|
||||
__xray_type__ = "xray.proxy.trojan.Account"
|
||||
|
||||
password: str
|
||||
|
||||
@classmethod
|
||||
def generate_password(cls) -> str:
|
||||
"""Generate secure password"""
|
||||
return generate_uuid()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanUser(BaseXrayModel):
|
||||
"""Trojan user configuration"""
|
||||
email: str
|
||||
account: TrojanAccount
|
||||
level: int = 0
|
||||
|
||||
@classmethod
|
||||
def create(cls, email: str, password: Optional[str] = None) -> 'TrojanUser':
|
||||
"""Create Trojan user with optional password generation"""
|
||||
if password is None:
|
||||
password = TrojanAccount.generate_password()
|
||||
account = TrojanAccount(password=password)
|
||||
return cls(email=email, account=account)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanFallback(BaseXrayModel):
|
||||
"""Trojan fallback configuration"""
|
||||
dest: str
|
||||
type: str = "tcp"
|
||||
xver: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrojanServerConfig(XrayConfig):
|
||||
"""Trojan server configuration"""
|
||||
__xray_type__ = "xray.proxy.trojan.ServerConfig"
|
||||
|
||||
users: List[TrojanUser] = field(default_factory=list)
|
||||
fallbacks: Optional[List[TrojanFallback]] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray API format with proper structure"""
|
||||
config = {
|
||||
"_TypedMessage_": self.__xray_type__,
|
||||
"clients": []
|
||||
}
|
||||
|
||||
# Convert users to proper format
|
||||
for user in self.users:
|
||||
client_data = {
|
||||
"password": user.account.password,
|
||||
"level": user.level,
|
||||
"email": user.email
|
||||
}
|
||||
config["clients"].append(client_data)
|
||||
|
||||
if self.fallbacks:
|
||||
config["fallbacks"] = [fb.to_dict() for fb in self.fallbacks]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Shadowsocks Protocol
|
||||
@dataclass
|
||||
class ShadowsocksAccount(XrayConfig):
|
||||
"""Shadowsocks account configuration"""
|
||||
__xray_type__ = "xray.proxy.shadowsocks.Account"
|
||||
|
||||
method: str # aes-256-gcm, aes-128-gcm, chacha20-poly1305, etc.
|
||||
password: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShadowsocksUser(BaseXrayModel):
|
||||
"""Shadowsocks user configuration"""
|
||||
email: str
|
||||
account: ShadowsocksAccount
|
||||
level: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShadowsocksServerConfig(XrayConfig):
|
||||
"""Shadowsocks server configuration"""
|
||||
__xray_type__ = "xray.proxy.shadowsocks.ServerConfig"
|
||||
|
||||
users: List[ShadowsocksUser] = field(default_factory=list)
|
||||
network: str = "tcp,udp"
|
||||
|
||||
|
||||
# Protocol config factory
|
||||
def create_protocol_config(protocol: XrayProtocol, **kwargs) -> XrayConfig:
|
||||
"""Factory to create protocol configurations"""
|
||||
protocol_map = {
|
||||
XrayProtocol.VLESS: VLESSInboundConfig,
|
||||
XrayProtocol.VMESS: VMeSSInboundConfig,
|
||||
XrayProtocol.TROJAN: TrojanServerConfig,
|
||||
XrayProtocol.SHADOWSOCKS: ShadowsocksServerConfig,
|
||||
}
|
||||
|
||||
config_class = protocol_map.get(protocol)
|
||||
if not config_class:
|
||||
raise ValueError(f"Unsupported protocol: {protocol}")
|
||||
|
||||
return config_class(**kwargs)
|
389
vpn/xray_api_v2/models/security.py
Normal file
389
vpn/xray_api_v2/models/security.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""Security configuration models for Xray"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, SecurityType
|
||||
|
||||
|
||||
# TLS Configuration
|
||||
@dataclass
|
||||
class Certificate(BaseXrayModel):
|
||||
"""TLS certificate configuration"""
|
||||
certificateFile: Optional[str] = None
|
||||
keyFile: Optional[str] = None
|
||||
certificate: Optional[List[str]] = None # PEM format lines
|
||||
key: Optional[List[str]] = None # PEM format lines
|
||||
usage: str = "encipherment"
|
||||
ocspStapling: int = 3600
|
||||
oneTimeLoading: bool = False
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format"""
|
||||
config = {}
|
||||
|
||||
if self.certificateFile and self.keyFile:
|
||||
config["certificateFile"] = self.certificateFile
|
||||
config["keyFile"] = self.keyFile
|
||||
elif self.certificate and self.key:
|
||||
config["certificate"] = self.certificate
|
||||
config["key"] = self.key
|
||||
|
||||
config["usage"] = self.usage
|
||||
|
||||
if self.ocspStapling:
|
||||
config["ocspStapling"] = self.ocspStapling
|
||||
|
||||
if self.oneTimeLoading:
|
||||
config["OneTimeLoading"] = self.oneTimeLoading
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@dataclass
|
||||
class TLSConfig(XrayConfig):
|
||||
"""TLS configuration"""
|
||||
__xray_type__ = "xray.transport.internet.tls.Config"
|
||||
|
||||
serverName: Optional[str] = None
|
||||
allowInsecure: bool = False
|
||||
alpn: Optional[List[str]] = None
|
||||
minVersion: str = "1.2"
|
||||
maxVersion: str = "1.3"
|
||||
preferServerCipherSuites: bool = True
|
||||
cipherSuites: Optional[str] = None
|
||||
certificates: Optional[List[Certificate]] = None
|
||||
disableSystemRoot: bool = False
|
||||
enableSessionResumption: bool = False
|
||||
fingerprint: Optional[str] = None # Client-side
|
||||
pinnedPeerCertificateChainSha256: Optional[List[str]] = None
|
||||
rejectUnknownSni: bool = False # Server-side
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format with proper field handling"""
|
||||
config = super().to_xray_json()
|
||||
|
||||
# Handle certificates properly
|
||||
if self.certificates:
|
||||
config["certificates"] = [cert.to_xray_json() for cert in self.certificates]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# REALITY Configuration
|
||||
@dataclass
|
||||
class REALITYConfig(XrayConfig):
|
||||
"""REALITY configuration"""
|
||||
__xray_type__ = "xray.transport.internet.reality.Config"
|
||||
|
||||
# Server-side
|
||||
show: bool = False
|
||||
dest: Optional[str] = None # e.g., "example.com:443"
|
||||
xver: int = 0
|
||||
serverNames: Optional[List[str]] = None
|
||||
privateKey: Optional[str] = None
|
||||
shortIds: Optional[List[str]] = None
|
||||
|
||||
# Client-side
|
||||
serverName: Optional[str] = None
|
||||
fingerprint: str = "chrome"
|
||||
publicKey: Optional[str] = None
|
||||
shortId: Optional[str] = None
|
||||
spiderX: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def generate_keys(cls) -> Dict[str, str]:
|
||||
"""Generate REALITY key pair using xray x25519"""
|
||||
import subprocess
|
||||
try:
|
||||
# Use xray x25519 to generate proper keys
|
||||
result = subprocess.run(['xray', 'x25519'], capture_output=True, text=True, check=True)
|
||||
lines = result.stdout.strip().split('\n')
|
||||
|
||||
private_key = ""
|
||||
public_key = ""
|
||||
|
||||
for line in lines:
|
||||
if line.startswith('Private key:'):
|
||||
private_key = line.split(': ')[1].strip()
|
||||
elif line.startswith('Public key:'):
|
||||
public_key = line.split(': ')[1].strip()
|
||||
|
||||
return {
|
||||
"privateKey": private_key,
|
||||
"publicKey": public_key
|
||||
}
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, IndexError):
|
||||
# Fallback to base64 encoded random bytes (32 bytes for X25519)
|
||||
import base64
|
||||
private_bytes = secrets.token_bytes(32)
|
||||
public_bytes = secrets.token_bytes(32)
|
||||
return {
|
||||
"privateKey": base64.b64encode(private_bytes).decode().rstrip('='),
|
||||
"publicKey": base64.b64encode(public_bytes).decode().rstrip('=')
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def generate_short_id(cls) -> str:
|
||||
"""Generate random short ID (1-16 hex chars)"""
|
||||
# Generate 1-8 bytes (2-16 hex chars)
|
||||
length = secrets.randbelow(8) + 1
|
||||
return secrets.token_hex(length)
|
||||
|
||||
|
||||
# XTLS Configuration (deprecated but still supported)
|
||||
@dataclass
|
||||
class XTLSConfig(XrayConfig):
|
||||
"""XTLS configuration (legacy)"""
|
||||
__xray_type__ = "xray.transport.internet.xtls.Config"
|
||||
|
||||
serverName: Optional[str] = None
|
||||
allowInsecure: bool = False
|
||||
alpn: Optional[List[str]] = None
|
||||
minVersion: str = "1.2"
|
||||
maxVersion: str = "1.3"
|
||||
preferServerCipherSuites: bool = True
|
||||
cipherSuites: Optional[str] = None
|
||||
certificates: Optional[List[Certificate]] = None
|
||||
disableSystemRoot: bool = False
|
||||
fingerprint: Optional[str] = None
|
||||
rejectUnknownSni: bool = False
|
||||
|
||||
|
||||
# Security factory
|
||||
def create_tls_config(
|
||||
server_name: Optional[str] = None,
|
||||
cert_file: Optional[str] = None,
|
||||
key_file: Optional[str] = None,
|
||||
alpn: Optional[List[str]] = None,
|
||||
fingerprint: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> TLSConfig:
|
||||
"""Create TLS configuration"""
|
||||
config = TLSConfig(
|
||||
serverName=server_name,
|
||||
alpn=alpn or ["h2", "http/1.1"],
|
||||
fingerprint=fingerprint,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
if cert_file and key_file:
|
||||
config.certificates = [Certificate(
|
||||
certificateFile=cert_file,
|
||||
keyFile=key_file
|
||||
)]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def create_reality_config(
|
||||
dest: str,
|
||||
server_names: Optional[List[str]] = None,
|
||||
private_key: Optional[str] = None,
|
||||
short_ids: Optional[List[str]] = None,
|
||||
**kwargs
|
||||
) -> REALITYConfig:
|
||||
"""Create REALITY configuration for server"""
|
||||
if not private_key:
|
||||
keys = REALITYConfig.generate_keys()
|
||||
private_key = keys["privateKey"]
|
||||
|
||||
if not short_ids:
|
||||
short_ids = [REALITYConfig.generate_short_id()]
|
||||
|
||||
return REALITYConfig(
|
||||
show=False,
|
||||
dest=dest,
|
||||
serverNames=server_names or [dest.split(":")[0]],
|
||||
privateKey=private_key,
|
||||
shortIds=short_ids,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
def create_reality_client_config(
|
||||
server_name: str,
|
||||
public_key: str,
|
||||
short_id: str,
|
||||
fingerprint: str = "chrome",
|
||||
**kwargs
|
||||
) -> REALITYConfig:
|
||||
"""Create REALITY configuration for client"""
|
||||
return REALITYConfig(
|
||||
serverName=server_name,
|
||||
publicKey=public_key,
|
||||
shortId=short_id,
|
||||
fingerprint=fingerprint,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
|
||||
# Certificate generation utilities
|
||||
def generate_self_signed_certificate(
|
||||
common_name: str = "localhost",
|
||||
san_list: Optional[List[str]] = None,
|
||||
days: int = 365,
|
||||
key_size: int = 2048,
|
||||
save_to_files: bool = False,
|
||||
cert_path: Optional[str] = None,
|
||||
key_path: Optional[str] = None
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Generate self-signed certificate
|
||||
|
||||
Args:
|
||||
common_name: Certificate common name
|
||||
san_list: Subject Alternative Names (domains/IPs)
|
||||
days: Certificate validity in days
|
||||
key_size: RSA key size
|
||||
save_to_files: Whether to save to files
|
||||
cert_path: Path to save certificate
|
||||
key_path: Path to save private key
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID, ExtensionOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
except ImportError:
|
||||
raise ImportError("cryptography package is required for certificate generation. Install with: pip install cryptography")
|
||||
|
||||
# Generate private key
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_size,
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
# Create certificate subject
|
||||
subject = issuer = x509.Name([
|
||||
x509.NameAttribute(NameOID.COUNTRY_NAME, "US"),
|
||||
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "State"),
|
||||
x509.NameAttribute(NameOID.LOCALITY_NAME, "City"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Xray Self-Signed"),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, common_name),
|
||||
])
|
||||
|
||||
# Create certificate builder
|
||||
builder = x509.CertificateBuilder()
|
||||
builder = builder.subject_name(subject)
|
||||
builder = builder.issuer_name(issuer)
|
||||
builder = builder.public_key(private_key.public_key())
|
||||
builder = builder.serial_number(x509.random_serial_number())
|
||||
builder = builder.not_valid_before(datetime.utcnow())
|
||||
builder = builder.not_valid_after(datetime.utcnow() + timedelta(days=days))
|
||||
|
||||
# Add Subject Alternative Names
|
||||
san_list = san_list or [common_name]
|
||||
alt_names = []
|
||||
for san in san_list:
|
||||
if san.replace('.', '').isdigit(): # IP address
|
||||
alt_names.append(x509.IPAddress(ipaddress.ip_address(san)))
|
||||
else: # Domain name
|
||||
alt_names.append(x509.DNSName(san))
|
||||
|
||||
builder = builder.add_extension(
|
||||
x509.SubjectAlternativeName(alt_names),
|
||||
critical=False,
|
||||
)
|
||||
|
||||
# Add basic constraints
|
||||
builder = builder.add_extension(
|
||||
x509.BasicConstraints(ca=True, path_length=0),
|
||||
critical=True,
|
||||
)
|
||||
|
||||
# Add key usage
|
||||
builder = builder.add_extension(
|
||||
x509.KeyUsage(
|
||||
digital_signature=True,
|
||||
content_commitment=False,
|
||||
key_encipherment=True,
|
||||
data_encipherment=False,
|
||||
key_agreement=False,
|
||||
key_cert_sign=True,
|
||||
crl_sign=False,
|
||||
encipher_only=False,
|
||||
decipher_only=False,
|
||||
),
|
||||
critical=True,
|
||||
)
|
||||
|
||||
# Sign certificate
|
||||
certificate = builder.sign(private_key, hashes.SHA256(), default_backend())
|
||||
|
||||
# Serialize to PEM
|
||||
cert_pem = certificate.public_bytes(serialization.Encoding.PEM).decode('utf-8')
|
||||
key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
).decode('utf-8')
|
||||
|
||||
# Save to files if requested
|
||||
if save_to_files:
|
||||
cert_file = cert_path or f"{common_name}_cert.pem"
|
||||
key_file = key_path or f"{common_name}_key.pem"
|
||||
|
||||
with open(cert_file, 'w') as f:
|
||||
f.write(cert_pem)
|
||||
|
||||
with open(key_file, 'w') as f:
|
||||
f.write(key_pem)
|
||||
|
||||
# Set appropriate permissions
|
||||
Path(key_file).chmod(0o600)
|
||||
|
||||
return cert_pem, key_pem
|
||||
|
||||
|
||||
def create_tls_config_with_self_signed(
|
||||
common_name: str = "localhost",
|
||||
san_list: Optional[List[str]] = None,
|
||||
alpn: Optional[List[str]] = None,
|
||||
**tls_kwargs
|
||||
) -> Tuple[TLSConfig, str, str]:
|
||||
"""
|
||||
Create TLS configuration with self-signed certificate
|
||||
|
||||
Returns:
|
||||
Tuple of (TLSConfig, certificate_pem, private_key_pem)
|
||||
"""
|
||||
cert_pem, key_pem = generate_self_signed_certificate(
|
||||
common_name=common_name,
|
||||
san_list=san_list
|
||||
)
|
||||
|
||||
# Convert PEM to lines for Xray format
|
||||
cert_lines = cert_pem.strip().split('\n')
|
||||
key_lines = key_pem.strip().split('\n')
|
||||
|
||||
# Create certificate config
|
||||
certificate = Certificate(
|
||||
certificate=cert_lines,
|
||||
key=key_lines,
|
||||
oneTimeLoading=True
|
||||
)
|
||||
|
||||
# Create TLS config
|
||||
tls_config = TLSConfig(
|
||||
serverName=common_name,
|
||||
alpn=alpn or ["h2", "http/1.1"],
|
||||
certificates=[certificate],
|
||||
**tls_kwargs
|
||||
)
|
||||
|
||||
return tls_config, cert_pem, key_pem
|
||||
|
||||
|
||||
# Add missing import
|
||||
try:
|
||||
import ipaddress
|
||||
except ImportError:
|
||||
ipaddress = None
|
241
vpn/xray_api_v2/models/transports.py
Normal file
241
vpn/xray_api_v2/models/transports.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Transport configuration models for Xray"""
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from .base import BaseXrayModel, XrayConfig, TransportProtocol
|
||||
|
||||
|
||||
# TCP Transport
|
||||
@dataclass
|
||||
class TCPSettings(XrayConfig):
|
||||
"""TCP transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.tcp.Config"
|
||||
|
||||
acceptProxyProtocol: bool = False
|
||||
header: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# KCP Transport
|
||||
@dataclass
|
||||
class KCPSettings(XrayConfig):
|
||||
"""KCP transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.kcp.Config"
|
||||
|
||||
mtu: int = 1350
|
||||
tti: int = 50
|
||||
uplinkCapacity: int = 5
|
||||
downlinkCapacity: int = 20
|
||||
congestion: bool = False
|
||||
readBufferSize: int = 2
|
||||
writeBufferSize: int = 2
|
||||
header: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# WebSocket Transport
|
||||
@dataclass
|
||||
class WebSocketSettings(XrayConfig):
|
||||
"""WebSocket transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.websocket.Config"
|
||||
|
||||
path: str = "/"
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
acceptProxyProtocol: bool = False
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format"""
|
||||
config = super().to_xray_json()
|
||||
# Ensure headers is a dict even if empty
|
||||
if self.headers:
|
||||
config["headers"] = self.headers
|
||||
return config
|
||||
|
||||
|
||||
# HTTP/2 Transport
|
||||
@dataclass
|
||||
class HTTPSettings(XrayConfig):
|
||||
"""HTTP/2 transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.http.Config"
|
||||
|
||||
path: str = "/"
|
||||
host: Optional[List[str]] = None
|
||||
method: str = "PUT"
|
||||
headers: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
|
||||
# XHTTP Transport (New)
|
||||
@dataclass
|
||||
class XHTTPSettings(XrayConfig):
|
||||
"""XHTTP transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.xhttp.Config"
|
||||
|
||||
path: str = "/"
|
||||
host: Optional[str] = None
|
||||
method: str = "GET"
|
||||
headers: Optional[Dict[str, Any]] = None
|
||||
mode: str = "auto"
|
||||
|
||||
|
||||
# Domain Socket Transport
|
||||
@dataclass
|
||||
class DomainSocketSettings(XrayConfig):
|
||||
"""Domain socket transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.domainsocket.Config"
|
||||
|
||||
path: str
|
||||
abstract: bool = False
|
||||
padding: bool = False
|
||||
|
||||
|
||||
# QUIC Transport
|
||||
@dataclass
|
||||
class QUICSettings(XrayConfig):
|
||||
"""QUIC transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.quic.Config"
|
||||
|
||||
security: str = "none"
|
||||
key: str = ""
|
||||
header: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
# gRPC Transport
|
||||
@dataclass
|
||||
class GRPCSettings(XrayConfig):
|
||||
"""gRPC transport settings"""
|
||||
__xray_type__ = "xray.transport.internet.grpc.encoding.Config"
|
||||
|
||||
serviceName: str = ""
|
||||
multiMode: bool = False
|
||||
idle_timeout: int = 60
|
||||
health_check_timeout: int = 20
|
||||
permit_without_stream: bool = False
|
||||
initial_windows_size: int = 0
|
||||
|
||||
|
||||
# Stream Settings
|
||||
@dataclass
|
||||
class StreamSettings(BaseXrayModel):
|
||||
"""Stream settings for inbound/outbound"""
|
||||
|
||||
network: TransportProtocol = TransportProtocol.TCP
|
||||
security: Optional[str] = None
|
||||
tlsSettings: Optional[Any] = None
|
||||
xtlsSettings: Optional[Any] = None
|
||||
realitySettings: Optional[Any] = None
|
||||
tcpSettings: Optional[TCPSettings] = None
|
||||
kcpSettings: Optional[KCPSettings] = None
|
||||
wsSettings: Optional[WebSocketSettings] = None
|
||||
httpSettings: Optional[HTTPSettings] = None
|
||||
xhttpSettings: Optional[XHTTPSettings] = None
|
||||
dsSettings: Optional[DomainSocketSettings] = None
|
||||
quicSettings: Optional[QUICSettings] = None
|
||||
grpcSettings: Optional[GRPCSettings] = None
|
||||
sockopt: Optional[Dict[str, Any]] = None
|
||||
|
||||
def to_xray_json(self) -> Dict[str, Any]:
|
||||
"""Convert to Xray format with correct field names"""
|
||||
config = {
|
||||
"network": self.network.value if isinstance(self.network, TransportProtocol) else self.network
|
||||
}
|
||||
|
||||
if self.security:
|
||||
config["security"] = self.security
|
||||
|
||||
# Map transport settings
|
||||
transport_map = {
|
||||
TransportProtocol.TCP: ("tcpSettings", self.tcpSettings),
|
||||
TransportProtocol.KCP: ("kcpSettings", self.kcpSettings),
|
||||
TransportProtocol.WS: ("wsSettings", self.wsSettings),
|
||||
TransportProtocol.HTTP: ("httpSettings", self.httpSettings),
|
||||
TransportProtocol.XHTTP: ("xhttpSettings", self.xhttpSettings),
|
||||
TransportProtocol.DOMAINSOCKET: ("dsSettings", self.dsSettings),
|
||||
TransportProtocol.QUIC: ("quicSettings", self.quicSettings),
|
||||
TransportProtocol.GRPC: ("grpcSettings", self.grpcSettings),
|
||||
}
|
||||
|
||||
network = self.network if isinstance(self.network, TransportProtocol) else TransportProtocol(self.network)
|
||||
field_name, settings = transport_map.get(network, (None, None))
|
||||
|
||||
if field_name and settings:
|
||||
config[field_name] = settings.to_xray_json() if hasattr(settings, 'to_xray_json') else settings
|
||||
|
||||
# Add security settings
|
||||
if self.tlsSettings:
|
||||
config["tlsSettings"] = self.tlsSettings if isinstance(self.tlsSettings, dict) else self.tlsSettings.to_xray_json()
|
||||
if self.xtlsSettings:
|
||||
config["xtlsSettings"] = self.xtlsSettings if isinstance(self.xtlsSettings, dict) else self.xtlsSettings.to_xray_json()
|
||||
if self.realitySettings:
|
||||
config["realitySettings"] = self.realitySettings if isinstance(self.realitySettings, dict) else self.realitySettings.to_xray_json()
|
||||
|
||||
if self.sockopt:
|
||||
config["sockopt"] = self.sockopt
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Factory functions
|
||||
def create_tcp_stream(
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create TCP stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.TCP,
|
||||
security=security,
|
||||
tcpSettings=TCPSettings(**kwargs) if kwargs else None
|
||||
)
|
||||
|
||||
|
||||
def create_ws_stream(
|
||||
path: str = "/",
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create WebSocket stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.WS,
|
||||
security=security,
|
||||
wsSettings=WebSocketSettings(path=path, headers=headers, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
def create_grpc_stream(
|
||||
service_name: str = "",
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create gRPC stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.GRPC,
|
||||
security=security,
|
||||
grpcSettings=GRPCSettings(serviceName=service_name, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
def create_http_stream(
|
||||
path: str = "/",
|
||||
host: Optional[List[str]] = None,
|
||||
security: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create HTTP/2 stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.HTTP,
|
||||
security=security,
|
||||
httpSettings=HTTPSettings(path=path, host=host, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
def create_xhttp_stream(
|
||||
path: str = "/",
|
||||
host: Optional[str] = None,
|
||||
security: Optional[str] = None,
|
||||
mode: str = "auto",
|
||||
**kwargs
|
||||
) -> StreamSettings:
|
||||
"""Create XHTTP stream settings"""
|
||||
return StreamSettings(
|
||||
network=TransportProtocol.XHTTP,
|
||||
security=security,
|
||||
xhttpSettings=XHTTPSettings(path=path, host=host, mode=mode, **kwargs)
|
||||
)
|
0
vpn/xray_api_v2/serializers/__init__.py
Normal file
0
vpn/xray_api_v2/serializers/__init__.py
Normal file
184
vpn/xray_api_v2/stats.py
Normal file
184
vpn/xray_api_v2/stats.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""Statistics functionality for Xray API"""
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict, Optional, Tuple, Any
|
||||
from datetime import datetime
|
||||
|
||||
from .client import XrayClient
|
||||
from .models.base import BaseXrayModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatItem(BaseXrayModel):
|
||||
"""Single statistics item"""
|
||||
name: str
|
||||
value: int
|
||||
|
||||
@property
|
||||
def parts(self) -> List[str]:
|
||||
"""Split stat name into parts"""
|
||||
return self.name.split(">>>")
|
||||
|
||||
@property
|
||||
def stat_type(self) -> str:
|
||||
"""Get stat type (inbound/outbound/user)"""
|
||||
return self.parts[0] if self.parts else ""
|
||||
|
||||
@property
|
||||
def tag(self) -> str:
|
||||
"""Get inbound/outbound tag"""
|
||||
return self.parts[1] if len(self.parts) > 1 else ""
|
||||
|
||||
@property
|
||||
def metric(self) -> str:
|
||||
"""Get metric name (traffic/uplink/downlink)"""
|
||||
return self.parts[-1] if self.parts else ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemStats(BaseXrayModel):
|
||||
"""System statistics"""
|
||||
numGoroutine: int = 0
|
||||
numGC: int = 0
|
||||
alloc: int = 0
|
||||
totalAlloc: int = 0
|
||||
sys: int = 0
|
||||
mallocs: int = 0
|
||||
frees: int = 0
|
||||
liveObjects: int = 0
|
||||
pauseTotalNs: int = 0
|
||||
uptime: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'SystemStats':
|
||||
"""Create from API response with proper field mapping"""
|
||||
# Map API response fields to model fields
|
||||
field_mapping = {
|
||||
'NumGoroutine': 'numGoroutine',
|
||||
'NumGC': 'numGC',
|
||||
'Alloc': 'alloc',
|
||||
'TotalAlloc': 'totalAlloc',
|
||||
'Sys': 'sys',
|
||||
'Mallocs': 'mallocs',
|
||||
'Frees': 'frees',
|
||||
'LiveObjects': 'liveObjects',
|
||||
'PauseTotalNs': 'pauseTotalNs',
|
||||
'Uptime': 'uptime'
|
||||
}
|
||||
|
||||
normalized = {}
|
||||
for api_key, model_key in field_mapping.items():
|
||||
if api_key in data:
|
||||
normalized[model_key] = data[api_key]
|
||||
|
||||
return cls(**normalized)
|
||||
|
||||
@property
|
||||
def uptime_seconds(self) -> int:
|
||||
"""Get uptime in seconds"""
|
||||
return self.uptime
|
||||
|
||||
@property
|
||||
def memory_mb(self) -> float:
|
||||
"""Get allocated memory in MB"""
|
||||
return self.alloc / 1024 / 1024
|
||||
|
||||
|
||||
class StatsManager:
|
||||
"""Manager for Xray statistics"""
|
||||
|
||||
def __init__(self, client: XrayClient):
|
||||
self.client = client
|
||||
|
||||
def get_all_stats(self, reset: bool = False) -> List[StatItem]:
|
||||
"""Get all statistics"""
|
||||
stats = self.client.get_stats("", reset=reset)
|
||||
result = []
|
||||
for stat in stats:
|
||||
if isinstance(stat, dict) and 'name' in stat and 'value' in stat:
|
||||
result.append(StatItem(name=stat['name'], value=stat['value']))
|
||||
return result
|
||||
|
||||
def get_inbound_stats(self, tag: str, reset: bool = False) -> Dict[str, int]:
|
||||
"""Get statistics for specific inbound"""
|
||||
pattern = f"inbound>>>{tag}>>>traffic>>>"
|
||||
stats = self.client.get_stats(pattern, reset=reset)
|
||||
|
||||
result = {"uplink": 0, "downlink": 0}
|
||||
for stat in stats:
|
||||
item = StatItem(**stat)
|
||||
if item.metric in result:
|
||||
result[item.metric] = item.value
|
||||
|
||||
return result
|
||||
|
||||
def get_outbound_stats(self, tag: str, reset: bool = False) -> Dict[str, int]:
|
||||
"""Get statistics for specific outbound"""
|
||||
pattern = f"outbound>>>{tag}>>>traffic>>>"
|
||||
stats = self.client.get_stats(pattern, reset=reset)
|
||||
|
||||
result = {"uplink": 0, "downlink": 0}
|
||||
for stat in stats:
|
||||
item = StatItem(**stat)
|
||||
if item.metric in result:
|
||||
result[item.metric] = item.value
|
||||
|
||||
return result
|
||||
|
||||
def get_user_stats(self, email: str, reset: bool = False) -> Dict[str, int]:
|
||||
"""Get statistics for specific user"""
|
||||
pattern = f"user>>>{email}>>>traffic>>>"
|
||||
stats = self.client.get_stats(pattern, reset=reset)
|
||||
|
||||
result = {"uplink": 0, "downlink": 0}
|
||||
for stat in stats:
|
||||
item = StatItem(**stat)
|
||||
if item.metric in result:
|
||||
result[item.metric] = item.value
|
||||
|
||||
return result
|
||||
|
||||
def get_user_online_info(self, email: str) -> Dict[str, Any]:
|
||||
"""Get user online information"""
|
||||
online = self.client.get_online_stats(email)
|
||||
ips_data = self.client.get_online_ips(email)
|
||||
|
||||
return {
|
||||
"email": email,
|
||||
"online": online.get("count", 0) > 0,
|
||||
"sessions": online.get("count", 0),
|
||||
"ips": ips_data
|
||||
}
|
||||
|
||||
def get_system_stats(self) -> SystemStats:
|
||||
"""Get system statistics"""
|
||||
stats = self.client.get_system_stats()
|
||||
return SystemStats.from_dict(stats)
|
||||
|
||||
def get_traffic_summary(self) -> Dict[str, Dict[str, int]]:
|
||||
"""Get traffic summary for all inbounds/outbounds"""
|
||||
all_stats = self.get_all_stats()
|
||||
summary = {
|
||||
"inbounds": {},
|
||||
"outbounds": {},
|
||||
"users": {}
|
||||
}
|
||||
|
||||
for stat in all_stats:
|
||||
if stat.stat_type == "inbound":
|
||||
if stat.tag not in summary["inbounds"]:
|
||||
summary["inbounds"][stat.tag] = {"uplink": 0, "downlink": 0}
|
||||
summary["inbounds"][stat.tag][stat.metric] = stat.value
|
||||
|
||||
elif stat.stat_type == "outbound":
|
||||
if stat.tag not in summary["outbounds"]:
|
||||
summary["outbounds"][stat.tag] = {"uplink": 0, "downlink": 0}
|
||||
summary["outbounds"][stat.tag][stat.metric] = stat.value
|
||||
|
||||
elif stat.stat_type == "user":
|
||||
user_email = stat.parts[1] if len(stat.parts) > 1 else ""
|
||||
if user_email and user_email not in summary["users"]:
|
||||
summary["users"][user_email] = {"uplink": 0, "downlink": 0}
|
||||
if user_email:
|
||||
summary["users"][user_email][stat.metric] = stat.value
|
||||
|
||||
return summary
|
392
vpn/xray_api_v2/subscription.py
Normal file
392
vpn/xray_api_v2/subscription.py
Normal file
@@ -0,0 +1,392 @@
|
||||
"""Subscription link generation for Xray protocols"""
|
||||
import json
|
||||
import base64
|
||||
import urllib.parse
|
||||
from typing import Dict, Any, Optional, List, Union
|
||||
from .models.base import BaseXrayModel
|
||||
from .models.inbound import InboundConfig
|
||||
from .models.protocols import VLESSInboundConfig, VMeSSInboundConfig, TrojanServerConfig
|
||||
|
||||
|
||||
class SubscriptionLinkGenerator:
|
||||
"""Generate subscription links for various Xray protocols"""
|
||||
|
||||
@staticmethod
|
||||
def _url_encode(value: str) -> str:
|
||||
"""URL encode string with safe characters"""
|
||||
return urllib.parse.quote(value, safe='')
|
||||
|
||||
@staticmethod
|
||||
def _format_ipv6(address: str) -> str:
|
||||
"""Format IPv6 address with brackets if needed"""
|
||||
if ':' in address and not address.startswith('['):
|
||||
return f'[{address}]'
|
||||
return address
|
||||
|
||||
@classmethod
|
||||
def generate_vless_link(cls,
|
||||
uuid: str,
|
||||
address: str,
|
||||
port: int,
|
||||
remark: str = "VLESS",
|
||||
encryption: str = "none",
|
||||
security: str = "none",
|
||||
sni: Optional[str] = None,
|
||||
transport_type: str = "tcp",
|
||||
path: Optional[str] = None,
|
||||
host: Optional[str] = None,
|
||||
alpn: Optional[str] = None,
|
||||
fp: str = "chrome",
|
||||
flow: Optional[str] = None,
|
||||
allow_insecure: bool = False,
|
||||
# REALITY parameters
|
||||
pbk: Optional[str] = None,
|
||||
sid: Optional[str] = None,
|
||||
spx: Optional[str] = None,
|
||||
**kwargs) -> str:
|
||||
"""
|
||||
Generate VLESS subscription link
|
||||
|
||||
Args:
|
||||
uuid: Client UUID
|
||||
address: Server address
|
||||
port: Server port
|
||||
remark: Link description/name
|
||||
encryption: Encryption method (default: none)
|
||||
security: Security layer (none, tls, reality)
|
||||
sni: SNI for TLS
|
||||
transport_type: Transport type (tcp, ws, grpc, xhttp, h2, kcp)
|
||||
path: Path for WebSocket/HTTP2/gRPC
|
||||
host: Host header for WebSocket
|
||||
alpn: ALPN negotiation
|
||||
fp: Fingerprint
|
||||
flow: Flow control (xtls-rprx-vision, etc.)
|
||||
pbk: REALITY public key
|
||||
sid: REALITY short ID
|
||||
spx: REALITY spider X
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
VLESS subscription link
|
||||
"""
|
||||
# Build query parameters
|
||||
params = {
|
||||
'encryption': encryption,
|
||||
'security': security,
|
||||
'type': transport_type,
|
||||
'fp': fp
|
||||
}
|
||||
|
||||
# Add optional parameters
|
||||
if sni:
|
||||
params['sni'] = sni
|
||||
if path:
|
||||
params['path'] = path # Don't double encode - urlencode will handle it
|
||||
if host:
|
||||
params['host'] = host
|
||||
if alpn:
|
||||
params['alpn'] = alpn
|
||||
if flow:
|
||||
params['flow'] = flow
|
||||
if allow_insecure:
|
||||
params['allowInsecure'] = '1'
|
||||
|
||||
# Add REALITY parameters
|
||||
if pbk:
|
||||
params['pbk'] = pbk
|
||||
if sid:
|
||||
params['sid'] = sid
|
||||
if spx:
|
||||
params['spx'] = spx
|
||||
|
||||
# Add any additional parameters
|
||||
params.update(kwargs)
|
||||
|
||||
# Remove None values
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
# Build query string (keep - and _ safe for REALITY keys)
|
||||
query_string = urllib.parse.urlencode(params, safe='-_')
|
||||
|
||||
# Format address (handle IPv6)
|
||||
formatted_address = cls._format_ipv6(address)
|
||||
|
||||
# Build VLESS URL
|
||||
url = f"vless://{uuid}@{formatted_address}:{port}?{query_string}#{cls._url_encode(remark)}"
|
||||
|
||||
return url
|
||||
|
||||
@classmethod
|
||||
def generate_vmess_link(cls,
|
||||
uuid: str,
|
||||
address: str,
|
||||
port: int,
|
||||
remark: str = "VMess",
|
||||
alterId: int = 0,
|
||||
security: str = "auto",
|
||||
network: str = "tcp",
|
||||
transport_type: Optional[str] = None,
|
||||
path: Optional[str] = None,
|
||||
host: Optional[str] = None,
|
||||
tls: str = "",
|
||||
sni: Optional[str] = None,
|
||||
alpn: Optional[str] = None,
|
||||
fp: Optional[str] = None,
|
||||
allow_insecure: bool = False,
|
||||
**kwargs) -> str:
|
||||
"""
|
||||
Generate VMess subscription link
|
||||
|
||||
Args:
|
||||
uuid: Client UUID
|
||||
address: Server address
|
||||
port: Server port
|
||||
remark: Link description/name
|
||||
alterId: Alter ID (default: 0)
|
||||
security: Security method (auto, aes-128-gcm, chacha20-poly1305, none)
|
||||
network: Network type (tcp, kcp, ws, h2, quic, grpc)
|
||||
transport_type: Transport type (same as network for compatibility)
|
||||
path: Path for WebSocket/HTTP2/gRPC
|
||||
host: Host header
|
||||
tls: TLS settings ("" for none, "tls" for TLS)
|
||||
sni: SNI for TLS
|
||||
alpn: ALPN negotiation
|
||||
fp: Fingerprint
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
VMess subscription link
|
||||
"""
|
||||
# Use transport_type if provided, otherwise use network
|
||||
net = transport_type or network
|
||||
|
||||
# Build VMess configuration object
|
||||
vmess_config = {
|
||||
'v': '2',
|
||||
'ps': remark,
|
||||
'add': address,
|
||||
'port': str(port),
|
||||
'id': uuid,
|
||||
'aid': str(alterId),
|
||||
'scy': security,
|
||||
'net': net,
|
||||
'type': kwargs.get('type', 'none'),
|
||||
'host': host or '',
|
||||
'path': path or '',
|
||||
'tls': tls,
|
||||
'sni': sni or '',
|
||||
'alpn': alpn or '',
|
||||
'fp': fp or ''
|
||||
}
|
||||
|
||||
# Add allowInsecure only if True (VMess format - try multiple approaches)
|
||||
if allow_insecure:
|
||||
vmess_config['allowInsecure'] = 1
|
||||
vmess_config['skip-cert-verify'] = True # For compatibility with some clients
|
||||
|
||||
# Add any additional parameters
|
||||
for key, value in kwargs.items():
|
||||
if key not in vmess_config and value is not None:
|
||||
vmess_config[key] = str(value)
|
||||
|
||||
# Convert to JSON and base64 encode
|
||||
json_str = json.dumps(vmess_config, separators=(',', ':'))
|
||||
encoded = base64.b64encode(json_str.encode('utf-8')).decode('ascii')
|
||||
|
||||
return f"vmess://{encoded}"
|
||||
|
||||
@classmethod
|
||||
def generate_trojan_link(cls,
|
||||
password: str,
|
||||
address: str,
|
||||
port: int,
|
||||
remark: str = "Trojan",
|
||||
security: str = "tls",
|
||||
sni: Optional[str] = None,
|
||||
transport_type: str = "tcp",
|
||||
path: Optional[str] = None,
|
||||
host: Optional[str] = None,
|
||||
alpn: Optional[str] = None,
|
||||
fp: str = "chrome",
|
||||
allow_insecure: bool = False,
|
||||
**kwargs) -> str:
|
||||
"""
|
||||
Generate Trojan subscription link
|
||||
|
||||
Args:
|
||||
password: Trojan password
|
||||
address: Server address
|
||||
port: Server port
|
||||
remark: Link description/name
|
||||
security: Security layer (tls, none)
|
||||
sni: SNI for TLS
|
||||
transport_type: Transport type (tcp, ws, grpc)
|
||||
path: Path for WebSocket/gRPC
|
||||
host: Host header
|
||||
alpn: ALPN negotiation
|
||||
fp: Fingerprint
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Trojan subscription link
|
||||
"""
|
||||
# Build query parameters
|
||||
params = {
|
||||
'security': security,
|
||||
'type': transport_type,
|
||||
'fp': fp
|
||||
}
|
||||
|
||||
# Add optional parameters
|
||||
if sni:
|
||||
params['sni'] = sni
|
||||
if path:
|
||||
params['path'] = path # Don't double encode - urlencode will handle it
|
||||
if host:
|
||||
params['host'] = host
|
||||
if alpn:
|
||||
params['alpn'] = alpn
|
||||
if allow_insecure:
|
||||
params['allowInsecure'] = '1'
|
||||
|
||||
# Add any additional parameters
|
||||
params.update(kwargs)
|
||||
|
||||
# Remove None values
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
# Build query string
|
||||
query_string = urllib.parse.urlencode(params)
|
||||
|
||||
# Format address (handle IPv6)
|
||||
formatted_address = cls._format_ipv6(address)
|
||||
|
||||
# URL encode password
|
||||
encoded_password = cls._url_encode(password)
|
||||
|
||||
# Build Trojan URL
|
||||
url = f"trojan://{encoded_password}@{formatted_address}:{port}?{query_string}#{cls._url_encode(remark)}"
|
||||
|
||||
return url
|
||||
|
||||
@classmethod
|
||||
def generate_shadowsocks_link(cls,
|
||||
method: str,
|
||||
password: str,
|
||||
address: str,
|
||||
port: int,
|
||||
remark: str = "Shadowsocks") -> str:
|
||||
"""
|
||||
Generate Shadowsocks subscription link
|
||||
|
||||
Args:
|
||||
method: Encryption method
|
||||
password: SS password
|
||||
address: Server address
|
||||
port: Server port
|
||||
remark: Link description/name
|
||||
|
||||
Returns:
|
||||
Shadowsocks subscription link
|
||||
"""
|
||||
# Format: ss://base64(method:password)@server:port#remark
|
||||
user_info = f"{method}:{password}"
|
||||
encoded_user_info = base64.b64encode(user_info.encode('utf-8')).decode('ascii')
|
||||
|
||||
# Format address (handle IPv6)
|
||||
formatted_address = cls._format_ipv6(address)
|
||||
|
||||
return f"ss://{encoded_user_info}@{formatted_address}:{port}#{cls._url_encode(remark)}"
|
||||
|
||||
@classmethod
|
||||
def generate_subscription_from_inbound(cls,
|
||||
inbound: InboundConfig,
|
||||
server_address: str,
|
||||
remark_prefix: str = "",
|
||||
**transport_params) -> List[str]:
|
||||
"""
|
||||
Generate subscription links from inbound configuration
|
||||
|
||||
Args:
|
||||
inbound: Inbound configuration
|
||||
server_address: Public server address
|
||||
remark_prefix: Prefix for link remarks
|
||||
**transport_params: Additional transport parameters
|
||||
|
||||
Returns:
|
||||
List of subscription links for all users
|
||||
"""
|
||||
links = []
|
||||
protocol = inbound.protocol.lower()
|
||||
port = inbound.port
|
||||
tag = inbound.tag
|
||||
|
||||
# Extract protocol-specific settings
|
||||
if isinstance(inbound.settings, VLESSInboundConfig):
|
||||
for client in inbound.settings.clients:
|
||||
remark = f"{remark_prefix}{tag}_{client.email}" if remark_prefix else f"{tag}_{client.email}"
|
||||
link = cls.generate_vless_link(
|
||||
uuid=client.account.id,
|
||||
address=server_address,
|
||||
port=port,
|
||||
remark=remark,
|
||||
**transport_params
|
||||
)
|
||||
links.append(link)
|
||||
|
||||
elif isinstance(inbound.settings, VMeSSInboundConfig):
|
||||
for client in inbound.settings.user:
|
||||
remark = f"{remark_prefix}{tag}_{client.email}" if remark_prefix else f"{tag}_{client.email}"
|
||||
link = cls.generate_vmess_link(
|
||||
uuid=client.account.id,
|
||||
address=server_address,
|
||||
port=port,
|
||||
remark=remark,
|
||||
**transport_params
|
||||
)
|
||||
links.append(link)
|
||||
|
||||
elif isinstance(inbound.settings, TrojanServerConfig):
|
||||
for client in inbound.settings.users:
|
||||
remark = f"{remark_prefix}{tag}_{client.email}" if remark_prefix else f"{tag}_{client.email}"
|
||||
link = cls.generate_trojan_link(
|
||||
password=client.password,
|
||||
address=server_address,
|
||||
port=port,
|
||||
remark=remark,
|
||||
**transport_params
|
||||
)
|
||||
links.append(link)
|
||||
|
||||
return links
|
||||
|
||||
@classmethod
|
||||
def create_subscription_content(cls, links: List[str]) -> str:
|
||||
"""
|
||||
Create base64 encoded subscription content
|
||||
|
||||
Args:
|
||||
links: List of subscription links
|
||||
|
||||
Returns:
|
||||
Base64 encoded subscription content
|
||||
"""
|
||||
content = '\n'.join(links)
|
||||
return base64.b64encode(content.encode('utf-8')).decode('ascii')
|
||||
|
||||
@classmethod
|
||||
def parse_subscription_content(cls, content: str) -> List[str]:
|
||||
"""
|
||||
Parse base64 encoded subscription content
|
||||
|
||||
Args:
|
||||
content: Base64 encoded subscription content
|
||||
|
||||
Returns:
|
||||
List of subscription links
|
||||
"""
|
||||
try:
|
||||
decoded = base64.b64decode(content).decode('utf-8')
|
||||
return [link.strip() for link in decoded.split('\n') if link.strip()]
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid subscription content: {e}")
|
Reference in New Issue
Block a user