Files
dy/backend/rpa_engine/egress_channels.py
T
2026-08-26 17:18:09 +08:00

340 lines
11 KiB
Python

"""Discover and select server egress channels for account-bound IM traffic.
One public address may be reached through a private address on the host (for
example, an ECS secondary private IP mapped to an EIP). A channel therefore
keeps both values: ``source_ip`` is bound on the socket and ``public_ip`` is
what the remote service observes.
"""
from __future__ import annotations
import asyncio
import ipaddress
import json
import logging
import os
import socket
import subprocess
import threading
import time
from dataclasses import dataclass
from typing import Iterable
import httpx
import requests
from requests.adapters import HTTPAdapter
logger = logging.getLogger("rpa_engine.egress")
_DISCOVERY_TTL_SECONDS = 300.0
_PROBE_TIMEOUT_SECONDS = 6.0
_MAX_CHANNEL_ATTEMPTS = 8
_PROBE_URLS = (
"https://www.cloudflare.com/cdn-cgi/trace",
"https://api64.ipify.org?format=json",
)
@dataclass(frozen=True)
class LocalAddress:
source_ip: str | None
interface: str
is_default: bool = False
@dataclass(frozen=True)
class EgressChannel:
public_ip: str
source_ip: str | None
interface: str = ""
is_default: bool = False
@property
def id(self) -> str:
return self.public_ip
@dataclass(frozen=True)
class EgressSnapshot:
channels: tuple[EgressChannel, ...]
errors: tuple[str, ...]
detected_at: float
class EgressChannelUnavailable(RuntimeError):
pass
_cache_lock = threading.Lock()
_cached_snapshot: EgressSnapshot | None = None
def clamp_attempts(value: int | None) -> int:
try:
parsed = int(value or 1)
except (TypeError, ValueError):
parsed = 1
return max(1, min(_MAX_CHANNEL_ATTEMPTS, parsed))
def _usable_source_ip(value: str) -> bool:
try:
addr = ipaddress.ip_address(str(value or "").strip())
except ValueError:
return False
return bool(
addr.version == 4
and not addr.is_loopback
and not addr.is_link_local
and not addr.is_multicast
and not addr.is_unspecified
)
def _linux_local_addresses() -> list[LocalAddress]:
if os.name != "posix":
return []
try:
proc = subprocess.run(
["ip", "-j", "-4", "addr", "show", "scope", "global"],
capture_output=True,
text=True,
timeout=3,
check=False,
)
payload = json.loads(proc.stdout or "[]") if proc.returncode == 0 else []
except (OSError, subprocess.SubprocessError, json.JSONDecodeError):
return []
found: list[LocalAddress] = []
for item in payload if isinstance(payload, list) else []:
interface = str(item.get("ifname") or "")
for info in item.get("addr_info") or []:
source_ip = str(info.get("local") or "").strip()
if _usable_source_ip(source_ip):
found.append(LocalAddress(source_ip, interface))
return found
def _socket_local_addresses() -> list[LocalAddress]:
found: list[LocalAddress] = []
names = {socket.gethostname(), socket.getfqdn()}
for name in names:
try:
records = socket.getaddrinfo(name, None, socket.AF_INET, socket.SOCK_STREAM)
except OSError:
continue
for record in records:
source_ip = str(record[4][0] or "").strip()
if _usable_source_ip(source_ip):
found.append(LocalAddress(source_ip, name))
return found
def local_address_candidates() -> list[LocalAddress]:
"""Return the default route plus each bindable global/private IPv4."""
candidates = [LocalAddress(None, "default", True)]
seen: set[str] = set()
for item in [*_linux_local_addresses(), *_socket_local_addresses()]:
source_ip = str(item.source_ip or "")
if not source_ip or source_ip in seen:
continue
seen.add(source_ip)
candidates.append(item)
return candidates
def _extract_public_ip(response: httpx.Response) -> str:
text = response.text.strip()
content_type = response.headers.get("content-type", "").lower()
candidate = ""
if "json" in content_type or text.startswith("{"):
try:
candidate = str(response.json().get("ip") or "").strip()
except (ValueError, AttributeError):
candidate = ""
if not candidate:
for line in text.splitlines():
if line.startswith("ip="):
candidate = line.partition("=")[2].strip()
break
if not candidate and "\n" not in text and len(text) <= 64:
candidate = text
try:
addr = ipaddress.ip_address(candidate)
except ValueError:
return ""
return str(addr) if addr.version == 4 else ""
async def _probe_local_address(candidate: LocalAddress) -> tuple[EgressChannel | None, str]:
transport = httpx.AsyncHTTPTransport(
local_address=candidate.source_ip,
retries=0,
)
last_error = ""
try:
async with httpx.AsyncClient(
transport=transport,
timeout=httpx.Timeout(_PROBE_TIMEOUT_SECONDS),
follow_redirects=True,
trust_env=False,
) as client:
for url in _PROBE_URLS:
try:
response = await client.get(url, headers={"Accept": "text/plain, application/json"})
response.raise_for_status()
public_ip = _extract_public_ip(response)
if public_ip:
return (
EgressChannel(
public_ip=public_ip,
source_ip=candidate.source_ip,
interface=candidate.interface,
is_default=candidate.is_default,
),
"",
)
last_error = "探测响应中没有 IPv4"
except Exception as exc: # one endpoint may be unavailable
last_error = str(exc) or type(exc).__name__
finally:
await transport.aclose()
label = candidate.source_ip or "默认路由"
return None, f"{label}: {last_error or '无法访问公网探测服务'}"
def _dedupe_channels(channels: Iterable[EgressChannel]) -> tuple[EgressChannel, ...]:
by_public_ip: dict[str, EgressChannel] = {}
order: list[str] = []
for channel in channels:
existing = by_public_ip.get(channel.public_ip)
if existing is None:
by_public_ip[channel.public_ip] = channel
order.append(channel.public_ip)
continue
# Keep an explicit bindable source when possible, while preserving the
# fact that this is also the host's default public route.
if existing.source_ip is None and channel.source_ip:
by_public_ip[channel.public_ip] = EgressChannel(
public_ip=channel.public_ip,
source_ip=channel.source_ip,
interface=channel.interface,
is_default=existing.is_default or channel.is_default,
)
elif channel.is_default and not existing.is_default:
by_public_ip[channel.public_ip] = EgressChannel(
public_ip=existing.public_ip,
source_ip=existing.source_ip,
interface=existing.interface,
is_default=True,
)
return tuple(by_public_ip[key] for key in order)
async def discover_egress_channels(*, force: bool = False) -> EgressSnapshot:
global _cached_snapshot
now = time.time()
with _cache_lock:
cached = _cached_snapshot
if not force and cached and now - cached.detected_at < _DISCOVERY_TTL_SECONDS:
return cached
candidates = await asyncio.to_thread(local_address_candidates)
results = await asyncio.gather(*(_probe_local_address(item) for item in candidates))
channels = _dedupe_channels(item[0] for item in results if item[0] is not None)
errors = tuple(item[1] for item in results if item[1])
snapshot = EgressSnapshot(channels=channels, errors=errors, detected_at=time.time())
with _cache_lock:
_cached_snapshot = snapshot
return snapshot
async def resolve_fixed_channel(public_ip: str) -> EgressChannel:
selected = str(public_ip or "").strip()
if not selected:
return EgressChannel(public_ip="", source_ip=None, interface="default", is_default=True)
snapshot = await discover_egress_channels()
for channel in snapshot.channels:
if channel.public_ip == selected:
return channel
raise EgressChannelUnavailable(
f"指定公网通道 {selected} 当前不可用;请在账号编辑中重新检测并选择可用通道"
)
async def resolve_send_channels(
preferred_public_ip: str = "",
max_attempts: int = 1,
) -> list[EgressChannel]:
"""Order channels for one serial send operation.
The ordinary one-channel automatic mode deliberately avoids discovery so
a temporary outage of the probe service never blocks existing sends.
"""
preferred = str(preferred_public_ip or "").strip()
attempts = clamp_attempts(max_attempts)
if not preferred and attempts == 1:
return [EgressChannel(public_ip="", source_ip=None, interface="default", is_default=True)]
snapshot = await discover_egress_channels()
channels = list(snapshot.channels)
if not channels:
if preferred:
raise EgressChannelUnavailable(
f"指定公网通道 {preferred} 无法探测;请检查服务器网卡、路由或公网访问"
)
return [EgressChannel(public_ip="", source_ip=None, interface="default", is_default=True)]
ordered: list[EgressChannel] = []
if preferred:
selected = next((item for item in channels if item.public_ip == preferred), None)
if selected is None:
raise EgressChannelUnavailable(
f"指定公网通道 {preferred} 当前不可用;请在账号编辑中重新检测"
)
ordered.append(selected)
else:
default = next((item for item in channels if item.is_default), None)
if default is not None:
ordered.append(default)
ordered.extend(item for item in channels if item not in ordered)
return ordered[:attempts]
class _SourceAddressAdapter(HTTPAdapter):
"""Requests adapter that binds outgoing sockets to one local IPv4."""
def __init__(self, source_ip: str, *args, **kwargs):
self._source_address = (source_ip, 0)
super().__init__(*args, **kwargs)
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
pool_kwargs["source_address"] = self._source_address
return super().init_poolmanager(connections, maxsize, block=block, **pool_kwargs)
def proxy_manager_for(self, proxy, **proxy_kwargs):
proxy_kwargs["source_address"] = self._source_address
return super().proxy_manager_for(proxy, **proxy_kwargs)
def source_bound_requests_session(source_ip: str | None = None) -> requests.Session:
client = requests.Session()
source = str(source_ip or "").strip()
if source:
client.trust_env = False
adapter = _SourceAddressAdapter(source)
client.mount("http://", adapter)
client.mount("https://", adapter)
return client
def reset_egress_cache_for_tests() -> None:
global _cached_snapshot
with _cache_lock:
_cached_snapshot = None