460 lines
16 KiB
Python
460 lines
16 KiB
Python
# -*- 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_name(UTF-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
|