1823 lines
68 KiB
Python
1823 lines
68 KiB
Python
import sys
|
||
import json
|
||
import time
|
||
import base64
|
||
import random
|
||
import signal
|
||
import logging
|
||
import socket
|
||
import threading
|
||
import subprocess
|
||
import urllib.parse
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
from dataclasses import dataclass, asdict
|
||
from datetime import datetime
|
||
|
||
import yaml
|
||
import requests
|
||
from flask import Flask, jsonify, request
|
||
|
||
# 配置日志
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||
)
|
||
logger = logging.getLogger('airtosocks')
|
||
|
||
DEFAULT_TEST_URL = 'http://ifconfig.me/ip'
|
||
|
||
# 常量
|
||
BASE_DIR = Path(__file__).parent
|
||
CONFIG_DIR = BASE_DIR / 'config'
|
||
DATA_DIR = BASE_DIR / 'data'
|
||
BIN_DIR = BASE_DIR / 'bin'
|
||
XRAY_BIN = BIN_DIR / 'xray'
|
||
|
||
CONFIG_DIR.mkdir(exist_ok=True)
|
||
DATA_DIR.mkdir(exist_ok=True)
|
||
BIN_DIR.mkdir(exist_ok=True)
|
||
|
||
|
||
@dataclass
|
||
class ProxyNode:
|
||
"""代理节点信息"""
|
||
id: str
|
||
name: str
|
||
protocol: str # vmess, vless, trojan, ss
|
||
address: str
|
||
port: int
|
||
uuid: str = ""
|
||
alter_id: int = 0
|
||
security: str = "auto"
|
||
network: str = "tcp"
|
||
ws_path: str = ""
|
||
ws_host: str = ""
|
||
tls: bool = False
|
||
sni: str = ""
|
||
flow: str = ""
|
||
raw_link: str = ""
|
||
available: bool = False
|
||
last_check: str = ""
|
||
socks_port: int = 0 # 分配的本地socks5端口
|
||
xray_pid: int = 0
|
||
subscription_id: str = ""
|
||
|
||
|
||
@dataclass
|
||
class Subscription:
|
||
id: str
|
||
name: str
|
||
url: str
|
||
node_ids: List[str]
|
||
last_sync: str = ""
|
||
last_error: str = ""
|
||
sync_interval: int = 0 # 分钟,0=不自动同步
|
||
|
||
|
||
@dataclass
|
||
class NodePool:
|
||
id: str
|
||
name: str
|
||
node_ids: List[str]
|
||
created_at: str = ""
|
||
|
||
|
||
def safe_b64decode(value: str) -> bytes:
|
||
padding = (-len(value)) % 4
|
||
return base64.b64decode(value + ('=' * padding))
|
||
|
||
|
||
def make_node_id(protocol: str, address: str, port: int, user_id: str) -> str:
|
||
return f"{protocol}:{address}:{port}:{user_id}"
|
||
|
||
|
||
class LinkParser:
|
||
"""解析代理链接"""
|
||
|
||
@staticmethod
|
||
def parse_vmess(link: str) -> Optional[ProxyNode]:
|
||
"""解析vmess链接"""
|
||
try:
|
||
if not link.startswith('vmess://'):
|
||
return None
|
||
|
||
encoded = link[8:]
|
||
data = json.loads(safe_b64decode(encoded).decode('utf-8'))
|
||
address = str(data.get('add', ''))
|
||
port = int(data.get('port', 443))
|
||
uuid = str(data.get('id', ''))
|
||
|
||
node = ProxyNode(
|
||
id=make_node_id('vmess', address, port, uuid),
|
||
name=str(data.get('ps', data.get('add', 'unknown'))),
|
||
protocol='vmess',
|
||
address=address,
|
||
port=port,
|
||
uuid=uuid,
|
||
alter_id=int(data.get('aid', 0)),
|
||
security=str(data.get('scy', 'auto')),
|
||
network=str(data.get('net', 'tcp')),
|
||
ws_path=str(data.get('path', '')),
|
||
ws_host=str(data.get('host', '')),
|
||
tls=str(data.get('tls', '')) == 'tls',
|
||
sni=str(data.get('sni') or data.get('host') or ''),
|
||
raw_link=link
|
||
)
|
||
return node
|
||
except Exception as e:
|
||
logger.error(f"解析vmess链接失败: {e}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def parse_vless(link: str) -> Optional[ProxyNode]:
|
||
"""解析vless链接"""
|
||
try:
|
||
if not link.startswith('vless://'):
|
||
return None
|
||
|
||
parsed = urllib.parse.urlparse(link)
|
||
params = urllib.parse.parse_qs(parsed.query)
|
||
|
||
address = parsed.hostname or ''
|
||
port = parsed.port or 443
|
||
uuid = parsed.username or ''
|
||
|
||
node = ProxyNode(
|
||
id=make_node_id('vless', address, port, uuid),
|
||
name=urllib.parse.unquote(parsed.fragment) if parsed.fragment else parsed.hostname or '',
|
||
protocol='vless',
|
||
address=address,
|
||
port=port,
|
||
uuid=uuid,
|
||
security=params.get('security', ['none'])[0],
|
||
network=params.get('type', ['tcp'])[0],
|
||
ws_path=params.get('path', ['/'])[0],
|
||
ws_host=params.get('host', [''])[0],
|
||
tls=params.get('security', ['none'])[0] == 'tls',
|
||
sni=params.get('sni', [address])[0] or '',
|
||
flow=params.get('flow', [''])[0] or '',
|
||
raw_link=link
|
||
)
|
||
return node
|
||
except Exception as e:
|
||
logger.error(f"解析vless链接失败: {e}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def parse_trojan(link: str) -> Optional[ProxyNode]:
|
||
"""解析trojan链接"""
|
||
try:
|
||
if not link.startswith('trojan://'):
|
||
return None
|
||
|
||
parsed = urllib.parse.urlparse(link)
|
||
params = urllib.parse.parse_qs(parsed.query)
|
||
|
||
address = parsed.hostname or ''
|
||
port = parsed.port or 443
|
||
password = parsed.password or parsed.username or ''
|
||
|
||
node = ProxyNode(
|
||
id=make_node_id('trojan', address, port, password),
|
||
name=urllib.parse.unquote(parsed.fragment) if parsed.fragment else parsed.hostname or '',
|
||
protocol='trojan',
|
||
address=address,
|
||
port=port,
|
||
uuid=password,
|
||
network=params.get('type', ['tcp'])[0],
|
||
tls=True,
|
||
sni=params.get('sni', [address])[0] or '',
|
||
raw_link=link
|
||
)
|
||
return node
|
||
except Exception as e:
|
||
logger.error(f"解析trojan链接失败: {e}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def parse_ss(link: str) -> Optional[ProxyNode]:
|
||
"""解析ss链接"""
|
||
try:
|
||
if not link.startswith('ss://'):
|
||
return None
|
||
|
||
# ss://base64(method:password)@server:port#name
|
||
# 或 ss://base64(method:password@server:port)#name
|
||
content = link[5:]
|
||
if '@' in content:
|
||
# 新格式
|
||
parsed = urllib.parse.urlparse(link)
|
||
userinfo = safe_b64decode(parsed.username or '').decode('utf-8')
|
||
method, password = userinfo.split(':', 1)
|
||
address = parsed.hostname or ''
|
||
port = parsed.port or 8388
|
||
|
||
node = ProxyNode(
|
||
id=make_node_id('ss', address, port, password),
|
||
name=urllib.parse.unquote(parsed.fragment) if parsed.fragment else parsed.hostname or '',
|
||
protocol='ss',
|
||
address=address,
|
||
port=port,
|
||
uuid=password,
|
||
security=method,
|
||
raw_link=link
|
||
)
|
||
return node
|
||
else:
|
||
# 旧格式 ss://base64
|
||
payload = content.split('#')[0]
|
||
decoded = safe_b64decode(payload).decode('utf-8')
|
||
parts = decoded.rsplit('@', 1)
|
||
method, password = parts[0].split(':', 1)
|
||
host, port = parts[1].rsplit(':', 1)
|
||
|
||
node = ProxyNode(
|
||
id=make_node_id('ss', host, int(port), password),
|
||
name=content.split('#')[1] if '#' in content else host,
|
||
protocol='ss',
|
||
address=host,
|
||
port=int(port),
|
||
uuid=password,
|
||
security=method,
|
||
raw_link=link
|
||
)
|
||
return node
|
||
except Exception as e:
|
||
logger.error(f"解析ss链接失败: {e}")
|
||
return None
|
||
|
||
@staticmethod
|
||
def parse_link(link: str) -> Optional[ProxyNode]:
|
||
"""自动识别并解析链接"""
|
||
link = link.strip()
|
||
if link.startswith('vmess://'):
|
||
return LinkParser.parse_vmess(link)
|
||
elif link.startswith('vless://'):
|
||
return LinkParser.parse_vless(link)
|
||
elif link.startswith('trojan://'):
|
||
return LinkParser.parse_trojan(link)
|
||
elif link.startswith('ss://'):
|
||
return LinkParser.parse_ss(link)
|
||
else:
|
||
logger.warning(f"不支持的协议: {link[:20]}...")
|
||
return None
|
||
|
||
|
||
class XrayConfigGenerator:
|
||
"""生成xray配置"""
|
||
|
||
@staticmethod
|
||
def generate_config(node: ProxyNode, socks_port: int) -> Dict[str, Any]:
|
||
"""生成xray配置"""
|
||
config = {
|
||
"log": {
|
||
"loglevel": "warning"
|
||
},
|
||
"inbounds": [
|
||
{
|
||
"tag": "socks",
|
||
"port": socks_port,
|
||
"listen": "0.0.0.0",
|
||
"protocol": "socks",
|
||
"settings": {
|
||
"auth": "noauth",
|
||
"udp": True
|
||
}
|
||
}
|
||
],
|
||
"outbounds": [XrayConfigGenerator._get_outbound(node)],
|
||
"routing": {
|
||
"domainStrategy": "AsIs"
|
||
}
|
||
}
|
||
return config
|
||
|
||
@staticmethod
|
||
def _get_outbound(node: ProxyNode) -> Dict[str, Any]:
|
||
"""根据节点类型生成outbound配置"""
|
||
if node.protocol == 'vmess':
|
||
return {
|
||
"protocol": "vmess",
|
||
"settings": {
|
||
"vnext": [{
|
||
"address": node.address,
|
||
"port": node.port,
|
||
"users": [{
|
||
"id": node.uuid,
|
||
"alterId": node.alter_id,
|
||
"security": node.security
|
||
}]
|
||
}]
|
||
},
|
||
"streamSettings": XrayConfigGenerator._get_stream_settings(node)
|
||
}
|
||
elif node.protocol == 'vless':
|
||
vless_user: Dict[str, Any] = {
|
||
"id": node.uuid,
|
||
"encryption": "none"
|
||
}
|
||
if node.flow:
|
||
vless_user["flow"] = node.flow
|
||
return {
|
||
"protocol": "vless",
|
||
"settings": {
|
||
"vnext": [{
|
||
"address": node.address,
|
||
"port": node.port,
|
||
"users": [vless_user]
|
||
}]
|
||
},
|
||
"streamSettings": XrayConfigGenerator._get_stream_settings(node)
|
||
}
|
||
elif node.protocol == 'trojan':
|
||
return {
|
||
"protocol": "trojan",
|
||
"settings": {
|
||
"servers": [{
|
||
"address": node.address,
|
||
"port": node.port,
|
||
"password": node.uuid
|
||
}]
|
||
},
|
||
"streamSettings": XrayConfigGenerator._get_stream_settings(node)
|
||
}
|
||
elif node.protocol == 'ss':
|
||
return {
|
||
"protocol": "shadowsocks",
|
||
"settings": {
|
||
"servers": [{
|
||
"address": node.address,
|
||
"port": node.port,
|
||
"method": node.security,
|
||
"password": node.uuid
|
||
}]
|
||
}
|
||
}
|
||
return {}
|
||
|
||
@staticmethod
|
||
def _get_stream_settings(node: ProxyNode) -> Dict[str, Any]:
|
||
"""生成传输层配置"""
|
||
settings: Dict[str, Any] = {
|
||
"network": node.network if node.network in ['tcp', 'ws', 'grpc', 'http'] else "tcp"
|
||
}
|
||
|
||
if node.tls:
|
||
settings["security"] = "tls"
|
||
settings["tlsSettings"] = {
|
||
"serverName": node.sni,
|
||
"allowInsecure": True
|
||
}
|
||
|
||
if node.network == 'ws':
|
||
settings["wsSettings"] = {
|
||
"path": node.ws_path,
|
||
"headers": {
|
||
"Host": node.ws_host or node.address
|
||
}
|
||
}
|
||
|
||
return settings
|
||
|
||
|
||
class ProxyManager:
|
||
"""代理管理器"""
|
||
|
||
def __init__(self):
|
||
self.nodes: Dict[str, ProxyNode] = {}
|
||
self.lock = threading.RLock()
|
||
self.base_port = 10000
|
||
self.port_pool = set(range(self.base_port, self.base_port + 1000))
|
||
self.used_ports = set()
|
||
self.xray_processes: Dict[str, subprocess.Popen] = {}
|
||
self.config_file = CONFIG_DIR / 'proxies.json'
|
||
self.subscription_file = CONFIG_DIR / 'subscriptions.json'
|
||
self.subscriptions: Dict[str, Subscription] = {}
|
||
self.pools: Dict[str, NodePool] = {}
|
||
self.pool_file = CONFIG_DIR / 'pools.json'
|
||
self._load_nodes()
|
||
self._load_subscriptions()
|
||
self._load_pools()
|
||
|
||
def _load_nodes(self):
|
||
"""加载已保存的节点"""
|
||
if self.config_file.exists():
|
||
try:
|
||
with open(self.config_file, 'r') as f:
|
||
data = json.load(f)
|
||
for node_data in data:
|
||
node = ProxyNode(**node_data)
|
||
self.nodes[node.id] = node
|
||
logger.info(f"加载了 {len(self.nodes)} 个节点")
|
||
except Exception as e:
|
||
logger.error(f"加载节点失败: {e}")
|
||
|
||
def _save_nodes(self):
|
||
"""保存节点到文件"""
|
||
try:
|
||
with open(self.config_file, 'w') as f:
|
||
json.dump([asdict(n) for n in self.nodes.values()], f, indent=2, ensure_ascii=False)
|
||
except Exception as e:
|
||
logger.error(f"保存节点失败: {e}")
|
||
|
||
def _load_subscriptions(self):
|
||
if self.subscription_file.exists():
|
||
try:
|
||
with open(self.subscription_file, 'r') as f:
|
||
data = json.load(f)
|
||
for sub_data in data:
|
||
sub = Subscription(**sub_data)
|
||
self.subscriptions[sub.id] = sub
|
||
logger.info(f"加载了 {len(self.subscriptions)} 个订阅")
|
||
except Exception as e:
|
||
logger.error(f"加载订阅失败: {e}")
|
||
|
||
def _save_subscriptions(self):
|
||
try:
|
||
with open(self.subscription_file, 'w') as f:
|
||
json.dump([asdict(s) for s in self.subscriptions.values()], f, indent=2, ensure_ascii=False)
|
||
except Exception as e:
|
||
logger.error(f"保存订阅失败: {e}")
|
||
|
||
def _load_pools(self):
|
||
if self.pool_file.exists():
|
||
try:
|
||
with open(self.pool_file, 'r') as f:
|
||
data = json.load(f)
|
||
for p in data:
|
||
pool = NodePool(**p)
|
||
self.pools[pool.id] = pool
|
||
logger.info(f"加载了 {len(self.pools)} 个节点池")
|
||
except Exception as e:
|
||
logger.error(f"加载节点池失败: {e}")
|
||
|
||
def _save_pools(self):
|
||
try:
|
||
with open(self.pool_file, 'w') as f:
|
||
json.dump([asdict(p) for p in self.pools.values()], f, indent=2, ensure_ascii=False)
|
||
except Exception as e:
|
||
logger.error(f"保存节点池失败: {e}")
|
||
|
||
def search_nodes(self, keyword: str, only_available: bool = True) -> List[ProxyNode]:
|
||
"""按关键字搜索节点"""
|
||
kw = keyword.strip().lower()
|
||
with self.lock:
|
||
results = []
|
||
for n in self.nodes.values():
|
||
if '0.0.0.0' in n.address:
|
||
continue
|
||
if only_available and not n.available:
|
||
continue
|
||
text = f"{n.name} {n.address} {n.protocol}".lower()
|
||
if kw in text:
|
||
results.append(n)
|
||
return results
|
||
|
||
def create_pool(self, name: str, node_ids: List[str]) -> NodePool:
|
||
pool_id = f"pool:{base64.urlsafe_b64encode(name.encode()).decode().rstrip('=')}:{int(time.time())}"
|
||
pool = NodePool(
|
||
id=pool_id,
|
||
name=name,
|
||
node_ids=node_ids,
|
||
created_at=datetime.now().isoformat()
|
||
)
|
||
with self.lock:
|
||
self.pools[pool_id] = pool
|
||
self._save_pools()
|
||
return pool
|
||
|
||
def update_pool(self, pool_id: str, name: str = "", node_ids: Optional[List[str]] = None) -> Optional[NodePool]:
|
||
with self.lock:
|
||
pool = self.pools.get(pool_id)
|
||
if not pool:
|
||
return None
|
||
if name:
|
||
pool.name = name
|
||
if node_ids is not None:
|
||
pool.node_ids = node_ids
|
||
self._save_pools()
|
||
return pool
|
||
|
||
def delete_pool(self, pool_id: str) -> bool:
|
||
with self.lock:
|
||
if pool_id not in self.pools:
|
||
return False
|
||
del self.pools[pool_id]
|
||
self._save_pools()
|
||
return True
|
||
|
||
def get_pool(self, pool_id: str) -> Optional[NodePool]:
|
||
with self.lock:
|
||
return self.pools.get(pool_id)
|
||
|
||
def get_all_pools(self) -> List[NodePool]:
|
||
with self.lock:
|
||
return list(self.pools.values())
|
||
|
||
def get_random_from_pool(self, pool_id: str) -> Optional[ProxyNode]:
|
||
"""从指定池中随机获取可用节点"""
|
||
with self.lock:
|
||
pool = self.pools.get(pool_id)
|
||
if not pool:
|
||
return None
|
||
candidates = []
|
||
for nid in pool.node_ids:
|
||
n = self.nodes.get(nid)
|
||
if n and n.available and n.socks_port > 0:
|
||
candidates.append(n)
|
||
if not candidates:
|
||
# 降级:返回任意有端口的
|
||
for nid in pool.node_ids:
|
||
n = self.nodes.get(nid)
|
||
if n and n.socks_port > 0 and '0.0.0.0' not in n.address:
|
||
candidates.append(n)
|
||
if not candidates:
|
||
return None
|
||
random.shuffle(candidates)
|
||
for node in candidates:
|
||
if self._is_port_alive(node.socks_port):
|
||
return node
|
||
return None
|
||
|
||
def add_link(self, link: str) -> Tuple[bool, str]:
|
||
"""添加代理链接"""
|
||
return self.add_link_with_subscription(link, "")
|
||
|
||
def add_link_with_subscription(self, link: str, subscription_id: str = "") -> Tuple[bool, str]:
|
||
"""添加代理链接并记录订阅来源"""
|
||
node = LinkParser.parse_link(link)
|
||
if not node:
|
||
return False, "解析链接失败"
|
||
|
||
with self.lock:
|
||
if node.id in self.nodes:
|
||
existing = self.nodes[node.id]
|
||
if subscription_id:
|
||
existing.subscription_id = subscription_id
|
||
self._save_nodes()
|
||
return False, "节点已存在"
|
||
|
||
node.subscription_id = subscription_id
|
||
self.nodes[node.id] = node
|
||
self._save_nodes()
|
||
logger.info(f"添加节点: {node.name} ({node.protocol}://{node.address}:{node.port})")
|
||
return True, f"添加成功: {node.name}"
|
||
|
||
def add_links(self, links: List[str]) -> Tuple[int, int]:
|
||
"""批量添加链接"""
|
||
success = 0
|
||
fail = 0
|
||
for link in links:
|
||
ok, _ = self.add_link(link)
|
||
if ok:
|
||
success += 1
|
||
else:
|
||
fail += 1
|
||
return success, fail
|
||
|
||
def _normalize_subscription_text(self, text: str) -> str:
|
||
stripped = text.strip()
|
||
if not stripped:
|
||
return ""
|
||
if '://' in stripped:
|
||
return stripped
|
||
try:
|
||
return safe_b64decode(stripped).decode('utf-8', errors='ignore')
|
||
except Exception:
|
||
return stripped
|
||
|
||
def _extract_proxy_links(self, text: str) -> List[str]:
|
||
normalized = self._normalize_subscription_text(text)
|
||
links: List[str] = []
|
||
prefixes = ('vmess://', 'vless://', 'trojan://', 'ss://')
|
||
for line in normalized.replace('\r', '\n').split('\n'):
|
||
line = line.strip()
|
||
if line.startswith(prefixes):
|
||
links.append(line)
|
||
return links
|
||
|
||
def sync_subscription(self, subscription_id: str) -> Tuple[bool, str, int, int]:
|
||
with self.lock:
|
||
sub = self.subscriptions.get(subscription_id)
|
||
if not sub:
|
||
return False, '订阅不存在', 0, 0
|
||
url = sub.url
|
||
|
||
try:
|
||
resp = requests.get(url, timeout=30)
|
||
resp.raise_for_status()
|
||
links = self._extract_proxy_links(resp.text)
|
||
if not links:
|
||
with self.lock:
|
||
sub.last_sync = datetime.now().isoformat()
|
||
sub.last_error = '订阅中没有可解析的代理链接'
|
||
self._save_subscriptions()
|
||
return False, sub.last_error, 0, 0
|
||
|
||
with self.lock:
|
||
old_node_ids = list(sub.node_ids)
|
||
|
||
for node_id in old_node_ids:
|
||
self.delete_node(node_id)
|
||
|
||
success = 0
|
||
fail = 0
|
||
new_node_ids: List[str] = []
|
||
for link in links:
|
||
ok, _ = self.add_link_with_subscription(link, subscription_id)
|
||
node = LinkParser.parse_link(link)
|
||
if ok and node:
|
||
new_node_ids.append(node.id)
|
||
success += 1
|
||
else:
|
||
fail += 1
|
||
|
||
with self.lock:
|
||
sub.node_ids = new_node_ids
|
||
sub.last_sync = datetime.now().isoformat()
|
||
sub.last_error = '' if success > 0 else '没有成功导入任何节点'
|
||
self._save_subscriptions()
|
||
|
||
return success > 0, '同步完成', success, fail
|
||
except Exception as e:
|
||
with self.lock:
|
||
sub = self.subscriptions.get(subscription_id)
|
||
if sub:
|
||
sub.last_sync = datetime.now().isoformat()
|
||
sub.last_error = str(e)
|
||
self._save_subscriptions()
|
||
return False, f'同步失败: {e}', 0, 0
|
||
|
||
def add_subscription(self, url: str, name: str = '', sync_interval: int = 0) -> Tuple[bool, str, Optional[Subscription], int, int]:
|
||
subscription_id = f"sub:{base64.urlsafe_b64encode(url.encode()).decode().rstrip('=')}"
|
||
with self.lock:
|
||
if subscription_id in self.subscriptions:
|
||
return False, '订阅已存在', None, 0, 0
|
||
sub = Subscription(
|
||
id=subscription_id,
|
||
name=name or url,
|
||
url=url,
|
||
node_ids=[],
|
||
sync_interval=sync_interval
|
||
)
|
||
self.subscriptions[subscription_id] = sub
|
||
self._save_subscriptions()
|
||
|
||
ok, message, success, fail = self.sync_subscription(subscription_id)
|
||
with self.lock:
|
||
sub = self.subscriptions.get(subscription_id)
|
||
return ok, message, sub, success, fail
|
||
|
||
def delete_subscription(self, subscription_id: str) -> bool:
|
||
with self.lock:
|
||
sub = self.subscriptions.get(subscription_id)
|
||
if not sub:
|
||
return False
|
||
node_ids = list(sub.node_ids)
|
||
|
||
for node_id in node_ids:
|
||
self.delete_node(node_id)
|
||
|
||
with self.lock:
|
||
self.subscriptions.pop(subscription_id, None)
|
||
self._save_subscriptions()
|
||
return True
|
||
|
||
def get_all_subscriptions(self) -> List[Subscription]:
|
||
with self.lock:
|
||
return list(self.subscriptions.values())
|
||
|
||
def get_available_port(self) -> Optional[int]:
|
||
"""获取可用端口"""
|
||
with self.lock:
|
||
available = self.port_pool - self.used_ports
|
||
if not available:
|
||
return None
|
||
port = random.choice(list(available))
|
||
self.used_ports.add(port)
|
||
return port
|
||
|
||
def release_port(self, port: int):
|
||
"""释放端口"""
|
||
with self.lock:
|
||
self.used_ports.discard(port)
|
||
|
||
def start_xray(self, node_id: str) -> bool:
|
||
"""启动xray进程"""
|
||
node = self.nodes.get(node_id)
|
||
if not node:
|
||
return False
|
||
|
||
# 停止旧进程
|
||
self.stop_xray(node_id)
|
||
|
||
# 获取端口
|
||
socks_port = self.get_available_port()
|
||
if not socks_port:
|
||
logger.error("没有可用端口")
|
||
return False
|
||
|
||
try:
|
||
# 生成配置
|
||
config = XrayConfigGenerator.generate_config(node, socks_port)
|
||
config_file = DATA_DIR / f'xray_{node_id}.json'
|
||
|
||
with open(config_file, 'w') as f:
|
||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||
|
||
# 启动xray
|
||
if not XRAY_BIN.exists():
|
||
logger.error(f"xray不存在: {XRAY_BIN}")
|
||
self.release_port(socks_port)
|
||
return False
|
||
|
||
proc = subprocess.Popen(
|
||
[str(XRAY_BIN), 'run', '-config', str(config_file)],
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL
|
||
)
|
||
|
||
# 等待一下检查进程是否启动
|
||
time.sleep(0.5)
|
||
if proc.poll() is not None:
|
||
logger.error(f"xray进程启动失败: {node_id}")
|
||
self.release_port(socks_port)
|
||
return False
|
||
|
||
with self.lock:
|
||
node.socks_port = socks_port
|
||
node.xray_pid = proc.pid
|
||
self.xray_processes[node_id] = proc
|
||
self._save_nodes()
|
||
|
||
logger.info(f"启动xray: {node.name} -> socks5://127.0.0.1:{socks_port}")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"启动xray失败: {e}")
|
||
self.release_port(socks_port)
|
||
return False
|
||
|
||
def stop_xray(self, node_id: str):
|
||
"""停止xray进程"""
|
||
with self.lock:
|
||
proc = self.xray_processes.pop(node_id, None)
|
||
node = self.nodes.get(node_id)
|
||
|
||
if proc:
|
||
try:
|
||
proc.terminate()
|
||
proc.wait(timeout=5)
|
||
except:
|
||
proc.kill()
|
||
|
||
if node and node.socks_port:
|
||
self.release_port(node.socks_port)
|
||
node.socks_port = 0
|
||
node.xray_pid = 0
|
||
self._save_nodes()
|
||
|
||
def check_node(self, node_id: str, timeout: int = 8) -> bool:
|
||
"""检查节点可用性"""
|
||
node = self.nodes.get(node_id)
|
||
if not node:
|
||
return False
|
||
|
||
# 如果没有运行,先启动
|
||
if node.socks_port == 0:
|
||
if not self.start_xray(node_id):
|
||
return False
|
||
time.sleep(0.3)
|
||
|
||
# 通过代理测试连接
|
||
try:
|
||
proxies = {
|
||
'http': f'socks5h://127.0.0.1:{node.socks_port}',
|
||
'https': f'socks5h://127.0.0.1:{node.socks_port}'
|
||
}
|
||
|
||
test_url = DEFAULT_TEST_URL
|
||
resp = requests.get(
|
||
test_url,
|
||
proxies=proxies,
|
||
timeout=timeout
|
||
)
|
||
|
||
available = resp.status_code in (200, 204)
|
||
|
||
with self.lock:
|
||
node.available = available
|
||
node.last_check = datetime.now().isoformat()
|
||
self._save_nodes()
|
||
|
||
return available
|
||
|
||
except Exception as e:
|
||
logger.warning(f"检查节点失败 {node.name}: {e}")
|
||
with self.lock:
|
||
node.available = False
|
||
node.last_check = datetime.now().isoformat()
|
||
self._save_nodes()
|
||
return False
|
||
|
||
def _check_node_wrapper(self, node_id: str):
|
||
try:
|
||
self.check_node(node_id)
|
||
except Exception as e:
|
||
logger.warning(f"检查异常 {node_id}: {e}")
|
||
|
||
def check_all_nodes(self, max_workers: int = 20):
|
||
"""并发检查所有节点"""
|
||
node_ids = list(self.nodes.keys())
|
||
if not node_ids:
|
||
return
|
||
logger.info(f"开始检查 {len(node_ids)} 个节点, 并发={max_workers}...")
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
with ThreadPoolExecutor(max_workers=min(max_workers, len(node_ids))) as pool:
|
||
pool.map(self._check_node_wrapper, node_ids)
|
||
logger.info("检查完成")
|
||
|
||
def _is_port_alive(self, port: int) -> bool:
|
||
try:
|
||
s = socket.create_connection(("127.0.0.1", port), timeout=1)
|
||
s.close()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def get_random_available(self) -> Optional[ProxyNode]:
|
||
"""获取随机可用节点,返回前快速验证端口是否存活"""
|
||
with self.lock:
|
||
available_nodes = [n for n in self.nodes.values() if n.available and n.socks_port > 0]
|
||
if not available_nodes:
|
||
available_nodes = [n for n in self.nodes.values() if n.socks_port > 0 and '0.0.0.0' not in n.address]
|
||
if not available_nodes:
|
||
return None
|
||
|
||
# 打乱顺序,逐个验证端口存活
|
||
random.shuffle(available_nodes)
|
||
for node in available_nodes:
|
||
if self._is_port_alive(node.socks_port):
|
||
return node
|
||
return None
|
||
|
||
def get_all_nodes(self) -> List[ProxyNode]:
|
||
"""获取所有节点"""
|
||
with self.lock:
|
||
return list(self.nodes.values())
|
||
|
||
def delete_node(self, node_id: str) -> bool:
|
||
"""删除节点"""
|
||
with self.lock:
|
||
if node_id not in self.nodes:
|
||
return False
|
||
|
||
subscription_id = self.nodes[node_id].subscription_id
|
||
self.stop_xray(node_id)
|
||
del self.nodes[node_id]
|
||
if subscription_id and subscription_id in self.subscriptions:
|
||
sub = self.subscriptions[subscription_id]
|
||
sub.node_ids = [nid for nid in sub.node_ids if nid != node_id]
|
||
self._save_subscriptions()
|
||
self._save_nodes()
|
||
return True
|
||
|
||
def cleanup(self):
|
||
"""清理所有进程"""
|
||
for node_id in list(self.xray_processes.keys()):
|
||
self.stop_xray(node_id)
|
||
|
||
|
||
class HealthChecker:
|
||
"""健康检查调度器"""
|
||
|
||
def __init__(self, manager: ProxyManager, interval: int = 600):
|
||
self.manager = manager
|
||
self.interval = interval # 默认5分钟
|
||
self.running = False
|
||
self.thread = None
|
||
|
||
def start(self):
|
||
"""启动定时检查"""
|
||
if self.running:
|
||
return
|
||
|
||
self.running = True
|
||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||
self.thread.start()
|
||
logger.info(f"健康检查已启动,间隔 {self.interval} 秒")
|
||
|
||
def stop(self):
|
||
"""停止定时检查"""
|
||
self.running = False
|
||
if self.thread:
|
||
self.thread.join(timeout=5)
|
||
logger.info("健康检查已停止")
|
||
|
||
def _run(self):
|
||
"""运行检查循环"""
|
||
while self.running:
|
||
try:
|
||
self.manager.check_all_nodes()
|
||
except Exception as e:
|
||
logger.error(f"健康检查异常: {e}")
|
||
|
||
# 等待下次检查
|
||
for _ in range(self.interval):
|
||
if not self.running:
|
||
break
|
||
time.sleep(1)
|
||
|
||
|
||
class SubscriptionSyncer:
|
||
"""订阅自动同步调度器,每60秒检查一次,到期的订阅自动同步"""
|
||
|
||
def __init__(self, manager: ProxyManager):
|
||
self.manager = manager
|
||
self.running = False
|
||
self.thread = None
|
||
|
||
def start(self):
|
||
if self.running:
|
||
return
|
||
self.running = True
|
||
self.thread = threading.Thread(target=self._run, daemon=True)
|
||
self.thread.start()
|
||
logger.info("订阅自动同步已启动")
|
||
|
||
def stop(self):
|
||
self.running = False
|
||
if self.thread:
|
||
self.thread.join(timeout=5)
|
||
logger.info("订阅自动同步已停止")
|
||
|
||
def _run(self):
|
||
while self.running:
|
||
try:
|
||
self._check_and_sync()
|
||
except Exception as e:
|
||
logger.error(f"订阅自动同步异常: {e}")
|
||
for _ in range(60):
|
||
if not self.running:
|
||
break
|
||
time.sleep(1)
|
||
|
||
def _check_and_sync(self):
|
||
now = datetime.now()
|
||
for sub in list(self.manager.get_all_subscriptions()):
|
||
if sub.sync_interval <= 0:
|
||
continue
|
||
if not sub.last_sync:
|
||
continue
|
||
try:
|
||
last = datetime.fromisoformat(sub.last_sync)
|
||
elapsed_min = (now - last).total_seconds() / 60
|
||
if elapsed_min >= sub.sync_interval:
|
||
logger.info(f"自动同步订阅: {sub.name}")
|
||
self.manager.sync_subscription(sub.id)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
# Flask应用
|
||
app = Flask(__name__)
|
||
manager = ProxyManager()
|
||
checker = HealthChecker(manager)
|
||
syncer = SubscriptionSyncer(manager)
|
||
|
||
|
||
@app.route('/')
|
||
def index():
|
||
"""首页"""
|
||
return '''
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<title>AirToSocks - 代理转换系统</title>
|
||
<meta charset="utf-8">
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; padding: 20px; }
|
||
.container { max-width: 1200px; margin: 0 auto; }
|
||
h1 { text-align: center; color: #00d9ff; margin-bottom: 30px; font-size: 2.5em; }
|
||
.card { background: #16213e; border-radius: 12px; padding: 20px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); }
|
||
.card h2 { color: #00d9ff; margin-bottom: 15px; }
|
||
textarea { width: 100%; height: 150px; background: #0f3460; border: 1px solid #00d9ff; border-radius: 8px; color: #fff; padding: 15px; font-size: 14px; resize: vertical; }
|
||
textarea::placeholder { color: #888; }
|
||
.btn { background: linear-gradient(135deg, #00d9ff, #0066ff); border: none; color: white; padding: 12px 24px; border-radius: 8px; cursor: pointer; font-size: 16px; margin: 10px 5px; transition: transform 0.2s, box-shadow 0.2s; }
|
||
.btn:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,217,255,0.4); }
|
||
.btn-success { background: linear-gradient(135deg, #00ff88, #00cc6a); }
|
||
.btn-danger { background: linear-gradient(135deg, #ff4757, #ff3344); }
|
||
.stats { display: flex; gap: 20px; flex-wrap: wrap; }
|
||
.stat-card { flex: 1; min-width: 150px; background: #0f3460; padding: 20px; border-radius: 8px; text-align: center; }
|
||
.stat-value { font-size: 2em; color: #00d9ff; font-weight: bold; }
|
||
.stat-label { color: #888; margin-top: 5px; }
|
||
.node-list { margin-top: 20px; }
|
||
.node-item { display: flex; align-items: center; background: #0f3460; padding: 15px; border-radius: 8px; margin-bottom: 10px; }
|
||
.node-status { width: 12px; height: 12px; border-radius: 50%; margin-right: 15px; }
|
||
.node-status.online { background: #00ff88; box-shadow: 0 0 8px #00ff88; }
|
||
.node-status.offline { background: #ff4757; }
|
||
.node-info { flex: 1; }
|
||
.node-name { font-weight: bold; }
|
||
.node-detail { color: #888; font-size: 0.9em; }
|
||
.node-socks { background: #1a1a2e; padding: 8px 12px; border-radius: 6px; font-family: monospace; }
|
||
.api-info { background: #0f3460; padding: 15px; border-radius: 8px; margin-top: 10px; }
|
||
.api-url { font-family: monospace; color: #00d9ff; word-break: break-all; }
|
||
.alert { padding: 15px; border-radius: 8px; margin-bottom: 15px; }
|
||
.alert-success { background: rgba(0,255,136,0.2); border: 1px solid #00ff88; }
|
||
.alert-info { background: rgba(0,217,255,0.2); border: 1px solid #00d9ff; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>AirToSocks</h1>
|
||
|
||
<div class="card">
|
||
<h2>订阅管理</h2>
|
||
<p style="color: #888; margin-bottom: 10px;">填写订阅地址,系统会抓取并同步其中的 vmess 链接</p>
|
||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px;">
|
||
<input id="subName" placeholder="订阅名称(可选)" style="flex: 1; min-width: 180px; background: #0f3460; border: 1px solid #00d9ff; border-radius: 8px; color: #fff; padding: 12px;">
|
||
<input id="subUrl" placeholder="https://example.com/subscription" style="flex: 2; min-width: 250px; background: #0f3460; border: 1px solid #00d9ff; border-radius: 8px; color: #fff; padding: 12px;">
|
||
<select id="subInterval" style="flex: 0.8; min-width: 140px; background: #0f3460; border: 1px solid #00d9ff; border-radius: 8px; color: #fff; padding: 12px;">
|
||
<option value="0">不自动更新</option>
|
||
<option value="60">每1小时</option>
|
||
<option value="120">每2小时</option>
|
||
<option value="360">每6小时</option>
|
||
<option value="720">每12小时</option>
|
||
<option value="1440">每24小时</option>
|
||
</select>
|
||
</div>
|
||
<div style="margin-top: 15px;">
|
||
<button class="btn" onclick="addSubscription()">添加订阅</button>
|
||
<button class="btn btn-success" onclick="loadSubscriptions()">刷新订阅</button>
|
||
</div>
|
||
<div class="node-list" id="subList" style="margin-top: 15px;"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>自定义节点池</h2>
|
||
<p style="color: #888; margin-bottom: 10px;">创建专属节点池,通过关键字搜索快速选择节点,然后通过池的随机接口获取代理</p>
|
||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px;">
|
||
<input id="poolName" placeholder="池名称 (如: 香港专线)" style="flex: 1; min-width: 180px; background: #0f3460; border: 1px solid #00d9ff; border-radius: 8px; color: #fff; padding: 12px;">
|
||
<input id="poolSearch" placeholder="关键字搜索 (如: 香港, 日本, 专线)" style="flex: 2; min-width: 250px; background: #0f3460; border: 1px solid #00d9ff; border-radius: 8px; color: #fff; padding: 12px;">
|
||
<button class="btn" onclick="searchPoolNodes()">搜索</button>
|
||
</div>
|
||
<div id="poolSearchResults" style="margin-bottom: 10px;"></div>
|
||
<div id="poolSelectedNodes"></div>
|
||
<div style="margin-bottom: 15px;">
|
||
<button class="btn btn-success" onclick="createPool()" id="btnCreatePool" disabled>创建节点池</button>
|
||
<span id="poolSelectedCount" style="color: #888; margin-left: 10px;">已选 0 个节点</span>
|
||
</div>
|
||
<h3 style="color: #00d9ff; margin-bottom: 10px;">已有节点池</h3>
|
||
<div id="poolList"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>导入代理链接</h2>
|
||
<p style="color: #888; margin-bottom: 10px;">支持 vmess://, vless://, trojan://, ss:// 格式,每行一个</p>
|
||
<textarea id="links" placeholder="在此粘贴代理链接... vmess://... vless://... trojan://... ss://..."></textarea>
|
||
<div style="margin-top: 15px;">
|
||
<button class="btn" onclick="addLinks()">添加链接</button>
|
||
<button class="btn btn-success" onclick="checkAll()">检查全部</button>
|
||
<button class="btn" onclick="getRandom()">随机获取</button>
|
||
</div>
|
||
<div id="result"></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>统计信息</h2>
|
||
<div class="stats" id="stats">
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="total">0</div>
|
||
<div class="stat-label">总节点数</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="available">0</div>
|
||
<div class="stat-label">可用节点</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="protocols">0</div>
|
||
<div class="stat-label">协议类型</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>API 接口</h2>
|
||
<div class="api-info">
|
||
<p><strong>全局随机获取 socks5:</strong></p>
|
||
<p class="api-url" id="apiUrl"></p>
|
||
<p style="margin-top: 10px; color: #888;">访问此链接将随机返回一个可用的 socks5 代理信息</p>
|
||
</div>
|
||
<div class="api-info" style="margin-top: 10px;">
|
||
<p><strong>节点池随机获取 socks5:</strong></p>
|
||
<p class="api-url" id="apiUrlPool"></p>
|
||
<p style="margin-top: 10px; color: #888;">将 <pool_id> 替换为节点池 ID,可从下方节点池列表复制</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>节点列表</h2>
|
||
<div class="node-list" id="nodeList"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
document.getElementById('apiUrl').textContent = window.location.origin + '/api/random';
|
||
document.getElementById('apiUrlPool').textContent = window.location.origin + '/api/pools/<pool_id>/random';
|
||
|
||
function showResult(msg, type='info') {
|
||
const el = document.getElementById('result');
|
||
el.innerHTML = `<div class="alert alert-${type}">${msg}</div>`;
|
||
setTimeout(() => el.innerHTML = '', 5000);
|
||
}
|
||
|
||
async function addLinks() {
|
||
const links = document.getElementById('links').value.trim();
|
||
if (!links) return showResult('请输入代理链接', 'info');
|
||
|
||
try {
|
||
const resp = await fetch('/api/links', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({links: links.split('\\n').filter(l => l.trim())})
|
||
});
|
||
const data = await resp.json();
|
||
showResult(`添加完成: 成功 ${data.success} 个, 失败 ${data.fail} 个`, 'success');
|
||
document.getElementById('links').value = '';
|
||
loadNodes();
|
||
} catch (e) {
|
||
showResult('添加失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function addSubscription() {
|
||
const name = document.getElementById('subName').value.trim();
|
||
const url = document.getElementById('subUrl').value.trim();
|
||
const syncInterval = parseInt(document.getElementById('subInterval').value) || 0;
|
||
if (!url) return showResult('请输入订阅地址', 'info');
|
||
|
||
try {
|
||
const resp = await fetch('/api/subscriptions', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({name, url, sync_interval: syncInterval})
|
||
});
|
||
const data = await resp.json();
|
||
if (!resp.ok) {
|
||
throw new Error(data.error || '添加订阅失败');
|
||
}
|
||
showResult(`订阅同步完成: 成功 ${data.success} 个, 失败 ${data.fail} 个`, 'success');
|
||
document.getElementById('subName').value = '';
|
||
document.getElementById('subUrl').value = '';
|
||
document.getElementById('subInterval').value = '0';
|
||
loadSubscriptions();
|
||
loadNodes();
|
||
} catch (e) {
|
||
showResult('订阅添加失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function deleteSubscription(id) {
|
||
if (!confirm('确定删除此订阅及其同步的 vmess 节点?')) return;
|
||
try {
|
||
await fetch(`/api/subscriptions/${encodeURIComponent(id)}`, {method: 'DELETE'});
|
||
showResult('订阅已删除,关联节点已清理', 'success');
|
||
loadSubscriptions();
|
||
loadNodes();
|
||
} catch (e) {
|
||
showResult('删除订阅失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function syncSubscription(id) {
|
||
try {
|
||
showResult('正在同步订阅...', 'info');
|
||
const resp = await fetch(`/api/subscriptions/${encodeURIComponent(id)}/sync`, {method: 'POST'});
|
||
const data = await resp.json();
|
||
if (!resp.ok) {
|
||
throw new Error(data.error || '同步失败');
|
||
}
|
||
showResult(`订阅同步完成: 成功 ${data.success} 个, 失败 ${data.fail} 个`, 'success');
|
||
loadSubscriptions();
|
||
loadNodes();
|
||
} catch (e) {
|
||
showResult('同步订阅失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function loadSubscriptions() {
|
||
try {
|
||
const resp = await fetch('/api/subscriptions');
|
||
const subs = await resp.json();
|
||
const list = document.getElementById('subList');
|
||
if (!subs.length) {
|
||
list.innerHTML = '<p style="color: #888; text-align: center; padding: 20px;">暂无订阅</p>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = subs.map(s => {
|
||
const intervalLabel = s.sync_interval > 0 ? `每${s.sync_interval>=1440?(s.sync_interval/1440)+'天':s.sync_interval>=60?(s.sync_interval/60)+'小时':s.sync_interval+'分钟'}更新` : '手动更新';
|
||
return `<div class="node-item">
|
||
<div class="node-info">
|
||
<div class="node-name">${s.name} <span style="font-size:11px;color:#00d9ff;background:#0a1a3e;padding:2px 6px;border-radius:4px">${intervalLabel}</span></div>
|
||
<div class="node-detail">节点: ${s.node_ids.length} | ${s.last_sync ? '同步: ' + new Date(s.last_sync).toLocaleString() : '未同步'}${s.last_error ? ' | 错误: ' + s.last_error : ''}</div>
|
||
</div>
|
||
<button class="btn btn-success" onclick="syncSubscription('${s.id}')" style="margin-left: 10px; padding: 8px 12px;">同步</button>
|
||
<button class="btn btn-danger" onclick="deleteSubscription('${s.id}')" style="margin-left: 5px; padding: 8px 12px;">删除</button>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
console.error('加载订阅失败', e);
|
||
}
|
||
}
|
||
|
||
async function checkAll() {
|
||
showResult('正在检查所有节点...', 'info');
|
||
try {
|
||
await fetch('/api/check', {method: 'POST'});
|
||
showResult('检查完成', 'success');
|
||
loadNodes();
|
||
} catch (e) {
|
||
showResult('检查失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function getRandom() {
|
||
try {
|
||
const resp = await fetch('/api/random');
|
||
if (resp.ok) {
|
||
const data = await resp.json();
|
||
showResult(`随机代理: socks5://${data.host}:${data.port}`, 'success');
|
||
} else {
|
||
showResult('没有可用的代理节点');
|
||
}
|
||
} catch (e) {
|
||
showResult('获取失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function deleteNode(id) {
|
||
if (!confirm('确定删除此节点?')) return;
|
||
try {
|
||
await fetch(`/api/links/${id}`, {method: 'DELETE'});
|
||
loadNodes();
|
||
} catch (e) {
|
||
showResult('删除失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function checkNode(id) {
|
||
try {
|
||
showResult('正在检查...', 'info');
|
||
await fetch(`/api/check/${id}`, {method: 'POST'});
|
||
showResult('检查完成', 'success');
|
||
loadNodes();
|
||
} catch (e) {
|
||
showResult('检查失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function loadNodes() {
|
||
try {
|
||
const resp = await fetch('/api/links');
|
||
const nodes = await resp.json();
|
||
|
||
const protocols = new Set(nodes.map(n => n.protocol));
|
||
const available = nodes.filter(n => n.available).length;
|
||
|
||
document.getElementById('total').textContent = nodes.length;
|
||
document.getElementById('available').textContent = available;
|
||
document.getElementById('protocols').textContent = protocols.size;
|
||
|
||
const list = document.getElementById('nodeList');
|
||
if (nodes.length === 0) {
|
||
list.innerHTML = '<p style="color: #888; text-align: center; padding: 20px;">暂无节点,请先导入代理链接</p>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = nodes.map(n => `
|
||
<div class="node-item">
|
||
<div class="node-status ${n.available ? 'online' : 'offline'}"></div>
|
||
<div class="node-info">
|
||
<div class="node-name">${n.name}</div>
|
||
<div class="node-detail">${n.protocol.toUpperCase()} | ${n.address}:${n.port} | ${n.last_check ? '检查: ' + new Date(n.last_check).toLocaleString() : '未检查'}</div>
|
||
</div>
|
||
${n.socks_port ? `<div class="node-socks">socks5://127.0.0.1:${n.socks_port}</div>` : ''}
|
||
<button class="btn" onclick="checkNode('${n.id}')" style="margin-left: 10px; padding: 8px 12px;">检查</button>
|
||
<button class="btn btn-danger" onclick="deleteNode('${n.id}')" style="margin-left: 5px; padding: 8px 12px;">删除</button>
|
||
</div>
|
||
`).join('');
|
||
} catch (e) {
|
||
console.error('加载节点失败', e);
|
||
}
|
||
}
|
||
|
||
loadNodes();
|
||
loadSubscriptions();
|
||
loadPools();
|
||
setInterval(loadNodes, 30000);
|
||
setInterval(loadSubscriptions, 30000);
|
||
setInterval(loadPools, 30000);
|
||
|
||
// ===== 节点池管理 =====
|
||
let poolSearchResults = [];
|
||
let poolSelectedIds = new Set();
|
||
|
||
async function searchPoolNodes() {
|
||
const q = document.getElementById('poolSearch').value.trim();
|
||
if (!q) return;
|
||
try {
|
||
const resp = await fetch(`/api/nodes/search?q=${encodeURIComponent(q)}&available=0`);
|
||
poolSearchResults = await resp.json();
|
||
// 不重置 poolSelectedIds,保留之前的选择
|
||
renderPoolSearchResults();
|
||
renderSelectedNodes();
|
||
updatePoolSelectedCount();
|
||
} catch (e) {
|
||
console.error('搜索失败', e);
|
||
}
|
||
}
|
||
|
||
function renderPoolSearchResults() {
|
||
const el = document.getElementById('poolSearchResults');
|
||
if (!poolSearchResults.length) {
|
||
el.innerHTML = '<p style="color:#888">没有匹配的节点</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = `<div style="margin-bottom:6px">
|
||
<button class="btn" style="padding:6px 10px;font-size:12px" onclick="selectSearchAll(true)">搜索结果全选</button>
|
||
<button class="btn" style="padding:6px 10px;font-size:12px" onclick="selectSearchAll(false)">搜索结果全不选</button>
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:6px;max-height:200px;overflow-y:auto;padding:8px;background:#0f3460;border-radius:8px">
|
||
${poolSearchResults.map(n => `<label style="display:flex;align-items:center;gap:4px;padding:4px 8px;background:${poolSelectedIds.has(n.id)?'#1a3a6e':'#0a1a3e'};border-radius:6px;cursor:pointer;font-size:13px">
|
||
<input type="checkbox" ${poolSelectedIds.has(n.id)?'checked':''} onchange="togglePoolNode('${n.id}',this.checked)">
|
||
<span style="color:${n.available?'#00ff88':'#ff4757'}">●</span>
|
||
${n.name.substring(0,20)}
|
||
</label>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderSelectedNodes() {
|
||
const el = document.getElementById('poolSelectedNodes');
|
||
if (poolSelectedIds.size === 0) {
|
||
el.innerHTML = '';
|
||
return;
|
||
}
|
||
// 从搜索结果缓存和已有节点中查找名称
|
||
const allNodes = [...poolSearchResults];
|
||
el.innerHTML = `<div style="margin-top:8px;padding:8px;background:#0a1a3e;border-radius:8px;border:1px solid #00d9ff">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||
<span style="color:#00d9ff;font-weight:bold">已选 ${poolSelectedIds.size} 个节点</span>
|
||
<button class="btn btn-danger" style="padding:4px 8px;font-size:12px" onclick="clearAllSelected()">清空选择</button>
|
||
</div>
|
||
<div style="display:flex;flex-wrap:wrap;gap:4px;max-height:120px;overflow-y:auto">
|
||
${[...poolSelectedIds].map(id => {
|
||
const n = allNodes.find(x => x.id === id);
|
||
const label = n ? n.name.substring(0,18) : id.substring(0,30);
|
||
return `<span style="display:inline-flex;align-items:center;gap:3px;padding:3px 6px;background:#1a3a6e;border-radius:4px;font-size:12px">
|
||
${label}
|
||
<span style="cursor:pointer;color:#ff4757" onclick="removeSelected('${id}')">×</span>
|
||
</span>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function removeSelected(id) {
|
||
poolSelectedIds.delete(id);
|
||
renderPoolSearchResults();
|
||
renderSelectedNodes();
|
||
updatePoolSelectedCount();
|
||
}
|
||
|
||
function clearAllSelected() {
|
||
poolSelectedIds.clear();
|
||
renderPoolSearchResults();
|
||
renderSelectedNodes();
|
||
updatePoolSelectedCount();
|
||
}
|
||
|
||
function togglePoolNode(id, checked) {
|
||
if (checked) poolSelectedIds.add(id); else poolSelectedIds.delete(id);
|
||
renderPoolSearchResults();
|
||
renderSelectedNodes();
|
||
updatePoolSelectedCount();
|
||
}
|
||
|
||
function selectSearchAll(checked) {
|
||
poolSearchResults.forEach(n => { if (checked) poolSelectedIds.add(n.id); else poolSelectedIds.delete(n.id); });
|
||
renderPoolSearchResults();
|
||
renderSelectedNodes();
|
||
updatePoolSelectedCount();
|
||
}
|
||
|
||
function updatePoolSelectedCount() {
|
||
document.getElementById('poolSelectedCount').textContent = `已选 ${poolSelectedIds.size} 个节点`;
|
||
document.getElementById('btnCreatePool').disabled = poolSelectedIds.size === 0;
|
||
}
|
||
|
||
async function createPool() {
|
||
const name = document.getElementById('poolName').value.trim();
|
||
if (!name) return showResult('请输入池名称', 'info');
|
||
if (poolSelectedIds.size === 0) return showResult('请先搜索并选择节点', 'info');
|
||
try {
|
||
const resp = await fetch('/api/pools', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({name, node_ids: [...poolSelectedIds]})
|
||
});
|
||
const data = await resp.json();
|
||
if (!resp.ok) throw new Error(data.error);
|
||
showResult(`节点池"${name}"创建成功, ${data.node_ids.length} 个节点`, 'success');
|
||
document.getElementById('poolName').value = '';
|
||
document.getElementById('poolSearch').value = '';
|
||
document.getElementById('poolSearchResults').innerHTML = '';
|
||
document.getElementById('poolSelectedNodes').innerHTML = '';
|
||
poolSelectedIds.clear();
|
||
updatePoolSelectedCount();
|
||
loadPools();
|
||
} catch (e) {
|
||
showResult('创建失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function deletePool(id) {
|
||
if (!confirm('确定删除此节点池?')) return;
|
||
try {
|
||
await fetch(`/api/pools/${encodeURIComponent(id)}`, {method: 'DELETE'});
|
||
loadPools();
|
||
} catch (e) {
|
||
showResult('删除失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function getRandomFromPool(id) {
|
||
try {
|
||
const resp = await fetch(`/api/pools/${encodeURIComponent(id)}/random`);
|
||
if (resp.ok) {
|
||
const d = await resp.json();
|
||
showResult(`池内随机代理: socks5://${d.host}:${d.port} (${d.source.name.substring(0,15)})`, 'success');
|
||
} else {
|
||
const d = await resp.json();
|
||
showResult(d.error || '池内没有可用代理');
|
||
}
|
||
} catch (e) {
|
||
showResult('获取失败: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function loadPools() {
|
||
try {
|
||
const resp = await fetch('/api/pools');
|
||
const pools = await resp.json();
|
||
const el = document.getElementById('poolList');
|
||
if (!pools.length) {
|
||
el.innerHTML = '<p style="color:#888;text-align:center;padding:15px">暂无节点池,上方搜索创建</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = pools.map(p => {
|
||
const apiBase = window.location.origin;
|
||
const apiUrl = `${apiBase}/api/pools/${encodeURIComponent(p.id)}/random`;
|
||
return `<div class="node-item">
|
||
<div class="node-info">
|
||
<div class="node-name">${p.name}</div>
|
||
<div class="node-detail" style="font-family:monospace;font-size:11px;color:#00d9ff;margin-bottom:2px">ID: ${p.id}</div>
|
||
<div class="node-detail">节点: ${p.node_count} | 可用: ${p.available_count} | 创建: ${new Date(p.created_at).toLocaleString()}</div>
|
||
<div class="api-url" style="margin-top:4px;font-size:12px">${apiUrl}</div>
|
||
</div>
|
||
<button class="btn" onclick="getRandomFromPool('${p.id}')" style="margin-left:10px;padding:8px 12px">随机获取</button>
|
||
<button class="btn btn-danger" onclick="deletePool('${p.id}')" style="margin-left:5px;padding:8px 12px">删除</button>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
console.error('加载节点池失败', e);
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|
||
'''
|
||
|
||
|
||
@app.route('/api/links', methods=['GET'])
|
||
def get_links():
|
||
"""获取所有节点"""
|
||
nodes = manager.get_all_nodes()
|
||
return jsonify([asdict(n) for n in nodes])
|
||
|
||
|
||
@app.route('/api/subscriptions', methods=['GET'])
|
||
def get_subscriptions():
|
||
subs = manager.get_all_subscriptions()
|
||
return jsonify([asdict(s) for s in subs])
|
||
|
||
|
||
@app.route('/api/subscriptions', methods=['POST'])
|
||
def add_subscription():
|
||
data = request.get_json() or {}
|
||
url = str(data.get('url', '')).strip()
|
||
name = str(data.get('name', '')).strip()
|
||
sync_interval = int(data.get('sync_interval', 0) or 0)
|
||
if not url:
|
||
return jsonify({'error': '订阅地址不能为空'}), 400
|
||
|
||
ok, message, sub, success, fail = manager.add_subscription(url, name, sync_interval)
|
||
status = 200 if ok else 400
|
||
return jsonify({
|
||
'success': success,
|
||
'fail': fail,
|
||
'message': message,
|
||
'subscription': asdict(sub) if sub else None,
|
||
'error': None if ok else message
|
||
}), status
|
||
|
||
|
||
@app.route('/api/subscriptions/<path:subscription_id>/sync', methods=['POST'])
|
||
def sync_subscription(subscription_id):
|
||
ok, message, success, fail = manager.sync_subscription(subscription_id)
|
||
status = 200 if ok else 400
|
||
return jsonify({
|
||
'success': success,
|
||
'fail': fail,
|
||
'message': message,
|
||
'error': None if ok else message
|
||
}), status
|
||
|
||
|
||
@app.route('/api/subscriptions/<path:subscription_id>', methods=['DELETE'])
|
||
def delete_subscription(subscription_id):
|
||
ok = manager.delete_subscription(subscription_id)
|
||
status = 200 if ok else 404
|
||
return jsonify({'success': ok}), status
|
||
|
||
|
||
@app.route('/api/nodes/search')
|
||
def search_nodes():
|
||
keyword = request.args.get('q', '')
|
||
if not keyword:
|
||
return jsonify([])
|
||
only_available = request.args.get('available', '1') == '1'
|
||
nodes = manager.search_nodes(keyword, only_available=only_available)
|
||
return jsonify([{'id': n.id, 'name': n.name, 'protocol': n.protocol, 'address': n.address, 'port': n.port, 'available': n.available} for n in nodes])
|
||
|
||
|
||
@app.route('/api/pools', methods=['GET'])
|
||
def get_pools():
|
||
pools = manager.get_all_pools()
|
||
result = []
|
||
for p in pools:
|
||
d = asdict(p)
|
||
d['node_count'] = len(p.node_ids)
|
||
d['available_count'] = sum(1 for nid in p.node_ids if (n := manager.nodes.get(nid)) and n.available)
|
||
result.append(d)
|
||
return jsonify(result)
|
||
|
||
|
||
@app.route('/api/pools', methods=['POST'])
|
||
def create_pool():
|
||
data = request.get_json() or {}
|
||
name = str(data.get('name', '')).strip()
|
||
node_ids = data.get('node_ids', [])
|
||
if not name:
|
||
return jsonify({'error': '池名称不能为空'}), 400
|
||
if not node_ids:
|
||
return jsonify({'error': '至少选择一个节点'}), 400
|
||
pool = manager.create_pool(name, node_ids)
|
||
return jsonify(asdict(pool)), 201
|
||
|
||
|
||
@app.route('/api/pools/<path:pool_id>', methods=['PUT'])
|
||
def update_pool(pool_id):
|
||
data = request.get_json() or {}
|
||
name = str(data.get('name', '')).strip()
|
||
node_ids = data.get('node_ids')
|
||
pool = manager.update_pool(pool_id, name=name if name else "", node_ids=node_ids)
|
||
if not pool:
|
||
return jsonify({'error': '池不存在'}), 404
|
||
return jsonify(asdict(pool))
|
||
|
||
|
||
@app.route('/api/pools/<path:pool_id>', methods=['DELETE'])
|
||
def delete_pool(pool_id):
|
||
ok = manager.delete_pool(pool_id)
|
||
return jsonify({'success': ok}), 200 if ok else 404
|
||
|
||
|
||
@app.route('/api/pools/<path:pool_id>/random')
|
||
def random_from_pool(pool_id):
|
||
node = manager.get_random_from_pool(pool_id)
|
||
if not node:
|
||
pool = manager.get_pool(pool_id)
|
||
if not pool:
|
||
return jsonify({'error': '池不存在'}), 404
|
||
return jsonify({'error': '池内没有可用的代理'}), 404
|
||
public_host = request.host.split(':', 1)[0]
|
||
return jsonify({
|
||
'protocol': 'socks5',
|
||
'host': public_host,
|
||
'port': node.socks_port,
|
||
'url': f'socks5://{public_host}:{node.socks_port}',
|
||
'available': node.available,
|
||
'last_check': node.last_check,
|
||
'source': {
|
||
'name': node.name,
|
||
'protocol': node.protocol,
|
||
'address': node.address,
|
||
'port': node.port
|
||
}
|
||
})
|
||
|
||
|
||
@app.route('/api/links', methods=['POST'])
|
||
def add_links():
|
||
"""添加链接"""
|
||
data = request.get_json()
|
||
links = data.get('links', [])
|
||
if isinstance(links, str):
|
||
links = links.split('\n')
|
||
|
||
success, fail = manager.add_links([l.strip() for l in links if l.strip()])
|
||
return jsonify({'success': success, 'fail': fail})
|
||
|
||
|
||
@app.route('/api/links/<node_id>', methods=['DELETE'])
|
||
def delete_link(node_id):
|
||
"""删除节点"""
|
||
ok = manager.delete_node(node_id)
|
||
return jsonify({'success': ok})
|
||
|
||
|
||
@app.route('/api/check', methods=['POST'])
|
||
def check_all():
|
||
"""检查所有节点"""
|
||
# 异步检查
|
||
threading.Thread(target=manager.check_all_nodes, daemon=True).start()
|
||
return jsonify({'status': 'checking'})
|
||
|
||
|
||
@app.route('/api/check/<node_id>', methods=['POST'])
|
||
def check_node(node_id):
|
||
"""检查单个节点"""
|
||
ok = manager.check_node(node_id)
|
||
return jsonify({'success': ok})
|
||
|
||
|
||
@app.route('/api/random')
|
||
def random_proxy():
|
||
"""获取随机可用代理"""
|
||
node = manager.get_random_available()
|
||
if not node:
|
||
return jsonify({'error': '没有可用的代理,请先导入节点并检测'}), 404
|
||
|
||
public_host = request.host.split(':', 1)[0]
|
||
return jsonify({
|
||
'protocol': 'socks5',
|
||
'host': public_host,
|
||
'port': node.socks_port,
|
||
'url': f'socks5://{public_host}:{node.socks_port}',
|
||
'available': node.available,
|
||
'last_check': node.last_check,
|
||
'source': {
|
||
'name': node.name,
|
||
'protocol': node.protocol,
|
||
'address': node.address,
|
||
'port': node.port
|
||
}
|
||
})
|
||
|
||
|
||
@app.route('/api/random/info')
|
||
def random_proxy_info():
|
||
"""获取随机代理详细信息(文本格式)"""
|
||
node = manager.get_random_available()
|
||
if node is None:
|
||
return '没有可用的代理', 404
|
||
|
||
public_host = request.host.split(':', 1)[0]
|
||
info = f"""AirToSocks 代理信息
|
||
==================
|
||
协议: SOCKS5
|
||
地址: {public_host}
|
||
端口: {node.socks_port}
|
||
|
||
来源节点
|
||
--------
|
||
名称: {node.name}
|
||
协议: {node.protocol.upper()}
|
||
服务器: {node.address}:{node.port}
|
||
"""
|
||
return info, 200, {'Content-Type': 'text/plain; charset=utf-8'}
|
||
|
||
|
||
def download_xray():
|
||
"""下载xray-core"""
|
||
if XRAY_BIN.exists():
|
||
logger.info("xray已存在")
|
||
return True
|
||
|
||
logger.info("正在下载xray-core...")
|
||
import platform
|
||
import zipfile
|
||
machine = platform.machine().lower()
|
||
|
||
# 映射架构
|
||
if machine in ['x86_64', 'amd64']:
|
||
arch = '64'
|
||
elif machine in ['aarch64', 'arm64']:
|
||
arch = 'arm64-v8a'
|
||
elif machine in ['armv7l', 'armhf']:
|
||
arch = 'arm32-v7a'
|
||
else:
|
||
arch = '64'
|
||
|
||
try:
|
||
api_url = 'https://api.github.com/repos/XTLS/Xray-core/releases/latest'
|
||
release_resp = requests.get(api_url, timeout=30)
|
||
release_resp.raise_for_status()
|
||
release = release_resp.json()
|
||
|
||
asset_url = None
|
||
candidates = [f'Xray-linux-{arch}.zip']
|
||
for asset in release.get('assets', []):
|
||
name = asset.get('name', '')
|
||
if name in candidates:
|
||
asset_url = asset.get('browser_download_url')
|
||
break
|
||
|
||
if not asset_url:
|
||
available = ', '.join(asset.get('name', '') for asset in release.get('assets', []))
|
||
raise RuntimeError(f'未找到适合当前架构的 Xray 资产,arch={arch},assets={available}')
|
||
|
||
resp = requests.get(asset_url, timeout=60)
|
||
resp.raise_for_status()
|
||
|
||
zip_file = BIN_DIR / 'xray.zip'
|
||
with open(zip_file, 'wb') as f:
|
||
f.write(resp.content)
|
||
|
||
with zipfile.ZipFile(zip_file, 'r') as zf:
|
||
zf.extractall(BIN_DIR)
|
||
|
||
# 设置执行权限
|
||
XRAY_BIN.chmod(0o755)
|
||
zip_file.unlink()
|
||
|
||
logger.info("xray-core下载完成")
|
||
return True
|
||
|
||
except Exception as e:
|
||
logger.error(f"下载xray失败: {e}")
|
||
return False
|
||
|
||
|
||
def main():
|
||
"""主函数"""
|
||
# 下载xray
|
||
if not download_xray():
|
||
logger.error("无法下载xray-core,请手动下载")
|
||
logger.info("下载地址: https://github.com/XTLS/Xray-core/releases")
|
||
sys.exit(1)
|
||
|
||
# 读取配置
|
||
config_file = BASE_DIR / 'config.yaml'
|
||
if config_file.exists():
|
||
with open(config_file) as f:
|
||
config = yaml.safe_load(f) or {}
|
||
else:
|
||
config = {}
|
||
|
||
# 获取端口
|
||
port = config.get('port', 8080)
|
||
check_interval = config.get('check_interval', 600)
|
||
|
||
# 启动健康检查
|
||
checker.interval = check_interval
|
||
checker.start()
|
||
|
||
# 启动订阅自动同步
|
||
syncer.start()
|
||
|
||
# 初始检查
|
||
threading.Thread(target=manager.check_all_nodes, daemon=True).start()
|
||
|
||
# 信号处理
|
||
def signal_handler(sig, frame):
|
||
logger.info("正在关闭...")
|
||
syncer.stop()
|
||
checker.stop()
|
||
manager.cleanup()
|
||
sys.exit(0)
|
||
|
||
signal.signal(signal.SIGINT, signal_handler)
|
||
signal.signal(signal.SIGTERM, signal_handler)
|
||
|
||
# 启动Web服务
|
||
logger.info(f"启动Web服务: http://0.0.0.0:{port}")
|
||
app.run(host='0.0.0.0', port=port, debug=False)
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|