Spaces:
Paused
Paused
| """Persistent per-account IPv6 SOCKS5 proxy pool.""" | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import logging | |
| import shutil | |
| import socket | |
| import threading | |
| import time | |
| from pathlib import Path | |
| from autoteam import config as _config | |
| from autoteam.ipv6_proxy import _add_ipv6, _del_ipv6, _handle_socks5, ipv6_proxy_enabled, random_ipv6 | |
| logger = logging.getLogger(__name__) | |
| def _normalized_email(email: str | None) -> str: | |
| return (email or "").strip().lower() | |
| def _format_proxy_host(host: str) -> str: | |
| host = (host or "").strip() | |
| if ":" in host and not host.startswith("["): | |
| return f"[{host}]" | |
| return host | |
| def _pool_file() -> Path: | |
| raw = getattr(_config, "IPV6_PROXY_POOL_FILE", "") or "" | |
| return Path(raw) if raw else Path(_config.PROJECT_ROOT) / "ipv6_pool.json" | |
| def _listen_host() -> str: | |
| return getattr(_config, "IPV6_PROXY_LISTEN_HOST", "0.0.0.0") or "0.0.0.0" | |
| def _local_host() -> str: | |
| return getattr(_config, "IPV6_PROXY_LOCAL_HOST", "127.0.0.1") or "127.0.0.1" | |
| def _public_host() -> str: | |
| return ( | |
| getattr(_config, "IPV6_PROXY_PUBLIC_HOST", "") | |
| or getattr(_config, "PUBLIC_IPV4", "") | |
| or _local_host() | |
| ) | |
| def _allowed_ips() -> set[str]: | |
| raw = getattr(_config, "IPV6_PROXY_ALLOWED_IPS", "") or "" | |
| values = {item.strip() for item in raw.split(",") if item.strip()} | |
| values.add("127.0.0.1") | |
| return values | |
| def _max_ttl_seconds() -> int: | |
| return max(0, int(getattr(_config, "IPV6_PROXY_TTL_SECONDS", 2 * 24 * 3600) or 0)) | |
| class _ProxyEntry: | |
| def __init__(self, email: str, ipv6_addr: str, port: int, created_at: float | None = None): | |
| self.email = _normalized_email(email) | |
| self.ipv6_addr = ipv6_addr | |
| self.port = int(port) | |
| self.created_at = float(created_at or time.time()) | |
| self._server: asyncio.base_events.Server | None = None | |
| self._loop: asyncio.AbstractEventLoop | None = None | |
| self._thread: threading.Thread | None = None | |
| self._main_task: asyncio.Task | None = None | |
| self._handler_tasks: set[asyncio.Task] = set() | |
| self._ready = threading.Event() | |
| self._error: BaseException | None = None | |
| def proxy_url(self) -> str: | |
| return f"socks5://{_format_proxy_host(_public_host())}:{self.port}" | |
| def local_proxy_url(self) -> str: | |
| return f"socks5://{_format_proxy_host(_local_host())}:{self.port}" | |
| def to_dict(self) -> dict: | |
| return { | |
| "email": self.email, | |
| "ipv6_addr": self.ipv6_addr, | |
| "port": self.port, | |
| "created_at": self.created_at, | |
| } | |
| def is_healthy(self) -> bool: | |
| if not self._server or not self._loop or not self._thread: | |
| return False | |
| if not self._loop.is_running() or not self._thread.is_alive(): | |
| return False | |
| try: | |
| with socket.create_connection(("127.0.0.1", self.port), timeout=1): | |
| return True | |
| except OSError: | |
| return False | |
| def start(self) -> None: | |
| if self.is_healthy(): | |
| return | |
| if self._server or self._loop or self._thread: | |
| self.stop() | |
| self._ready.clear() | |
| self._error = None | |
| _add_ipv6(self.ipv6_addr) | |
| self._loop = asyncio.new_event_loop() | |
| source_addr = self.ipv6_addr | |
| listen_port = self.port | |
| ready = self._ready | |
| async def _checked_handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: | |
| task = asyncio.current_task() | |
| if task is not None: | |
| self._handler_tasks.add(task) | |
| try: | |
| peer = writer.get_extra_info("peername") | |
| allowed = _allowed_ips() | |
| if peer and "*" not in allowed and peer[0] not in allowed: | |
| logger.warning("[IPv6Pool] rejected client %s for %s:%d", peer[0], self.email, listen_port) | |
| writer.close() | |
| try: | |
| await writer.wait_closed() | |
| except Exception: | |
| pass | |
| return | |
| await _handle_socks5(reader, writer, source_addr) | |
| finally: | |
| if task is not None: | |
| self._handler_tasks.discard(task) | |
| async def _run() -> None: | |
| server = await asyncio.start_server(_checked_handler, _listen_host(), listen_port) | |
| self._server = server | |
| ready.set() | |
| try: | |
| async with server: | |
| await server.serve_forever() | |
| except asyncio.CancelledError: | |
| pass | |
| def _thread_main() -> None: | |
| assert self._loop is not None | |
| asyncio.set_event_loop(self._loop) | |
| try: | |
| self._main_task = self._loop.create_task(_run()) | |
| self._loop.run_until_complete(self._main_task) | |
| except BaseException as exc: | |
| self._error = exc | |
| ready.set() | |
| finally: | |
| try: | |
| pending = [task for task in asyncio.all_tasks(self._loop) if not task.done()] | |
| for task in pending: | |
| task.cancel() | |
| if pending: | |
| self._loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) | |
| self._loop.run_until_complete(self._loop.shutdown_asyncgens()) | |
| except Exception: | |
| pass | |
| finally: | |
| self._loop.close() | |
| self._thread = threading.Thread(target=_thread_main, daemon=True, name=f"ipv6pool-{listen_port}") | |
| self._thread.start() | |
| self._ready.wait(timeout=10) | |
| if self._error: | |
| self._clear_runtime_refs() | |
| raise self._error | |
| if not self._server: | |
| self._clear_runtime_refs() | |
| raise RuntimeError(f"IPv6Pool proxy port {self.port} startup timed out") | |
| logger.info("[IPv6Pool] started %s -> %s (exit %s)", self.email, self.proxy_url, self.ipv6_addr) | |
| def ensure_started(self) -> bool: | |
| if self.is_healthy(): | |
| return False | |
| logger.warning("[IPv6Pool] proxy unhealthy, restarting: %s:%d", self.email, self.port) | |
| self.start() | |
| return True | |
| def _clear_runtime_refs(self) -> None: | |
| self._server = None | |
| self._loop = None | |
| self._thread = None | |
| self._main_task = None | |
| self._handler_tasks.clear() | |
| def stop(self) -> None: | |
| loop = self._loop | |
| server = self._server | |
| thread = self._thread | |
| if server and loop and loop.is_running(): | |
| async def _shutdown() -> None: | |
| server.close() | |
| await server.wait_closed() | |
| for task in list(self._handler_tasks): | |
| if not task.done(): | |
| task.cancel() | |
| if self._handler_tasks: | |
| await asyncio.gather(*self._handler_tasks, return_exceptions=True) | |
| self._handler_tasks.clear() | |
| if self._main_task and not self._main_task.done(): | |
| self._main_task.cancel() | |
| future = asyncio.run_coroutine_threadsafe(_shutdown(), loop) | |
| try: | |
| future.result(timeout=5) | |
| except Exception: | |
| pass | |
| if thread: | |
| thread.join(timeout=5) | |
| if thread.is_alive() and loop and loop.is_running(): | |
| try: | |
| loop.call_soon_threadsafe(loop.stop) | |
| except Exception: | |
| pass | |
| thread.join(timeout=2) | |
| _del_ipv6(self.ipv6_addr) | |
| self._clear_runtime_refs() | |
| self._ready.clear() | |
| logger.info("[IPv6Pool] stopped %s:%d (%s)", self.email, self.port, self.ipv6_addr) | |
| class IPv6Pool: | |
| """Persistent manager mapping one email to one IPv6 proxy.""" | |
| def __init__(self): | |
| self._entries: dict[str, _ProxyEntry] = {} | |
| self._used_ports: set[int] = set() | |
| self._loaded = False | |
| self._lock = threading.RLock() | |
| self._last_error = "" | |
| def is_enabled(self) -> bool: | |
| return ipv6_proxy_enabled() | |
| def _load_locked(self) -> None: | |
| if self._loaded: | |
| return | |
| self._loaded = True | |
| path = _pool_file() | |
| if not path.exists(): | |
| return | |
| try: | |
| data = json.loads(path.read_text(encoding="utf-8")) | |
| except Exception as exc: | |
| logger.warning("[IPv6Pool] load failed: %s", exc) | |
| return | |
| if not isinstance(data, list): | |
| return | |
| for item in data: | |
| if not isinstance(item, dict): | |
| continue | |
| email = _normalized_email(item.get("email")) | |
| ipv6_addr = str(item.get("ipv6_addr") or "").strip() | |
| port = int(item.get("port") or 0) | |
| if not email or not ipv6_addr or port <= 0: | |
| continue | |
| self._entries[email] = _ProxyEntry(email, ipv6_addr, port, item.get("created_at")) | |
| self._used_ports.add(port) | |
| def _save_locked(self) -> None: | |
| path = _pool_file() | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| data = [entry.to_dict() for entry in self._entries.values()] | |
| path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") | |
| def _find_free_port_locked(self) -> int: | |
| start = int(getattr(_config, "IPV6_PROXY_PORT_START", 30000) or 30000) | |
| end = int(getattr(_config, "IPV6_PROXY_PORT_END", 39999) or 39999) | |
| if end <= start: | |
| end = start + 1 | |
| for port in range(start, end + 1): | |
| if port in self._used_ports: | |
| continue | |
| try: | |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: | |
| sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | |
| sock.bind((_listen_host(), port)) | |
| return port | |
| except OSError: | |
| continue | |
| raise RuntimeError("IPv6Pool has no free proxy port") | |
| def start(self, active_emails: set[str] | list[str] | None = None) -> None: | |
| if not self.is_enabled(): | |
| logger.info("[IPv6Pool] disabled") | |
| return | |
| with self._lock: | |
| self._load_locked() | |
| if active_emails is not None: | |
| wanted = {_normalized_email(email) for email in active_emails if _normalized_email(email)} | |
| stale = [email for email in self._entries if email not in wanted] | |
| for email in stale: | |
| entry = self._entries.pop(email) | |
| self._used_ports.discard(entry.port) | |
| entry.stop() | |
| if stale: | |
| self._save_locked() | |
| logger.info("[IPv6Pool] removed %d stale entries before startup", len(stale)) | |
| for entry in list(self._entries.values()): | |
| try: | |
| entry.start() | |
| except Exception as exc: | |
| self._last_error = str(exc) | |
| logger.warning("[IPv6Pool] restore failed for %s: %s", entry.email, exc) | |
| logger.info("[IPv6Pool] restored %d entries", len(self._entries)) | |
| def assign(self, email: str) -> str | None: | |
| email_l = _normalized_email(email) | |
| if not email_l or not self.is_enabled(): | |
| return None | |
| with self._lock: | |
| self._load_locked() | |
| entry = self._entries.get(email_l) | |
| if entry is not None: | |
| entry.ensure_started() | |
| return entry.proxy_url | |
| ipv6_addr = random_ipv6() | |
| port = self._find_free_port_locked() | |
| entry = _ProxyEntry(email_l, ipv6_addr, port) | |
| try: | |
| entry.start() | |
| except Exception as exc: | |
| self._last_error = str(exc) | |
| raise | |
| self._entries[email_l] = entry | |
| self._used_ports.add(port) | |
| self._save_locked() | |
| return entry.proxy_url | |
| def ensure(self, email: str) -> str | None: | |
| return self.assign(email) | |
| def get_proxy_url(self, email: str) -> str | None: | |
| email_l = _normalized_email(email) | |
| with self._lock: | |
| self._load_locked() | |
| entry = self._entries.get(email_l) | |
| return entry.proxy_url if entry else None | |
| def get_local_proxy_url(self, email: str) -> str | None: | |
| email_l = _normalized_email(email) | |
| with self._lock: | |
| self._load_locked() | |
| entry = self._entries.get(email_l) | |
| return entry.local_proxy_url if entry else None | |
| def get_playwright_proxy(self, email: str) -> dict[str, str] | None: | |
| local_url = self.get_local_proxy_url(email) | |
| return {"server": local_url} if local_url else None | |
| def release(self, email: str) -> bool: | |
| email_l = _normalized_email(email) | |
| if not email_l: | |
| return False | |
| with self._lock: | |
| self._load_locked() | |
| entry = self._entries.pop(email_l, None) | |
| if not entry: | |
| return False | |
| self._used_ports.discard(entry.port) | |
| entry.stop() | |
| self._save_locked() | |
| return True | |
| def ensure_active(self, active_emails: set[str] | list[str], *, create_missing: bool = False) -> int: | |
| if not self.is_enabled(): | |
| return 0 | |
| repaired = 0 | |
| active = {_normalized_email(email) for email in active_emails if _normalized_email(email)} | |
| with self._lock: | |
| self._load_locked() | |
| stale = [email for email in self._entries if email not in active] | |
| for email in stale: | |
| entry = self._entries.pop(email) | |
| self._used_ports.discard(entry.port) | |
| entry.stop() | |
| for email in active: | |
| entry = self._entries.get(email) | |
| if entry is None: | |
| if create_missing: | |
| self.assign(email) | |
| continue | |
| if entry.ensure_started(): | |
| repaired += 1 | |
| if stale: | |
| self._save_locked() | |
| logger.info("[IPv6Pool] removed %d inactive entries", len(stale)) | |
| return repaired | |
| def cleanup_expired(self) -> int: | |
| ttl = _max_ttl_seconds() | |
| if ttl <= 0: | |
| return 0 | |
| now = time.time() | |
| removed = 0 | |
| with self._lock: | |
| self._load_locked() | |
| expired = [email for email, entry in self._entries.items() if now - entry.created_at > ttl] | |
| for email in expired: | |
| entry = self._entries.pop(email) | |
| self._used_ports.discard(entry.port) | |
| entry.stop() | |
| removed += 1 | |
| if removed: | |
| self._save_locked() | |
| return removed | |
| def cleanup_all(self, account_emails: set[str] | list[str]) -> int: | |
| active = {_normalized_email(email) for email in account_emails if _normalized_email(email)} | |
| removed = 0 | |
| with self._lock: | |
| self._load_locked() | |
| stale = [email for email in self._entries if email not in active] | |
| for email in stale: | |
| entry = self._entries.pop(email) | |
| self._used_ports.discard(entry.port) | |
| entry.stop() | |
| removed += 1 | |
| if removed: | |
| self._save_locked() | |
| removed += self.cleanup_expired() | |
| return removed | |
| def list_all(self) -> list[dict]: | |
| with self._lock: | |
| self._load_locked() | |
| return [ | |
| { | |
| **entry.to_dict(), | |
| "proxy_url": entry.proxy_url, | |
| "local_proxy_url": entry.local_proxy_url, | |
| "healthy": entry.is_healthy(), | |
| } | |
| for entry in self._entries.values() | |
| ] | |
| def preflight(self) -> dict: | |
| required = bool(getattr(_config, "AUTOTEAM_IPV6_POOL_REQUIRED", False)) | |
| enabled = self.is_enabled() | |
| prefix = str(getattr(_config, "IPV6_PREFIX", "") or "").strip() | |
| iface = str(getattr(_config, "IPV6_IFACE", "") or "").strip() | |
| start = int(getattr(_config, "IPV6_PROXY_PORT_START", 30000) or 30000) | |
| end = int(getattr(_config, "IPV6_PROXY_PORT_END", 39999) or 39999) | |
| ip_command_found = shutil.which("ip") is not None | |
| errors: list[str] = [] | |
| warnings: list[str] = [] | |
| if required and not enabled: | |
| errors.append("ipv6_required_but_disabled") | |
| if enabled and not prefix: | |
| errors.append("missing_ipv6_prefix") | |
| if enabled and not iface: | |
| errors.append("missing_ipv6_iface") | |
| if enabled and not ip_command_found: | |
| errors.append("missing_ip_command") | |
| if start <= 0 or end <= 0 or end < start: | |
| errors.append("invalid_port_range") | |
| if enabled and getattr(_config, "IPV6_PROXY_USE_SUDO", False) and shutil.which("sudo") is None: | |
| warnings.append("missing_sudo_command") | |
| return { | |
| "ok": not errors, | |
| "enabled": enabled, | |
| "required": required, | |
| "prefix_configured": bool(prefix), | |
| "iface_configured": bool(iface), | |
| "iface": iface, | |
| "ip_command_found": ip_command_found, | |
| "port_range": {"start": start, "end": end, "valid": start > 0 and end >= start}, | |
| "pool_file": str(_pool_file()), | |
| "errors": errors, | |
| "warnings": warnings, | |
| } | |
| def status(self) -> dict: | |
| with self._lock: | |
| self._load_locked() | |
| entries = [ | |
| { | |
| **entry.to_dict(), | |
| "proxy_url": entry.proxy_url, | |
| "local_proxy_url": entry.local_proxy_url, | |
| "healthy": entry.is_healthy(), | |
| } | |
| for entry in self._entries.values() | |
| ] | |
| used_ports = len(self._used_ports) | |
| preflight = self.preflight() | |
| start = preflight["port_range"]["start"] | |
| end = preflight["port_range"]["end"] | |
| port_capacity = max(0, end - start + 1) if preflight["port_range"]["valid"] else 0 | |
| unhealthy_count = sum(1 for entry in entries if not entry.get("healthy")) | |
| ttl = _max_ttl_seconds() | |
| now = time.time() | |
| expired_count = ( | |
| sum(1 for entry in entries if now - float(entry.get("created_at") or now) > ttl) | |
| if ttl > 0 | |
| else 0 | |
| ) | |
| return { | |
| "enabled": preflight["enabled"], | |
| "required": preflight["required"], | |
| "ok": preflight["ok"] and unhealthy_count == 0, | |
| "count": len(entries), | |
| "unhealthy_count": unhealthy_count, | |
| "expired_count": expired_count, | |
| "used_ports": used_ports, | |
| "port_capacity": port_capacity, | |
| "port_usage_ratio": (used_ports / port_capacity) if port_capacity else 0.0, | |
| "last_error": self._last_error, | |
| "preflight": preflight, | |
| "entries": entries, | |
| } | |
| def stop_all(self) -> None: | |
| with self._lock: | |
| self._load_locked() | |
| for entry in list(self._entries.values()): | |
| entry.stop() | |
| ipv6_pool = IPv6Pool() | |