p2p: HubUnion — hubs in one UUID room form a union API (name@hub, /api/hubs, POST /api/sessions {"hub"}); legacy 1v1 only on lowest-named hub; merge phone p2p_ws_client fixes; update wechat group image

This commit is contained in:
Liang Jiaqing committed 2026-11-03 16:42:15 +08:00
1 parent 9f358ea7f4
commit 56547d36ac
3 files changed
+219 -71

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 184 KiB

+39 -47
View File
@@ -2,13 +2,12 @@
import asyncio import asyncio
import os import os
import socket import socket
import time
from pathlib import Path from pathlib import Path
from fastapi import Response from fastapi import Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from p2p_ws_client import ( from p2p_ws_client import (
Group, HTTPExporter, HTTPLocalProxy, connect_saved, create_code, load_room, Group, HTTPExporter, HubUnion, connect_saved, create_code, load_room,
retry_forever, save_room, retry_forever, save_room,
) )
@@ -17,10 +16,11 @@ def install(app, *, web_port, token, here):
signal = os.environ.get("GA_P2P_SIGNAL", "ws://47.101.182.29:49157/ws") signal = os.environ.get("GA_P2P_SIGNAL", "ws://47.101.182.29:49157/ws")
name = os.environ.get("GA_P2P_NAME", "ga-hub-phone") name = os.environ.get("GA_P2P_NAME", "ga-hub-phone")
rooms = Path(os.environ.get("GA_P2P_ROOMS", "~/.p2p_ws/rooms.json")).expanduser() rooms = Path(os.environ.get("GA_P2P_ROOMS", "~/.p2p_ws/rooms.json")).expanduser()
# 组内每个 hub 一个成员名;连接按需建立:主叫代理对方 API,被叫导出本机 API。 # 组内每个 hub 一个成员名(hub: 前缀供 customer 自动连接);被叫导出本机 API。
member = os.environ.get("GA_P2P_MEMBER", f"{socket.gethostname()}-{web_port}") member = "hub:" + os.environ.get("GA_P2P_MEMBER", f"{socket.gethostname()}-{web_port}")
state = {"task": None, "invite": None, "status": "idle", "error": None, state = {"task": None, "invite": None, "status": "idle", "error": None,
"started": False, "group": None, "proxies": {}} "started": False, "group": None, "legacy": None}
union = HubUnion() # 本机兼作 customer:/p2p/api/ 即组内其他 hub 的并集
app.state.p2p_pair_open = True app.state.p2p_pair_open = True
async def export(ws): async def export(ws):
@@ -58,31 +58,28 @@ def install(app, *, web_port, token, here):
finally: finally:
await exporter.close() await exporter.close()
async def use_hub(ws, peer): # ---- 旧 1 对 1 兼容(旧版手机直连 UUID 房):只由在线 hub 中名字最小者进房。手机全升级后删本段 ----
proxy = await HTTPLocalProxy(ws).start() async def legacy(group):
state["proxies"][peer] = proxy while True:
try: hubs = [m for m in group.members if m.startswith("hub:")]
await proxy.wait_closed() lead = group._ws is not None and member < min(hubs, default="hub:\U0010ffff")
finally: task, pairing = state["legacy"], state["task"] and not state["task"].done()
if state["proxies"].get(peer) is proxy: if lead and not pairing and (task is None or task.done()):
del state["proxies"][peer] state["legacy"] = asyncio.create_task(reconnect())
await proxy.close() elif not lead and group._ws is not None and task and not task.done():
task.cancel()
async def reap_idle(group): state["status"] = "standby"
while True: # 10 分钟没请求的代理断开,下次 /p2p/open 再按需重连 await asyncio.sleep(0.5)
await asyncio.sleep(60) # ---- 旧 1 对 1 兼容 end ----
for peer, proxy in list(state["proxies"].items()):
if time.monotonic() - proxy.last_used > 600:
group.close(peer)
def start_group(): def start_group():
if state["group"]: if state["group"]:
return return
room = load_room(name, path=rooms) room = load_room(name, path=rooms)
group = state["group"] = Group( group = state["group"] = Group(
room["signal_url"], room["room"], member, serve_customer, use_hub, room["signal_url"], room["room"], member, serve_customer, union.use,
) )
state["group_tasks"] = [asyncio.create_task(group.run()), asyncio.create_task(reap_idle(group))] state["group_tasks"] = [asyncio.create_task(group.run()), asyncio.create_task(legacy(group))]
async def new_pair(): async def new_pair():
try: try:
@@ -96,8 +93,7 @@ def install(app, *, web_port, token, here):
try: try:
await export(ws) await export(ws)
finally: finally:
await ws.close() await ws.close() # 之后由 legacy() 接管重连
await reconnect()
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except Exception as exc: except Exception as exc:
@@ -112,7 +108,7 @@ def install(app, *, web_port, token, here):
except KeyError: except KeyError:
state["task"] = asyncio.create_task(new_pair()) state["task"] = asyncio.create_task(new_pair())
else: else:
state["task"] = asyncio.create_task(reconnect()) start_group()
return Response(content='''<!doctype html><meta charset="utf-8"><title>PC pairing</title> return Response(content='''<!doctype html><meta charset="utf-8"><title>PC pairing</title>
<h2>Phone pairing</h2><p>Enter this code on the phone:</p><pre id="code">waiting...</pre> <h2>Phone pairing</h2><p>Enter this code on the phone:</p><pre id="code">waiting...</pre>
<p id="state">starting...</p><script> <p id="state">starting...</p><script>
@@ -122,26 +118,23 @@ document.querySelector('#state').textContent=s.status+(s.error?' : '+s.error:'')
if(s.status!=='connected')setTimeout(poll,1000)}catch(e){document.querySelector('#state').textContent=e}} if(s.status!=='connected')setTimeout(poll,1000)}catch(e){document.querySelector('#state').textContent=e}}
poll();</script>''', media_type="text/html") poll();</script>''', media_type="text/html")
@app.get("/p2p/hubs") @app.api_route("/p2p/api/{path:path}", methods=["GET", "POST"])
async def p2p_hubs(): async def p2p_api(request: Request, path: str):
"""同组在线 hub {成员名: 本地代理地址,未连接为 null}。""" """组内其他 hub 的并集 API(会话名 name@hub),用法同 /api/。首次调用才连接。"""
group, proxies = state["group"], state["proxies"]
members = sorted(group.members) if group else []
return JSONResponse({m: getattr(proxies.get(m), "url", None) for m in members})
@app.get("/p2p/open")
async def p2p_open(hub: str):
"""按需连接 hub,返回本地代理地址,之后按原 /api/ 访问。"""
group = state["group"] group = state["group"]
if not group or hub not in group.members: if not group:
return JSONResponse({"error": "hub offline"}, status_code=404) return JSONResponse({"error": "not paired"}, status_code=404)
group.open(hub) if not group.auto:
for _ in range(200): group.auto = ("hub:",)
if proxy := state["proxies"].get(hub): group._sync(group.members)
proxy.last_used = time.monotonic() for _ in range(100):
return JSONResponse({"url": proxy.url}) if union.hubs or not group.members:
await asyncio.sleep(0.1) break
return JSONResponse({"error": "connect timeout"}, status_code=504) await asyncio.sleep(0.1)
query = request.url.query
status, body = await union.request(
request.method, f"/api/{path}" + (f"?{query}" if query else ""), await request.body())
return Response(body, status_code=status, media_type="application/json")
@app.get("/pair/status") @app.get("/pair/status")
async def pair_status(): async def pair_status():
@@ -161,7 +154,6 @@ poll();</script>''', media_type="text/html")
load_room(name, path=rooms) load_room(name, path=rooms)
except KeyError: except KeyError:
return return
state["task"] = asyncio.create_task(reconnect())
start_group() start_group()
app.add_event_handler("startup", startup) app.add_event_handler("startup", startup)
+180 -24
View File
@@ -4,6 +4,7 @@ import asyncio
import base64 import base64
import hashlib import hashlib
import hmac import hmac
import inspect
import json import json
import logging import logging
import math import math
@@ -41,6 +42,25 @@ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
# 仅用于过滤不知道本项目的扫描和噪声,不是安全秘密。 # 仅用于过滤不知道本项目的扫描和噪声,不是安全秘密。
# 即使该值公开,握手访问安全仍由不可猜测、不得泄露的 UUID 保证。 # 即使该值公开,握手访问安全仍由不可猜测、不得泄露的 UUID 保证。
DEFAULT_ACCESS_KEY = "ljq-p2p-ws-default-access-key-v1-7f3c9a2e" DEFAULT_ACCESS_KEY = "ljq-p2p-ws-default-access-key-v1-7f3c9a2e"
DEFAULT_STUN_URL = "stun:47.101.182.29:49157"
STANDARD_SIGNAL_URL = "ws://47.101.182.29:49157/ws"
def secure_signal_url(url: str) -> str:
"""信令统一走 47 VPS 明文 ws(无 TLS)。
传输层自带 X25519+HKDF 端到端加密, 信令只转发密文, TLS 不增加机密性,
反而多一跳 jump1 反代(且不转 UDP STUN)。2.33.4 曾把旧配对改写成
wss://p2p.gaagent.ai, 这里把这类已保存配对原房间迁回 47, 房间号不变。
"""
parts = urlsplit(url)
if parts.hostname == "p2p.gaagent.ai":
std = urlsplit(STANDARD_SIGNAL_URL)
return urlunsplit((std.scheme, std.netloc, parts.path or std.path, parts.query, parts.fragment))
return url
def signed_url(url: str, action: str, target: str, key: str = DEFAULT_ACCESS_KEY) -> str: def signed_url(url: str, action: str, target: str, key: str = DEFAULT_ACCESS_KEY) -> str:
"""为一次请求添加短时有效、不可重放的 HMAC 查询参数。""" """为一次请求添加短时有效、不可重放的 HMAC 查询参数。"""
@@ -55,7 +75,7 @@ def signed_url(url: str, action: str, target: str, key: str = DEFAULT_ACCESS_KEY
def code_url(signal_url: str, endpoint: str) -> str: def code_url(signal_url: str, endpoint: str) -> str:
"""把 ws(s)://host/[prefix/]ws 转成 http(s)://host/[prefix/]code/...。""" """把 ws(s)://host/[prefix/]ws 转成 http(s)://host/[prefix/]code/...。"""
parts = urlsplit(signal_url) parts = urlsplit(secure_signal_url(signal_url))
scheme = {"ws": "http", "wss": "https"}.get(parts.scheme) scheme = {"ws": "http", "wss": "https"}.get(parts.scheme)
if not scheme: if not scheme:
raise ValueError("signal_url must use ws:// or wss://") raise ValueError("signal_url must use ws:// or wss://")
@@ -119,11 +139,33 @@ def open_(key: bytes, frame: bytes):
# ---- merged from signal.py ---- # ---- merged from signal.py ----
log = logging.getLogger("p2p_ws.signal") log = logging.getLogger("p2p_ws.signal")
def _ws_connect_kw():
"""websockets.connect 的固定实参。
信令必须直连。websockets ≥14 的 connect() 默认读 HTTPS_PROXY/ALL_PROXY, 而 GA 会把给 LLM
配的翻墙代理写进这些变量(android_entry._apply_proxy) —— 于是短码换房(urlopen 已显式清代理,
见文末 _direct_opener)与信令 WS 分走两条路: 换码直连、信令走代理。代理多半不支持非标端口
49157, 且 socks5 还需 python_socks(未打包 → ImportError), 结果是配了代理的用户必然连不上,
报的还是与网络无关的错。这里显式声明不走代理, 与「两步必须同路」的不变量对齐。
proxy= 在 <14 不存在, 故按签名探测而非 try/except —— 后者会把真实的连接错误一并吞掉。"""
kw = {"max_size": 1 << 21}
try:
if "proxy" in inspect.signature(websockets.connect).parameters:
kw["proxy"] = None
except (TypeError, ValueError):
pass
return kw
_WS_CONNECT_KW = _ws_connect_kw()
class Signal: class Signal:
def __init__( def __init__(
self, url: str, room: str, retries: int = 6, self, url: str, room: str, retries: int = 6,
access_key: str = DEFAULT_ACCESS_KEY, access_key: str = DEFAULT_ACCESS_KEY,
): ):
url = secure_signal_url(url)
separator = "&" if "?" in url else "?" separator = "&" if "?" in url else "?"
self.url = f"{url}{separator}room={room}" self.url = f"{url}{separator}room={room}"
self.room = room self.room = room
@@ -144,18 +186,19 @@ class Signal:
async def start(self): async def start(self):
# 半开连接(切网/熄屏)靠 ping 在 ~25s 内暴露,默认 20+20s 太慢 # 半开连接(切网/熄屏)靠 ping 在 ~25s 内暴露,默认 20+20s 太慢
self._ws = await websockets.connect( self._ws = await websockets.connect(
self._signed_url(), max_size=1 << 21, ping_interval=15, ping_timeout=10, self._signed_url(), ping_interval=15, ping_timeout=10, **_WS_CONNECT_KW)
)
self._ready.set() self._ready.set()
self._task = asyncio.create_task(self._loop()) self._task = asyncio.create_task(self._loop())
return self return self
async def close(self): async def close(self):
self._closing = True self._closing = True
self._ready.clear()
if self._task: if self._task:
self._task.cancel() self._task.cancel()
if self._ws: if self._ws:
await self._ws.close() # 失效会话不等对端关闭握手,先中止本地传输以释放房间。
self._ws.transport.abort()
@property @property
def alive(self) -> bool: def alive(self) -> bool:
@@ -185,7 +228,11 @@ class Signal:
async def send(self, obj: dict, timeout: float = 15): async def send(self, obj: dict, timeout: float = 15):
"""等连接就绪后发送;重连期间会阻塞而不是直接失败。""" """等连接就绪后发送;重连期间会阻塞而不是直接失败。"""
if self._closing:
raise ConnectionError("signal is closed")
await asyncio.wait_for(self._ready.wait(), timeout) await asyncio.wait_for(self._ready.wait(), timeout)
if self._closing:
raise ConnectionError("signal is closed")
await self._ws.send(json.dumps(obj)) await self._ws.send(json.dumps(obj))
async def _loop(self): async def _loop(self):
@@ -273,6 +320,7 @@ class P2PSocket:
self._direct_open = asyncio.Event() self._direct_open = asyncio.Event()
self._peer_watch = None self._peer_watch = None
self._direct_task = None self._direct_task = None
self._close_task = None
self._upgrade_prepare = asyncio.Event() self._upgrade_prepare = asyncio.Event()
self._upgrade_ack = asyncio.Event() self._upgrade_ack = asyncio.Event()
self._upgrade_commit = asyncio.Event() self._upgrade_commit = asyncio.Event()
@@ -391,17 +439,23 @@ class P2PSocket:
return item return item
async def close(self): async def close(self):
if self.closed: if self._close_task is None:
return self.closed = True
self.closed = True for task in (self._peer_watch, self._direct_task):
if self._peer_watch is not None and self._peer_watch is not asyncio.current_task(): if task is not None and task is not asyncio.current_task():
self._peer_watch.cancel() task.cancel()
if self._direct_task is not None and self._direct_task is not asyncio.current_task(): self._inbox.put_nowait(_Closed("socket is closed"))
self._direct_task.cancel() self.mode = "closed"
await self._drop_direct() self._close_task = asyncio.create_task(self._finish_close())
await self.signal.close() await asyncio.shield(self._close_task)
self._inbox.put_nowait(_Closed("socket is closed"))
self.mode = "closed" async def _finish_close(self):
await self.signal.close() # 先撤销信令,不能让 RTC 清理阻塞房间释放。
cleanup = asyncio.create_task(self._drop_direct())
_, pending = await asyncio.wait({cleanup}, timeout=1)
if pending:
cleanup.cancel() # wait_for 会等取消完成,遇到慢清理仍可能无界等待。
log.warning("direct cleanup exceeded 1s; reconnect may proceed")
async def __aenter__(self): async def __aenter__(self):
return self return self
@@ -588,10 +642,18 @@ class _Closed:
# ---- merged from invite.py ---- # ---- merged from invite.py ----
_CODE = re.compile(r"^[0-9]{9}$") _CODE = re.compile(r"^[0-9]{9}$")
# 短码换房与随后的 WS 信令必须走同一条路: websockets 从不读环境代理, 而 urlopen 默认读
# (GA 给 LLM 配的翻墙代理会写进 HTTP_PROXY/http_proxy)。两步分道时, 换码走代理、信令直连,
# 代理不支持非标端口(如 49157)就只在换码这步炸, 报出与网络无关的 RemoteDisconnected。
# 信令服务器是直连服务, 这里显式清空代理与之对齐。
_direct_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
async def _post_json(url: str, timeout: float = 15): async def _post_json(url: str, timeout: float = 15):
def request(): def request():
req = urllib.request.Request(url, data=b"", method="POST") req = urllib.request.Request(url, data=b"", method="POST",
with urllib.request.urlopen(req, timeout=timeout) as response: headers={"User-Agent": "GenericAgent-P2P/1.0"})
with _direct_opener.open(req, timeout=timeout) as response:
return json.load(response) return json.load(response)
try: try:
@@ -599,6 +661,8 @@ async def _post_json(url: str, timeout: float = 15):
except urllib.error.HTTPError as exc: except urllib.error.HTTPError as exc:
reason = exc.read().decode(errors="replace").strip() reason = exc.read().decode(errors="replace").strip()
raise ConnectionError(f"short-code server rejected request: {exc.code} {reason}") from exc raise ConnectionError(f"short-code server rejected request: {exc.code} {reason}") from exc
except Exception as exc: # 网络层失败带上 URL 与异常类型: 原来只剩一句 RemoteDisconnected, 无从判断死在哪一步
raise ConnectionError(f"short-code server unreachable ({type(exc).__name__}: {exc}) url={url}") from exc
# ---- optional HTTP-over-P2P tunnel ---- # ---- optional HTTP-over-P2P tunnel ----
_HTTP_REQ, _HTTP_RES = "p2p-http.req", "p2p-http.res" _HTTP_REQ, _HTTP_RES = "p2p-http.req", "p2p-http.res"
@@ -1004,7 +1068,7 @@ def save_room(signal_url: str, room: str, *, name: str = "default", path=ROOMS_F
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
rooms = _load_rooms(path) rooms = _load_rooms(path)
rooms[name] = { rooms[name] = {
"signal_url": signal_url, "signal_url": secure_signal_url(signal_url),
"room": room, "room": room,
"saved_at": int(time.time()), "saved_at": int(time.time()),
} }
@@ -1021,7 +1085,10 @@ def save_room(signal_url: str, room: str, *, name: str = "default", path=ROOMS_F
def load_room(name: str = "default", *, path=ROOMS_FILE): def load_room(name: str = "default", *, path=ROOMS_FILE):
"""读取命名配对,返回包含 signal_url/room/saved_at 的字典。""" """读取命名配对,返回包含 signal_url/room/saved_at 的字典。"""
try: try:
return _load_rooms(path)[name].copy() saved = _load_rooms(path)[name].copy()
if "signal_url" in saved:
saved["signal_url"] = secure_signal_url(saved["signal_url"])
return saved
except KeyError as exc: except KeyError as exc:
raise KeyError(f"saved room not found: {name}") from exc raise KeyError(f"saved room not found: {name}") from exc
@@ -1128,9 +1195,10 @@ class Group:
同名重入会顶掉旧连接,因此每个进程须用唯一成员名。 同名重入会顶掉旧连接,因此每个进程须用唯一成员名。
""" """
def __init__(self, signal_url, room, me, serve, use, *, access_key=DEFAULT_ACCESS_KEY): def __init__(self, signal_url, room, me, serve, use, *, access_key=DEFAULT_ACCESS_KEY, auto=()):
self.signal_url, self.room, self.me = signal_url, room, me self.signal_url, self.room, self.me = secure_signal_url(signal_url), room, me
self.serve, self.use, self.access_key = serve, use, access_key self.serve, self.use, self.access_key = serve, use, access_key
self.auto = tuple(auto) # 名字以这些前缀开头的成员一上线就自动 open
self.members: set[str] = set() self.members: set[str] = set()
self._ws = None self._ws = None
self._wanted: set[str] = set() self._wanted: set[str] = set()
@@ -1160,13 +1228,14 @@ class Group:
seq = 0 # 服务端广播在锁外发送可能乱序,只认更大的 seq seq = 0 # 服务端广播在锁外发送可能乱序,只认更大的 seq
async with websockets.connect( async with websockets.connect(
signed_url(url, "ws", room, self.access_key), ping_interval=15, ping_timeout=10, signed_url(url, "ws", room, self.access_key), ping_interval=15, ping_timeout=10,
**_WS_CONNECT_KW,
) as ws: ) as ws:
self._ws = ws
try: try:
async for raw in ws: async for raw in ws:
msg = json.loads(raw) msg = json.loads(raw)
if msg.get("type") == "members" and msg["seq"] > seq: if msg.get("type") == "members" and msg["seq"] > seq:
seq = msg["seq"] seq = msg["seq"]
self._ws = ws # 收到名单才算在线:members 与 _ws 同时有效
self._sync(set(msg["members"]) - {self.me}) self._sync(set(msg["members"]) - {self.me})
elif msg.get("type") == "call": elif msg.get("type") == "call":
self._answer(msg["from"]) self._answer(msg["from"])
@@ -1178,7 +1247,8 @@ class Group:
for tasks in (self._out, self._in): for tasks in (self._out, self._in):
for peer in set(tasks) - members: for peer in set(tasks) - members:
tasks.pop(peer).cancel() tasks.pop(peer).cancel()
for peer in (self._wanted & members) - set(self._out): wanted = (self._wanted & members) | {p for p in members if p.startswith(self.auto)}
for peer in wanted - set(self._out):
self._out[peer] = asyncio.create_task(retry_forever(lambda peer=peer: self._call(peer))) self._out[peer] = asyncio.create_task(retry_forever(lambda peer=peer: self._call(peer)))
async def _call(self, peer): async def _call(self, peer):
@@ -1210,12 +1280,98 @@ class Group:
await socket.close() await socket.close()
class HubUnion:
"""customer 侧把组内所有 hub 合成一个 hub API;hub 本身不改。
作 Group(auto=("hub:",)) 的 use 回调,每台 hub 一条 socket。会话名对外为
name@hub(不同 PC 的 GA 常同名);/api/peers 逐台带各自 psig 增量拉取再合并;
GET /api/hubs 列在线 hub;POST /api/sessions 按 body 的 "hub" 选机,缺省取名字最小者。
"""
def __init__(self, timeout=22):
self.hubs, self.rows, self.timeout = {}, {}, timeout # rows: hub -> (psig, rows)
self._pending, self._seq = {}, 0
async def use(self, socket, peer):
hub = peer.split(":", 1)[-1]
_claim_receiver(socket, self)
self.hubs[hub] = socket
try:
while True:
with suppress(TypeError, ValueError, AttributeError):
message = json.loads(await socket.recv())
future = self._pending.get(message.get("id"))
if future is not None and not future.done():
future.set_result(message)
finally:
if self.hubs.get(hub) is socket:
del self.hubs[hub]
self.rows.pop(hub, None)
_release_receiver(socket, self)
async def _one(self, hub, method, target, body=b""):
self._seq += 1
rid = f"u{self._seq}"
future = self._pending[rid] = asyncio.get_running_loop().create_future()
try:
await self.hubs[hub].send(json.dumps({
"v": 1, "type": _HTTP_REQ, "id": rid, "method": method, "target": target,
"headers": [["Content-Type", "application/json"]] if body else [],
"body": base64.b64encode(body).decode("ascii")}, separators=(",", ":")))
res = await asyncio.wait_for(future, self.timeout)
finally:
self._pending.pop(rid, None)
return int(res.get("status") or 502), base64.b64decode(res.get("body") or "")
async def request(self, method, target, body=b""):
"""返回 (status, body bytes)。"""
parts = urlsplit(target)
path = parts.path.split("/")
if parts.path == "/api/hubs":
return 200, json.dumps(sorted(self.hubs)).encode()
if parts.path == "/api/peers":
return await self._peers(dict(parse_qsl(parts.query, keep_blank_values=True)).get("psig"))
hub = None
if len(path) > 3 and path[1] == "api" and "@" in path[2]:
path[2], _, hub = path[2].rpartition("@")
target = urlunsplit(("", "", "/".join(path), parts.query, ""))
elif body:
with suppress(ValueError, AttributeError):
hub = json.loads(body).get("hub")
hub = hub if hub in self.hubs else min(self.hubs, default=None)
if hub is None:
return 503, b'{"error":"no hub online","code":503}'
status, data = await self._one(hub, method, target, body)
if parts.path == "/api/sessions" and status == 200:
with suppress(ValueError, TypeError):
data = json.loads(data)
data = json.dumps({**data, "name": f"{data['name']}@{hub}", "hub": hub}).encode()
return status, data
async def _peers(self, psig):
async def pull(hub):
target = "/api/peers?" + urlencode({"psig": self.rows.get(hub, ("",))[0]})
status, data = await self._one(hub, "GET", target)
reply = json.loads(data) if status == 200 else {}
if "rows" in reply and hub in self.hubs:
self.rows[hub] = (reply["psig"], [
{**row, "name": f"{row['name']}@{hub}", "hub": hub} for row in reply["rows"]])
hubs = sorted(self.hubs)
await asyncio.gather(*map(pull, hubs), return_exceptions=True) # 某台超时沿用它上次的行
rows = [row for hub in hubs for row in self.rows.get(hub, ("", []))[1]]
if psig is None:
return 200, json.dumps(rows).encode()
sig = hashlib.md5(json.dumps(rows, sort_keys=True).encode()).hexdigest()[:12]
return 200, json.dumps({"same": 1} if psig == sig else {"rows": rows, "psig": sig}).encode()
connect = P2PSocket.connect connect = P2PSocket.connect
__all__ = [ __all__ = [
"DEFAULT_ACCESS_KEY", "ROOMS_FILE", "Invite", "P2PSocket", "Signal", "DEFAULT_ACCESS_KEY", "ROOMS_FILE", "Invite", "P2PSocket", "Signal",
"HTTPExporter", "HTTPLocalProxy", "export_http", "mount_http", "HTTPExporter", "HTTPLocalProxy", "export_http", "mount_http",
"connect", "connect_code", "connect_saved", "create_code", "load_room", "connect", "connect_code", "connect_saved", "create_code", "load_room",
"save_room", "signed_url", "Group", "pair_room", "retry_forever", "save_room", "signed_url", "Group", "HubUnion", "pair_room", "retry_forever",
] ]