Files
AirToSocks/main.py

1823 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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="在此粘贴代理链接...&#10;&#10;vmess://...&#10;vless://...&#10;trojan://...&#10;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;">将 &lt;pool_id&gt; 替换为节点池 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}')">&times;</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()