552 lines
18 KiB
Python
552 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""桌面端与配置后台之间的认证和自动同步客户端。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import socket
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from app_version import APP_VERSION, release_status
|
|
from runtime_paths import application_data_dir
|
|
|
|
|
|
SCRIPT_DIR = application_data_dir()
|
|
CONNECTION_FILE = SCRIPT_DIR / "backend_connection.json"
|
|
RUNTIME_FILE = SCRIPT_DIR / "backend_runtime.json"
|
|
DEFAULT_SERVER_URL = "http://xchat.zhenyangtang.com.cn"
|
|
DESKTOP_SYNC_KEY = "wcrpa-v1-H3q9mT7xK2pN8cR5vL4sF6dB1yG0uJ"
|
|
DESKTOP_CONFIG_PATH = "/api/v1/desktop/config"
|
|
_LOCK = threading.RLock()
|
|
_SENSITIVE_NAME_PARTS = (
|
|
"api_key",
|
|
"apikey",
|
|
"key",
|
|
"token",
|
|
"secret",
|
|
"password",
|
|
"passwd",
|
|
"authorization",
|
|
"cookie",
|
|
"credential",
|
|
"signature",
|
|
"auth",
|
|
)
|
|
_INLINE_SECRET_RE = re.compile(
|
|
r"(?i)(--?(?:api[-_]?key|token|secret|password|passwd|authorization|auth)\s*[=:]\s*)([^\s,;]+)"
|
|
)
|
|
|
|
|
|
class BackendError(RuntimeError):
|
|
"""后台通信或响应错误。"""
|
|
|
|
|
|
class AuthenticationError(BackendError):
|
|
"""登录状态无效。"""
|
|
|
|
|
|
def _process_is_running(pid: int) -> bool:
|
|
"""检查运行状态文件中的进程是否仍存在,不向进程发送终止信号。"""
|
|
if pid <= 0:
|
|
return False
|
|
if pid == os.getpid():
|
|
return True
|
|
if os.name == "nt":
|
|
try:
|
|
import ctypes
|
|
|
|
process_query_limited_information = 0x1000
|
|
still_active = 259
|
|
kernel32 = ctypes.windll.kernel32
|
|
kernel32.OpenProcess.argtypes = [
|
|
ctypes.c_ulong,
|
|
ctypes.c_int,
|
|
ctypes.c_ulong,
|
|
]
|
|
kernel32.OpenProcess.restype = ctypes.c_void_p
|
|
kernel32.GetExitCodeProcess.argtypes = [
|
|
ctypes.c_void_p,
|
|
ctypes.POINTER(ctypes.c_ulong),
|
|
]
|
|
kernel32.GetExitCodeProcess.restype = ctypes.c_int
|
|
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
|
|
kernel32.CloseHandle.restype = ctypes.c_int
|
|
handle = kernel32.OpenProcess(
|
|
process_query_limited_information, False, pid
|
|
)
|
|
if not handle:
|
|
return False
|
|
try:
|
|
exit_code = ctypes.c_ulong()
|
|
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
return False
|
|
return exit_code.value == still_active
|
|
finally:
|
|
kernel32.CloseHandle(handle)
|
|
except (AttributeError, OSError, ValueError):
|
|
return False
|
|
try:
|
|
os.kill(pid, 0)
|
|
except ProcessLookupError:
|
|
return False
|
|
except PermissionError:
|
|
return True
|
|
except OSError:
|
|
return False
|
|
return True
|
|
|
|
|
|
def discover_local_runtime() -> dict[str, Any]:
|
|
"""读取并校验同项目后台发布的运行信息。"""
|
|
try:
|
|
info = json.loads(RUNTIME_FILE.read_text(encoding="utf-8"))
|
|
url = normalize_server_url(info.get("server_url"))
|
|
port = int(info.get("port", 0))
|
|
pid = int(info.get("pid", 0))
|
|
parsed = urllib.parse.urlparse(url)
|
|
if (
|
|
not 1 <= port <= 65535
|
|
or parsed.port != port
|
|
or not _process_is_running(pid)
|
|
):
|
|
raise ValueError
|
|
return {
|
|
"server_url": url,
|
|
"port": port,
|
|
"pid": pid,
|
|
"local_sync_token": str(info.get("local_sync_token") or ""),
|
|
"started_at": str(info.get("started_at") or ""),
|
|
}
|
|
except (OSError, ValueError, TypeError, BackendError):
|
|
return {}
|
|
|
|
|
|
def discover_local_server_url() -> str:
|
|
"""读取后台发布的实际端口;文件无效时回退到默认地址。"""
|
|
return str(discover_local_runtime().get("server_url") or DEFAULT_SERVER_URL)
|
|
|
|
|
|
def discover_local_sync_token() -> str:
|
|
return str(discover_local_runtime().get("local_sync_token") or "")
|
|
|
|
|
|
def _is_local_server_url(value: Any) -> bool:
|
|
try:
|
|
hostname = urllib.parse.urlparse(normalize_server_url(value)).hostname
|
|
except BackendError:
|
|
return False
|
|
return hostname in ("127.0.0.1", "localhost", "::1")
|
|
|
|
|
|
def default_settings() -> dict[str, Any]:
|
|
return {
|
|
"server_url": discover_local_server_url(),
|
|
"username": "",
|
|
"access_token": "",
|
|
"auto_sync": True,
|
|
"sync_interval_seconds": 300,
|
|
"last_version": 0,
|
|
"last_sync_at": "",
|
|
"last_error": "",
|
|
"last_release": {
|
|
"latest_version": APP_VERSION,
|
|
"download_url": "",
|
|
"release_notes": "",
|
|
"force_upgrade": False,
|
|
"updated_at": "",
|
|
},
|
|
}
|
|
|
|
|
|
def load_settings() -> dict[str, Any]:
|
|
settings = default_settings()
|
|
with _LOCK:
|
|
try:
|
|
saved = json.loads(CONNECTION_FILE.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError, TypeError):
|
|
return settings
|
|
if isinstance(saved, dict):
|
|
settings.update({key: saved[key] for key in settings if key in saved})
|
|
settings["server_url"] = normalize_server_url(settings.get("server_url"))
|
|
discovered_url = discover_local_server_url()
|
|
if discovered_url != DEFAULT_SERVER_URL and _is_local_server_url(settings["server_url"]):
|
|
settings["server_url"] = discovered_url
|
|
settings["auto_sync"] = bool(settings.get("auto_sync", True))
|
|
if not isinstance(settings.get("last_release"), dict):
|
|
settings["last_release"] = default_settings()["last_release"]
|
|
try:
|
|
settings["sync_interval_seconds"] = max(
|
|
60, int(settings.get("sync_interval_seconds", 300))
|
|
)
|
|
settings["last_version"] = max(0, int(settings.get("last_version", 0)))
|
|
except (TypeError, ValueError):
|
|
settings["sync_interval_seconds"] = 300
|
|
settings["last_version"] = 0
|
|
return settings
|
|
|
|
|
|
def save_settings(settings: dict[str, Any]) -> None:
|
|
data = default_settings()
|
|
data.update({key: settings[key] for key in data if key in settings})
|
|
data["server_url"] = normalize_server_url(data["server_url"])
|
|
temporary = CONNECTION_FILE.with_suffix(".json.tmp")
|
|
with _LOCK:
|
|
temporary.write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
)
|
|
os.replace(temporary, CONNECTION_FILE)
|
|
|
|
|
|
def normalize_server_url(value: Any) -> str:
|
|
url = str(value or DEFAULT_SERVER_URL).strip().rstrip("/")
|
|
if not url.startswith(("http://", "https://")):
|
|
url = "http://" + url
|
|
parsed = urllib.parse.urlparse(url)
|
|
if not parsed.hostname:
|
|
raise BackendError("后台地址格式不正确")
|
|
return url
|
|
|
|
|
|
def _development_mode_enabled(config: dict[str, Any]) -> bool:
|
|
value = config.get("AI_DEVELOPMENT_MODE", False)
|
|
if isinstance(value, str):
|
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
return bool(value)
|
|
|
|
|
|
def _sensitive_name(name: Any) -> bool:
|
|
normalized = str(name or "").strip().lower().replace("-", "_")
|
|
return any(part in normalized for part in _SENSITIVE_NAME_PARTS)
|
|
|
|
|
|
def redact_diagnostic_value(value: Any, *, field_name: str = "") -> Any:
|
|
"""Return a JSON-safe diagnostic copy with credentials removed."""
|
|
if _sensitive_name(field_name):
|
|
return "[已配置,值已隐藏]" if value not in (None, "", [], {}) else "[未配置]"
|
|
if isinstance(value, dict):
|
|
return {
|
|
str(key): redact_diagnostic_value(item, field_name=str(key))
|
|
for key, item in value.items()
|
|
}
|
|
if isinstance(value, list):
|
|
result = []
|
|
hide_next = False
|
|
for item in value:
|
|
if hide_next:
|
|
result.append("[值已隐藏]")
|
|
hide_next = False
|
|
continue
|
|
result.append(redact_diagnostic_value(item))
|
|
if isinstance(item, str) and _sensitive_name(item.lstrip("-")):
|
|
hide_next = True
|
|
return result
|
|
if isinstance(value, str):
|
|
text = value
|
|
if re.match(r"(?i)^bearer\s+\S+", text.strip()):
|
|
return "[认证值已隐藏]"
|
|
if re.match(r"(?i)^(?:sk|app)-[A-Za-z0-9_.-]{8,}$", text.strip()):
|
|
return "[密钥值已隐藏]"
|
|
text = _INLINE_SECRET_RE.sub(r"\1[值已隐藏]", text)
|
|
if text.startswith(("http://", "https://")):
|
|
return diagnostic_url(text)
|
|
return text
|
|
return value
|
|
|
|
|
|
def diagnostic_url(value: Any) -> str:
|
|
"""Keep the request destination visible while hiding credentials in its query."""
|
|
url = str(value or "")
|
|
try:
|
|
parsed = urllib.parse.urlsplit(url)
|
|
if not parsed.scheme or not parsed.netloc:
|
|
return url
|
|
hostname = parsed.hostname or ""
|
|
if ":" in hostname and not hostname.startswith("["):
|
|
hostname = f"[{hostname}]"
|
|
netloc = hostname
|
|
if parsed.port:
|
|
netloc += f":{parsed.port}"
|
|
query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
|
|
safe_query = urllib.parse.urlencode(
|
|
[
|
|
(name, "[值已隐藏]" if _sensitive_name(name) else item)
|
|
for name, item in query
|
|
]
|
|
)
|
|
return urllib.parse.urlunsplit(
|
|
(parsed.scheme, netloc, parsed.path, safe_query, "")
|
|
)
|
|
except (TypeError, ValueError):
|
|
return url
|
|
|
|
|
|
def _config_diagnostics(
|
|
config: dict[str, Any], response: dict[str, Any], request_url: str
|
|
) -> list[str]:
|
|
if not _development_mode_enabled(config):
|
|
return []
|
|
safe_config = redact_diagnostic_value(config)
|
|
config_json = json.dumps(safe_config, ensure_ascii=False, indent=2, sort_keys=True)
|
|
return [
|
|
f"[开发模式] 云端配置请求地址: {diagnostic_url(request_url)}",
|
|
(
|
|
f"[开发模式] 云端配置版本: v{int(response.get('version', 0))}"
|
|
f";更新时间: {response.get('updated_at') or '未提供'}"
|
|
),
|
|
f"[开发模式] 获取到的云端配置(敏感值已隐藏):\n{config_json}",
|
|
]
|
|
|
|
|
|
def is_configured(settings: dict[str, Any] | None = None) -> bool:
|
|
current = settings or load_settings()
|
|
return bool(
|
|
current.get("server_url")
|
|
and (current.get("access_token") or discover_local_sync_token())
|
|
)
|
|
|
|
|
|
def connection_summary() -> dict[str, Any]:
|
|
settings = load_settings()
|
|
return {
|
|
"configured": is_configured(settings),
|
|
"authenticated": bool(settings.get("access_token")),
|
|
"local_discovered": bool(discover_local_sync_token()),
|
|
"server_url": settings["server_url"],
|
|
"username": settings.get("username", ""),
|
|
"auto_sync": settings["auto_sync"],
|
|
"sync_interval_seconds": settings["sync_interval_seconds"],
|
|
"last_version": settings["last_version"],
|
|
"last_sync_at": settings.get("last_sync_at", ""),
|
|
"last_error": settings.get("last_error", ""),
|
|
}
|
|
|
|
|
|
def _request(
|
|
method: str,
|
|
server_url: str,
|
|
path: str,
|
|
*,
|
|
token: str = "",
|
|
payload: dict[str, Any] | None = None,
|
|
local_sync_token: str = "",
|
|
desktop_sync_key: str = "",
|
|
timeout: float = 10.0,
|
|
) -> tuple[int, dict[str, Any]]:
|
|
url = normalize_server_url(server_url) + path
|
|
body = None
|
|
headers = {"Accept": "application/json", "User-Agent": "WeCom-RPA/1.0"}
|
|
if payload is not None:
|
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
headers["Content-Type"] = "application/json; charset=utf-8"
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
if local_sync_token:
|
|
headers["X-Desktop-Sync-Token"] = local_sync_token
|
|
if desktop_sync_key:
|
|
headers["X-Desktop-Sync-Key"] = desktop_sync_key
|
|
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
raw = response.read().decode("utf-8")
|
|
data = json.loads(raw) if raw else {}
|
|
return int(response.status), data
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
data = json.loads(exc.read().decode("utf-8"))
|
|
except Exception:
|
|
data = {"error": f"后台返回 HTTP {exc.code}"}
|
|
message = str(data.get("error") or data.get("message") or f"HTTP {exc.code}")
|
|
if exc.code in (401, 403):
|
|
raise AuthenticationError(message) from exc
|
|
raise BackendError(message) from exc
|
|
except (urllib.error.URLError, TimeoutError, socket.timeout) as exc:
|
|
reason = getattr(exc, "reason", exc)
|
|
raise BackendError(f"无法连接后台:{reason}") from exc
|
|
except (ValueError, TypeError) as exc:
|
|
raise BackendError("后台响应不是有效 JSON") from exc
|
|
|
|
|
|
def login(
|
|
server_url: str,
|
|
username: str,
|
|
password: str,
|
|
*,
|
|
auto_sync: bool = True,
|
|
) -> dict[str, Any]:
|
|
server_url = normalize_server_url(server_url)
|
|
username = str(username or "").strip()
|
|
if not username or not password:
|
|
raise AuthenticationError("请输入用户名和密码")
|
|
_, response = _request(
|
|
"POST",
|
|
server_url,
|
|
"/api/v1/auth/login",
|
|
payload={
|
|
"username": username,
|
|
"password": password,
|
|
"device_name": socket.gethostname(),
|
|
},
|
|
)
|
|
token = str(response.get("access_token") or "")
|
|
if not token:
|
|
raise AuthenticationError("后台没有返回登录令牌")
|
|
settings = load_settings()
|
|
settings.update(
|
|
{
|
|
"server_url": server_url,
|
|
"username": response.get("user", {}).get("username", username),
|
|
"access_token": token,
|
|
"auto_sync": bool(auto_sync),
|
|
"last_error": "",
|
|
}
|
|
)
|
|
save_settings(settings)
|
|
return response
|
|
|
|
|
|
def logout(*, revoke_remote: bool = True) -> None:
|
|
settings = load_settings()
|
|
if revoke_remote and settings.get("access_token"):
|
|
try:
|
|
_request(
|
|
"POST",
|
|
settings["server_url"],
|
|
"/api/v1/auth/logout",
|
|
token=settings["access_token"],
|
|
payload={},
|
|
timeout=5.0,
|
|
)
|
|
except BackendError:
|
|
pass
|
|
settings.update(
|
|
{
|
|
"username": "",
|
|
"access_token": "",
|
|
"last_error": "",
|
|
"last_version": 0,
|
|
"last_sync_at": "",
|
|
}
|
|
)
|
|
save_settings(settings)
|
|
|
|
|
|
def _apply_config_response(
|
|
response: dict[str, Any], settings: dict[str, Any], *, request_url: str = ""
|
|
) -> dict[str, Any]:
|
|
config = response.get("config")
|
|
if not isinstance(config, dict):
|
|
raise BackendError("后台没有返回有效的模型配置")
|
|
version = int(response.get("version", 0))
|
|
import ai_config
|
|
|
|
applied = ai_config.apply_settings(config, persist=True)
|
|
release = release_status(response.get("release"))
|
|
cached_release = {
|
|
key: release[key]
|
|
for key in (
|
|
"latest_version",
|
|
"download_url",
|
|
"release_notes",
|
|
"force_upgrade",
|
|
"updated_at",
|
|
)
|
|
}
|
|
settings.update(
|
|
{
|
|
"last_version": version,
|
|
"last_sync_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"last_error": "",
|
|
"last_release": cached_release,
|
|
}
|
|
)
|
|
save_settings(settings)
|
|
return {
|
|
"synced": True,
|
|
"version": version,
|
|
"applied_count": len(applied),
|
|
"updated_at": response.get("updated_at", ""),
|
|
"message": f"已同步云端配置 v{version}",
|
|
"release": release,
|
|
"local_app_version": APP_VERSION,
|
|
"update_available": release["update_available"],
|
|
"force_upgrade": release["force_upgrade"],
|
|
"diagnostics": _config_diagnostics(config, response, request_url),
|
|
}
|
|
|
|
|
|
def cached_release_status() -> dict[str, Any]:
|
|
"""网络不可用时读取上次成功同步的升级策略。"""
|
|
return release_status(load_settings().get("last_release"))
|
|
|
|
|
|
def sync_config(*, force: bool = False, timeout: float = 10.0) -> dict[str, Any]:
|
|
"""保留账号登录和同机后台的兼容同步能力。"""
|
|
settings = load_settings()
|
|
if not is_configured(settings):
|
|
return {"synced": False, "reason": "not_configured", "message": "尚未登录后台"}
|
|
if not settings.get("auto_sync") and not force:
|
|
return {"synced": False, "reason": "disabled", "message": "自动同步已关闭"}
|
|
try:
|
|
_, response = _request(
|
|
"GET",
|
|
settings["server_url"],
|
|
"/api/v1/config",
|
|
token=settings["access_token"],
|
|
local_sync_token=(
|
|
"" if settings.get("access_token") else discover_local_sync_token()
|
|
),
|
|
timeout=timeout,
|
|
)
|
|
return _apply_config_response(
|
|
response,
|
|
settings,
|
|
request_url=normalize_server_url(settings["server_url"]) + "/api/v1/config",
|
|
)
|
|
except Exception as exc:
|
|
settings["last_error"] = str(exc)
|
|
save_settings(settings)
|
|
raise
|
|
|
|
|
|
def sync_cloud_config(*, timeout: float = 10.0) -> dict[str, Any]:
|
|
"""无需用户登录,从固定云端读取桌面运行配置。"""
|
|
settings = load_settings()
|
|
settings.update(
|
|
{
|
|
"server_url": DEFAULT_SERVER_URL,
|
|
"username": "",
|
|
"access_token": "",
|
|
"auto_sync": True,
|
|
}
|
|
)
|
|
try:
|
|
_, response = _request(
|
|
"GET",
|
|
DEFAULT_SERVER_URL,
|
|
DESKTOP_CONFIG_PATH,
|
|
desktop_sync_key=DESKTOP_SYNC_KEY,
|
|
timeout=timeout,
|
|
)
|
|
return _apply_config_response(
|
|
response,
|
|
settings,
|
|
request_url=normalize_server_url(DEFAULT_SERVER_URL) + DESKTOP_CONFIG_PATH,
|
|
)
|
|
except Exception as exc:
|
|
settings["last_error"] = str(exc)
|
|
save_settings(settings)
|
|
raise
|
|
|
|
|
|
def startup_sync_config(*, timeout: float = 3.0) -> dict[str, Any]:
|
|
"""软件启动前先请求固定云端,使首屏直接使用服务器配置。"""
|
|
return sync_cloud_config(timeout=timeout)
|