新增设备身份管理、认证限流、并发通道、Webhook路由、RBAC权限控制、SSE/轮询降级等全套网关通道功能,包含: 1. 设备身份生成与签名验证 2. 设备令牌认证与速率限制 3. 内存+数据库双重设备注册表 4. 并发通道限流管理 5. Webhook安全处理与路由 6. RBAC权限校验系统 7. OpenAI API兼容适配层 8. Tailscale认证支持 9. HTTP轮询降级机制
174 lines
5.3 KiB
Python
174 lines
5.3 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
WHOIS_CACHE_TTL_MS = 60_000
|
|
WHOIS_ERROR_TTL_MS = 5_000
|
|
WHOIS_TIMEOUT_S = 5.0
|
|
|
|
|
|
@dataclass
|
|
class TailscaleWhoisIdentity:
|
|
login: str
|
|
name: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class _CacheEntry:
|
|
value: TailscaleWhoisIdentity | None
|
|
expires_at: float
|
|
|
|
|
|
_whois_cache: dict[str, _CacheEntry] = {}
|
|
|
|
|
|
def _read_cached_whois(ip: str, now_ms: float) -> TailscaleWhoisIdentity | None | bool:
|
|
"""Returns None=cache miss, False=cached null, or the identity."""
|
|
entry = _whois_cache.get(ip)
|
|
if entry is None:
|
|
return None
|
|
if entry.expires_at <= now_ms:
|
|
del _whois_cache[ip]
|
|
return None
|
|
return entry.value if entry.value is not None else False # type: ignore[return-value]
|
|
|
|
|
|
def _write_cached_whois(ip: str, value: TailscaleWhoisIdentity | None, ttl_ms: float) -> None:
|
|
_whois_cache[ip] = _CacheEntry(value=value, expires_at=time.time() * 1000 + ttl_ms)
|
|
|
|
|
|
def _parse_whois_identity(payload: dict) -> TailscaleWhoisIdentity | None:
|
|
user_profile = payload.get("UserProfile") or payload.get("userProfile") or payload.get("User")
|
|
if not isinstance(user_profile, dict):
|
|
return None
|
|
login = (
|
|
user_profile.get("LoginName")
|
|
or user_profile.get("Login")
|
|
or user_profile.get("loginName")
|
|
or user_profile.get("login")
|
|
)
|
|
if not login or not isinstance(login, str):
|
|
return None
|
|
name = (
|
|
user_profile.get("DisplayName")
|
|
or user_profile.get("displayName")
|
|
or user_profile.get("Name")
|
|
or user_profile.get("name")
|
|
)
|
|
return TailscaleWhoisIdentity(
|
|
login=login.lower().strip(),
|
|
name=name.strip() if isinstance(name, str) and name.strip() else None,
|
|
)
|
|
|
|
|
|
def _parse_possibly_noisy_json(stdout: str) -> dict:
|
|
trimmed = stdout.strip()
|
|
start = trimmed.find("{")
|
|
end = trimmed.rfind("}")
|
|
if start >= 0 and end > start:
|
|
return json.loads(trimmed[start : end + 1])
|
|
return json.loads(trimmed)
|
|
|
|
|
|
async def _get_tailscale_binary() -> str | None:
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"which",
|
|
"tailscale",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
stdout, _ = await proc.communicate()
|
|
if proc.returncode == 0 and stdout:
|
|
return stdout.decode().strip()
|
|
except FileNotFoundError:
|
|
logger.debug("tailscale binary not found via 'which'")
|
|
except Exception:
|
|
logger.debug("tailscale binary lookup failed via 'which'", exc_info=True)
|
|
|
|
import os
|
|
|
|
mac_path = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
|
|
if os.path.exists(mac_path) and os.access(mac_path, os.X_OK):
|
|
return mac_path
|
|
return None
|
|
|
|
|
|
async def read_tailscale_whois_identity(
|
|
ip: str,
|
|
timeout_s: float = WHOIS_TIMEOUT_S,
|
|
cache_ttl_ms: float = WHOIS_CACHE_TTL_MS,
|
|
error_ttl_ms: float = WHOIS_ERROR_TTL_MS,
|
|
) -> TailscaleWhoisIdentity | None:
|
|
normalized = ip.strip()
|
|
if not normalized:
|
|
return None
|
|
|
|
now_ms = time.time() * 1000
|
|
cached = _read_cached_whois(normalized, now_ms)
|
|
if cached is not None:
|
|
return cached if cached is not False else None
|
|
|
|
tailscale_bin = await _get_tailscale_binary()
|
|
if not tailscale_bin:
|
|
logger.debug("tailscale binary not found, whois unavailable")
|
|
_write_cached_whois(normalized, None, error_ttl_ms)
|
|
return None
|
|
|
|
proc: asyncio.subprocess.Process | None = None
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
tailscale_bin,
|
|
"whois",
|
|
"--json",
|
|
normalized,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
stdout, stderr = await asyncio.wait_for(
|
|
proc.communicate(),
|
|
timeout=timeout_s,
|
|
)
|
|
if proc.returncode != 0:
|
|
logger.debug("tailscale whois failed for %s: %s", normalized, stderr.decode().strip())
|
|
_write_cached_whois(normalized, None, error_ttl_ms)
|
|
return None
|
|
payload = _parse_possibly_noisy_json(stdout.decode())
|
|
identity = _parse_whois_identity(payload)
|
|
_write_cached_whois(normalized, identity, cache_ttl_ms)
|
|
return identity
|
|
except TimeoutError:
|
|
logger.debug("tailscale whois timeout for %s", normalized)
|
|
if proc is not None:
|
|
try:
|
|
proc.kill()
|
|
await proc.wait()
|
|
except Exception:
|
|
pass
|
|
_write_cached_whois(normalized, None, error_ttl_ms)
|
|
return None
|
|
except Exception:
|
|
logger.debug("tailscale whois error for %s", normalized, exc_info=True)
|
|
if proc is not None:
|
|
try:
|
|
proc.kill()
|
|
await proc.wait()
|
|
except Exception:
|
|
pass
|
|
_write_cached_whois(normalized, None, error_ttl_ms)
|
|
return None
|
|
|
|
|
|
def get_tailscale_user_from_headers(headers: dict | None) -> tuple[str, str] | None:
|
|
if not headers:
|
|
return None
|
|
login = headers.get("tailscale-user-login")
|
|
if not login:
|
|
return None
|
|
name = headers.get("tailscale-user-name") or login
|
|
return (login.lower().strip(), name.strip() if isinstance(name, str) else login)
|