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:
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
@@ -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
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
Reference in new issue
Block a user