mirror of
https://github.com/GSManagerXZ/GameServerManager.git
synced 2026-09-07 00:23:47 +08:00
Add EasyTier installation, profile storage, secure runtime control, authenticated plugin UI, and operator documentation. Harden embedded plugin auth/assets and FRP helper installation alongside the new networking workflow.
425 lines
17 KiB
JavaScript
425 lines
17 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
|
||
window.createEasyTierSecurityUI = (dependencies) => {
|
||
const {
|
||
api,
|
||
byId,
|
||
createElement,
|
||
getContext,
|
||
updateProfileView,
|
||
resetForm,
|
||
setBusy,
|
||
showError,
|
||
announce,
|
||
openModal,
|
||
openConfirm,
|
||
notify,
|
||
} = dependencies;
|
||
|
||
let localState = {
|
||
profileId: "",
|
||
security: null,
|
||
credentials: [],
|
||
aclStats: null,
|
||
};
|
||
|
||
const setLocalState = (patch) => {
|
||
localState = { ...localState, ...patch };
|
||
};
|
||
|
||
const isCurrentProfile = (profileId) => getContext().profileId === profileId;
|
||
|
||
const clearSecret = () => {
|
||
byId("credentialSecretValue").textContent = "";
|
||
};
|
||
|
||
const reset = (profileId = "") => {
|
||
clearSecret();
|
||
setLocalState({ profileId, security: null, credentials: [], aclStats: null });
|
||
render();
|
||
};
|
||
|
||
const load = async (profileId) => {
|
||
if (!profileId) {
|
||
reset();
|
||
return;
|
||
}
|
||
setLocalState({ profileId, security: null, credentials: [], aclStats: null });
|
||
render();
|
||
const security = await api.getSecurity(profileId);
|
||
if (!isCurrentProfile(profileId)) return;
|
||
setLocalState({ security });
|
||
render();
|
||
const context = getContext();
|
||
if (
|
||
security.enabled &&
|
||
security.supportsCredentials &&
|
||
context.instance?.status === "running"
|
||
) {
|
||
await loadCredentials(false);
|
||
}
|
||
};
|
||
|
||
const loadCredentials = async (notifyOnError = true) => {
|
||
const { profileId } = getContext();
|
||
if (!profileId) return;
|
||
try {
|
||
const credentials = await api.listCredentials(profileId);
|
||
if (!isCurrentProfile(profileId)) return;
|
||
setLocalState({ credentials });
|
||
renderCredentials();
|
||
} catch (error) {
|
||
if (notifyOnError) showError(error);
|
||
}
|
||
};
|
||
|
||
const setFormDisabled = (selector, disabled) => {
|
||
document.querySelectorAll(selector).forEach((control) => {
|
||
control.disabled = disabled;
|
||
});
|
||
};
|
||
|
||
const renderNotice = (security, context) => {
|
||
const notice = byId("securityCapabilityNotice");
|
||
if (!context.profile) {
|
||
notice.textContent = "选择 Profile 后显示 Secure Mode 能力。";
|
||
notice.className = "banner banner-neutral";
|
||
return;
|
||
}
|
||
if (!security) {
|
||
notice.textContent = "正在读取安全能力…";
|
||
notice.className = "banner banner-neutral";
|
||
return;
|
||
}
|
||
if (!security.supportsSecureMode) {
|
||
notice.textContent = "当前 EasyTier 二进制不支持 Secure Mode;请升级后重新检测能力。";
|
||
notice.className = "banner banner-error";
|
||
return;
|
||
}
|
||
if (["running", "starting", "stopping"].includes(context.instance?.status)) {
|
||
notice.textContent = "安全配置为启动时配置。请先停止实例再编辑;临时凭据仍可在运行中管理。";
|
||
notice.className = "banner banner-warning";
|
||
return;
|
||
}
|
||
notice.textContent = "安全配置将写入 Profile;重新启动实例后生效。私钥由服务端加密保存。";
|
||
notice.className = "banner banner-neutral";
|
||
};
|
||
|
||
const renderPeerPins = (security) => {
|
||
const container = byId("peerPinsList");
|
||
const peers = security?.peers || [];
|
||
if (peers.length === 0) {
|
||
container.replaceChildren(createElement("p", { className: "empty-state", text: "当前 Profile 没有 Peer。" }));
|
||
return;
|
||
}
|
||
const rows = peers.map((peer) => {
|
||
const row = createElement("div", { className: "peer-pin-row" });
|
||
const identity = document.createElement("div");
|
||
identity.append(
|
||
createElement("strong", { className: "mono", text: peer.uri }),
|
||
createElement("span", { className: "hint", text: "固定 32 字节 X25519 公钥" }),
|
||
);
|
||
const input = document.createElement("input");
|
||
input.className = "mono";
|
||
input.type = "text";
|
||
input.placeholder = "Peer 公钥 base64(可留空)";
|
||
input.value = peer.peerPublicKey || "";
|
||
input.dataset.peerUri = peer.uri;
|
||
input.setAttribute("aria-label", `${peer.uri} 的固定公钥`);
|
||
row.append(identity, input);
|
||
return row;
|
||
});
|
||
container.replaceChildren(...rows);
|
||
};
|
||
|
||
const renderAclRules = (security) => {
|
||
const rules = security?.acl || [];
|
||
const rows = rules.map((rule) => {
|
||
const row = document.createElement("tr");
|
||
const identity = document.createElement("td");
|
||
identity.append(
|
||
createElement("strong", { text: rule.id }),
|
||
createElement("div", { className: "hint", text: rule.description || "—" }),
|
||
);
|
||
const action = createElement("td", { text: rule.action === "allow" ? "允许" : "拒绝" });
|
||
const protocol = createElement("td", { text: String(rule.protocol || "any").toUpperCase() });
|
||
const sourceParts = [
|
||
rule.source || "任意 CIDR",
|
||
rule.sourceGroups?.length ? `分组: ${rule.sourceGroups.join(", ")}` : "",
|
||
].filter(Boolean);
|
||
const destinationParts = [
|
||
rule.destination || "本机",
|
||
rule.destinationGroups?.length ? `分组: ${rule.destinationGroups.join(", ")}` : "",
|
||
].filter(Boolean);
|
||
const endpoints = createElement("td", {
|
||
className: "mono",
|
||
text: `${sourceParts.join(" · ")} → ${destinationParts.join(" · ")}`,
|
||
});
|
||
const ports = createElement("td", { className: "mono", text: rule.destinationPort || "任意" });
|
||
const actionCell = createElement("td", { className: "align-right" });
|
||
const remove = createElement("button", { className: "button button-danger", text: "删除" });
|
||
remove.type = "button";
|
||
remove.dataset.aclDelete = rule.id;
|
||
actionCell.append(remove);
|
||
row.append(identity, action, protocol, endpoints, ports, actionCell);
|
||
return row;
|
||
});
|
||
byId("aclRulesTableBody").replaceChildren(...rows);
|
||
const defaultAction = security?.aclDefaultAction === "deny" ? "deny" : "allow";
|
||
byId("aclDefaultAction").value = defaultAction;
|
||
byId("aclPolicyHint").textContent = defaultAction === "deny"
|
||
? "白名单模式:未命中允许规则的入站流量会被拒绝。保存前请确认管理和游戏端口已有允许规则。"
|
||
: "增量限制模式:未命中规则的入站流量保持允许。";
|
||
const empty = byId("aclRulesEmpty");
|
||
empty.hidden = rows.length > 0;
|
||
empty.textContent = defaultAction === "deny"
|
||
? "当前没有 ACL 规则;所有入站流量都会被拒绝。"
|
||
: "当前没有 ACL 规则;默认允许入站流量。";
|
||
const output = byId("aclStatsOutput");
|
||
output.hidden = !localState.aclStats;
|
||
output.textContent = JSON.stringify(localState.aclStats || {}, null, 2);
|
||
};
|
||
|
||
const renderCredentials = () => {
|
||
const rows = localState.credentials.map((credential) => {
|
||
const row = document.createElement("tr");
|
||
const id = createElement("td", { className: "mono", text: credential.id });
|
||
const groups = createElement("td", { text: credential.groups?.join(", ") || "无" });
|
||
const expires = createElement("td", {
|
||
text: credential.expiresAt ? new Date(credential.expiresAt).toLocaleString("zh-CN") : "未知",
|
||
});
|
||
const permissions = createElement("td", {
|
||
text: `${credential.allowRelay ? "可中继" : "禁止中继"} · ${credential.reusable ? "可复用" : "单设备"}`,
|
||
});
|
||
const actionCell = createElement("td", { className: "align-right" });
|
||
const revoke = createElement("button", { className: "button button-danger", text: "撤销" });
|
||
revoke.type = "button";
|
||
revoke.dataset.credentialRevoke = credential.id;
|
||
actionCell.append(revoke);
|
||
row.append(id, groups, expires, permissions, actionCell);
|
||
return row;
|
||
});
|
||
byId("credentialsTableBody").replaceChildren(...rows);
|
||
byId("credentialsEmpty").hidden = rows.length > 0;
|
||
};
|
||
|
||
const render = () => {
|
||
const context = getContext();
|
||
const security = localState.profileId === context.profileId ? localState.security : null;
|
||
renderNotice(security, context);
|
||
byId("secureModeStatus").textContent = security?.enabled ? "已启用" : "未启用";
|
||
byId("secureModeStatus").dataset.state = security?.enabled ? "running" : "unknown";
|
||
byId("secureModeEnabled").checked = Boolean(security?.enabled);
|
||
byId("localPublicKey").value = security?.localPublicKey || "";
|
||
renderPeerPins(security);
|
||
renderAclRules(security);
|
||
renderCredentials();
|
||
|
||
const status = context.instance?.status || "stopped";
|
||
const canConfigure = Boolean(
|
||
context.profile &&
|
||
security?.supportsSecureMode &&
|
||
!context.isBusy &&
|
||
!["running", "starting", "stopping"].includes(status)
|
||
);
|
||
const canUseCredentials = Boolean(
|
||
security?.enabled &&
|
||
security?.supportsCredentials &&
|
||
status === "running" &&
|
||
!context.isBusy
|
||
);
|
||
const canUseAclStats = Boolean(
|
||
security?.enabled &&
|
||
security?.supportsAcl &&
|
||
status === "running" &&
|
||
!context.isBusy
|
||
);
|
||
setFormDisabled("#secureModeForm input, #secureModeForm button", !canConfigure);
|
||
setFormDisabled("#peerPinsForm input, #peerPinsForm button", !canConfigure);
|
||
setFormDisabled("#aclPolicyForm select, #aclPolicyForm button", !canConfigure);
|
||
setFormDisabled("#aclRuleForm input, #aclRuleForm select, #aclRuleForm button, [data-acl-delete]", !canConfigure);
|
||
setFormDisabled("#credentialForm input, #credentialForm select, #credentialForm button, [data-credential-revoke], #refreshCredentialsButton", !canUseCredentials);
|
||
byId("refreshAclStatsButton").disabled = !canUseAclStats;
|
||
byId("securityWorkspace").classList.toggle("is-disabled", Boolean(context.profile && security && !security.supportsSecureMode));
|
||
};
|
||
|
||
const gatherPeerPins = () => (
|
||
[...byId("peerPinsList").querySelectorAll("[data-peer-uri]")].map((input) => ({
|
||
uri: input.dataset.peerUri,
|
||
...(input.value.trim() ? { peerPublicKey: input.value.trim() } : {}),
|
||
}))
|
||
);
|
||
|
||
const saveSecurity = async (patch) => {
|
||
const context = getContext();
|
||
if (!context.profileId || !localState.security) return;
|
||
setBusy(true);
|
||
try {
|
||
const view = await api.updateSecurity(context.profileId, {
|
||
enabled: localState.security.enabled,
|
||
peers: localState.security.peers,
|
||
aclDefaultAction: localState.security.aclDefaultAction,
|
||
acl: localState.security.acl,
|
||
...patch,
|
||
});
|
||
updateProfileView(view);
|
||
byId("localPrivateKey").value = "";
|
||
await load(context.profileId);
|
||
notify("success", "EasyTier 安全配置已保存");
|
||
} catch (error) {
|
||
showError(error);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const showCredentialSecret = (result) => {
|
||
byId("credentialSecretValue").textContent = result.secret;
|
||
openModal(byId("credentialSecretModal"));
|
||
};
|
||
|
||
const bind = () => {
|
||
byId("secureModeForm").addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const localPrivateKey = byId("localPrivateKey").value.trim();
|
||
saveSecurity({
|
||
enabled: byId("secureModeEnabled").checked,
|
||
...(localPrivateKey ? { localPrivateKey } : {}),
|
||
});
|
||
});
|
||
|
||
byId("generateStaticKeyButton").addEventListener("click", () => {
|
||
const { profileId, profile } = getContext();
|
||
if (!profileId) return;
|
||
openConfirm({
|
||
title: "轮换静态密钥",
|
||
message: `将为“${profile?.name || profileId}”生成新的 X25519 身份。已固定旧公钥的节点需要同步更新。`,
|
||
action: async () => {
|
||
setBusy(true);
|
||
try {
|
||
const result = await api.generateStaticKey(profileId);
|
||
updateProfileView(result.profile);
|
||
await load(profileId);
|
||
notify("success", "静态密钥已生成并加密保存");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
},
|
||
});
|
||
});
|
||
|
||
byId("peerPinsForm").addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
saveSecurity({ peers: gatherPeerPins() });
|
||
});
|
||
|
||
byId("aclPolicyForm").addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
saveSecurity({ aclDefaultAction: byId("aclDefaultAction").value });
|
||
});
|
||
|
||
byId("aclRuleForm").addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
if (!localState.security) return;
|
||
const data = new FormData(event.currentTarget);
|
||
const id = String(data.get("id") || "").trim();
|
||
if (!id) return;
|
||
const nextRule = {
|
||
id,
|
||
action: String(data.get("action")),
|
||
protocol: String(data.get("protocol")),
|
||
sourceGroups: String(data.get("sourceGroups") || "").split(",").map((value) => value.trim()).filter(Boolean),
|
||
destinationGroups: String(data.get("destinationGroups") || "").split(",").map((value) => value.trim()).filter(Boolean),
|
||
...(String(data.get("source") || "").trim() ? { source: String(data.get("source")).trim() } : {}),
|
||
...(String(data.get("destination") || "").trim() ? { destination: String(data.get("destination")).trim() } : {}),
|
||
...(String(data.get("destinationPort") || "").trim()
|
||
? { destinationPort: String(data.get("destinationPort")).trim() }
|
||
: {}),
|
||
...(String(data.get("description") || "").trim()
|
||
? { description: String(data.get("description")).trim() }
|
||
: {}),
|
||
};
|
||
const acl = [...localState.security.acl.filter((rule) => rule.id !== id), nextRule];
|
||
resetForm(event.currentTarget);
|
||
saveSecurity({ acl });
|
||
});
|
||
|
||
byId("aclRulesTableBody").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-acl-delete]");
|
||
if (!button || !localState.security) return;
|
||
const id = button.dataset.aclDelete;
|
||
openConfirm({
|
||
title: "删除 ACL 规则",
|
||
message: `确定删除规则“${id}”吗?`,
|
||
action: () => saveSecurity({ acl: localState.security.acl.filter((rule) => rule.id !== id) }),
|
||
});
|
||
});
|
||
|
||
byId("credentialForm").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const { profileId } = getContext();
|
||
if (!profileId) return;
|
||
const data = new FormData(event.currentTarget);
|
||
setBusy(true);
|
||
try {
|
||
const result = await api.generateCredential(profileId, {
|
||
ttlSeconds: Number(data.get("ttlSeconds")),
|
||
groups: String(data.get("groups") || "").split(",").map((value) => value.trim()).filter(Boolean),
|
||
allowedProxyCidrs: String(data.get("allowedProxyCidrs") || "").split(",").map((value) => value.trim()).filter(Boolean),
|
||
allowRelay: data.get("allowRelay") === "on",
|
||
reusable: data.get("reusable") === "on",
|
||
});
|
||
showCredentialSecret(result);
|
||
await loadCredentials(false);
|
||
notify("success", "临时凭据已生成;私钥只显示一次");
|
||
} catch (error) {
|
||
showError(error);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
});
|
||
|
||
byId("credentialsTableBody").addEventListener("click", (event) => {
|
||
const button = event.target.closest("[data-credential-revoke]");
|
||
if (!button) return;
|
||
const { profileId } = getContext();
|
||
const credentialId = button.dataset.credentialRevoke;
|
||
openConfirm({
|
||
title: "撤销临时凭据",
|
||
message: `撤销后,使用凭据 ${credentialId} 的节点将失去授权。`,
|
||
action: async () => {
|
||
await api.revokeCredential(profileId, credentialId);
|
||
await loadCredentials(false);
|
||
notify("success", "临时凭据已撤销");
|
||
},
|
||
});
|
||
});
|
||
|
||
byId("refreshCredentialsButton").addEventListener("click", () => loadCredentials());
|
||
byId("refreshAclStatsButton").addEventListener("click", async () => {
|
||
const { profileId } = getContext();
|
||
if (!profileId) return;
|
||
try {
|
||
const aclStats = await api.getAclStats(profileId);
|
||
setLocalState({ aclStats });
|
||
renderAclRules(localState.security);
|
||
announce("ACL 命中统计已刷新");
|
||
} catch (error) {
|
||
showError(error);
|
||
}
|
||
});
|
||
byId("copyCredentialSecretButton").addEventListener("click", async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(byId("credentialSecretValue").textContent || "");
|
||
announce("凭据私钥已复制");
|
||
} catch {
|
||
showError(new Error("无法复制凭据私钥,请检查浏览器权限"), false);
|
||
}
|
||
});
|
||
};
|
||
|
||
return { bind, load, render, reset, clearSecret };
|
||
};
|
||
})();
|