mirror of
https://github.com/honmashironeko/ProxyCat.git
synced 2026-10-29 11:50:48 +00:00
3502 lines
139 KiB
Python
3502 lines
139 KiB
Python
"""
|
||
模块名称:modules.proxyserver
|
||
功能描述:本地代理转发服务器:在配置端口上同时接受 HTTP 与 SOCKS5 入站请求,维持一个有目标容量的上游出口池(按来源取候选、逐条校验填充、按寿命单独替换)。
|
||
职责边界:负责:HTTP / SOCKS5 入站处理与隧道中继、出口池维持(取候选、校验填充、按寿命替换)、出口分配与失败归因、
|
||
IP 名单与代理认证、连接池与并发上限、出口真实 IP 的获取与缓存。不负责:代理抓取与验证评分(见 modules.proxypool 与 modules.modules 的
|
||
check_proxy)、记录持久化(modules.access_log 等)、服务器线程的创建与重启(app.py、ProxyCat.py)。
|
||
关键依赖:modules.modules、modules.proxy_workers、modules.access_log、
|
||
modules.domain_stats、modules.access_records、modules.logging_setup、
|
||
modules.loop_noise_filter、modules.pool_config_ini、config.getip、httpx。
|
||
已知限制:
|
||
1. 上游协议只支持 http / https / socks5;https 上游会真做 TLS,且一律不校验证书。
|
||
2. 重试上限固定 2 次,带请求体的明文 HTTP 请求不重试;归因口径分路径:HTTP 转发只记真正的上游传输错误,
|
||
其余失败也照常换出口重试;CONNECT / SOCKS5 隧道把握手失败等非传输错误也计入出口失败。
|
||
3. CONNECT 隧道与 SOCKS5 的写回只代表隧道已建立,要等上游回了数据才算成功;_check_worker 返回三态,None 表示这次没有结论。
|
||
4. 出口名额绑在连接任务上,_pipe 跑在 asyncio.gather 的子任务里,不能用 asyncio.current_task() 反查,必须显式传参。
|
||
5. 一个可用出口都没有时请求在 _wait_for_exit 排队等 exit_wait_timeout 秒,超时才回 503;空闲隧道不占每出口活跃额度。
|
||
6. 双栈监听下 IPv4 客户端的对端是 ::ffff:x.x.x.x,必须经 _peer_ip 归一化,否则 IP 名单静默失效;Windows 的 listen backlog 不生效。
|
||
7. 上游一字未回即断的 CONNECT 隧道不关客户端连接,由调用方换出口重放已缓冲请求(重放缓冲上限 64 KiB)。
|
||
"""
|
||
|
||
import asyncio, httpx, ipaddress, json, logging, socket, struct, time, base64, os, threading, errno, http.cookiejar, ssl
|
||
from collections import OrderedDict
|
||
from contextlib import AsyncExitStack, asynccontextmanager
|
||
from typing import NamedTuple
|
||
from modules.modules import (
|
||
get_message, load_ip_list, load_bypass_whitelist, check_bypass_match, port_in_use,
|
||
parse_bool_lenient, parse_proxy_url, split_host_port, build_socks5_auth_packet,
|
||
normalize_rotation_mode,
|
||
)
|
||
from modules.access_log import (
|
||
KIND_CONNECT, KIND_HTTP, KIND_SOCKS5, AccessRecord, AccessTracker, ClientIdentity,
|
||
sanitize_proxy,
|
||
)
|
||
from modules.access_records import RequestRecordStore
|
||
from modules.domain_stats import DomainStatsStore
|
||
from modules.logging_setup import LogCategory, ThrottledLogger, get_category_logger
|
||
from modules.loop_noise_filter import install_loop_noise_filter
|
||
from modules.proxy_workers import (
|
||
BLAME_IGNORED, BLAME_RETIRED, ProxyWorkerPool, StandbyQueue,
|
||
)
|
||
from config.getip import newip_list as getip_newip_list
|
||
|
||
logger = get_category_logger(LogCategory.PROXY)
|
||
|
||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||
logging.getLogger("hpack").setLevel(logging.WARNING)
|
||
logging.getLogger("h2").setLevel(logging.WARNING)
|
||
|
||
_SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
_BASE_DIR = os.path.dirname(_SERVER_DIR)
|
||
|
||
|
||
def _as_bool(value) -> bool:
|
||
return parse_bool_lenient(value)
|
||
|
||
|
||
def _v4_mapped_literal(address: str) -> str | None:
|
||
if address[:7].lower() != '::ffff:':
|
||
return None
|
||
parts = address[7:].split('.')
|
||
if len(parts) != 4:
|
||
return None
|
||
for part in parts:
|
||
if not (part.isascii() and part.isdigit()):
|
||
return None
|
||
if len(part) > 1 and part[0] == '0':
|
||
return None
|
||
if int(part) > 255:
|
||
return None
|
||
return '.'.join(parts)
|
||
|
||
|
||
def _remaining_time(deadline: float) -> float:
|
||
return max(0.0, deadline - asyncio.get_running_loop().time())
|
||
|
||
|
||
_BODY_CHUNK_SIZE = 64 * 1024
|
||
|
||
_BODY_READ_TIMEOUT = 30.0
|
||
|
||
_BODY_TOTAL_TIMEOUT = 300.0
|
||
|
||
_CLIENT_HEADER_TIMEOUT = 10.0
|
||
|
||
_IDLE_TUNNEL_TIMEOUT = 300.0
|
||
|
||
_LISTEN_BACKLOG = 2048
|
||
|
||
_STREAM_LIMIT = 32768
|
||
|
||
_RETRY_BACKOFF_SECONDS = 0.2
|
||
|
||
_TUNNEL_TOTAL_BUDGET_SECONDS = 20.0
|
||
|
||
_TUNNEL_REPLAY_LIMIT = 64 * 1024
|
||
|
||
_HAPPY_EYEBALLS_DELAY = 0.25
|
||
|
||
_RELAY_CHUNK = 64 * 1024
|
||
|
||
_REQUEST_MAINTENANCE_BUDGET = 5.0
|
||
|
||
_FILL_PROBE_FANOUT = 4
|
||
|
||
_FILL_BACKOFF_MAX = 60.0
|
||
|
||
_ROTATE_RETRY_SECONDS = 2.0
|
||
|
||
_DEMAND_RECENT_SECONDS = 10.0
|
||
|
||
_POOL_FETCH_MIN = 3
|
||
|
||
_FETCH_OVERSUBSCRIBE = 8
|
||
_FETCH_MAX_BATCH = 24
|
||
|
||
_EXIT_REAL_IP_CACHE_LIMIT = 4096
|
||
|
||
_REAL_IP_PROBE_TIMEOUT = 5.0
|
||
_REAL_IP_PROBE_RETRY_SECONDS = 600.0
|
||
_REAL_IP_PROBE_FANOUT = 3
|
||
|
||
_EXPAND_SATURATION_RATIO = 0.8
|
||
_EXPAND_RECOVER_UTILIZATION = 0.5
|
||
_EXPAND_TRIGGER_SECONDS = 1.0
|
||
_EXPAND_RECOVER_SECONDS = 30.0
|
||
|
||
_EXPAND_MAX_CAPACITY_BOOST = 2
|
||
|
||
_BURST_EXPAND_COOLDOWN = 5.0
|
||
|
||
_KEEPALIVE_IDLE_SECONDS = 60
|
||
_KEEPALIVE_INTERVAL_SECONDS = 10
|
||
|
||
|
||
def _tune_keepalive(sock) -> None:
|
||
try:
|
||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||
|
||
if not hasattr(socket, 'SIO_KEEPALIVE_VALS'):
|
||
if hasattr(socket, 'TCP_KEEPIDLE'):
|
||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, _KEEPALIVE_IDLE_SECONDS)
|
||
if hasattr(socket, 'TCP_KEEPINTVL'):
|
||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, _KEEPALIVE_INTERVAL_SECONDS)
|
||
if hasattr(socket, 'TCP_KEEPCNT'):
|
||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
|
||
return
|
||
|
||
ioctl = getattr(sock, 'ioctl', None)
|
||
if ioctl is None:
|
||
return
|
||
ioctl(socket.SIO_KEEPALIVE_VALS, (
|
||
1, _KEEPALIVE_IDLE_SECONDS * 1000, _KEEPALIVE_INTERVAL_SECONDS * 1000,
|
||
))
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
_SSL_PROTOCOL_MAX_SIZE = 64 * 1024
|
||
|
||
|
||
def _tune_asyncio_ssl_buffers() -> None:
|
||
try:
|
||
from asyncio import sslproto
|
||
if hasattr(sslproto.SSLProtocol, 'max_size'):
|
||
sslproto.SSLProtocol.max_size = _SSL_PROTOCOL_MAX_SIZE
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
_tune_asyncio_ssl_buffers()
|
||
|
||
_TLS_CONTEXT_LIMIT = 64
|
||
_tls_contexts = OrderedDict()
|
||
_tls_contexts_lock = threading.Lock()
|
||
|
||
|
||
class _SessionInjectingTLSContext(ssl.SSLContext):
|
||
def __new__(cls):
|
||
return super().__new__(cls, ssl.PROTOCOL_TLS_CLIENT)
|
||
|
||
def __init__(self):
|
||
self.check_hostname = False
|
||
self.verify_mode = ssl.CERT_NONE
|
||
self._session = None
|
||
|
||
def remember_session(self, session) -> None:
|
||
self._session = session
|
||
|
||
def wrap_bio(self, *args, **kwargs):
|
||
session = self._session
|
||
if session is None or 'session' in kwargs or len(args) >= 5:
|
||
return super().wrap_bio(*args, **kwargs)
|
||
try:
|
||
return super().wrap_bio(*args, session=session, **kwargs)
|
||
except Exception:
|
||
return super().wrap_bio(*args, **kwargs)
|
||
|
||
|
||
def _upstream_tls_context(host, port) -> ssl.SSLContext:
|
||
key = (host, int(port))
|
||
with _tls_contexts_lock:
|
||
context = _tls_contexts.get(key)
|
||
if context is None:
|
||
context = _SessionInjectingTLSContext()
|
||
_tls_contexts[key] = context
|
||
while len(_tls_contexts) > _TLS_CONTEXT_LIMIT:
|
||
_tls_contexts.popitem(last=False)
|
||
else:
|
||
_tls_contexts.move_to_end(key)
|
||
return context
|
||
|
||
|
||
def _remember_upstream_tls_session(host, port, writer) -> None:
|
||
try:
|
||
ssl_object = writer.get_extra_info('ssl_object')
|
||
if ssl_object is None:
|
||
return
|
||
session = ssl_object.session
|
||
if session is None or not session.has_ticket:
|
||
return
|
||
with _tls_contexts_lock:
|
||
context = _tls_contexts.get((host, int(port)))
|
||
if context is not None:
|
||
context.remember_session(session)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class _NullCookieJar(http.cookiejar.CookieJar):
|
||
def extract_cookies(self, response, request):
|
||
pass
|
||
|
||
def add_cookie_header(self, request):
|
||
pass
|
||
|
||
_MAX_HEADER_LINES = 100
|
||
_MAX_HEADER_BYTES = 64 * 1024
|
||
|
||
_TUNNEL_SWEEP_INTERVAL = 0.25
|
||
|
||
_REASON_SILENT_TUNNEL = 'silent-tunnel'
|
||
|
||
|
||
async def _iter_body(reader, length: int, deadline: float):
|
||
remaining = length
|
||
while remaining > 0:
|
||
chunk = await asyncio.wait_for(
|
||
reader.read(min(remaining, _BODY_CHUNK_SIZE)),
|
||
min(_BODY_READ_TIMEOUT, _remaining_time(deadline)),
|
||
)
|
||
if not chunk:
|
||
raise asyncio.IncompleteReadError(partial=b'', expected=remaining)
|
||
remaining -= len(chunk)
|
||
yield chunk
|
||
|
||
|
||
async def _iter_chunked_body(reader, deadline: float):
|
||
while True:
|
||
size_line = await asyncio.wait_for(
|
||
reader.readline(), min(_BODY_READ_TIMEOUT, _remaining_time(deadline)))
|
||
if not size_line:
|
||
raise asyncio.IncompleteReadError(partial=b'', expected=1)
|
||
try:
|
||
size = int(size_line.split(b';', 1)[0].strip() or b'0', 16)
|
||
except ValueError:
|
||
raise ValueError("malformed chunk size line") from None
|
||
if size == 0:
|
||
while True:
|
||
line = await asyncio.wait_for(
|
||
reader.readline(), min(_BODY_READ_TIMEOUT, _remaining_time(deadline)))
|
||
if line in (b'\r\n', b'\n', b''):
|
||
break
|
||
return
|
||
|
||
remaining = size
|
||
while remaining > 0:
|
||
chunk = await asyncio.wait_for(
|
||
reader.read(remaining), min(_BODY_READ_TIMEOUT, _remaining_time(deadline)))
|
||
if not chunk:
|
||
raise asyncio.IncompleteReadError(partial=b'', expected=remaining)
|
||
remaining -= len(chunk)
|
||
yield chunk
|
||
await asyncio.wait_for(
|
||
reader.readexactly(2), min(_BODY_READ_TIMEOUT, _remaining_time(deadline)))
|
||
|
||
|
||
|
||
def _client_request_body(headers, reader, deadline: float):
|
||
if 'chunked' in headers.get('transfer-encoding', '').lower():
|
||
return _iter_chunked_body(reader, deadline)
|
||
|
||
declared = headers.get('content-length')
|
||
if declared is None:
|
||
return None
|
||
try:
|
||
size = int(str(declared).strip())
|
||
except ValueError:
|
||
return None
|
||
return _iter_body(reader, size, deadline) if size > 0 else None
|
||
|
||
|
||
_REASON_PHRASES = {
|
||
400: 'Bad Request',
|
||
407: 'Proxy Authentication Required',
|
||
502: 'Bad Gateway',
|
||
503: 'Service Unavailable',
|
||
504: 'Gateway Timeout',
|
||
}
|
||
|
||
_HOP_BY_HOP_HEADERS = frozenset({
|
||
'connection', 'keep-alive', 'proxy-connection', 'proxy-authorization',
|
||
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
||
})
|
||
|
||
_UPSTREAM_TRANSPORT_ERRORS = (
|
||
httpx.ConnectError, httpx.ConnectTimeout,
|
||
httpx.ReadTimeout, httpx.WriteTimeout,
|
||
httpx.ReadError, httpx.WriteError, httpx.CloseError,
|
||
httpx.RemoteProtocolError, httpx.ProxyError,
|
||
)
|
||
|
||
_UPSTREAM_REQUEST_TIMEOUT = httpx.Timeout(30.0)
|
||
_UPSTREAM_TIMEOUT_EXTENSIONS = _UPSTREAM_REQUEST_TIMEOUT.as_dict()
|
||
|
||
|
||
def _merge_default_headers(client, headers):
|
||
if not isinstance(headers, dict):
|
||
merged = httpx.Headers(client.headers)
|
||
merged.update(headers)
|
||
return merged
|
||
pairs = []
|
||
overridden = set()
|
||
for key, value in headers.items():
|
||
key_bytes = key if isinstance(key, bytes) else key.encode('ascii')
|
||
value_bytes = value if isinstance(value, bytes) else value.encode('ascii')
|
||
overridden.add(key_bytes.lower())
|
||
pairs.append((key_bytes, value_bytes))
|
||
merged = [pair for pair in client.headers.raw
|
||
if pair[0].lower() not in overridden]
|
||
merged.extend(pairs)
|
||
return merged
|
||
|
||
|
||
def _build_upstream_request(client, method, url, headers, content):
|
||
return httpx.Request(
|
||
method, url,
|
||
headers=_merge_default_headers(client, headers),
|
||
content=content,
|
||
extensions={'timeout': _UPSTREAM_TIMEOUT_EXTENSIONS},
|
||
)
|
||
|
||
|
||
@asynccontextmanager
|
||
async def _stream_upstream(client, request):
|
||
response = await client.send(request, stream=True)
|
||
try:
|
||
yield response
|
||
finally:
|
||
await response.aclose()
|
||
|
||
|
||
class UnsupportedUpstreamProxyError(Exception):
|
||
pass
|
||
|
||
async def run_server(server):
|
||
try:
|
||
await server.start()
|
||
except asyncio.CancelledError:
|
||
logger.info(get_message('server_closing', server.language))
|
||
except Exception as e:
|
||
if not server.stop_server:
|
||
logger.error(f"Server error: {e}")
|
||
finally:
|
||
await server.stop()
|
||
|
||
|
||
_SUPPORTED_PROXY_SCHEMES = frozenset({'http', 'https', 'socks5'})
|
||
|
||
|
||
def validate_proxy(proxy):
|
||
try:
|
||
scheme, _auth, host, port = parse_proxy_url(proxy)
|
||
except ValueError:
|
||
return False
|
||
|
||
return bool(host) and scheme in _SUPPORTED_PROXY_SCHEMES and 0 < port < 65536
|
||
|
||
|
||
def _is_ipv4_literal(token: str) -> bool:
|
||
parts = token.split('.')
|
||
if len(parts) != 4:
|
||
return False
|
||
for part in parts:
|
||
if not (part.isascii() and part.isdigit()) or len(part) > 3:
|
||
return False
|
||
if int(part) > 255:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _real_ip_token(token: str):
|
||
candidate = token.strip().strip('"').strip()
|
||
if not candidate:
|
||
return None
|
||
|
||
if candidate.startswith('[') and ']' in candidate:
|
||
candidate = candidate[1:candidate.index(']')]
|
||
|
||
try:
|
||
parsed = ipaddress.ip_address(candidate)
|
||
except ValueError:
|
||
head, sep, tail = candidate.rpartition(':')
|
||
if sep and tail.isdigit() and _is_ipv4_literal(head):
|
||
return head
|
||
return None
|
||
|
||
mapped = getattr(parsed, 'ipv4_mapped', None)
|
||
return str(mapped) if mapped is not None else str(parsed)
|
||
|
||
|
||
def _parse_real_ip_echo(text: str):
|
||
raw = (text or '').strip()[:2048]
|
||
if not raw:
|
||
return None
|
||
candidates = []
|
||
if raw.startswith('{'):
|
||
try:
|
||
data = json.loads(raw)
|
||
except ValueError:
|
||
return None
|
||
for field in ('origin', 'ip', 'query'):
|
||
value = data.get(field)
|
||
if isinstance(value, str):
|
||
candidates.extend(value.replace(',', ' ').split())
|
||
else:
|
||
candidates = raw.replace('=', ' ').replace(',', ' ').split()
|
||
|
||
parsed = [ip for ip in (_real_ip_token(t) for t in reversed(candidates)) if ip]
|
||
for ip in parsed:
|
||
if ':' not in ip:
|
||
return ip
|
||
return parsed[0] if parsed else None
|
||
|
||
|
||
class MaintenanceOutcome(NamedTuple):
|
||
changed: bool
|
||
fetched: bool
|
||
gained: int
|
||
|
||
def __bool__(self):
|
||
return self.changed
|
||
|
||
|
||
_NO_MAINTENANCE = MaintenanceOutcome(False, False, 0)
|
||
|
||
|
||
class _ReplayBuffer:
|
||
__slots__ = ('data', 'overflowed', '_limit')
|
||
|
||
def __init__(self, limit):
|
||
self.data = bytearray()
|
||
self._limit = limit
|
||
self.overflowed = False
|
||
|
||
def add(self, chunk):
|
||
if self.overflowed:
|
||
return
|
||
if len(self.data) + len(chunk) > self._limit:
|
||
self.overflowed = True
|
||
self.data = bytearray()
|
||
return
|
||
self.data += chunk
|
||
|
||
def __len__(self):
|
||
return len(self.data)
|
||
|
||
@property
|
||
def replayable(self):
|
||
return not self.overflowed
|
||
|
||
|
||
class AsyncProxyServer:
|
||
|
||
def __init__(self, config, pool_service=None):
|
||
self.config = config
|
||
self._pool_service = pool_service
|
||
self._workers = ProxyWorkerPool()
|
||
self._init_config_values(config)
|
||
self._init_server_state()
|
||
self._init_connection_settings()
|
||
self._switch_lock = threading.Lock()
|
||
|
||
self.domain_stats = DomainStatsStore(config, _BASE_DIR)
|
||
self.access_records = RequestRecordStore(config, _BASE_DIR)
|
||
self.access_tracker = AccessTracker(
|
||
language=self.language,
|
||
stats=self.domain_stats,
|
||
emit_log=_as_bool(config.get('log_access_enabled', 'true')),
|
||
records=self.access_records,
|
||
)
|
||
|
||
self._last_source_error = None
|
||
|
||
@property
|
||
def proxies(self):
|
||
return [worker.url for worker in self._workers.roster]
|
||
|
||
@proxies.setter
|
||
def proxies(self, urls):
|
||
self._workers.set_workers(list(urls))
|
||
self._sync_current_proxy()
|
||
|
||
def _sync_current_proxy(self):
|
||
urls = self.proxies
|
||
if self.current_proxy not in urls:
|
||
self.current_proxy = urls[0] if urls else None
|
||
|
||
def active_workers(self):
|
||
now = time.monotonic()
|
||
limit = self.request_interval
|
||
snapshot = []
|
||
for entry in self._workers.snapshot():
|
||
expires_at = entry.get('expires_at') or 0.0
|
||
entry['expires_in'] = max(0.0, expires_at - now) if expires_at else None
|
||
entry['requests_left'] = (
|
||
max(0, limit - entry['requests_served']) if limit > 0 else None
|
||
)
|
||
snapshot.append(entry)
|
||
return snapshot
|
||
|
||
def source_proxies(self):
|
||
if self.proxy_source_mode == 'local':
|
||
return self._load_file_proxies()
|
||
return self.proxies
|
||
|
||
def roster_revision(self):
|
||
return self._workers.revision
|
||
|
||
def _init_config_values(self, config):
|
||
self.port = int(config.get('port', '1080'))
|
||
self.mode = config.get('mode', 'cycle')
|
||
previous_interval = getattr(self, 'interval', None)
|
||
self.interval = int(config.get('interval', '300'))
|
||
self.language = config.get('language', 'cn')
|
||
self.proxy_source_mode = config.get('proxy_source_mode', 'local').lower()
|
||
self.mode = normalize_rotation_mode(self.mode, self.proxy_source_mode)
|
||
self.api_proxy_url = config.get('api_proxy_url', '')
|
||
self.pool_remote_url = config.get('pool_remote_url', '')
|
||
self.check_proxies_on_startup = _as_bool(
|
||
config.get('check_proxies_on_startup', 'True')
|
||
)
|
||
self.check_proxies_on_use = _as_bool(
|
||
config.get('check_proxies_on_use', 'True')
|
||
)
|
||
self.real_ip_probe = parse_bool_lenient(
|
||
config.get('access_records_real_ip_probe'), False
|
||
)
|
||
self._real_ip_probe_endpoints = self._load_real_ip_probe_endpoints()
|
||
self.check_concurrency = int(config.get('check_concurrency', '50'))
|
||
self.max_concurrent_per_proxy = int(config.get('max_concurrent_per_proxy', '0'))
|
||
previous_exit_count = getattr(self, 'exit_count', None)
|
||
pending_trim = getattr(self, '_trim_now', False)
|
||
self.exit_count = max(1, int(
|
||
config.get('exit_count') or config.get('pool_proxy_count') or '5'
|
||
))
|
||
self.exit_wait_timeout = max(0, int(config.get('exit_wait_timeout', '15')))
|
||
self.auto_expand_enabled = parse_bool_lenient(
|
||
config.get('auto_expand_enabled'), True)
|
||
self._reset_elastic_capacity()
|
||
shrunk = previous_exit_count is not None and self.exit_count < previous_exit_count
|
||
self._trim_now = bool(pending_trim or shrunk)
|
||
|
||
self.client_max_keepalive = int(config.get('client_max_keepalive', '8'))
|
||
self.client_keepalive_expiry = int(config.get('client_keepalive_expiry', '30'))
|
||
self.client_idle_timeout = int(config.get('client_idle_timeout', '300'))
|
||
|
||
self.users = {}
|
||
if 'Users' in config:
|
||
self.users = dict(config['Users'].items())
|
||
self.auth_required = bool(self.users)
|
||
|
||
self.proxy_file = os.path.join(_BASE_DIR, 'config', os.path.basename(config.get('proxy_file', 'ip.txt')))
|
||
self.whitelist_file = os.path.join(_BASE_DIR, 'config', os.path.basename(config.get('whitelist_file', 'whitelist.txt')))
|
||
self.blacklist_file = os.path.join(_BASE_DIR, 'config', os.path.basename(config.get('blacklist_file', 'blacklist.txt')))
|
||
self.ip_auth_priority = config.get('ip_auth_priority', 'whitelist')
|
||
|
||
self.test_url = config.get('test_url', 'https://www.baidu.com')
|
||
self.whitelist = load_ip_list(self.whitelist_file)
|
||
self.blacklist = load_ip_list(self.blacklist_file)
|
||
|
||
self.request_interval = int(config.get('request_interval', '0'))
|
||
|
||
self.bypass_whitelist_file = os.path.join(_BASE_DIR, 'config', os.path.basename(
|
||
config.get('bypass_whitelist_file', 'bypass_whitelist.txt')))
|
||
self.bypass_whitelist = load_bypass_whitelist(self.bypass_whitelist_file)
|
||
|
||
self.last_switch_attempt = 0
|
||
self.switch_cooldown = int(config.get('switch_cooldown', '2'))
|
||
if getattr(self, 'proxy_check_cache', None) is None:
|
||
self.proxy_check_cache = {}
|
||
if getattr(self, 'last_check_time', None) is None:
|
||
self.last_check_time = {}
|
||
self.proxy_check_ttl = int(config.get('proxy_check_ttl', '60'))
|
||
self.check_cooldown = int(config.get('check_cooldown', '10'))
|
||
self.proxy_failure_cooldown = int(config.get('proxy_failure_cooldown', '3'))
|
||
self._retry_log = self._throttled_log(
|
||
'_retry_log', lambda: self.language, self.proxy_failure_cooldown)
|
||
self._silent_log = self._throttled_log(
|
||
'_silent_log', lambda: self.language, max(5, self.proxy_failure_cooldown * 5))
|
||
self._ceiling_log = self._throttled_log(
|
||
'_ceiling_log', lambda: self.language, 60)
|
||
self.tunnel_idle_timeout = int(config.get('tunnel_idle_timeout', '10'))
|
||
|
||
self._apply_connection_settings(config)
|
||
|
||
workers = getattr(self, '_workers', None)
|
||
if workers is not None:
|
||
workers.apply_capacity(self.max_concurrent_per_proxy)
|
||
workers.set_selection(self._selection_mode())
|
||
if previous_interval != self.interval:
|
||
self._replan_expiries()
|
||
|
||
tracker = getattr(self, 'access_tracker', None)
|
||
if tracker is not None:
|
||
tracker.language = self.language
|
||
tracker.emit_log = _as_bool(config.get('log_access_enabled', 'true'))
|
||
|
||
for store in (getattr(self, 'domain_stats', None),
|
||
getattr(self, 'access_records', None)):
|
||
if store is None:
|
||
continue
|
||
was_enabled = store.apply_config(config)
|
||
if getattr(self, 'running', False) and was_enabled != store.enabled:
|
||
if store.enabled:
|
||
store.start()
|
||
else:
|
||
store.stop()
|
||
|
||
def _init_server_state(self):
|
||
self.running = False
|
||
self.stop_server = False
|
||
self._listen_sock = None
|
||
self._acceptor = None
|
||
self._acceptor_stop = None
|
||
self.proxy_thread = None
|
||
self.tasks = set()
|
||
self.last_switch_time = time.time()
|
||
self.current_proxy = None
|
||
self.proxies = []
|
||
self._standby = StandbyQueue()
|
||
self._exit_real_ips = {}
|
||
self._exit_real_ip_probing = set()
|
||
self._idle_watch = {}
|
||
self._sweeper_task = None
|
||
self._last_demand_at = 0.0
|
||
self._fill_backoff = 0.0
|
||
self._next_fill_attempt = 0.0
|
||
self._last_rotate_attempt = 0.0
|
||
self._live_connections = 0
|
||
self._last_burst_expand = 0.0
|
||
self._expand_task = None
|
||
self.known_clients = OrderedDict()
|
||
self.last_start_error = None
|
||
|
||
if self.proxy_source_mode == 'local':
|
||
self._apply_local_proxy_list(f"从 {self.proxy_file} 加载本地代理列表")
|
||
|
||
def _apply_connection_settings(self, config):
|
||
previous_limit = getattr(self, 'max_concurrent_requests', None)
|
||
self.buffer_size = int(config.get('buffer_size', '8192'))
|
||
self.max_pool_size = int(config.get('max_pool_size', '500'))
|
||
self.max_concurrent_requests = int(config.get('max_concurrent_requests', '1000'))
|
||
|
||
self.client_max_connections = max(
|
||
int(config.get('client_max_connections', '1000')),
|
||
self.max_concurrent_per_proxy * 2 if self.max_concurrent_per_proxy
|
||
else self.max_concurrent_requests,
|
||
)
|
||
|
||
if (getattr(self, 'request_semaphore', None) is not None
|
||
and previous_limit != self.max_concurrent_requests):
|
||
self.request_semaphore = asyncio.Semaphore(self.max_concurrent_requests)
|
||
|
||
def _init_connection_settings(self):
|
||
self.request_semaphore = asyncio.Semaphore(self.max_concurrent_requests)
|
||
self.client_pool = {}
|
||
self.client_pool_lock = threading.Lock()
|
||
self._client_inflight = {}
|
||
self._clients_pending_close = {}
|
||
self._refill_lock = asyncio.Lock()
|
||
|
||
@property
|
||
def switching_proxy(self):
|
||
lock = getattr(self, '_refill_lock', None)
|
||
return bool(lock is not None and lock.locked())
|
||
|
||
def _reset_switch_timers(self):
|
||
self.last_switch_time = time.time()
|
||
self.last_switch_attempt = 0
|
||
|
||
def _apply_local_proxy_list(self, load_notice: str) -> bool:
|
||
logger.info(load_notice)
|
||
urls = self._load_file_proxies()
|
||
|
||
if not urls:
|
||
logger.error(f"从文件 {self.proxy_file} 加载代理失败,请检查文件是否存在且包含有效代理")
|
||
return False
|
||
|
||
previous = self.proxies
|
||
self.proxies = urls[:self._exit_target]
|
||
self._replan_expiries()
|
||
self._standby.clear()
|
||
self._standby.put(urls)
|
||
self._log_roster_reload(previous)
|
||
logger.info(get_message('proxy_workers_ready', self.language, len(self.proxies)))
|
||
return True
|
||
|
||
def _maybe_check_local_proxies(self) -> None:
|
||
if not self.check_proxies_on_startup:
|
||
return
|
||
try:
|
||
self._run_proxy_check_wherever()
|
||
except Exception as e:
|
||
logger.error(f"检查代理时出错: {str(e)}")
|
||
|
||
def _handle_source_mode_switch(self) -> None:
|
||
self.last_switch_attempt = 0
|
||
self._standby.clear()
|
||
|
||
if self.proxy_source_mode == 'api':
|
||
self.proxies = []
|
||
self.current_proxy = None
|
||
logger.info(get_message('api_mode_notice', self.language))
|
||
elif self.proxy_source_mode == 'pool':
|
||
self.proxies = []
|
||
self.current_proxy = None
|
||
logger.info(get_message('pool_mode_notice', self.language))
|
||
else:
|
||
if self._apply_local_proxy_list(
|
||
f"切换到{'负载均衡' if self.mode == 'loadbalance' else '循环模式'}模式,"
|
||
f"从 {self.proxy_file} 加载代理列表"
|
||
):
|
||
self._maybe_check_local_proxies()
|
||
|
||
def _handle_mode_change(self):
|
||
self._handle_source_mode_switch()
|
||
|
||
async def _check_proxies_wrapper(self):
|
||
await self._check_proxies()
|
||
|
||
def _schedule_async_task(self, coro):
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
loop.create_task(self._guard_task(coro))
|
||
except RuntimeError:
|
||
loop = self._event_loop if hasattr(self, '_event_loop') and self._event_loop else None
|
||
if loop and loop.is_running():
|
||
asyncio.run_coroutine_threadsafe(self._guard_task(coro), loop)
|
||
else:
|
||
coro.close()
|
||
logger.warning(
|
||
"无法调度异步任务:事件循环不可用,任务已被丢弃。事件循环状态: %s",
|
||
"不存在" if loop is None else "未运行"
|
||
)
|
||
|
||
@staticmethod
|
||
async def _guard_task(coro):
|
||
try:
|
||
await coro
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"后台任务异常: {e}")
|
||
|
||
def _run_proxy_check_wherever(self):
|
||
loop = getattr(self, '_event_loop', None)
|
||
if loop is not None and loop.is_running():
|
||
self._schedule_async_task(self._check_proxies_wrapper())
|
||
else:
|
||
asyncio.run(self._check_proxies())
|
||
|
||
def _throttled_log(self, attr: str, language_provider, interval: float):
|
||
current = getattr(self, attr, None)
|
||
if current is None:
|
||
return ThrottledLogger(logger, language_provider, interval)
|
||
current.set_interval(interval)
|
||
return current
|
||
|
||
async def async_reload_config(self, new_config, mode_changed=False, source_mode_changed=False):
|
||
old_port = self.port
|
||
|
||
self.config.update(new_config)
|
||
self._init_config_values(new_config)
|
||
|
||
if self.port != old_port:
|
||
logger.info(get_message('port_changed', self.language, old_port, self.port))
|
||
|
||
if mode_changed or source_mode_changed:
|
||
self._handle_mode_change()
|
||
|
||
def _running_loop(self):
|
||
loop = getattr(self, '_event_loop', None)
|
||
return loop if loop is not None and loop.is_running() else None
|
||
|
||
def _on_loop_thread(self) -> bool:
|
||
return threading.current_thread() is getattr(self, '_loop_thread', None)
|
||
|
||
def run_coroutine_sync(self, coro_factory, timeout=30):
|
||
if self._on_loop_thread():
|
||
raise RuntimeError("不能从服务自己的事件循环线程里同步等待协程")
|
||
|
||
loop = self._running_loop()
|
||
if loop is not None:
|
||
return asyncio.run_coroutine_threadsafe(coro_factory(), loop).result(timeout=timeout)
|
||
return asyncio.run(coro_factory())
|
||
|
||
def apply_config_sync(self, new_config, mode_changed=False, source_mode_changed=False,
|
||
reset_switch_timers=False, timeout=10) -> None:
|
||
if self._on_loop_thread():
|
||
raise RuntimeError("不能从服务自己的事件循环线程里同步应用配置")
|
||
|
||
loop = self._running_loop()
|
||
if loop is not None:
|
||
future = asyncio.run_coroutine_threadsafe(
|
||
self.async_reload_config(new_config, mode_changed, source_mode_changed), loop
|
||
)
|
||
if reset_switch_timers:
|
||
self._reset_switch_timers()
|
||
future.result(timeout=timeout)
|
||
return
|
||
|
||
self.config.update(new_config)
|
||
self._init_config_values(new_config)
|
||
if reset_switch_timers:
|
||
self._reset_switch_timers()
|
||
if mode_changed or source_mode_changed:
|
||
self._handle_mode_change()
|
||
|
||
def reload_local_proxies(self, run_check: bool) -> None:
|
||
self.last_switch_attempt = 0
|
||
if self._apply_local_proxy_list(f"重新加载代理列表文件 {self.proxy_file}"):
|
||
if run_check:
|
||
self._maybe_check_local_proxies()
|
||
|
||
def reload_ip_lists(self) -> None:
|
||
self.whitelist = load_ip_list(self.whitelist_file)
|
||
self.blacklist = load_ip_list(self.blacklist_file)
|
||
self.bypass_whitelist = load_bypass_whitelist(self.bypass_whitelist_file)
|
||
logger.info(get_message('ip_lists_reloaded', self.language,
|
||
len(self.whitelist), len(self.blacklist),
|
||
len(self.bypass_whitelist)))
|
||
|
||
def close_listener(self) -> None:
|
||
self._stop_acceptor()
|
||
|
||
def _startup_check_gate(self) -> bool:
|
||
if not self.check_proxies_on_startup:
|
||
logger.info(get_message('proxy_check_disabled', self.language))
|
||
return False
|
||
logger.info(get_message('proxy_check_start', self.language))
|
||
return True
|
||
|
||
async def run_startup_check(self) -> None:
|
||
if self._startup_check_gate():
|
||
await self._check_proxies()
|
||
|
||
def run_startup_check_blocking(self) -> None:
|
||
if self._startup_check_gate():
|
||
asyncio.run(self._check_proxies())
|
||
|
||
async def _check_proxies(self):
|
||
from modules.modules import check_proxies
|
||
|
||
current = self.proxies
|
||
if not current:
|
||
return
|
||
|
||
valid_proxies = await check_proxies(
|
||
current, test_url=self.test_url, concurrency=self.check_concurrency
|
||
)
|
||
if not valid_proxies:
|
||
logger.error(get_message('no_valid_proxies', self.language))
|
||
return
|
||
|
||
valid = set(valid_proxies)
|
||
checked_at = time.time()
|
||
for url in current:
|
||
self.proxy_check_cache[(url, self.test_url)] = (checked_at, url in valid)
|
||
for url in current:
|
||
if url not in valid:
|
||
self._workers.drop_worker(url, remember=True)
|
||
for url in valid:
|
||
self._workers.note_success(self._workers.find(url))
|
||
self._sync_current_proxy()
|
||
logger.info(get_message('valid_proxies', self.language, len(valid_proxies)))
|
||
|
||
def _load_file_proxies(self):
|
||
try:
|
||
proxy_file = os.path.join(_BASE_DIR, 'config', os.path.basename(self.proxy_file))
|
||
if os.path.exists(proxy_file):
|
||
with open(proxy_file, 'r', encoding='utf-8') as f:
|
||
proxies = [line.strip() for line in f if line.strip()]
|
||
return proxies
|
||
else:
|
||
logger.error(get_message('proxy_file_not_found', self.language, proxy_file))
|
||
return []
|
||
except Exception as e:
|
||
logger.error(get_message('load_proxy_file_error', self.language, str(e)))
|
||
return []
|
||
|
||
def _create_listen_socket(self):
|
||
error = None
|
||
for family, address in self._listen_candidates():
|
||
sock = socket.socket(family, socket.SOCK_STREAM)
|
||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||
try:
|
||
if family == socket.AF_INET6:
|
||
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
|
||
sock.bind(address)
|
||
except OSError as e:
|
||
sock.close()
|
||
error = e
|
||
continue
|
||
return sock
|
||
|
||
if not self._is_port_in_use(error):
|
||
raise error
|
||
raise RuntimeError(
|
||
get_message('port_in_use', self.language, self.port)
|
||
) from error
|
||
|
||
def _listen_candidates(self):
|
||
candidates = []
|
||
if socket.has_ipv6:
|
||
candidates.append((socket.AF_INET6, ('::', self.port)))
|
||
candidates.append((socket.AF_INET, ('0.0.0.0', self.port)))
|
||
return candidates
|
||
|
||
@staticmethod
|
||
def _is_port_in_use(error):
|
||
return bool(
|
||
getattr(error, 'winerror', None) in (10013, 10048)
|
||
or getattr(error, 'errno', None) == errno.EADDRINUSE
|
||
)
|
||
|
||
def _log_retry(self, remaining):
|
||
self._retry_log.warning(
|
||
'request_retry',
|
||
get_message('request_retry', self.language, remaining),
|
||
)
|
||
|
||
@staticmethod
|
||
def _render_response_head(response, keep_alive=False):
|
||
parts = [f'HTTP/1.1 {response.status_code} {response.reason_phrase}\r\n']
|
||
for header_name, header_value in response.headers.items():
|
||
if header_name.lower() not in _HOP_BY_HOP_HEADERS:
|
||
parts.append(f'{header_name}: {header_value}\r\n')
|
||
parts.append('Connection: keep-alive\r\n' if keep_alive else 'Connection: close\r\n')
|
||
parts.append('\r\n')
|
||
return ''.join(parts).encode()
|
||
|
||
@staticmethod
|
||
def _response_allows_reuse(response, method) -> bool:
|
||
if method == 'HEAD' or response.status_code in (204, 304):
|
||
return True
|
||
if 'transfer-encoding' in {name.lower() for name in response.headers.keys()}:
|
||
return False
|
||
return response.headers.get('content-length') is not None
|
||
|
||
async def _open_upstream(self, host, port, timeout=10, *, tls=False):
|
||
reader, writer = await asyncio.wait_for(
|
||
asyncio.open_connection(
|
||
host, port, happy_eyeballs_delay=_HAPPY_EYEBALLS_DELAY,
|
||
ssl=_upstream_tls_context(host, port) if tls else None,
|
||
),
|
||
timeout=timeout,
|
||
)
|
||
sock = writer.get_extra_info('socket')
|
||
if sock is not None:
|
||
try:
|
||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||
except OSError:
|
||
pass
|
||
_tune_keepalive(sock)
|
||
return reader, writer
|
||
|
||
@staticmethod
|
||
async def _close_upstream(writer) -> None:
|
||
if writer is None:
|
||
return
|
||
try:
|
||
writer.close()
|
||
await writer.wait_closed()
|
||
except Exception:
|
||
pass
|
||
|
||
def _accept_loop(self, listen_sock, loop):
|
||
try:
|
||
while not self.stop_server and not self._acceptor_stop.is_set():
|
||
try:
|
||
conn, _addr = listen_sock.accept()
|
||
except OSError as e:
|
||
if self.stop_server or self._acceptor_stop.is_set():
|
||
return
|
||
logger.debug(f"accept 异常(已忽略继续): {getattr(e, 'winerror', None) or e.errno}")
|
||
time.sleep(0.001)
|
||
continue
|
||
|
||
try:
|
||
conn.setblocking(False)
|
||
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||
_tune_keepalive(conn)
|
||
except OSError:
|
||
pass
|
||
|
||
try:
|
||
loop.call_soon_threadsafe(self._adopt_connection, conn, loop)
|
||
except RuntimeError:
|
||
try:
|
||
conn.close()
|
||
except OSError:
|
||
pass
|
||
return
|
||
finally:
|
||
if not self.stop_server and not self._acceptor_stop.is_set():
|
||
logger.error(
|
||
f"acceptor 线程意外退出(端口 {self.port}),此后新连接会被系统拒绝"
|
||
)
|
||
|
||
def _adopt_connection(self, conn, loop):
|
||
task = loop.create_task(self._serve_accepted(conn, loop))
|
||
self.tasks.add(task)
|
||
task.add_done_callback(self.tasks.discard)
|
||
|
||
async def _serve_accepted(self, conn, loop):
|
||
self._live_connections += 1
|
||
self._note_listen_pressure()
|
||
try:
|
||
try:
|
||
reader = asyncio.StreamReader(limit=_STREAM_LIMIT)
|
||
protocol = asyncio.StreamReaderProtocol(reader)
|
||
transport, _ = await loop.connect_accepted_socket(lambda: protocol, conn)
|
||
writer = asyncio.StreamWriter(transport, protocol, reader, loop)
|
||
except Exception as e:
|
||
logger.debug(f"接管已建立的连接失败(已关闭): {e}")
|
||
try:
|
||
conn.close()
|
||
except OSError:
|
||
pass
|
||
return
|
||
await self.handle_client(reader, writer)
|
||
finally:
|
||
self._live_connections -= 1
|
||
|
||
async def _serve_until_stopped(self) -> None:
|
||
while not self.stop_server:
|
||
await asyncio.sleep(0.2)
|
||
|
||
def _stop_acceptor(self) -> None:
|
||
event = getattr(self, '_acceptor_stop', None)
|
||
sock = getattr(self, '_listen_sock', None)
|
||
thread = getattr(self, '_acceptor', None)
|
||
|
||
if self._listen_sock is sock:
|
||
self._listen_sock = None
|
||
if event is not None:
|
||
event.set()
|
||
if sock is not None:
|
||
try:
|
||
sock.close()
|
||
except OSError:
|
||
pass
|
||
if thread is not None and thread.is_alive():
|
||
thread.join(timeout=2)
|
||
|
||
async def start(self):
|
||
self.stop_server = False
|
||
self.last_start_error = None
|
||
|
||
sock = None
|
||
try:
|
||
if port_in_use(self.port):
|
||
raise RuntimeError(
|
||
get_message('port_in_use', self.language, self.port)
|
||
)
|
||
|
||
sock = self._create_listen_socket()
|
||
|
||
loop = asyncio.get_running_loop()
|
||
self._event_loop = loop
|
||
self._loop_thread = threading.current_thread()
|
||
self.request_semaphore = asyncio.Semaphore(self.max_concurrent_requests)
|
||
self._refill_lock = asyncio.Lock()
|
||
self._workers.rebind()
|
||
self._reset_elastic_capacity()
|
||
self._workers.apply_capacity(self.max_concurrent_per_proxy)
|
||
self._workers.set_selection(self._selection_mode())
|
||
self._last_demand_at = 0.0
|
||
self._live_connections = 0
|
||
self._fill_backoff = 0.0
|
||
self._next_fill_attempt = 0.0
|
||
self._last_rotate_attempt = 0.0
|
||
self._last_burst_expand = 0.0
|
||
self._expand_task = None
|
||
self._exit_real_ip_probing = set()
|
||
with self.client_pool_lock:
|
||
self.client_pool.clear()
|
||
self._client_inflight.clear()
|
||
self._clients_pending_close.clear()
|
||
install_loop_noise_filter(loop)
|
||
if hasattr(loop, 'set_default_executor'):
|
||
import concurrent.futures
|
||
executor = concurrent.futures.ThreadPoolExecutor(max_workers=max(32, (os.cpu_count() or 1) * 4))
|
||
loop.set_default_executor(executor)
|
||
|
||
self.domain_stats.start()
|
||
self.access_records.start()
|
||
|
||
self.tasks.add(asyncio.create_task(self.cleanup_clients()))
|
||
self.tasks.add(asyncio.create_task(self._replenish_loop()))
|
||
self._ensure_tunnel_sweeper()
|
||
if self._sweeper_task is not None:
|
||
self.tasks.add(self._sweeper_task)
|
||
|
||
if hasattr(os, 'sched_setaffinity'):
|
||
try:
|
||
os.sched_setaffinity(0, range(os.cpu_count() or 1))
|
||
except Exception:
|
||
pass
|
||
|
||
sock.listen(_LISTEN_BACKLOG)
|
||
self._listen_sock = sock
|
||
self._acceptor_stop = threading.Event()
|
||
self._acceptor = threading.Thread(
|
||
target=self._accept_loop, args=(sock, loop),
|
||
name="proxycat-accept", daemon=True,
|
||
)
|
||
self._acceptor.start()
|
||
sock = None
|
||
|
||
self.running = True
|
||
logger.info(get_message('server_running', self.language, '0.0.0.0', self.port))
|
||
|
||
await self._serve_until_stopped()
|
||
|
||
except Exception as e:
|
||
if not self.stop_server:
|
||
self.last_start_error = str(e)
|
||
logger.error(get_message('server_start_error', self.language, str(e)))
|
||
finally:
|
||
self.running = False
|
||
self._stop_acceptor()
|
||
if sock is not None:
|
||
try:
|
||
sock.close()
|
||
except Exception:
|
||
pass
|
||
|
||
async def stop(self):
|
||
self.stop_server = True
|
||
|
||
tasks = list(self.tasks)
|
||
for task in tasks:
|
||
task.cancel()
|
||
if tasks:
|
||
await asyncio.gather(*tasks, return_exceptions=True)
|
||
self.tasks.difference_update(tasks)
|
||
|
||
self._stop_acceptor()
|
||
|
||
try:
|
||
loop = getattr(self, '_event_loop', None)
|
||
if loop:
|
||
default_executor = getattr(loop, '_default_executor', None)
|
||
if default_executor:
|
||
default_executor.shutdown(wait=False)
|
||
except Exception:
|
||
pass
|
||
|
||
self.domain_stats.stop()
|
||
self.access_records.stop()
|
||
|
||
if self.running:
|
||
self.running = False
|
||
logger.info(get_message('server_shutting_down', self.language))
|
||
|
||
def _expiry_enabled(self) -> bool:
|
||
return self.interval > 0 or self.request_interval > 0
|
||
|
||
def _rotation_is_continuous(self) -> bool:
|
||
return (self.proxy_source_mode != 'local'
|
||
and self.mode == 'continuous')
|
||
|
||
def _selection_mode(self) -> str:
|
||
if self.proxy_source_mode == 'local' and self.mode == 'cycle':
|
||
return 'order'
|
||
return 'load'
|
||
|
||
def _expired_exits(self, current_time):
|
||
if not self._expiry_enabled():
|
||
return []
|
||
return self._workers.expired_workers(current_time, self.request_interval)
|
||
|
||
def _replan_expiries(self) -> None:
|
||
self._workers.replan_expiries(self._plan_expiry(time.monotonic()))
|
||
|
||
def _plan_expiry(self, current_time) -> float:
|
||
if not self._expiry_enabled() or self.interval <= 0:
|
||
return 0.0
|
||
return current_time + self.interval
|
||
|
||
def _fetch_allowed(self, current_time) -> bool:
|
||
return current_time - self.last_switch_attempt >= self.switch_cooldown
|
||
|
||
async def _candidate_usable(self, url) -> bool:
|
||
if not self.check_proxies_on_use:
|
||
return True
|
||
return await self._check_worker(url) is True
|
||
|
||
def _take_candidates(self, count) -> list:
|
||
taken = []
|
||
while len(taken) < count:
|
||
url = self._standby.take()
|
||
if url is None:
|
||
break
|
||
if self._workers.find(url) is not None or self._workers.recently_retired(url):
|
||
continue
|
||
taken.append(url)
|
||
return taken
|
||
|
||
async def _probe_candidates(self, urls) -> list:
|
||
if len(urls) == 1:
|
||
return [await self._candidate_usable(urls[0])]
|
||
results = await asyncio.gather(
|
||
*(self._candidate_usable(url) for url in urls), return_exceptions=True)
|
||
return [result is True for result in results]
|
||
|
||
async def _fill_from_standby(self, max_fill=None) -> int:
|
||
filled = 0
|
||
while len(self._workers.roster) < self._exit_target:
|
||
if max_fill is not None and filled >= max_fill:
|
||
break
|
||
batch = self._take_candidates(_FILL_PROBE_FANOUT)
|
||
if not batch:
|
||
break
|
||
leftovers = []
|
||
for url, usable in zip(batch, await self._probe_candidates(batch)):
|
||
if not usable:
|
||
continue
|
||
if (len(self._workers.roster) >= self._exit_target
|
||
or (max_fill is not None and filled >= max_fill)):
|
||
leftovers.append(url)
|
||
continue
|
||
if self._workers.find(url) is not None:
|
||
continue
|
||
self._workers.add_worker(url, self._plan_expiry(time.monotonic()))
|
||
filled += 1
|
||
logger.info(get_message('proxy_exit_added', self.language, sanitize_proxy(url)))
|
||
if leftovers:
|
||
self._standby.put(leftovers)
|
||
return filled
|
||
|
||
def _reset_elastic_capacity(self):
|
||
self._expansion_extra = 0
|
||
self._capacity_boost = 1
|
||
self._hot_since = None
|
||
self._cool_since = None
|
||
self._expanding = False
|
||
|
||
def _elastic_in_use(self) -> bool:
|
||
return self._expansion_extra > 0 or self._capacity_boost != 1
|
||
|
||
@property
|
||
def _exit_target(self):
|
||
return self.exit_count + self._expansion_extra
|
||
|
||
def _expansion_ceiling(self) -> int:
|
||
return max(self.exit_count, min(self.exit_count * 2, self.max_concurrent_requests))
|
||
|
||
@property
|
||
def elastic_active(self) -> bool:
|
||
return (self._elastic_in_use()
|
||
or len(self._workers.roster) > self._exit_target)
|
||
|
||
def _drop_expired_when_idle(self) -> None:
|
||
expired = self._expired_exits(time.monotonic())
|
||
if not expired:
|
||
return
|
||
dropped = 0
|
||
for worker in expired:
|
||
if worker.inflight:
|
||
continue
|
||
if self._workers.drop_worker(worker.url):
|
||
dropped += 1
|
||
if dropped:
|
||
self._sync_current_proxy()
|
||
logger.info(get_message('exit_pool_drained', self.language,
|
||
len(self._workers.roster)))
|
||
|
||
def _lifetime_pending(self, worker, current_time) -> bool:
|
||
if not self._expiry_enabled():
|
||
return False
|
||
return not self._workers.lifetime_ended(
|
||
worker, current_time, self.request_interval)
|
||
|
||
def _is_excess(self, worker) -> bool:
|
||
if self._expansion_extra > 0:
|
||
return False
|
||
return len(self._workers.roster) > self.exit_count
|
||
|
||
def _release_excess_exits(self, include_live=False) -> bool:
|
||
target = self._exit_target
|
||
current_time = time.monotonic()
|
||
changed = False
|
||
for worker in sorted(
|
||
self._workers.roster,
|
||
key=lambda w: (self._lifetime_pending(w, current_time), w.last_used),
|
||
):
|
||
if len(self._workers.roster) <= target:
|
||
break
|
||
if worker.inflight:
|
||
continue
|
||
if not include_live and self._lifetime_pending(worker, current_time):
|
||
continue
|
||
self._workers.drop_worker(worker.url)
|
||
changed = True
|
||
if changed:
|
||
self._sync_current_proxy()
|
||
return changed
|
||
|
||
async def _review_elastic_capacity(self):
|
||
now = time.monotonic()
|
||
|
||
if len(self._workers.roster) > self._exit_target or self._trim_now:
|
||
async with self._refill_lock:
|
||
trim_now = self._trim_now
|
||
if trim_now:
|
||
released = self._release_excess_exits(include_live=True)
|
||
else:
|
||
released = self._release_excess_exits()
|
||
if trim_now and len(self._workers.roster) <= self._exit_target:
|
||
self._trim_now = False
|
||
if released:
|
||
logger.info(get_message('exit_pool_trimmed', self.language,
|
||
len(self._workers.roster)))
|
||
|
||
ratio = (self._workers.saturation(self.max_concurrent_per_proxy)
|
||
if self.auto_expand_enabled else None)
|
||
|
||
if ratio is None:
|
||
self._hot_since = None
|
||
self._cool_since = None
|
||
if self._elastic_in_use():
|
||
await self._shrink_elastic_capacity()
|
||
return
|
||
|
||
if ratio >= _EXPAND_SATURATION_RATIO:
|
||
self._cool_since = None
|
||
if self._hot_since is None:
|
||
self._hot_since = now
|
||
elif now - self._hot_since >= _EXPAND_TRIGGER_SECONDS:
|
||
await self._grow_elastic_capacity()
|
||
self._hot_since = now
|
||
return
|
||
|
||
used_ratio = self._workers.utilization(self.max_concurrent_per_proxy)
|
||
if used_ratio is not None and used_ratio <= _EXPAND_RECOVER_UTILIZATION:
|
||
self._hot_since = None
|
||
if not self._elastic_in_use():
|
||
self._cool_since = None
|
||
return
|
||
if self._cool_since is None:
|
||
self._cool_since = now
|
||
elif now - self._cool_since >= _EXPAND_RECOVER_SECONDS:
|
||
await self._shrink_elastic_capacity()
|
||
return
|
||
|
||
self._hot_since = None
|
||
self._cool_since = None
|
||
|
||
async def _grow_elastic_capacity(self):
|
||
if self._trim_now:
|
||
return
|
||
if self._expanding:
|
||
return
|
||
|
||
self._expanding = True
|
||
try:
|
||
await self._grow_one_step()
|
||
finally:
|
||
self._expanding = False
|
||
|
||
async def _grow_one_step(self):
|
||
adopted = max(self._expansion_extra,
|
||
max(0, len(self._workers.roster) - self.exit_count))
|
||
if adopted > self._expansion_extra:
|
||
self._expansion_extra = adopted
|
||
|
||
ceiling_extra = self._expansion_ceiling() - self.exit_count
|
||
if adopted >= ceiling_extra:
|
||
return
|
||
|
||
raised_extra = adopted + 1
|
||
self._expansion_extra = raised_extra
|
||
before = len(self._workers.roster)
|
||
outcome = await self._maintain_exit_pool()
|
||
|
||
if len(self._workers.roster) > before:
|
||
logger.info(get_message('elastic_expanded', self.language,
|
||
len(self._workers.roster)))
|
||
return
|
||
|
||
if self._expansion_extra == raised_extra:
|
||
self._expansion_extra = adopted
|
||
|
||
if not outcome.fetched or outcome.gained:
|
||
return
|
||
|
||
await self._raise_capacity_boost()
|
||
|
||
async def _raise_capacity_boost(self):
|
||
if not self.max_concurrent_per_proxy:
|
||
return
|
||
if self._capacity_boost >= _EXPAND_MAX_CAPACITY_BOOST:
|
||
return
|
||
|
||
self._capacity_boost = min(
|
||
_EXPAND_MAX_CAPACITY_BOOST, self._capacity_boost * 2)
|
||
self._workers.apply_capacity(
|
||
self.max_concurrent_per_proxy * self._capacity_boost)
|
||
logger.info(get_message('elastic_capacity_boosted', self.language,
|
||
self._capacity_boost))
|
||
|
||
async def _shrink_elastic_capacity(self):
|
||
self._cool_since = None
|
||
changed = False
|
||
|
||
if self._capacity_boost != 1:
|
||
self._capacity_boost = 1
|
||
self._workers.apply_capacity(self.max_concurrent_per_proxy)
|
||
changed = True
|
||
|
||
if self._expansion_extra:
|
||
self._expansion_extra = 0
|
||
changed = True
|
||
|
||
if changed:
|
||
logger.info(get_message('elastic_released', self.language,
|
||
len(self._workers.roster)))
|
||
|
||
def _note_listen_pressure(self) -> None:
|
||
if not self.auto_expand_enabled or not self.max_concurrent_per_proxy:
|
||
return
|
||
serving_exits = max(self._exit_target, len(self._workers.roster))
|
||
capacity = serving_exits * self.max_concurrent_per_proxy * self._capacity_boost
|
||
if self._live_connections <= capacity:
|
||
return
|
||
at_exit_ceiling = (self._expansion_extra
|
||
>= self._expansion_ceiling() - self.exit_count)
|
||
if at_exit_ceiling and self._capacity_boost >= _EXPAND_MAX_CAPACITY_BOOST:
|
||
self._ceiling_log.warning(
|
||
'burst-capacity-ceiling',
|
||
f"并发已顶到容量上限,但名册上限(exit_count × 2 = "
|
||
f"{self.exit_count * 2})与单出口额度放宽("
|
||
f"{_EXPAND_MAX_CAPACITY_BOOST} 倍)都到顶了,无法再扩。"
|
||
f"要继续扩容请调大「在用出口数」这一项(当前 {self.exit_count})。",
|
||
)
|
||
return
|
||
now = time.monotonic()
|
||
if now - self._last_burst_expand < _BURST_EXPAND_COOLDOWN:
|
||
return
|
||
if self._expand_task is not None and not self._expand_task.done():
|
||
return
|
||
self._last_burst_expand = now
|
||
try:
|
||
self._expand_task = asyncio.create_task(self._expand_immediately())
|
||
except RuntimeError:
|
||
self._expand_task = None
|
||
return
|
||
self.tasks.add(self._expand_task)
|
||
self._expand_task.add_done_callback(self.tasks.discard)
|
||
|
||
async def _expand_immediately(self):
|
||
try:
|
||
ceiling_extra = self._expansion_ceiling() - self.exit_count
|
||
adopted = max(self._expansion_extra,
|
||
max(0, len(self._workers.roster) - self.exit_count))
|
||
if adopted > self._expansion_extra:
|
||
self._expansion_extra = adopted
|
||
serving = max(self.exit_count + adopted, len(self._workers.roster))
|
||
raise_to = min(ceiling_extra, max(1, serving * 2 - self.exit_count))
|
||
|
||
if raise_to > adopted and not self._expanding:
|
||
self._expanding = True
|
||
previous_extra = self._expansion_extra
|
||
reached = False
|
||
try:
|
||
self._expansion_extra = raise_to
|
||
before = len(self._workers.roster)
|
||
await self._maintain_exit_pool(force_fetch=True)
|
||
reached = len(self._workers.roster) > before
|
||
if reached:
|
||
logger.info(get_message('elastic_expanded', self.language,
|
||
len(self._workers.roster)))
|
||
finally:
|
||
if not reached and self._expansion_extra == raise_to:
|
||
self._expansion_extra = max(previous_extra, adopted)
|
||
self._expanding = False
|
||
|
||
await self._raise_capacity_boost()
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"突发扩容出错(已忽略): {e}")
|
||
|
||
async def _fetch_into_standby(self) -> int | None:
|
||
missing = max(1, self._exit_target - len(self._workers.roster))
|
||
wanted = min(missing * _FETCH_OVERSUBSCRIBE, _FETCH_MAX_BATCH)
|
||
try:
|
||
urls = await asyncio.wait_for(
|
||
self._fetch_candidates(wanted), timeout=15
|
||
)
|
||
except asyncio.TimeoutError:
|
||
logger.error(get_message('proxy_get_timeout', self.language))
|
||
return None
|
||
except Exception as e:
|
||
logger.error(get_message('proxy_get_error', self.language, str(e)))
|
||
return None
|
||
|
||
if urls is None:
|
||
return None
|
||
if not urls:
|
||
logger.warning(get_message('proxy_batch_fetch_failed', self.language,
|
||
get_message('proxy_get_failed', self.language)))
|
||
return 0
|
||
|
||
usable = [url for url in urls
|
||
if self._workers.find(url) is None
|
||
and not self._workers.recently_retired(url)]
|
||
return self._standby.put(usable)
|
||
|
||
async def _replace_exit(self, victim) -> bool:
|
||
replacement = None
|
||
while True:
|
||
batch = self._take_candidates(_FILL_PROBE_FANOUT)
|
||
if not batch:
|
||
break
|
||
usable = [url for url, ok
|
||
in zip(batch, await self._probe_candidates(batch)) if ok]
|
||
if usable:
|
||
replacement = usable[0]
|
||
if len(usable) > 1:
|
||
self._standby.put(usable[1:])
|
||
break
|
||
if replacement is None:
|
||
return False
|
||
|
||
self._workers.add_worker(replacement, self._plan_expiry(time.monotonic()))
|
||
self._workers.drop_worker(victim.url, remember=False)
|
||
self._sync_current_proxy()
|
||
self.last_switch_time = time.time()
|
||
logger.info(get_message(
|
||
'proxy_exit_rotated', self.language,
|
||
sanitize_proxy(victim.url), sanitize_proxy(replacement),
|
||
))
|
||
return True
|
||
|
||
async def _rotate_expired(self, current_time) -> int:
|
||
rotated = 0
|
||
for victim in self._expired_exits(current_time):
|
||
if self._is_excess(victim):
|
||
self._workers.drop_worker(victim.url)
|
||
self._sync_current_proxy()
|
||
rotated += 1
|
||
continue
|
||
if not await self._replace_exit(victim):
|
||
if self.proxy_source_mode == 'local':
|
||
self._workers.readmit(victim, self._plan_expiry(time.monotonic()))
|
||
rotated += 1
|
||
continue
|
||
break
|
||
rotated += 1
|
||
return rotated
|
||
|
||
async def _replace_soonest(self) -> bool:
|
||
if not self._expiry_enabled():
|
||
return False
|
||
roster = self._workers.roster
|
||
if not roster:
|
||
return False
|
||
soonest = min(roster, key=lambda worker: worker.expires_at or float('inf'))
|
||
return await self._replace_exit(soonest)
|
||
|
||
async def _maintain_exit_pool(self, force_fetch=False, rotate=False,
|
||
max_fill=None) -> MaintenanceOutcome:
|
||
async with self._refill_lock:
|
||
filled = await self._fill_from_standby(max_fill)
|
||
|
||
rotated = 0
|
||
if rotate:
|
||
rotated = await self._rotate_expired(time.monotonic())
|
||
|
||
current_time = time.time()
|
||
expired = self._expired_exits(time.monotonic())
|
||
deficient = len(self._workers.roster) < self._exit_target
|
||
|
||
fetched = False
|
||
gained = 0
|
||
if ((deficient or (rotate and expired))
|
||
and (force_fetch or self._fetch_allowed(current_time))):
|
||
self.last_switch_attempt = current_time
|
||
result = await self._fetch_into_standby()
|
||
fetched = result is not None
|
||
gained = result or 0
|
||
room = None if max_fill is None else max(0, max_fill - filled)
|
||
filled += await self._fill_from_standby(room)
|
||
if rotate:
|
||
rotated += await self._rotate_expired(time.monotonic())
|
||
|
||
return MaintenanceOutcome(rotated > 0 or filled > 0, fetched, gained)
|
||
|
||
async def _maintain_exit_pool_for_request(self, **kwargs) -> MaintenanceOutcome:
|
||
if self._refill_lock.locked():
|
||
return _NO_MAINTENANCE
|
||
try:
|
||
return await asyncio.wait_for(
|
||
self._maintain_exit_pool(**kwargs), _REQUEST_MAINTENANCE_BUDGET)
|
||
except asyncio.TimeoutError:
|
||
self._silent_log.warning(
|
||
'request-maintenance-budget',
|
||
f"请求路径上的出口池维护超过 {_REQUEST_MAINTENANCE_BUDGET:g} 秒(先按现有名册继续,"
|
||
f"后台会接着补)",
|
||
)
|
||
return _NO_MAINTENANCE
|
||
|
||
async def _allocate_exit(self, exclude=None):
|
||
self._last_demand_at = time.monotonic()
|
||
wait_deadline = time.monotonic() + self.exit_wait_timeout
|
||
try:
|
||
if not self._workers.roster:
|
||
await self._maintain_exit_pool_for_request(force_fetch=True, max_fill=1)
|
||
if not self._workers.roster:
|
||
await self._wait_for_exit(wait_deadline)
|
||
elif (self._expired_exits(time.monotonic())
|
||
and time.monotonic() - self._last_rotate_attempt >= _ROTATE_RETRY_SECONDS):
|
||
self._last_rotate_attempt = time.monotonic()
|
||
await self._maintain_exit_pool_for_request(rotate=True, max_fill=1)
|
||
|
||
lease = await self._workers.acquire(asyncio.current_task(), exclude=exclude)
|
||
if lease is None:
|
||
if await self._wait_for_exit(wait_deadline):
|
||
lease = await self._workers.acquire(
|
||
asyncio.current_task(), exclude=exclude)
|
||
if lease is None:
|
||
return None, self.current_proxy
|
||
|
||
self.current_proxy = lease.url
|
||
return lease, lease.url
|
||
|
||
except Exception as e:
|
||
logger.error(get_message('proxy_allocate_error', self.language, str(e)))
|
||
return None, self.current_proxy
|
||
|
||
async def get_next_proxy(self, exclude=None):
|
||
_lease, proxy = await self._allocate_exit(exclude=exclude)
|
||
return proxy
|
||
|
||
async def _wait_for_exit(self, deadline=None) -> bool:
|
||
if deadline is None:
|
||
deadline = time.monotonic() + self.exit_wait_timeout
|
||
while not self.stop_server and not self._workers.roster:
|
||
remaining = deadline - time.monotonic()
|
||
if remaining <= 0:
|
||
break
|
||
self._last_demand_at = time.monotonic()
|
||
await self._workers.wait_for_roster_change(min(0.5, remaining))
|
||
return bool(self._workers.roster)
|
||
|
||
async def _replenish_loop(self):
|
||
while True:
|
||
await asyncio.sleep(1)
|
||
if not self.running:
|
||
return
|
||
try:
|
||
self._workers.expire_idle(wake=True)
|
||
await self._review_elastic_capacity()
|
||
self._schedule_real_ip_probes()
|
||
|
||
recent = (time.monotonic() - self._last_demand_at
|
||
<= _DEMAND_RECENT_SECONDS)
|
||
if not self._rotation_is_continuous() and not recent:
|
||
self._drop_expired_when_idle()
|
||
continue
|
||
|
||
if time.monotonic() < self._next_fill_attempt:
|
||
continue
|
||
before = len(self._workers.roster)
|
||
outcome = await self._maintain_exit_pool(rotate=True)
|
||
starved = (len(self._workers.roster) < self._exit_target
|
||
and len(self._workers.roster) <= before)
|
||
if starved or (outcome.fetched and not outcome.gained):
|
||
self._fill_backoff = min(
|
||
max(1.0, self._fill_backoff * 2), _FILL_BACKOFF_MAX)
|
||
else:
|
||
self._fill_backoff = 0.0
|
||
self._next_fill_attempt = time.monotonic() + self._fill_backoff
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"维持出口池出错(已忽略): {e}")
|
||
|
||
async def _fetch_candidates(self, missing):
|
||
if self.proxy_source_mode == 'api':
|
||
return await self._load_api_proxies(missing)
|
||
if self.proxy_source_mode == 'pool':
|
||
return await self._load_pool_proxies(missing)
|
||
return self._load_file_proxies()
|
||
|
||
def _note_source_error(self, error_text: str) -> None:
|
||
reason_type = str(error_text).split(':', 1)[0].strip()
|
||
key = {
|
||
'request': 'proxy_source_error_request',
|
||
'config': 'proxy_source_error_config',
|
||
}.get(reason_type, 'proxy_source_error_unknown')
|
||
self._last_source_error = (get_message(key, self.language), time.time())
|
||
|
||
def last_source_error(self):
|
||
if self._last_source_error is None:
|
||
return None
|
||
message, at = self._last_source_error
|
||
return {'message': message, 'at': at}
|
||
|
||
async def _load_api_proxies(self, limit=None):
|
||
wanted = None if limit is None else max(int(limit), _POOL_FETCH_MIN)
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
proxies = await loop.run_in_executor(None, getip_newip_list, wanted)
|
||
except Exception as e:
|
||
self._note_source_error(str(e))
|
||
logger.error(get_message('proxy_get_error', self.language, str(e)))
|
||
return None
|
||
self._last_source_error = None
|
||
return proxies
|
||
|
||
async def _load_pool_proxies(self, limit):
|
||
limit = max(int(limit), _POOL_FETCH_MIN)
|
||
if not self.pool_remote_url:
|
||
return await self._load_inprocess_pool_proxies(limit)
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10, verify=False,
|
||
trust_env=False) as client:
|
||
response = await client.get(self.pool_remote_url)
|
||
if response.status_code == 404:
|
||
logger.warning(get_message('pool_no_proxy', self.language))
|
||
return []
|
||
if response.status_code != 200:
|
||
logger.warning(get_message('pool_error_http_status', self.language, response.status_code))
|
||
return None
|
||
accepted = [line.strip() for line in response.text.split('\n')
|
||
if line.strip() and validate_proxy(line.strip())]
|
||
if not accepted:
|
||
logger.warning(get_message('pool_error_invalid_format', self.language, response.text[:100]))
|
||
return None
|
||
return accepted[:limit]
|
||
except httpx.ConnectError as e:
|
||
logger.error(get_message('pool_error_connect', self.language, str(e)))
|
||
return None
|
||
except httpx.TimeoutException:
|
||
logger.error(get_message('pool_error_timeout', self.language))
|
||
return None
|
||
except Exception as e:
|
||
logger.error(get_message('pool_fetch_failed', self.language, str(e)))
|
||
return None
|
||
|
||
async def _load_inprocess_pool_proxies(self, limit):
|
||
if self._pool_service is None:
|
||
logger.error(get_message('pool_unavailable', self.language,
|
||
get_message('pool_not_enabled', self.language)))
|
||
return None
|
||
|
||
if not self._pool_service.is_running:
|
||
logger.warning(get_message(
|
||
'pool_unavailable',
|
||
self.language,
|
||
self._pool_service.last_error
|
||
or get_message('pool_not_running', self.language),
|
||
))
|
||
return None
|
||
|
||
try:
|
||
entries = await self._pool_service.fetch_proxy_entries(limit)
|
||
except asyncio.TimeoutError:
|
||
logger.warning(get_message('pool_error_timeout', self.language))
|
||
return None
|
||
except Exception as e:
|
||
logger.error(get_message('pool_fetch_failed', self.language, str(e)))
|
||
return None
|
||
|
||
accepted = []
|
||
for entry in entries or ():
|
||
url = (entry.get('proxy_url') or '').strip()
|
||
if not validate_proxy(url):
|
||
continue
|
||
real_ip = (entry.get('real_ip') or '').strip()
|
||
if real_ip:
|
||
self._remember_exit_real_ip(url, real_ip)
|
||
accepted.append(url)
|
||
if not accepted:
|
||
logger.warning(get_message('pool_no_proxy', self.language))
|
||
return accepted
|
||
|
||
def _store_exit_real_ip(self, url, real_ip):
|
||
if not url:
|
||
return
|
||
self._exit_real_ips.pop(url, None)
|
||
self._exit_real_ips[url] = (real_ip, time.monotonic())
|
||
while len(self._exit_real_ips) > _EXIT_REAL_IP_CACHE_LIMIT:
|
||
self._exit_real_ips.pop(next(iter(self._exit_real_ips)))
|
||
|
||
def _remember_exit_real_ip(self, url, real_ip):
|
||
if not url or not real_ip:
|
||
return
|
||
self._store_exit_real_ip(url, real_ip)
|
||
|
||
def _real_ip_for(self, url) -> str:
|
||
entry = self._exit_real_ips.get(url)
|
||
return entry[0] if entry and entry[0] else ''
|
||
|
||
def _load_real_ip_probe_endpoints(self) -> tuple:
|
||
try:
|
||
from modules import pool_config_ini
|
||
|
||
pool_config = pool_config_ini.load_pool_config(
|
||
os.path.join(_BASE_DIR, 'config', 'config.ini'))
|
||
apis = getattr(pool_config.validator, 'ip_check_apis', ()) or ()
|
||
return tuple(str(api) for api in apis
|
||
if str(api).lower().startswith('https://'))
|
||
except Exception as e:
|
||
logger.warning(f"读取出口 IP 回显端点失败(真实 IP 探测将跳过): {e}")
|
||
return ()
|
||
|
||
def _schedule_real_ip_probes(self):
|
||
if not self.real_ip_probe or self.proxy_source_mode != 'api':
|
||
return
|
||
if not self._real_ip_probe_endpoints:
|
||
return
|
||
pending = [
|
||
worker.url for worker in self._workers.roster
|
||
if worker.url not in self._exit_real_ip_probing
|
||
and self._worker_needs_real_ip_probe(worker)
|
||
]
|
||
for url in pending[:_REAL_IP_PROBE_FANOUT]:
|
||
self._exit_real_ip_probing.add(url)
|
||
self._schedule_async_task(self._probe_exit_real_ip_wrapper(url))
|
||
|
||
def _worker_needs_real_ip_probe(self, worker) -> bool:
|
||
entry = self._exit_real_ips.get(worker.url)
|
||
if entry is None:
|
||
return True
|
||
value, probed_at = entry
|
||
if probed_at < worker.added_at:
|
||
return True
|
||
return (not value
|
||
and time.monotonic() - probed_at >= _REAL_IP_PROBE_RETRY_SECONDS)
|
||
|
||
async def _probe_exit_real_ip_wrapper(self, url):
|
||
try:
|
||
value = await self._probe_exit_real_ip(url)
|
||
except asyncio.CancelledError:
|
||
self._exit_real_ip_probing.discard(url)
|
||
raise
|
||
except Exception as e:
|
||
value = ''
|
||
logger.debug(f"探测出口真实 IP 出错 {sanitize_proxy(url)}: {e}")
|
||
self._exit_real_ip_probing.discard(url)
|
||
self._store_exit_real_ip(url, value or '')
|
||
|
||
async def _probe_exit_real_ip(self, url):
|
||
for endpoint in self._real_ip_probe_endpoints:
|
||
try:
|
||
async with self._lease_client(url) as client:
|
||
response = await client.get(
|
||
endpoint, timeout=_REAL_IP_PROBE_TIMEOUT)
|
||
except Exception:
|
||
continue
|
||
if response.status_code == 200:
|
||
real_ip = _parse_real_ip_echo(response.text)
|
||
if real_ip:
|
||
return real_ip
|
||
return ''
|
||
|
||
def time_until_next_switch(self):
|
||
mode = self.switch_countdown_mode()
|
||
if mode == 'request_count':
|
||
return min((max(0, self.request_interval - w.requests_served)
|
||
for w in self._workers.roster), default=0)
|
||
if mode == 'time':
|
||
now = time.monotonic()
|
||
return min((max(0.0, w.expires_at - now)
|
||
for w in self._workers.roster if w.expires_at), default=0.0)
|
||
return 0
|
||
|
||
def switch_countdown_mode(self):
|
||
if self.interval > 0:
|
||
return 'time'
|
||
if self.request_interval > 0:
|
||
return 'request_count'
|
||
return 'per_request'
|
||
|
||
_KNOWN_CLIENTS_LIMIT = 10000
|
||
|
||
def _record_known_client(self, client_key, client_ip, username_display):
|
||
if client_key in self.known_clients:
|
||
self.known_clients.move_to_end(client_key)
|
||
return
|
||
if len(self.known_clients) >= self._KNOWN_CLIENTS_LIMIT:
|
||
self.known_clients.popitem(last=False)
|
||
self.known_clients[client_key] = True
|
||
logger.info(get_message('new_client_connect', self.language, client_ip, username_display))
|
||
|
||
def check_ip_auth(self, ip):
|
||
try:
|
||
if not self.whitelist and not self.blacklist:
|
||
return True
|
||
|
||
if self.ip_auth_priority == 'whitelist':
|
||
if self.whitelist:
|
||
if ip in self.whitelist:
|
||
return True
|
||
return False
|
||
if self.blacklist:
|
||
return ip not in self.blacklist
|
||
return True
|
||
else:
|
||
if ip in self.blacklist:
|
||
return False
|
||
if self.whitelist:
|
||
return ip in self.whitelist
|
||
return True
|
||
except Exception as e:
|
||
logger.error(get_message('whitelist_error', self.language, str(e)))
|
||
return False
|
||
|
||
def _is_bypass_target(self, host):
|
||
return check_bypass_match(host, self.bypass_whitelist)
|
||
|
||
def _authenticate(self, headers):
|
||
if not self.auth_required:
|
||
return True
|
||
|
||
auth_header = headers.get('proxy-authorization', '')
|
||
if not auth_header:
|
||
return False
|
||
|
||
try:
|
||
scheme, credentials = auth_header.split()
|
||
if scheme.lower() != 'basic':
|
||
return False
|
||
|
||
decoded = base64.b64decode(credentials).decode()
|
||
username, password = decoded.split(':', 1)
|
||
|
||
if username in self.users and self.users[username] == password:
|
||
return username, password
|
||
|
||
except Exception:
|
||
pass
|
||
|
||
return False
|
||
|
||
@staticmethod
|
||
def _peer_ip(writer) -> str:
|
||
peername = writer.get_extra_info('peername') if writer is not None else None
|
||
if not peername:
|
||
return '-'
|
||
address = peername[0]
|
||
literal = _v4_mapped_literal(address)
|
||
if literal is not None:
|
||
return literal
|
||
try:
|
||
parsed = ipaddress.ip_address(address)
|
||
except ValueError:
|
||
return address
|
||
mapped = getattr(parsed, 'ipv4_mapped', None)
|
||
return str(mapped) if mapped is not None else address
|
||
|
||
def _client_identity(self, writer, username: str = '') -> ClientIdentity:
|
||
return ClientIdentity(ip=self._peer_ip(writer), username=username or '')
|
||
|
||
async def _reject(self, writer, access: AccessRecord, status_code: int, reason: str):
|
||
access.failed(reason, status_code)
|
||
phrase = _REASON_PHRASES.get(status_code, '')
|
||
try:
|
||
writer.write(
|
||
f'HTTP/1.1 {status_code} {phrase}\r\nConnection: close\r\n\r\n'.encode()
|
||
)
|
||
await writer.drain()
|
||
except (ConnectionError, ConnectionResetError, ConnectionAbortedError):
|
||
pass
|
||
|
||
async def _reject_tunnel(self, writer, access, established, status_code, reason):
|
||
if established:
|
||
access.failed(reason)
|
||
return
|
||
await self._reject(writer, access, status_code, reason)
|
||
|
||
async def _write_all(self, writer, data: bytes) -> bool:
|
||
try:
|
||
writer.write(data)
|
||
await writer.drain()
|
||
return True
|
||
except (ConnectionError, ConnectionResetError, ConnectionAbortedError):
|
||
return False
|
||
|
||
async def _close_connection(self, writer):
|
||
try:
|
||
if writer and not writer.is_closing():
|
||
writer.write_eof()
|
||
await writer.drain()
|
||
writer.close()
|
||
try:
|
||
await writer.wait_closed()
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
|
||
async def handle_client(self, reader, writer):
|
||
task = asyncio.current_task()
|
||
self.tasks.add(task)
|
||
try:
|
||
client_ip = self._peer_ip(writer)
|
||
if client_ip != '-' and not self.check_ip_auth(client_ip):
|
||
logger.warning(get_message('unauthorized_ip', self.language, client_ip))
|
||
writer.write(b'HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
|
||
await writer.drain()
|
||
return
|
||
|
||
header_deadline = asyncio.get_running_loop().time() + _CLIENT_HEADER_TIMEOUT
|
||
try:
|
||
first_byte = await asyncio.wait_for(reader.read(1), _CLIENT_HEADER_TIMEOUT)
|
||
except asyncio.TimeoutError:
|
||
return
|
||
if not first_byte:
|
||
return
|
||
|
||
if first_byte == b'\x05':
|
||
await self.handle_socks5_connection(reader, writer)
|
||
else:
|
||
await self._handle_client_impl(reader, writer, first_byte, header_deadline)
|
||
|
||
except Exception as e:
|
||
logger.error(get_message('client_handle_error', self.language, e))
|
||
finally:
|
||
self._workers.release(task)
|
||
try:
|
||
await self._close_connection(writer)
|
||
finally:
|
||
self.tasks.discard(task)
|
||
|
||
async def _pipe(self, reader, writer, remote_side=None, access=None, flow=None,
|
||
upstream=None, lease=None, record=None, hold_sink_on_dead=False):
|
||
source_side = 'client' if flow == 'to_upstream' else 'upstream'
|
||
sink_side = 'upstream' if flow == 'to_upstream' else 'client'
|
||
|
||
def _note_ended(side: str) -> None:
|
||
if access is not None:
|
||
access.tunnel_ended(side)
|
||
|
||
clean_eof = False
|
||
try:
|
||
while True:
|
||
try:
|
||
watching = self._needs_idle_watch(access, flow)
|
||
if watching:
|
||
self._ensure_tunnel_sweeper()
|
||
self._idle_watch[asyncio.current_task()] = (access, upstream)
|
||
try:
|
||
data = await reader.read(_RELAY_CHUNK)
|
||
finally:
|
||
if watching:
|
||
self._idle_watch.pop(asyncio.current_task(), None)
|
||
|
||
if not data:
|
||
_note_ended(source_side)
|
||
clean_eof = True
|
||
break
|
||
if record is not None:
|
||
record.add(data)
|
||
if lease is not None:
|
||
lease.touch()
|
||
if access is not None:
|
||
if flow == 'from_upstream':
|
||
first_relay = not access.upstream_data_seen
|
||
access.tunnel_relayed()
|
||
if first_relay:
|
||
self._note_exit_success(upstream)
|
||
elif flow == 'to_upstream':
|
||
access.tunnel_client_data()
|
||
try:
|
||
writer.write(data)
|
||
await writer.drain()
|
||
except (ConnectionError, ConnectionResetError):
|
||
if remote_side == 'writer':
|
||
self._blame_proxy(access, upstream, '')
|
||
_note_ended(sink_side)
|
||
break
|
||
except (ConnectionError, ConnectionResetError):
|
||
if remote_side == 'reader':
|
||
self._blame_proxy(access, upstream, '')
|
||
_note_ended(source_side)
|
||
break
|
||
except asyncio.CancelledError:
|
||
if access is not None and access.swept_as_dead:
|
||
_note_ended('upstream')
|
||
await self._abort_dead_tunnel(access, upstream)
|
||
except Exception as e:
|
||
if remote_side in ('reader', 'writer'):
|
||
self._blame_proxy(access, upstream, '')
|
||
logger.error(
|
||
"隧道中继出错(%s -> %s,出口 %s): %s",
|
||
source_side, sink_side,
|
||
sanitize_proxy(upstream) if upstream else '直接连接', e,
|
||
)
|
||
finally:
|
||
if hold_sink_on_dead and access is not None and not access.upstream_data_seen:
|
||
pass
|
||
elif clean_eof:
|
||
await self._half_close(writer)
|
||
else:
|
||
await self._close_connection(writer)
|
||
|
||
async def _half_close(self, writer):
|
||
try:
|
||
if writer and not writer.is_closing():
|
||
writer.write_eof()
|
||
await writer.drain()
|
||
except Exception:
|
||
pass
|
||
|
||
async def _relay_until_both_ends(self, to_upstream, from_upstream, upstream_writer):
|
||
try:
|
||
await asyncio.gather(to_upstream, from_upstream)
|
||
finally:
|
||
await self._close_connection(upstream_writer)
|
||
|
||
async def _relay_replayable_tunnel(self, reader, remote_reader, remote_writer, writer,
|
||
access, upstream, lease, record):
|
||
to_upstream = asyncio.ensure_future(self._pipe(
|
||
reader, remote_writer, remote_side='writer', access=access,
|
||
flow='to_upstream', upstream=upstream, lease=lease, record=record))
|
||
from_upstream = asyncio.ensure_future(self._pipe(
|
||
remote_reader, writer, remote_side='reader', access=access,
|
||
flow='from_upstream', upstream=upstream, lease=lease,
|
||
hold_sink_on_dead=True))
|
||
try:
|
||
done, _pending = await asyncio.wait(
|
||
{to_upstream, from_upstream}, return_when=asyncio.FIRST_COMPLETED)
|
||
if from_upstream in done and not access.upstream_data_seen:
|
||
to_upstream.cancel()
|
||
await asyncio.gather(to_upstream, from_upstream, return_exceptions=True)
|
||
except asyncio.CancelledError:
|
||
for task in (to_upstream, from_upstream):
|
||
task.cancel()
|
||
await asyncio.gather(to_upstream, from_upstream, return_exceptions=True)
|
||
raise
|
||
finally:
|
||
await self._close_connection(remote_writer)
|
||
|
||
def _needs_idle_watch(self, access, flow) -> bool:
|
||
return not (self.tunnel_idle_timeout <= 0 or access is None
|
||
or flow != 'from_upstream' or access.upstream_data_seen
|
||
or access.direct)
|
||
|
||
def _sweep_idle_tunnels(self):
|
||
if not self._idle_watch:
|
||
return
|
||
for task, (access, _upstream) in list(self._idle_watch.items()):
|
||
if task.done():
|
||
continue
|
||
if self._tunnel_is_dead(access):
|
||
access.swept_as_dead = True
|
||
task.cancel()
|
||
elif self._tunnel_is_abandoned(access):
|
||
access.swept_as_idle = True
|
||
task.cancel()
|
||
|
||
def _ensure_tunnel_sweeper(self):
|
||
task = getattr(self, '_sweeper_task', None)
|
||
if task is not None and not task.done():
|
||
return
|
||
try:
|
||
loop = asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
return
|
||
self._sweeper_task = loop.create_task(self._tunnel_sweep_loop())
|
||
|
||
async def _tunnel_sweep_loop(self):
|
||
while True:
|
||
await asyncio.sleep(_TUNNEL_SWEEP_INTERVAL)
|
||
if not self.running and not self._idle_watch:
|
||
return
|
||
try:
|
||
self._sweep_idle_tunnels()
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"清扫空闲隧道出错(已忽略): {e}")
|
||
|
||
def _tunnel_is_dead(self, access) -> bool:
|
||
if not access.client_data_seen:
|
||
return False
|
||
return (time.monotonic() - access.last_activity_at) >= self.tunnel_idle_timeout
|
||
|
||
def _tunnel_is_abandoned(self, access) -> bool:
|
||
if access.client_data_seen or access.upstream_data_seen:
|
||
return False
|
||
return (time.monotonic() - access.started_at) >= _IDLE_TUNNEL_TIMEOUT
|
||
|
||
async def _abort_dead_tunnel(self, access, upstream=None) -> None:
|
||
access.failed(get_message('access_tunnel_no_data', self.language))
|
||
self._schedule_async_task(self._confirm_dead_before_blaming(access, upstream))
|
||
|
||
async def _confirm_dead_before_blaming(self, access, upstream) -> None:
|
||
if access is not None and access.proxy_failure_reported:
|
||
return
|
||
if access is not None:
|
||
access.proxy_failure_reported = True
|
||
try:
|
||
await self._check_worker(upstream)
|
||
except Exception as e:
|
||
logger.debug(f"判死前的确认探测出错(已忽略): {e}")
|
||
|
||
def _has_alternative_exit(self, failed_url) -> bool:
|
||
return any(worker.url != failed_url for worker in self._workers.roster)
|
||
|
||
def _tunnel_can_replay(self, access, replay, deadline) -> bool:
|
||
if access is None or access.direct or access.upstream_data_seen:
|
||
return False
|
||
if access.tunnel_ended_by != 'upstream':
|
||
return False
|
||
if not replay.replayable:
|
||
return False
|
||
return deadline - asyncio.get_running_loop().time() > 0
|
||
|
||
def _blame_proxy(self, access, upstream=None, reason=_REASON_SILENT_TUNNEL) -> None:
|
||
if access is not None and (access.proxy_failure_reported or access.direct):
|
||
return
|
||
if access is not None:
|
||
access.proxy_failure_reported = True
|
||
self._schedule_async_task(self.handle_proxy_failure(upstream, reason))
|
||
|
||
async def _review_tunnel_outcome(self, access, upstream=None) -> None:
|
||
if access.upstream_data_seen or not access.client_data_seen:
|
||
return
|
||
if access.tunnel_ended_by != 'upstream':
|
||
return
|
||
self._blame_proxy(access, upstream)
|
||
|
||
def _split_proxy_auth(self, proxy_addr):
|
||
if '@' in proxy_addr:
|
||
auth, host = proxy_addr.rsplit('@', 1)
|
||
return auth, host
|
||
return None, proxy_addr
|
||
|
||
@staticmethod
|
||
def _socks5_atyp(host):
|
||
try:
|
||
socket.inet_aton(host)
|
||
return 1
|
||
except OSError:
|
||
pass
|
||
try:
|
||
socket.inet_pton(socket.AF_INET6, host)
|
||
return 4
|
||
except OSError:
|
||
return 3
|
||
|
||
def _build_client(self, proxy_url=None):
|
||
max_connections = self.client_max_connections
|
||
if proxy_url is None:
|
||
max_connections = max(max_connections, self.max_concurrent_requests)
|
||
kwargs = {
|
||
'limits': httpx.Limits(
|
||
max_keepalive_connections=self.client_max_keepalive,
|
||
max_connections=max_connections,
|
||
keepalive_expiry=self.client_keepalive_expiry,
|
||
),
|
||
'timeout': 30.0,
|
||
'http2': True,
|
||
'verify': False,
|
||
'follow_redirects': False,
|
||
'trust_env': False,
|
||
'cookies': _NullCookieJar(),
|
||
}
|
||
if proxy_url:
|
||
kwargs['proxies'] = {"all://": proxy_url}
|
||
return httpx.AsyncClient(**kwargs)
|
||
|
||
async def _create_client(self, proxy=None):
|
||
proxy_url = None
|
||
if proxy:
|
||
proxy_type, proxy_addr = proxy.split('://', 1)
|
||
proxy_auth, proxy_host_port = self._split_proxy_auth(proxy_addr)
|
||
if proxy_auth:
|
||
proxy_url = f"{proxy_type}://{proxy_auth}@{proxy_host_port}"
|
||
else:
|
||
proxy_url = f"{proxy_type}://{proxy_host_port}"
|
||
return self._build_client(proxy_url)
|
||
|
||
async def _acquire_pooled(self, key, factory, *, evict):
|
||
current_time = time.time()
|
||
expired_client = None
|
||
|
||
with self.client_pool_lock:
|
||
if key in self.client_pool:
|
||
client, last_used = self.client_pool[key]
|
||
in_use = bool(self._client_inflight.get(key))
|
||
if not client.is_closed and (
|
||
in_use or current_time - last_used < self.client_idle_timeout):
|
||
self.client_pool[key] = (client, current_time)
|
||
return client
|
||
expired_client = self.client_pool.pop(key)[0]
|
||
|
||
if expired_client is not None and not expired_client.is_closed:
|
||
await expired_client.aclose()
|
||
|
||
try:
|
||
client = factory()
|
||
if asyncio.iscoroutine(client):
|
||
client = await client
|
||
except Exception as e:
|
||
logger.error(f"创建客户端失败: {str(e)}")
|
||
raise
|
||
|
||
evicted_client = None
|
||
with self.client_pool_lock:
|
||
if key in self.client_pool:
|
||
evicted_client = client
|
||
winner = self.client_pool[key][0]
|
||
else:
|
||
pool_limit = max(self.max_pool_size,
|
||
2 * len(self._workers.roster),
|
||
4 * self.exit_count)
|
||
if evict and len(self.client_pool) >= pool_limit:
|
||
evictable = [k for k in self.client_pool
|
||
if not self._client_inflight.get(k)]
|
||
if evictable:
|
||
oldest_key = min(evictable, key=lambda x: self.client_pool[x][1])
|
||
evicted_client, _ = self.client_pool.pop(oldest_key)
|
||
self.client_pool[key] = (client, current_time)
|
||
winner = client
|
||
|
||
if evicted_client is not None and not evicted_client.is_closed:
|
||
await evicted_client.aclose()
|
||
return winner
|
||
|
||
@asynccontextmanager
|
||
async def _borrow_client(self, key, factory, *, evict):
|
||
client = await self._acquire_pooled(key, factory, evict=evict)
|
||
with self.client_pool_lock:
|
||
self._client_inflight[key] = self._client_inflight.get(key, 0) + 1
|
||
try:
|
||
yield client
|
||
finally:
|
||
pending = None
|
||
with self.client_pool_lock:
|
||
remaining = self._client_inflight.get(key, 1) - 1
|
||
if remaining > 0:
|
||
self._client_inflight[key] = remaining
|
||
else:
|
||
self._client_inflight.pop(key, None)
|
||
pending = self._clients_pending_close.pop(key, None)
|
||
for dead in pending or ():
|
||
if not dead.is_closed:
|
||
await dead.aclose()
|
||
|
||
def _lease_direct_client(self):
|
||
return self._borrow_client('__direct__', self._create_client, evict=False)
|
||
|
||
async def handle_socks5_connection(self, reader, writer):
|
||
try:
|
||
try:
|
||
request = await asyncio.wait_for(
|
||
self._read_socks5_request(reader, writer), _CLIENT_HEADER_TIMEOUT
|
||
)
|
||
except asyncio.TimeoutError:
|
||
logger.debug("SOCKS5 握手超时:客户端未在时限内发完协商报文")
|
||
return
|
||
if request is None:
|
||
return
|
||
|
||
username, dst_addr, dst_port, atyp = request
|
||
async with self.request_semaphore:
|
||
await self._socks5_forward(
|
||
reader, writer, username, dst_addr, dst_port, atyp
|
||
)
|
||
except Exception as e:
|
||
logger.error(get_message('socks5_connection_error', self.language, str(e)))
|
||
writer.write(b'\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00')
|
||
await writer.drain()
|
||
|
||
async def _read_socks5_request(self, reader, writer):
|
||
username = ''
|
||
nmethods = ord(await reader.readexactly(1))
|
||
await reader.readexactly(nmethods)
|
||
|
||
writer.write(b'\x05\x02' if self.auth_required else b'\x05\x00')
|
||
await writer.drain()
|
||
|
||
if self.auth_required:
|
||
auth_version = await reader.readexactly(1)
|
||
if auth_version != b'\x01':
|
||
writer.close()
|
||
return
|
||
|
||
ulen = ord(await reader.readexactly(1))
|
||
username = await reader.readexactly(ulen)
|
||
plen = ord(await reader.readexactly(1))
|
||
password = await reader.readexactly(plen)
|
||
|
||
username = username.decode()
|
||
password = password.decode()
|
||
|
||
if username in self.users and self.users[username] == password:
|
||
client_ip = self._peer_ip(writer)
|
||
if client_ip != '-':
|
||
self._record_known_client(
|
||
(client_ip, username), client_ip, f"{username}:****"
|
||
)
|
||
else:
|
||
writer.write(b'\x01\x01')
|
||
await writer.drain()
|
||
writer.close()
|
||
return
|
||
|
||
writer.write(b'\x01\x00')
|
||
await writer.drain()
|
||
|
||
version, cmd, _, atyp = struct.unpack('!BBBB', await reader.readexactly(4))
|
||
if cmd != 1:
|
||
writer.write(b'\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00')
|
||
await writer.drain()
|
||
writer.close()
|
||
return
|
||
|
||
if atyp == 1:
|
||
dst_addr = socket.inet_ntoa(await reader.readexactly(4))
|
||
elif atyp == 3:
|
||
addr_len = ord(await reader.readexactly(1))
|
||
dst_addr = (await reader.readexactly(addr_len)).decode()
|
||
elif atyp == 4:
|
||
dst_addr = socket.inet_ntop(socket.AF_INET6, await reader.readexactly(16))
|
||
else:
|
||
writer.write(b'\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00')
|
||
await writer.drain()
|
||
writer.close()
|
||
return
|
||
|
||
dst_port = struct.unpack('!H', await reader.readexactly(2))[0]
|
||
return username, dst_addr, dst_port, atyp
|
||
|
||
async def _socks5_forward(self, reader, writer, username, dst_addr, dst_port, atyp):
|
||
with self.access_tracker.track(
|
||
KIND_SOCKS5, '-', self._client_identity(writer, username), dst_addr, dst_port
|
||
) as access:
|
||
if self._is_bypass_target(dst_addr):
|
||
try:
|
||
logger.info(f"绕过代理直连 SOCKS5: {dst_addr}:{dst_port}")
|
||
access.use_direct()
|
||
remote_reader, remote_writer = await self._open_upstream(
|
||
dst_addr, dst_port)
|
||
if not await self._write_all(
|
||
writer, b'\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00'):
|
||
return
|
||
access.tunnel_ready()
|
||
await self._relay_until_both_ends(
|
||
self._pipe(reader, remote_writer, access=access, flow='to_upstream'),
|
||
self._pipe(remote_reader, writer, access=access, flow='from_upstream'),
|
||
remote_writer,
|
||
)
|
||
await self._review_tunnel_outcome(access)
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"直连 SOCKS5 {dst_addr}:{dst_port} 失败: {e}")
|
||
access.failed(str(e))
|
||
writer.write(b'\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00')
|
||
await writer.drain()
|
||
return
|
||
|
||
max_retries = 2
|
||
retry_count = 0
|
||
last_error = None
|
||
loop = asyncio.get_running_loop()
|
||
deadline = loop.time() + _TUNNEL_TOTAL_BUDGET_SECONDS
|
||
failed_url = None
|
||
|
||
while retry_count < max_retries:
|
||
upstream_writer = None
|
||
if retry_count and deadline - loop.time() <= 0:
|
||
last_error = last_error or "Tunnel time budget exhausted"
|
||
break
|
||
attempt_budget = (deadline - loop.time()) / (max_retries - retry_count)
|
||
attempt_deadline = loop.time() + attempt_budget
|
||
try:
|
||
lease, proxy = await self._allocate_exit(exclude=failed_url)
|
||
if not proxy:
|
||
raise Exception("No proxy available")
|
||
failed_url = proxy
|
||
|
||
access.use_proxy(proxy, real_ip=self._real_ip_for(proxy))
|
||
|
||
proxy_type, proxy_addr = proxy.split('://')
|
||
proxy_auth, proxy_host_port = self._split_proxy_auth(proxy_addr)
|
||
proxy_host, proxy_port = split_host_port(proxy_host_port)
|
||
proxy_port = int(proxy_port)
|
||
|
||
remote_reader, remote_writer = await self._open_upstream(
|
||
proxy_host, proxy_port, timeout=attempt_budget,
|
||
tls=(proxy_type == 'https'))
|
||
upstream_writer = remote_writer
|
||
handshake_timeout = max(0.0, attempt_deadline - loop.time())
|
||
|
||
if proxy_type == 'socks5':
|
||
await self._initiate_socks5(remote_reader, remote_writer, dst_addr, dst_port,
|
||
atyp, proxy, handshake_timeout)
|
||
elif proxy_type in ['http', 'https']:
|
||
await self._initiate_http(remote_reader, remote_writer, dst_addr, dst_port,
|
||
proxy_auth, handshake_timeout)
|
||
else:
|
||
raise UnsupportedUpstreamProxyError(
|
||
f"Unsupported proxy type: {proxy_type}")
|
||
|
||
if proxy_type == 'https':
|
||
_remember_upstream_tls_session(proxy_host, proxy_port, remote_writer)
|
||
|
||
if not await self._write_all(
|
||
writer, b'\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00'):
|
||
return
|
||
access.tunnel_ready()
|
||
if lease is not None:
|
||
lease.idle()
|
||
|
||
await self._relay_until_both_ends(
|
||
self._pipe(reader, remote_writer, remote_side='writer',
|
||
access=access, flow='to_upstream', upstream=proxy,
|
||
lease=lease),
|
||
self._pipe(remote_reader, writer, remote_side='reader',
|
||
access=access, flow='from_upstream', upstream=proxy,
|
||
lease=lease),
|
||
remote_writer,
|
||
)
|
||
|
||
await self._review_tunnel_outcome(access, proxy)
|
||
return
|
||
|
||
except (asyncio.TimeoutError, ConnectionRefusedError, ConnectionResetError) as e:
|
||
await self._close_upstream(upstream_writer)
|
||
last_error = e
|
||
self._log_retry(max_retries - retry_count - 1)
|
||
await self.handle_proxy_failure(proxy)
|
||
retry_count += 1
|
||
if retry_count < max_retries:
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
|
||
except UnsupportedUpstreamProxyError as e:
|
||
await self._close_upstream(upstream_writer)
|
||
last_error = e
|
||
retry_count += 1
|
||
if retry_count < max_retries:
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
|
||
except Exception as e:
|
||
await self._close_upstream(upstream_writer)
|
||
last_error = e
|
||
logger.error(get_message('socks5_connection_error', self.language, str(e)))
|
||
await self.handle_proxy_failure(proxy)
|
||
retry_count += 1
|
||
if retry_count < max_retries:
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
|
||
if last_error:
|
||
logger.error(get_message('all_retries_failed', self.language, str(last_error)))
|
||
access.failed(
|
||
str(last_error) if last_error
|
||
else get_message('pool_no_proxy', self.language))
|
||
writer.write(b'\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00')
|
||
await writer.drain()
|
||
|
||
async def _socks5_upstream_handshake(self, remote_reader, remote_writer,
|
||
host, port, atyp, auth, timeout=10.0):
|
||
if auth:
|
||
remote_writer.write(b'\x05\x02\x00\x02')
|
||
else:
|
||
remote_writer.write(b'\x05\x01\x00')
|
||
await remote_writer.drain()
|
||
|
||
try:
|
||
auth_method = await asyncio.wait_for(
|
||
remote_reader.readexactly(2), timeout=timeout
|
||
)
|
||
if auth_method[0] != 0x05:
|
||
raise Exception("Invalid SOCKS5 proxy response")
|
||
|
||
if auth_method[1] == 0x02 and auth:
|
||
username, password = auth.split(':', 1)
|
||
remote_writer.write(build_socks5_auth_packet(username, password))
|
||
await remote_writer.drain()
|
||
|
||
auth_response = await asyncio.wait_for(
|
||
remote_reader.readexactly(2), timeout=timeout
|
||
)
|
||
if auth_response[1] != 0x00:
|
||
raise Exception("Authentication failed")
|
||
|
||
if atyp == 1:
|
||
remote_writer.write(b'\x05\x01\x00\x01' + socket.inet_aton(host) +
|
||
port.to_bytes(2, 'big'))
|
||
elif atyp == 4:
|
||
remote_writer.write(b'\x05\x01\x00\x04' +
|
||
socket.inet_pton(socket.AF_INET6, host) +
|
||
port.to_bytes(2, 'big'))
|
||
else:
|
||
host_bytes = host.encode()
|
||
remote_writer.write(b'\x05\x01\x00\x03' +
|
||
len(host_bytes).to_bytes(1, 'big') +
|
||
host_bytes + port.to_bytes(2, 'big'))
|
||
|
||
await remote_writer.drain()
|
||
|
||
response = await asyncio.wait_for(
|
||
remote_reader.readexactly(4), timeout=timeout
|
||
)
|
||
if response[1] != 0x00:
|
||
error_codes = {
|
||
0x01: "General failure",
|
||
0x02: "Connection not allowed",
|
||
0x03: "Network unreachable",
|
||
0x04: "Host unreachable",
|
||
0x05: "Connection refused",
|
||
0x06: "TTL expired",
|
||
0x07: "Command not supported",
|
||
0x08: "Address type not supported"
|
||
}
|
||
error_msg = error_codes.get(response[1], f"Unknown error code {response[1]}")
|
||
raise Exception(f"Connection failed: {error_msg}")
|
||
|
||
if response[3] == 0x01:
|
||
await asyncio.wait_for(remote_reader.readexactly(6), timeout=timeout)
|
||
elif response[3] == 0x03:
|
||
domain_len = (await asyncio.wait_for(
|
||
remote_reader.readexactly(1), timeout=timeout
|
||
))[0]
|
||
await asyncio.wait_for(
|
||
remote_reader.readexactly(domain_len + 2), timeout=timeout
|
||
)
|
||
elif response[3] == 0x04:
|
||
await asyncio.wait_for(remote_reader.readexactly(18), timeout=timeout)
|
||
else:
|
||
raise Exception(f"Unsupported address type: {response[3]}")
|
||
|
||
except asyncio.TimeoutError:
|
||
raise Exception("SOCKS5 proxy response timeout")
|
||
|
||
async def _initiate_socks5(self, remote_reader, remote_writer, dst_addr, dst_port,
|
||
atyp=3, proxy=None, timeout=10.0):
|
||
auth = None
|
||
proxy_url = proxy if proxy is not None else self.current_proxy
|
||
if proxy_url:
|
||
_, proxy_addr = proxy_url.split('://', 1)
|
||
auth, _ = self._split_proxy_auth(proxy_addr)
|
||
|
||
try:
|
||
await self._socks5_upstream_handshake(
|
||
remote_reader, remote_writer, dst_addr, dst_port, atyp, auth, timeout
|
||
)
|
||
except Exception as e:
|
||
raise Exception(f"SOCKS5 initialization failed: {str(e)}")
|
||
|
||
|
||
async def _http_connect_handshake(self, remote_reader, remote_writer,
|
||
host, port, proxy_auth, timeout=10.0):
|
||
authority = f'[{host}]:{port}' if ':' in host else f'{host}:{port}'
|
||
connect_request = f'CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n'
|
||
if proxy_auth:
|
||
connect_request += f'Proxy-Authorization: Basic {base64.b64encode(proxy_auth.encode()).decode()}\r\n'
|
||
connect_request += '\r\n'
|
||
remote_writer.write(connect_request.encode())
|
||
await remote_writer.drain()
|
||
|
||
loop = asyncio.get_running_loop()
|
||
deadline = loop.time() + timeout
|
||
first_line = True
|
||
header_lines = 0
|
||
while True:
|
||
header_lines += 1
|
||
if header_lines > _MAX_HEADER_LINES:
|
||
raise Exception("Upstream proxy CONNECT response too long")
|
||
remaining = deadline - loop.time()
|
||
if remaining <= 0:
|
||
raise asyncio.TimeoutError("Upstream proxy CONNECT handshake timeout")
|
||
try:
|
||
line = await asyncio.wait_for(remote_reader.readline(), timeout=remaining)
|
||
except asyncio.TimeoutError:
|
||
raise asyncio.TimeoutError("Upstream proxy CONNECT handshake timeout")
|
||
if not line:
|
||
raise Exception("Upstream proxy closed connection unexpectedly")
|
||
if first_line:
|
||
first_line = False
|
||
status = None
|
||
parts = line.split(b' ', 2)
|
||
if len(parts) >= 2:
|
||
try:
|
||
status = int(parts[1])
|
||
except ValueError:
|
||
status = None
|
||
if status is None or not (200 <= status < 300):
|
||
raise Exception(
|
||
"Upstream proxy refused CONNECT: "
|
||
+ line[:80].decode('latin-1').strip()
|
||
)
|
||
if line == b'\r\n':
|
||
break
|
||
|
||
async def _initiate_http(self, remote_reader, remote_writer, dst_addr, dst_port, proxy_auth, timeout=10.0):
|
||
await self._http_connect_handshake(
|
||
remote_reader, remote_writer, dst_addr, dst_port, proxy_auth, timeout
|
||
)
|
||
|
||
async def _read_headers(self, reader):
|
||
headers = {}
|
||
consumed = 0
|
||
for _ in range(_MAX_HEADER_LINES):
|
||
line = await reader.readline()
|
||
consumed += len(line)
|
||
if consumed > _MAX_HEADER_BYTES:
|
||
logger.debug("请求头超过总字节上限(已断开)")
|
||
return None
|
||
if line == b'\r\n':
|
||
return headers
|
||
if line == b'':
|
||
return None
|
||
try:
|
||
name, value = line.decode('utf-8', errors='ignore').split(':', 1)
|
||
headers[name.strip().lower()] = value.strip()
|
||
except ValueError:
|
||
continue
|
||
logger.debug("请求头超过行数上限(已断开)")
|
||
return None
|
||
|
||
@staticmethod
|
||
def _strip_hop_by_hop(client_headers):
|
||
headers = client_headers.copy()
|
||
for hop_header in _HOP_BY_HOP_HEADERS:
|
||
headers.pop(hop_header, None)
|
||
return headers
|
||
|
||
def _forward_headers(self, client_headers, proxy):
|
||
headers = self._strip_hop_by_hop(client_headers)
|
||
|
||
proxy_type, proxy_addr = proxy.split('://', 1)
|
||
if proxy_type in ('http', 'https') and '@' in proxy_addr:
|
||
auth, _ = proxy_addr.rsplit('@', 1)
|
||
headers['proxy-authorization'] = (
|
||
f'Basic {base64.b64encode(auth.encode()).decode()}'
|
||
)
|
||
return headers
|
||
|
||
async def _handle_client_impl(self, reader, writer, first_byte, header_deadline=None):
|
||
loop = asyncio.get_running_loop()
|
||
if header_deadline is None:
|
||
header_deadline = loop.time() + _CLIENT_HEADER_TIMEOUT
|
||
try:
|
||
while True:
|
||
try:
|
||
request_line = first_byte + await asyncio.wait_for(
|
||
reader.readline(), _remaining_time(header_deadline))
|
||
except asyncio.TimeoutError:
|
||
return
|
||
if not request_line:
|
||
return
|
||
|
||
try:
|
||
method, path, version = request_line.decode('utf-8', errors='ignore').split()
|
||
except (ValueError, UnicodeDecodeError) as e:
|
||
return
|
||
|
||
try:
|
||
headers = await asyncio.wait_for(
|
||
self._read_headers(reader), _remaining_time(header_deadline))
|
||
except asyncio.TimeoutError:
|
||
return
|
||
if headers is None:
|
||
return
|
||
|
||
username = ''
|
||
if self.auth_required:
|
||
auth_result = self._authenticate(headers)
|
||
if not auth_result:
|
||
writer.write(b'HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="Proxy"\r\nConnection: close\r\n\r\n')
|
||
await writer.drain()
|
||
return
|
||
elif isinstance(auth_result, tuple):
|
||
username, _ = auth_result
|
||
client_ip = self._peer_ip(writer)
|
||
if client_ip != '-':
|
||
self._record_known_client(
|
||
(client_ip, username), client_ip, f"{username}:***"
|
||
)
|
||
|
||
if method != 'CONNECT' and '100-continue' in headers.get('expect', '').lower():
|
||
writer.write(b'HTTP/1.1 100 Continue\r\n\r\n')
|
||
await writer.drain()
|
||
headers.pop('expect', None)
|
||
|
||
client = self._client_identity(writer, username)
|
||
if method == 'CONNECT':
|
||
with self.access_tracker.track(KIND_CONNECT, method, client) as access:
|
||
async with self.request_semaphore:
|
||
await self._handle_connect(path, reader, writer, access)
|
||
return
|
||
|
||
keep_alive = (version == 'HTTP/1.1'
|
||
and 'close' not in headers.get('connection', '').lower())
|
||
with self.access_tracker.track(KIND_HTTP, method, client) as access:
|
||
reusable = await self._handle_request(
|
||
method, path, headers, reader, writer, access,
|
||
keep_alive=keep_alive)
|
||
if not reusable:
|
||
return
|
||
|
||
self._workers.release(asyncio.current_task())
|
||
|
||
try:
|
||
first_byte = await asyncio.wait_for(reader.read(1), _CLIENT_HEADER_TIMEOUT)
|
||
except asyncio.TimeoutError:
|
||
return
|
||
if not first_byte or first_byte == b'\x05':
|
||
return
|
||
header_deadline = loop.time() + _CLIENT_HEADER_TIMEOUT
|
||
|
||
except (ConnectionError, ConnectionResetError, ConnectionAbortedError):
|
||
return
|
||
except asyncio.CancelledError:
|
||
return
|
||
except Exception as e:
|
||
if not isinstance(e, (ConnectionError, ConnectionResetError, ConnectionAbortedError,
|
||
asyncio.CancelledError, asyncio.TimeoutError)):
|
||
logger.error(get_message('client_request_error', self.language, str(e)))
|
||
|
||
async def _handle_connect(self, path, reader, writer, access):
|
||
try:
|
||
host, port = split_host_port(path)
|
||
except ValueError:
|
||
await self._reject(writer, access, 400, get_message('access_reason_bad_target', self.language, path))
|
||
return
|
||
|
||
access.host, access.port = host, port
|
||
|
||
if self._is_bypass_target(host):
|
||
try:
|
||
logger.info(f"绕过代理直连 HTTPS: {host}:{port}")
|
||
access.use_direct()
|
||
remote_reader, remote_writer = await self._open_upstream(host, port)
|
||
if not await self._write_all(
|
||
writer, b'HTTP/1.1 200 Connection Established\r\n\r\n'):
|
||
return
|
||
access.tunnel_ready(200)
|
||
await self._relay_until_both_ends(
|
||
self._pipe(reader, remote_writer, access=access, flow='to_upstream'),
|
||
self._pipe(remote_reader, writer, access=access, flow='from_upstream'),
|
||
remote_writer,
|
||
)
|
||
await self._review_tunnel_outcome(access)
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"直连 {host}:{port} 失败: {e}")
|
||
await self._reject(writer, access, 502, get_message('access_reason_direct_failed', self.language, e))
|
||
return
|
||
|
||
max_retries = 2
|
||
retry_count = 0
|
||
last_error = None
|
||
loop = asyncio.get_running_loop()
|
||
deadline = loop.time() + _TUNNEL_TOTAL_BUDGET_SECONDS
|
||
failed_url = None
|
||
established = False
|
||
replay = _ReplayBuffer(_TUNNEL_REPLAY_LIMIT)
|
||
|
||
while retry_count < max_retries:
|
||
upstream_writer = None
|
||
if retry_count and deadline - loop.time() <= 0:
|
||
await self._reject_tunnel(writer, access, established, 504,
|
||
last_error or "Tunnel time budget exhausted")
|
||
return
|
||
attempt_budget = (deadline - loop.time()) / (max_retries - retry_count)
|
||
attempt_deadline = loop.time() + attempt_budget
|
||
try:
|
||
lease, proxy = await self._allocate_exit(exclude=failed_url)
|
||
if not proxy:
|
||
await self._reject_tunnel(writer, access, established, 503,
|
||
get_message('pool_no_proxy', self.language))
|
||
return
|
||
failed_url = proxy
|
||
|
||
access.use_proxy(proxy, real_ip=self._real_ip_for(proxy))
|
||
|
||
try:
|
||
proxy_type, proxy_addr = proxy.split('://')
|
||
proxy_auth, proxy_host_port = self._split_proxy_auth(proxy_addr)
|
||
proxy_host, proxy_port = split_host_port(proxy_host_port)
|
||
proxy_port = int(proxy_port)
|
||
|
||
remote_reader, remote_writer = await self._open_upstream(
|
||
proxy_host, proxy_port, timeout=attempt_budget,
|
||
tls=(proxy_type == 'https'))
|
||
upstream_writer = remote_writer
|
||
|
||
if proxy_type in ('http', 'https'):
|
||
try:
|
||
await self._http_connect_handshake(
|
||
remote_reader, remote_writer, host, port, proxy_auth,
|
||
timeout=max(0.0, attempt_deadline - loop.time()),
|
||
)
|
||
except asyncio.TimeoutError:
|
||
await self._close_upstream(upstream_writer)
|
||
await self.handle_proxy_failure(proxy)
|
||
last_error = "Upstream proxy CONNECT handshake timeout"
|
||
retry_count += 1
|
||
if retry_count >= max_retries or not self._has_alternative_exit(proxy):
|
||
await self._reject_tunnel(writer, access, established, 504, last_error)
|
||
return
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
except Exception as e:
|
||
await self._close_upstream(upstream_writer)
|
||
await self.handle_proxy_failure(proxy)
|
||
last_error = f"Bad Gateway: {e}"
|
||
retry_count += 1
|
||
if retry_count >= max_retries or not self._has_alternative_exit(proxy):
|
||
await self._reject_tunnel(writer, access, established, 502, last_error)
|
||
return
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
elif proxy_type == 'socks5':
|
||
await self._socks5_upstream_handshake(
|
||
remote_reader, remote_writer, host, port,
|
||
self._socks5_atyp(host), proxy_auth,
|
||
)
|
||
else:
|
||
raise UnsupportedUpstreamProxyError("Unsupported proxy type")
|
||
|
||
if proxy_type == 'https':
|
||
_remember_upstream_tls_session(proxy_host, proxy_port, remote_writer)
|
||
|
||
if not established:
|
||
if not await self._write_all(
|
||
writer, b'HTTP/1.1 200 Connection Established\r\n\r\n'):
|
||
return
|
||
established = True
|
||
elif len(replay):
|
||
if not await self._write_all(remote_writer, bytes(replay.data)):
|
||
return
|
||
access.tunnel_client_data()
|
||
access.tunnel_ready(200)
|
||
if lease is not None:
|
||
lease.idle()
|
||
|
||
await self._relay_replayable_tunnel(
|
||
reader, remote_reader, remote_writer, writer,
|
||
access, proxy, lease, replay,
|
||
)
|
||
|
||
await self._review_tunnel_outcome(access, proxy)
|
||
|
||
if not self._tunnel_can_replay(access, replay, deadline):
|
||
return
|
||
access.retry_tunnel()
|
||
retry_count += 1
|
||
if retry_count >= max_retries or not self._has_alternative_exit(proxy):
|
||
return
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
|
||
except asyncio.TimeoutError:
|
||
|
||
await self._close_upstream(upstream_writer)
|
||
await self.handle_proxy_failure(proxy)
|
||
last_error = "Connection Timeout"
|
||
retry_count += 1
|
||
if retry_count >= max_retries or not self._has_alternative_exit(proxy):
|
||
logger.error(get_message('connect_timeout', self.language))
|
||
await self._reject_tunnel(writer, access, established, 504, last_error)
|
||
return
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
except UnsupportedUpstreamProxyError as e:
|
||
await self._close_upstream(upstream_writer)
|
||
last_error = str(e)
|
||
retry_count += 1
|
||
if retry_count < max_retries:
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
await self._reject_tunnel(writer, access, established, 502, last_error)
|
||
return
|
||
|
||
except Exception as e:
|
||
|
||
await self._close_upstream(upstream_writer)
|
||
await self.handle_proxy_failure(proxy)
|
||
last_error = str(e)
|
||
retry_count += 1
|
||
if retry_count >= max_retries or not self._has_alternative_exit(proxy):
|
||
await self._reject_tunnel(writer, access, established, 502, last_error)
|
||
return
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
|
||
except Exception as e:
|
||
await self._close_upstream(upstream_writer)
|
||
last_error = str(e)
|
||
retry_count += 1
|
||
if retry_count < max_retries:
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
await self._reject_tunnel(writer, access, established, 502, last_error)
|
||
return
|
||
|
||
|
||
async def _handle_request(self, method, path, headers, reader, writer, access,
|
||
keep_alive=False):
|
||
async with self.request_semaphore, AsyncExitStack() as borrowed_clients:
|
||
target_host = None
|
||
target_port = 0
|
||
host_header = headers.get('host', '')
|
||
if '://' in path:
|
||
request_url = path
|
||
from urllib.parse import urlparse
|
||
parsed = urlparse(path)
|
||
target_host = parsed.hostname
|
||
target_port = parsed.port or (443 if parsed.scheme == 'https' else 80)
|
||
elif host_header:
|
||
request_url = f'http://{host_header}{path}'
|
||
try:
|
||
target_host, target_port = split_host_port(host_header, default_port=80)
|
||
except ValueError:
|
||
target_host, target_port = host_header, 80
|
||
else:
|
||
await self._reject(writer, access, 400, get_message('access_reason_bad_target', self.language, path))
|
||
return
|
||
|
||
if target_host:
|
||
access.host, access.port = target_host, target_port
|
||
|
||
body = _client_request_body(
|
||
headers, reader,
|
||
asyncio.get_running_loop().time() + _BODY_TOTAL_TIMEOUT)
|
||
reuse_ok = keep_alive and body is None
|
||
response_started = False
|
||
|
||
if target_host and self._is_bypass_target(target_host):
|
||
try:
|
||
logger.info(f"绕过代理直连 HTTP: {method} {path}")
|
||
access.use_direct()
|
||
client = await borrowed_clients.enter_async_context(
|
||
self._lease_direct_client())
|
||
try:
|
||
request = _build_upstream_request(
|
||
client, method, request_url,
|
||
self._strip_hop_by_hop(headers), body)
|
||
async with _stream_upstream(client, request) as response:
|
||
response_started = True
|
||
allow_reuse = self._response_allows_reuse(response, method) and reuse_ok
|
||
writer.write(self._render_response_head(response, keep_alive=allow_reuse))
|
||
access.succeeded(response.status_code)
|
||
try:
|
||
pending = 0
|
||
async for chunk in response.aiter_raw():
|
||
if not chunk:
|
||
break
|
||
try:
|
||
writer.write(chunk)
|
||
pending += len(chunk)
|
||
if pending >= self.buffer_size:
|
||
await writer.drain()
|
||
pending = 0
|
||
except (ConnectionError, ConnectionResetError, ConnectionAbortedError):
|
||
return
|
||
await writer.drain()
|
||
except (ConnectionError, ConnectionResetError, ConnectionAbortedError):
|
||
return
|
||
return allow_reuse
|
||
except Exception as e:
|
||
if response_started:
|
||
return
|
||
logger.error(f"直连请求失败 {path}: {e}")
|
||
await self._reject(writer, access, 502, get_message('access_reason_direct_failed', self.language, e))
|
||
return
|
||
except Exception as e:
|
||
if response_started:
|
||
return
|
||
logger.error(f"直连请求失败 {path}: {e}")
|
||
await self._reject(writer, access, 502, get_message('access_reason_direct_failed', self.language, e))
|
||
return
|
||
|
||
max_retries = 1 if body is not None else 2
|
||
retry_count = 0
|
||
last_error = None
|
||
failed_url = None
|
||
|
||
while retry_count < max_retries:
|
||
response_started = False
|
||
try:
|
||
proxy = await self.get_next_proxy(exclude=failed_url)
|
||
if not proxy:
|
||
await self._reject(writer, access, 503, get_message('pool_no_proxy', self.language))
|
||
return
|
||
failed_url = proxy
|
||
|
||
access.use_proxy(proxy, real_ip=self._real_ip_for(proxy))
|
||
|
||
try:
|
||
client = await borrowed_clients.enter_async_context(
|
||
self._lease_client(proxy))
|
||
|
||
proxy_headers = self._forward_headers(headers, proxy)
|
||
|
||
try:
|
||
request = _build_upstream_request(
|
||
client, method, request_url, proxy_headers, body)
|
||
async with _stream_upstream(client, request) as response:
|
||
response_started = True
|
||
allow_reuse = self._response_allows_reuse(response, method) and reuse_ok
|
||
writer.write(self._render_response_head(response, keep_alive=allow_reuse))
|
||
access.succeeded(response.status_code)
|
||
self._note_exit_success(proxy)
|
||
|
||
body_complete = True
|
||
try:
|
||
pending = 0
|
||
async for chunk in response.aiter_raw():
|
||
if not chunk:
|
||
break
|
||
try:
|
||
writer.write(chunk)
|
||
pending += len(chunk)
|
||
if pending >= self.buffer_size:
|
||
await writer.drain()
|
||
pending = 0
|
||
except (ConnectionError, ConnectionResetError, ConnectionAbortedError):
|
||
return
|
||
except Exception:
|
||
body_complete = False
|
||
break
|
||
|
||
await writer.drain()
|
||
except (ConnectionError, ConnectionResetError, ConnectionAbortedError):
|
||
return
|
||
except Exception:
|
||
body_complete = False
|
||
|
||
|
||
return allow_reuse and body_complete
|
||
|
||
except httpx.RequestError as e:
|
||
if response_started:
|
||
return
|
||
if isinstance(e, _UPSTREAM_TRANSPORT_ERRORS):
|
||
await self.handle_proxy_failure(proxy)
|
||
else:
|
||
logger.error(f"请求未能送达上游(不归因给出口) {method} {request_url}: {e}")
|
||
|
||
last_error = "Request Error"
|
||
retry_count += 1
|
||
if retry_count < max_retries:
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
await self._reject(writer, access, 502, last_error)
|
||
return
|
||
except Exception as e:
|
||
if response_started:
|
||
return
|
||
if isinstance(e, (ConnectionError, ConnectionResetError, ConnectionAbortedError)):
|
||
await self.handle_proxy_failure(proxy)
|
||
|
||
last_error = str(e)
|
||
retry_count += 1
|
||
if retry_count < max_retries:
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
await self._reject(writer, access, 502, last_error)
|
||
return
|
||
|
||
|
||
except httpx.HTTPError as e:
|
||
if response_started:
|
||
return
|
||
logger.error(f"请求处理错误(不归因给出口) {method} {request_url}: {e}")
|
||
last_error = str(e)
|
||
retry_count += 1
|
||
if retry_count < max_retries:
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
await self._reject(writer, access, 502, last_error)
|
||
return
|
||
except Exception as e:
|
||
if response_started:
|
||
return
|
||
if isinstance(e, (ConnectionError, ConnectionResetError, ConnectionAbortedError)):
|
||
await self.handle_proxy_failure(proxy)
|
||
|
||
last_error = str(e)
|
||
retry_count += 1
|
||
if retry_count < max_retries:
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
await self._reject(writer, access, 502, last_error)
|
||
return
|
||
|
||
|
||
except Exception as e:
|
||
if response_started:
|
||
return
|
||
if isinstance(e, (ConnectionError, ConnectionResetError, ConnectionAbortedError)):
|
||
|
||
await self.handle_proxy_failure()
|
||
last_error = str(e)
|
||
retry_count += 1
|
||
if retry_count < max_retries:
|
||
self._log_retry(max_retries - retry_count)
|
||
await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
|
||
continue
|
||
await self._reject(writer, access, 502, last_error)
|
||
return
|
||
if not isinstance(e, (asyncio.CancelledError,)):
|
||
logger.error(f"请求处理错误 {method} {path}: {str(e)}")
|
||
try:
|
||
await self._reject(writer, access, 502, str(e))
|
||
except Exception:
|
||
pass
|
||
return
|
||
|
||
async def _close_client_for(self, proxy_url):
|
||
with self.client_pool_lock:
|
||
entry = self.client_pool.pop(proxy_url, None)
|
||
if entry is None:
|
||
return
|
||
client = entry[0]
|
||
if self._client_inflight.get(proxy_url, 0) > 0:
|
||
self._clients_pending_close.setdefault(proxy_url, []).append(client)
|
||
return
|
||
if not client.is_closed:
|
||
await client.aclose()
|
||
|
||
def _lease_client(self, proxy):
|
||
return self._borrow_client(
|
||
proxy, lambda: self._create_client(proxy), evict=True,
|
||
)
|
||
|
||
def _worker_for_failure(self, task, proxy):
|
||
lease = self._workers.lease_of(task)
|
||
if lease is not None:
|
||
return lease, lease.worker
|
||
if proxy:
|
||
return None, self._workers.find(proxy)
|
||
return None, self._workers.find(self.current_proxy)
|
||
|
||
async def handle_proxy_failure(self, proxy=None, reason=''):
|
||
try:
|
||
task = asyncio.current_task()
|
||
lease, worker = self._worker_for_failure(task, proxy)
|
||
if worker is None:
|
||
return
|
||
|
||
if lease is not None and lease.worker is worker:
|
||
outcome = self._workers.release(task, blame=True, reason=reason)
|
||
else:
|
||
outcome = self._workers.report_failure(worker, reason=reason)
|
||
|
||
if outcome is None or outcome == BLAME_IGNORED:
|
||
return
|
||
|
||
if reason == _REASON_SILENT_TUNNEL:
|
||
self._silent_log.warning(
|
||
('silent', worker.url),
|
||
get_message(
|
||
'proxy_silent_upstream', self.language,
|
||
worker.masked, int(self.tunnel_idle_timeout),
|
||
),
|
||
)
|
||
|
||
if outcome != BLAME_RETIRED and worker.suspect:
|
||
self._schedule_async_task(self._probe_suspect_worker(worker.url))
|
||
|
||
if outcome != BLAME_RETIRED:
|
||
return
|
||
|
||
logger.warning(get_message('proxy_worker_retired', self.language, worker.masked))
|
||
self._sync_current_proxy()
|
||
await self._close_client_for(worker.url)
|
||
self._schedule_async_task(self._probe_retired_worker(worker.url))
|
||
except Exception as e:
|
||
logger.error(f"代理失败处理出错: {str(e)}")
|
||
|
||
async def _probe_suspect_worker(self, proxy):
|
||
try:
|
||
verdict = await self._check_worker(proxy)
|
||
except Exception as e:
|
||
logger.error(get_message('proxy_check_error', self.language, str(e)))
|
||
return
|
||
|
||
if verdict is not False:
|
||
return
|
||
|
||
worker = self._workers.find(proxy)
|
||
if worker is None:
|
||
return
|
||
|
||
if len(self._workers.roster) <= 1:
|
||
async with self._refill_lock:
|
||
if not len(self._standby) and self._fetch_allowed(time.time()):
|
||
self.last_switch_attempt = time.time()
|
||
await self._fetch_into_standby()
|
||
replaced = await self._replace_exit(worker)
|
||
if replaced:
|
||
await self._close_client_for(worker.url)
|
||
return
|
||
|
||
if not self._workers.drop_worker(proxy, remember=True):
|
||
return
|
||
logger.warning(get_message('proxy_worker_retired', self.language, worker.masked))
|
||
self._sync_current_proxy()
|
||
await self._close_client_for(worker.url)
|
||
|
||
async def _probe_retired_worker(self, proxy):
|
||
try:
|
||
await self._check_worker(proxy)
|
||
except Exception as e:
|
||
logger.error(get_message('proxy_check_error', self.language, str(e)))
|
||
|
||
async def switch_proxy(self):
|
||
try:
|
||
current_time = time.time()
|
||
if self.switching_proxy:
|
||
return {'switching': True}
|
||
|
||
elapsed = current_time - self.last_switch_attempt
|
||
if elapsed < self.switch_cooldown:
|
||
remaining = round(self.switch_cooldown - elapsed, 1)
|
||
logger.info(f"刷新冷却中,剩余 {remaining:.1f} 秒")
|
||
return {'cooldown': remaining}
|
||
|
||
self._standby.clear()
|
||
await self._maintain_exit_pool(force_fetch=True)
|
||
async with self._refill_lock:
|
||
await self._replace_soonest()
|
||
return bool(self.proxies)
|
||
|
||
except Exception as e:
|
||
logger.error(get_message('proxy_switch_error', self.language, str(e)))
|
||
return False
|
||
|
||
async def _check_worker(self, proxy):
|
||
if not proxy:
|
||
return None
|
||
|
||
try:
|
||
test_url = self.test_url
|
||
cache_key = (proxy, test_url)
|
||
current_time = time.time()
|
||
|
||
cached = self.proxy_check_cache.get(cache_key)
|
||
if cached is not None and current_time - cached[0] < self.proxy_check_ttl:
|
||
return cached[1]
|
||
|
||
last_check = self.last_check_time.get(proxy)
|
||
if last_check is not None and current_time - last_check < self.check_cooldown:
|
||
return None
|
||
self.last_check_time[proxy] = current_time
|
||
|
||
try:
|
||
from modules.modules import check_proxy
|
||
|
||
is_valid = await check_proxy(proxy, test_url)
|
||
except Exception as e:
|
||
logger.error(f"出口检测错误: {sanitize_proxy(proxy)} - {str(e)}")
|
||
return None
|
||
|
||
logger.info(
|
||
f"出口检查结果: {sanitize_proxy(proxy)} - {'有效' if is_valid else '无效'}"
|
||
)
|
||
self.proxy_check_cache[cache_key] = (time.time(), is_valid)
|
||
self._report_proxy_use_to_pool(proxy, is_valid, test_url)
|
||
self._write_back_verdict(proxy, is_valid)
|
||
return is_valid
|
||
|
||
except Exception as e:
|
||
logger.error(f"出口检测异常: {str(e)}")
|
||
return None
|
||
|
||
def _note_exit_success(self, proxy):
|
||
if not proxy:
|
||
return
|
||
try:
|
||
worker = self._workers.find(proxy)
|
||
if worker is None or (worker.failures == 0 and not worker.suspect):
|
||
return
|
||
self._workers.note_success(worker)
|
||
except Exception as e:
|
||
logger.debug(f"记录出口成功时出错(已忽略): {e}")
|
||
|
||
def _write_back_verdict(self, proxy, is_valid):
|
||
worker = self._workers.find(proxy)
|
||
if worker is None:
|
||
return
|
||
if is_valid:
|
||
self._workers.note_success(worker)
|
||
else:
|
||
self._workers.report_failure(worker, reason='probe')
|
||
|
||
def _split_proxy_address(self, proxy_url):
|
||
try:
|
||
address = str(proxy_url).split('://', 1)[-1]
|
||
_auth, host_port = self._split_proxy_auth(address)
|
||
host, port = split_host_port(host_port)
|
||
except (ValueError, AttributeError, TypeError):
|
||
return None
|
||
return (host, int(port)) if host and port else None
|
||
|
||
def _report_proxy_use_to_pool(self, proxy, is_valid, test_url):
|
||
if self.proxy_source_mode != 'pool' or self.pool_remote_url or self._pool_service is None:
|
||
return
|
||
|
||
parsed = self._split_proxy_address(proxy)
|
||
if parsed is None:
|
||
return
|
||
|
||
ip, port = parsed
|
||
try:
|
||
self._schedule_async_task(
|
||
self._pool_service.report_proxy_use(ip, port, is_valid, test_url)
|
||
)
|
||
except Exception as e:
|
||
logger.debug(f"使用期反馈调度失败(已忽略): {e}")
|
||
|
||
def _log_roster_reload(self, previous_urls):
|
||
previous = len(previous_urls)
|
||
current = self.proxies
|
||
if previous == len(current) and set(previous_urls) == set(current):
|
||
return
|
||
|
||
samples = ', '.join(sanitize_proxy(url) for url in current[:3])
|
||
if len(current) > 3:
|
||
samples += f" ... (+{len(current) - 3})"
|
||
if not samples:
|
||
samples = get_message('no_proxy', self.language)
|
||
logger.info(get_message('proxy_switch', self.language, len(current), previous))
|
||
logger.info(f"当前出口: {samples}")
|
||
|
||
async def cleanup_clients(self):
|
||
while True:
|
||
try:
|
||
with self.client_pool_lock:
|
||
current_time = time.time()
|
||
expired_clients = []
|
||
for proxy, (client, last_used) in list(self.client_pool.items()):
|
||
if self._client_inflight.get(proxy, 0) > 0:
|
||
continue
|
||
if current_time - last_used > self.client_idle_timeout:
|
||
expired_clients.append(client)
|
||
del self.client_pool[proxy]
|
||
for client in expired_clients:
|
||
await client.aclose()
|
||
for cache_key in list(self.proxy_check_cache.keys()):
|
||
cached_at = self.proxy_check_cache[cache_key][0]
|
||
if current_time - cached_at > self.proxy_check_ttl:
|
||
self.proxy_check_cache.pop(cache_key, None)
|
||
live = {proxy for proxy, _test_url in self.proxy_check_cache}
|
||
for proxy in list(self.last_check_time.keys()):
|
||
if proxy not in live:
|
||
self.last_check_time.pop(proxy, None)
|
||
except Exception as e:
|
||
logger.error(f"清理客户端池错误: {str(e)}")
|
||
await asyncio.sleep(30)
|
||
|