Files
server_manager/src/services/commands/remote_rpc.py
T
2026-06-09 14:01:20 +08:00

460 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Remote server database/RPC queries and role RPC helpers."""
from .shared import *
from .erlang import _ensure_epmd_daemon, get_erl_cmd, get_local_ip
def query_remote_servers(db_host: str, db_port: int, db_user: str, db_pass: str,
db_name: str) -> List[Dict[str, str]]:
"""从登录服数据库查询远程服务器列表
Args:
db_host: 数据库地址
db_port: 数据库端口
db_user: 数据库用户名
db_pass: 数据库密码
db_name: 数据库名(登录服数据库,如 ai002_login_s900
Returns:
服务器列表,每项包含 server_id, server_node, center_node 等信息
"""
try:
import pymysql
except ImportError:
# 尝试使用 mysql-connector
try:
import mysql.connector as pymysql
except ImportError:
raise ImportError("需要安装 pymysql 或 mysql-connector-python: pip install pymysql")
servers = []
try:
# 连接数据库
conn = pymysql.connect(
host=db_host,
port=db_port,
user=db_user,
password=db_pass,
database=db_name,
charset='utf8mb4'
)
cursor = conn.cursor()
# 查询 server_info 表
# 注意: running 字段是运行时字段(sync=false),不存储在数据库中
sql = """
SELECT server_id, server_node, center_node, game_db
FROM server_info
WHERE server_node IS NOT NULL AND server_node != ''
ORDER BY server_id
"""
cursor.execute(sql)
for row in cursor.fetchall():
server_id, server_node, center_node, game_db = row
servers.append({
'server_id': str(server_id),
'server_node': str(server_node) if server_node else '',
'center_node': str(center_node) if center_node else '',
'game_db': str(game_db) if game_db else '',
'running': False # 运行状态需要实时检测,这里默认为 False
})
cursor.close()
conn.close()
except Exception as e:
raise Exception(f"数据库查询失败: {str(e)}")
return servers
def _erl_quoted_atom(node: str) -> str:
"""将节点名转为 Erlang 源码中的单引号原子字面量。"""
n = (node or "").strip()
if not n:
raise ValueError("登录服节点为空")
return "'" + n.replace("\\", "\\\\").replace("'", "\\'") + "'"
# 由 file:script/1 加载。输出 SM_COUNT 行 + 每行「服务器ID\t名称base64\t节点明文」。
# 注意:file:script 生成的匿名 fun 属于 erl_eval,若本地与远程 OTP 版本不一致会 badfun。
# 需确保本地 erl 路径所指版本与登录服 OTP 版本相同。
_FETCH_REMOTE_RPC_SCRIPT = """begin
LN = __LOGIN_ATOM__,
R = rpc:call(LN, erlang, apply, [
fun() ->
ms_cache:tab2list_foldl(server_temp_info,
fun(OneServer, ResultAcc) ->
ServerId = element(2, server_temp_info_c:get_server_id(OneServer)),
ServerName = unicode:characters_to_binary([
element(2, server_temp_info_c:get_server_name(OneServer))
]),
case server_info_lib:get_server_node(ServerId) of
{_, Node} ->
[{ServerId, ServerName, Node} | ResultAcc];
_ ->
ResultAcc
end
end, [])
end, []]),
case R of
{badrpc, Err} ->
io:format(standard_io, "RPC_ERROR: ~p~n", [Err]),
erlang:halt(2, [{flush, true}]);
_ when is_list(R) ->
RowLine = fun({Id, NameBin, Node}) ->
Nb = case NameBin of B when is_binary(B) -> B; _ -> <<>> end,
NameB64 = binary_to_list(base64:encode(Nb)),
NodeStr = case Node of
N when is_atom(N) -> unicode:characters_to_list(atom_to_binary(N, utf8));
N when is_list(N) -> N;
N when is_binary(N) -> unicode:characters_to_list(N);
_ -> lists:flatten(io_lib:format("~p", [Node]))
end,
Sid = lists:flatten(io_lib:format("~w", [Id])),
lists:flatten([Sid, $\\t, NameB64, $\\t, NodeStr])
end,
io:format(standard_io, "SM_COUNT\\t~w~n", [length(R)]),
lists:foreach(
fun(Row) ->
io:format(standard_io, "~s~n", [RowLine(Row)])
end, R),
erlang:halt(0, [{flush, true}]);
Other ->
io:format(standard_io, "RPC_ERROR: ~p~n", [Other]),
erlang:halt(3, [{flush, true}])
end
end.
"""
def query_remote_servers_from_login_rpc(
login_node: str,
cookie: str,
erl_path: Optional[str] = None,
timeout: int = 120,
) -> List[Dict[str, Any]]:
"""通过向登录服节点 ``rpc:call`` 获取游戏服列表。
使用 ``erl`` + ``file:script/1`` 执行临时脚本,避免 Windows 超长 ``-eval``。
注意:脚本中的匿名 fun 属于 erl_eval,需要本地 erl 与登录服 OTP 版本一致,
否则远程执行会 badfun。
返回每项含 server_id、server_nameUTF-8 文本)、server_node、running(默认 False)。
Raises:
ValueError: 参数无效
Exception: erl 执行失败、RPC 错误或输出无法解析
"""
ln = _erl_quoted_atom(login_node)
ping_node = f"sm_ls_{int(time.time() * 1000) % 100000}"
cookie_arg = (cookie or "").strip() or "ddxq2-node"
if any(c in cookie_arg for c in " \t\r\n'\""):
raise ValueError("Cookie 不能包含空格或引号(请使用项目设置中的纯文本 cookie)")
erl = get_erl_cmd(erl_path)
_ensure_epmd_daemon(erl_path)
# 与 check_nodes_status 一致:本机节点优先用 get_local_ip(),再试 127.0.0.1
host_parts: List[str] = []
lip = get_local_ip()
if lip:
host_parts.append(lip)
if "127.0.0.1" not in host_parts:
host_parts.append("127.0.0.1")
if not host_parts:
host_parts = ["127.0.0.1"]
def _looks_like_vm_nodistribution(stderr: str, stdout: str) -> bool:
c = (stderr or "") + (stdout or "")
return any(
x in c
for x in (
"nodistribution",
"application_start_failure",
"failed_to_start_child,net_kernel",
"Kernel pid terminated",
)
)
script_body = _FETCH_REMOTE_RPC_SCRIPT.replace("__LOGIN_ATOM__", ln)
result: Optional[Any] = None
for idx, host_part in enumerate(host_parts):
tmp_path: Optional[str] = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
suffix=".erl",
delete=False,
encoding="utf-8",
newline="\n",
) as tf:
tf.write(script_body)
tmp_path = tf.name
path_for_erl = str(Path(tmp_path).resolve()).replace("\\", "/")
if '"' in path_for_erl:
raise ValueError("临时脚本路径含引号,无法传给 Erlang")
# 脚本内已 halt;若脚本本身无法解析,file:script 返回 {error,_}
eval_launch = (
f'case file:script("{path_for_erl}") of '
"{{error, E}} -> io:format(\"SCRIPT_ERROR: ~p~n\", [E]), halt(1); "
"_ -> halt(0) end."
)
result = subprocess.run(
[
erl,
"-noshell",
"-name", f"{ping_node}@{host_part}",
"-setcookie", cookie_arg,
"-eval", eval_launch,
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if IS_WINDOWS else 0,
)
except subprocess.TimeoutExpired as e:
raise Exception(f"从登录服加载超时({timeout}s: {e}") from e
except FileNotFoundError:
raise Exception(
f"找不到 erl 可执行文件: {erl}"
"请确认已安装 Erlang/OTP,或在项目设置中填写正确的 Erlang 安装路径。"
)
except Exception as e:
raise Exception(f"执行 erl 失败: {e}") from e
finally:
if tmp_path:
try:
os.unlink(tmp_path)
except OSError:
pass
if result is None:
raise Exception("内部错误:未获得 erl 执行结果")
if (
result.returncode != 0
and _looks_like_vm_nodistribution(result.stderr, result.stdout)
and idx < len(host_parts) - 1
):
continue
break
if result is None:
raise Exception("内部错误:未获得 erl 执行结果")
out = (result.stdout or "").strip()
err = (result.stderr or "").strip()
# Windows 下 -noshell 时 io:format 有时落在 stderr;与 stdout 合并后再解析
combined_text = ((result.stdout or "") + "\n" + (result.stderr or "")).strip()
rpc_err_lines = [line for line in combined_text.splitlines() if "RPC_ERROR:" in line]
if rpc_err_lines:
raise Exception(f"登录服 RPC 失败: {rpc_err_lines[0]}")
if result.returncode != 0:
raise Exception(f"erl 退出码 {result.returncode}: {(err or out)[:2000]}")
servers: List[Dict[str, Any]] = []
for line in combined_text.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("RPC_ERROR") or line.startswith("SCRIPT_ERROR"):
continue
if line.startswith("SM_COUNT\t"):
continue
# 跳过 Eshell/版本等无关行
if "Eshell" in line or "Erlang/OTP" in line:
continue
parts = line.split("\t", 2)
if len(parts) < 3:
continue
sid_s, name_b64, node_s = parts[0], parts[1], parts[2]
try:
raw = base64.b64decode(name_b64.encode("ascii"))
name_dec = raw.decode("utf-8")
except Exception:
try:
name_dec = base64.b64decode(name_b64.encode("ascii")).decode("utf-8", errors="replace")
except Exception:
name_dec = ""
servers.append(
{
"server_id": str(sid_s).strip(),
"server_name": name_dec,
"server_node": str(node_s).strip(),
"running": False,
}
)
sm_count: Optional[int] = None
for line in combined_text.splitlines():
if line.strip().startswith("SM_COUNT\t"):
try:
sm_count = int(line.strip().split("\t", 1)[1])
except (ValueError, IndexError):
pass
break
if sm_count is not None and sm_count > 0 and len(servers) == 0:
raise Exception(
f"登录服报告 SM_COUNT={sm_count} 条,但未解析出数据行(可能编码或输出分流异常)。"
f" 原始 stdout 前 500 字: {(result.stdout or '')[:500]!r}"
)
return servers
def get_login_db_name(prefix: str, login_server_id: int) -> str:
"""生成登录服数据库名
Args:
prefix: 项目前缀
login_server_id: 登录服ID
Returns:
数据库名,如 ai002_login_s900
"""
return f"{prefix}_login_s{login_server_id}"
def _format_target_node(server_name: str, target_ip: Optional[str]) -> str:
if '@' in server_name:
return server_name
ip = target_ip if target_ip else get_local_ip()
return f"{server_name}@{ip}"
def _argv_to_shell_line(args: List[str]) -> str:
"""将实际传给 subprocess 的参数列表格式化为可复制的命令行字符串。"""
if IS_WINDOWS:
return subprocess.list2cmdline(args)
return shlex.join(args)
def rpc_clear_server_on_center_and_login(
center_node: str,
login_node: str,
server_id: Union[int, str],
cookie: str,
erl_path: Optional[str] = None,
timeout: int = 45,
) -> Tuple[int, str, str, str]:
"""Notify center/login nodes to clear a game server.
Returns:
(returncode, stdout, stderr, full_command_line)
"""
clean_center_node = str(center_node or "").strip().strip("'\"")
clean_login_node = str(login_node or "").strip().strip("'\"")
if not clean_center_node:
raise ValueError("center_node is empty")
if not clean_login_node:
raise ValueError("login_node is empty")
try:
sid = int(str(server_id).strip())
except (TypeError, ValueError) as exc:
raise ValueError(f"invalid server_id: {server_id!r}") from exc
cookie_arg = (cookie or "").strip() or "ddxq2-node"
if any(c in cookie_arg for c in " \t\r\n'\""):
raise ValueError("cookie cannot contain whitespace or quotes")
erl = get_erl_cmd(erl_path)
local_ip = get_local_ip()
ping_node = f"clear_{os.getpid()}_{time.time_ns() % 100000000}"
center_atom = _erl_quoted_atom(clean_center_node)
login_atom = _erl_quoted_atom(clean_login_node)
eval_code = (
f"CenterNode = {center_atom}, "
f"LoginNode = {login_atom}, "
f"ServerId = {sid}, "
"CenterResult = rpc:call(CenterNode, center_node_gs_lib, clear_server, [ServerId]), "
"LoginResult = rpc:call(LoginNode, login_server_app, clear_server, [ServerId]), "
"io:format(\"CENTER: ~p~nLOGIN: ~p~n\", [CenterResult, LoginResult]), "
"case {CenterResult, LoginResult} of "
"{{badrpc, _}, _} -> erlang:halt(2, [{flush, true}]); "
"{_, {badrpc, _}} -> erlang:halt(3, [{flush, true}]); "
"_ -> erlang:halt(0, [{flush, true}]) "
"end."
)
args = [
erl,
"-noshell",
"-name", f"{ping_node}@{local_ip}",
"-setcookie", cookie_arg,
"-eval", eval_code,
]
cmd_line = _argv_to_shell_line(args)
try:
_ensure_epmd_daemon(erl_path)
result = subprocess.run(
args,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if IS_WINDOWS else 0,
)
return result.returncode, (result.stdout or ""), (result.stderr or ""), cmd_line
except subprocess.TimeoutExpired:
return -1, "", f"RPC timeout after {timeout}s", cmd_line
except Exception as e:
return -1, "", str(e), cmd_line
def rpc_role_gs_trace_network(
server_name: str,
cookie: str,
role_id: int,
enable: bool,
target_ip: Optional[str] = None,
erl_path: Optional[str] = None,
) -> Tuple[int, str, str, str]:
"""远程调用 ``role_gs:trace_network/1`` 或 ``trace_network_close/1``。
Returns:
(returncode, stdout, stderr, full_command_line)
"""
erl = get_erl_cmd(erl_path)
ip = get_local_ip()
ping_node = f"sm_rpc_{int(time.time() * 1000) % 100000}"
target_node = _format_target_node(server_name, target_ip)
func = "trace_network" if enable else "trace_network_close"
eval_code = (
f"R = rpc:call('{target_node}', role_gs, {func}, [{int(role_id)}]), "
f"io:format(\"~p~n\", [R]), halt(0)."
)
args = [
erl,
"-noshell",
"-name", f"{ping_node}@{ip}",
"-setcookie", cookie,
"-eval", eval_code,
]
cmd_line = _argv_to_shell_line(args)
try:
r = subprocess.run(
args,
capture_output=True,
text=True,
timeout=45,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0) if IS_WINDOWS else 0,
)
return r.returncode, (r.stdout or ""), (r.stderr or ""), cmd_line
except subprocess.TimeoutExpired:
return -1, "", "RPC 超时", cmd_line
except Exception as e:
return -1, "", str(e), cmd_line