"""外网隧道接入:插件上报公网 URL,写入 public_base_url。 方案 B(插件上报):插件进程启动 cloudflared quick tunnel 拿到公网 HTTPS 地址后, POST /api/bot/tunnel/publish 上报,核心鉴权后写入 public_base_url。 停止/卸载时 release,恢复发布前的地址。 public_base_url 的写入复用 config_update.prepare_config_update 的运行时/持久化路径, 同步更新内存 STATE 与 config.json,前端 /api/system/config 立即生效(修问题 A)。 隧道自身状态(publisher/url/prev/events)存 data/tunnel_state.json。 """ from __future__ import annotations import json import logging import os import time import urllib.parse from dataclasses import dataclass from pathlib import Path from typing import Any, Callable from src.webui.config_update import prepare_config_update logger = logging.getLogger("trpg") @dataclass(frozen=True) class TunnelDependencies: config_state: Callable[[], dict[str, Any]] save_config: Callable[[], None] list_plugins: Callable[[], dict[str, Any]] def _data_dir() -> Path: env = os.getenv("TRPG_DATA_DIR", "").strip() if env: return Path(env) # tunnel.py -> services -> webui -> src -> 主程序根 return Path(__file__).resolve().parents[3] / "data" def _state_file() -> Path: return _data_dir() / "tunnel_state.json" def _atomic_write_json(path: Path, data: dict[str, Any]) -> None: path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(path.suffix + ".tmp") tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") os.replace(tmp, path) def _read_state() -> dict[str, Any]: try: data = json.loads(_state_file().read_text(encoding="utf-8")) return data if isinstance(data, dict) else {} except (OSError, ValueError): return {} def _write_state(state: dict[str, Any]) -> None: _atomic_write_json(_state_file(), state) def _record_event(state: dict[str, Any], event: str, plugin_id: str, url: str = "") -> None: events = state.get("events") if not isinstance(events, list): events = [] events.append({ "event": event, "plugin_id": plugin_id, "url": url, "at": time.time(), }) state["events"] = events[-20:] def _valid_public_url(url: str) -> str: """校验并归一化公网 URL:必须 https、合法主机名、长度受限。""" text = str(url or "").strip().rstrip("/") if len(text) > 253: raise ValueError("隧道地址过长") parsed = urllib.parse.urlparse(text) if parsed.scheme != "https" or not parsed.hostname: raise ValueError("隧道地址必须是有效的 https URL") return text def _commit_public_base_url(dependencies: TunnelDependencies, url: str) -> None: """走标准配置更新路径,写盘失败时恢复内存与原配置。""" config_state = dependencies.config_state() prepared = prepare_config_update(config_state, {"public_base_url": url}) if prepared.error: raise ValueError(prepared.error) previous = dict(config_state) config_state.clear() config_state.update(prepared.state) try: dependencies.save_config() except Exception: config_state.clear() config_state.update(previous) try: dependencies.save_config() except Exception: logger.exception("公网 URL 配置回滚写盘失败") raise def publish_tunnel_url( dependencies: TunnelDependencies, plugin_id: str, url: str ) -> dict[str, Any]: """插件上报公网 URL,写入 public_base_url(首次保存 prev 供 release 恢复)。 同一时刻只允许一个插件发布;需先 release 旧 publisher 再发布新插件。 """ normalized = _valid_public_url(url) state = _read_state() current_publisher = state.get("publisher_plugin_id") if current_publisher and current_publisher != plugin_id: installed = { p.get("id") for p in dependencies.list_plugins().get("plugins", []) } if current_publisher not in installed: # 旧 publisher 已卸载(幽灵状态,如手动删目录/异常退出未 release):接管并继承其 prev_url。 logger.warning("隧道 publisher %s 已卸载,自动接管", current_publisher) state["publisher_plugin_id"] = "" state["url"] = "" else: raise ValueError(f"隧道已由插件 {current_publisher} 发布,需先停止后再发布") if "prev_url" not in state: state["prev_url"] = str( dependencies.config_state().get("public_base_url") or "" ) previous_public_url = str( dependencies.config_state().get("public_base_url") or "" ) _commit_public_base_url(dependencies, normalized) state.update({ "publisher_plugin_id": plugin_id, "url": normalized, "published_at": time.time(), }) _record_event(state, "publish", plugin_id, normalized) try: _write_state(state) except Exception: try: _commit_public_base_url(dependencies, previous_public_url) except Exception as rollback_error: raise RuntimeError("隧道状态写入失败,且公网 URL 回滚失败") from rollback_error raise logger.info("隧道已发布: plugin=%s url=%s", plugin_id, normalized) return {"ok": True, "public_base_url": normalized} def release_tunnel_url( dependencies: TunnelDependencies, plugin_id: str ) -> dict[str, Any]: """停止/卸载时恢复发布前的 public_base_url(仅当该插件是当前 publisher)。""" state = _read_state() if state.get("publisher_plugin_id") != plugin_id: return {"ok": True, "released": False} prev = str(state.get("prev_url") or "") previous_public_url = str( dependencies.config_state().get("public_base_url") or "" ) _commit_public_base_url(dependencies, prev) _record_event(state, "release", plugin_id) state = {"publisher_plugin_id": "", "url": "", "prev_url": "", "events": state.get("events", [])} try: _write_state(state) except Exception: try: _commit_public_base_url(dependencies, previous_public_url) except Exception as rollback_error: raise RuntimeError("隧道状态清理失败,且公网 URL 回滚失败") from rollback_error raise logger.info("隧道已释放: plugin=%s", plugin_id) return {"ok": True, "released": True, "restored": prev} def tunnel_status(dependencies: TunnelDependencies) -> dict[str, Any]: """当前隧道状态 + 可用提供者(声明 tunnel.publish 权限的已安装插件)。""" state = _read_state() providers: list[dict[str, Any]] = [] try: for plugin in dependencies.list_plugins().get("plugins", []): if "tunnel.publish" in (plugin.get("permissions") or []): providers.append({ "plugin_id": plugin.get("id", ""), "name": plugin.get("name", plugin.get("id", "")), "running": bool(plugin.get("running")), "min_app_version": plugin.get("min_app_version", ""), "needs_core_update": bool(plugin.get("needs_core_update")), }) except Exception: logger.exception("读取隧道提供者列表失败") return { "ok": True, "active": bool(state.get("url")), "url": state.get("url", ""), "published_at": state.get("published_at", 0), "public_base_url": dependencies.config_state().get("public_base_url", ""), "providers": providers, }