diff --git a/Auth/RemoteCommandGuard.cs b/Auth/RemoteCommandGuard.cs index abaaebf..e532c08 100644 --- a/Auth/RemoteCommandGuard.cs +++ b/Auth/RemoteCommandGuard.cs @@ -4,8 +4,8 @@ namespace SLDataAPI.Auth; /// -/// SLDataAPI 自身管理 CLI(sldataapi / 别名 slda)的远程执行硬拒绝(层 1), -/// 以及"当前线程正在执行远程控制通道下发的命令"标记(供层 1 兜底与层 2 人工确认使用)。 +/// SLDataAPI 自身管理 CLI(sldataapi / 别名 slda)的远程执行硬拒绝, +/// 以及"当前线程正在执行远程控制通道下发的命令"标记。 /// /// 动机:远程控制通道只能证明"请求持有某把 API Key",无法证明操作者身份。 /// 一旦某把 Key 拿到 /control/console/ 授权,就能凭控制台命令无限增发新 Key, @@ -27,7 +27,7 @@ public static class RemoteCommandGuard /// 控制面拒绝文案(HTTP 403 / WS result 的 message)。 public const string RemoteDenyMessage = "拒绝执行:SLDataAPI 管理命令(sldataapi / slda)不允许通过远程控制通道执行。" + - "API Key 的创建与吊销只能在服务器本地控制台(LocalAdmin / RemoteAdmin / 游戏内控制台)操作,并需人工确认。"; + "API Key 的创建与吊销只能在服务器本地控制台(LocalAdmin / RemoteAdmin / 游戏内控制台)操作。"; /// /// 命令是否会调起 SLDataAPI 管理 CLI。逐个空白分隔的 token 判定(不只看首 token, @@ -87,16 +87,4 @@ public void Dispose() if (_remoteDepth > 0) _remoteDepth--; } } - - // ────────────── y/n 回答解析 ────────────── - - /// 解析人工确认的回答:仅明确的肯定回答算通过,空/无法识别一律按拒绝。 - public static bool IsAffirmative(string? answer) - { - if (string.IsNullOrWhiteSpace(answer)) - return false; - - string a = answer!.Trim().ToLowerInvariant(); - return a is "y" or "yes" or "是"; - } } diff --git a/Commands/ApikeyConfirmTestCommand.cs b/Commands/ApikeyConfirmTestCommand.cs deleted file mode 100644 index 7243cd0..0000000 --- a/Commands/ApikeyConfirmTestCommand.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using CommandSystem; -using SLDataAPI.Services; - -namespace SLDataAPI.Commands; - -/// -/// 确认通道自检:走完整条人工确认通道(新弹 cmd 窗口 → 行模式 → 对话框), -/// 但不碰任何 Key。装机 / 升级后用它验证确认窗口能不能正常弹出来, -/// 而不用真去铸一把 Key 再吊销。 -/// -public sealed class ApikeyConfirmTestCommand : ICommand -{ - public string Command => "confirmtest"; - public string[] Aliases => new[] { "testconfirm" }; - public string Description => "自检人工确认通道(弹出确认窗口,不创建也不吊销任何 Key)"; - - public bool Execute(ArraySegment arguments, ICommandSender sender, out string response) - { - var model = new ConfirmPanelModel - { - Action = ConfirmAction.SelfTest, - KeyId = "self-test", - Note = "仅自检确认通道,不会写入 apikey.config", - }; - - if (!OperatorConfirmService.Confirm(model, out string reason)) - { - response = $"确认通道自检未通过:{reason}\n" + - "预期行为:服务器桌面上新弹出一个 cmd 窗口显示确认面板(LocalAdmin 窗口不受影响),在该窗口按 Y。"; - return false; - } - - response = "确认通道自检通过:已收到服务端操作者的确认(未创建、未吊销任何 Key)。"; - return true; - } -} diff --git a/Commands/ApikeyCreateCommand.cs b/Commands/ApikeyCreateCommand.cs index a1e39de..f498364 100644 --- a/Commands/ApikeyCreateCommand.cs +++ b/Commands/ApikeyCreateCommand.cs @@ -2,7 +2,6 @@ using System.Text; using CommandSystem; using SLDataAPI.Auth; -using SLDataAPI.Services; namespace SLDataAPI.Commands; @@ -27,23 +26,7 @@ public bool Execute(ArraySegment arguments, ICommandSender sender, out s ? string.Join(" ", arguments.Array!, arguments.Offset + 2, arguments.Count - 2) : ""; - // 层 2:真正的服务端操作者(LocalAdmin / RemoteAdmin / 游戏内控制台)必须在服务器桌面 - // 新弹出的确认窗口里显式确认才会铸出新 Key。远程控制通道在层 1 已被硬拒绝,走不到这里; - // 即使走到,Confirm 也会因远程执行上下文在弹窗之前直接拒绝。 - var confirm = new ConfirmPanelModel - { - Action = ConfirmAction.Create, - KeyId = id, - Template = template.Trim().ToLowerInvariant(), - Note = note, - }; - if (!OperatorConfirmService.Confirm(confirm, out string denyReason)) - { - response = $"已中止创建 API Key(未获服务端确认):{denyReason}\n确认面板会在服务器桌面新弹出一个 cmd 窗口(不占用 LocalAdmin 窗口),请在那个窗口按 Y 确认。"; - Log.Warn($"[SLDataAPI] API Key 创建未获确认,已中止 id={id}:{denyReason}"); - return false; - } - + // 远程控制通道已被 RemoteCommandGuard 硬拒绝,走不到这里。 if (!ApiKeyService.TryCreate(id, template, note, out string plaintext, out string error)) { response = "创建失败: " + error; @@ -62,4 +45,4 @@ public bool Execute(ArraySegment arguments, ICommandSender sender, out s Log.Info($"[SLDataAPI] 已创建 API Key id={id} template={template}(明文仅回显给命令发送者)"); return true; } -} \ No newline at end of file +} diff --git a/Commands/ApikeyParentCommand.cs b/Commands/ApikeyParentCommand.cs index 444700d..2baa082 100644 --- a/Commands/ApikeyParentCommand.cs +++ b/Commands/ApikeyParentCommand.cs @@ -10,20 +10,19 @@ public sealed class ApikeyParentCommand : ParentCommand public override string Command => "apikey"; public override string[] Aliases => new[] { "key", "keys" }; - public override string Description => "API Key 管理(create / revoke / list / confirmtest)"; + public override string Description => "API Key 管理(create / revoke / list)"; public override void LoadGeneratedCommands() { RegisterCommand(new ApikeyCreateCommand()); RegisterCommand(new ApikeyRevokeCommand()); RegisterCommand(new ApikeyListCommand()); - RegisterCommand(new ApikeyConfirmTestCommand()); } protected override bool ExecuteParent(ArraySegment arguments, ICommandSender sender, out string response) { response = "用法:\n sldataapi apikey create [note]\n sldataapi apikey revoke \n" + - " sldataapi apikey list\n sldataapi apikey confirmtest(自检确认窗口,不动任何 Key)"; + " sldataapi apikey list"; return false; } -} \ No newline at end of file +} diff --git a/Commands/ApikeyRevokeCommand.cs b/Commands/ApikeyRevokeCommand.cs index ceefbc2..802d3f5 100644 --- a/Commands/ApikeyRevokeCommand.cs +++ b/Commands/ApikeyRevokeCommand.cs @@ -1,7 +1,6 @@ using System; using CommandSystem; using SLDataAPI.Auth; -using SLDataAPI.Services; namespace SLDataAPI.Commands; @@ -22,19 +21,7 @@ public bool Execute(ArraySegment arguments, ICommandSender sender, out s string id = arguments.Array![arguments.Offset]; - // 层 2:与 create 同理——吊销同样是密钥面变更(可被用来把值班 Key 踢掉后重铸),需要人工确认 - var confirm = new ConfirmPanelModel - { - Action = ConfirmAction.Revoke, - KeyId = id, - }; - if (!OperatorConfirmService.Confirm(confirm, out string denyReason)) - { - response = $"已中止吊销 API Key(未获服务端确认):{denyReason}\n确认面板会在服务器桌面新弹出一个 cmd 窗口(不占用 LocalAdmin 窗口),请在那个窗口按 Y 确认。"; - Log.Warn($"[SLDataAPI] API Key 吊销未获确认,已中止 id={id}:{denyReason}"); - return false; - } - + // 远程控制通道已被 RemoteCommandGuard 硬拒绝,走不到这里。 if (!ApiKeyService.TryRevoke(id, out string error)) { response = "吊销失败: " + error; @@ -45,4 +32,4 @@ public bool Execute(ArraySegment arguments, ICommandSender sender, out s Log.Info($"[SLDataAPI] 已吊销 API Key id={id}"); return true; } -} \ No newline at end of file +} diff --git a/Config.cs b/Config.cs index 88fe7c2..68773a7 100644 --- a/Config.cs +++ b/Config.cs @@ -132,15 +132,4 @@ public class Config /// 控制日志最大条数,超出自动删除最旧条目(0/负数 = 不清理)。 public int ControlLogMaxRecords { get; set; } = 500; - - // ================== API Key 管理的人工确认(v2.6.0-preview-DevOnly 安全加固) ================== - - /// - /// `sldataapi apikey create|revoke` 的确认面板等待服务端操作者按键的秒数 - /// (在服务器桌面新弹出的 cmd 窗口里 Y 确认 / N 取消,默认取消;LocalAdmin 窗口不受影响)。 - /// 超时按拒绝处理(不创建 / 不吊销)。取值钳制在 5–120 秒; - /// 注意确认期间命令所在的主线程处于等待状态,不宜设置过长。 - /// 该确认不可关闭:远程控制通道已被硬拒绝执行这些命令,本地确认是最后一道人工闸门。 - /// - public int ApiKeyConfirmTimeoutSeconds { get; set; } = 20; } diff --git a/Control/ControlController.cs b/Control/ControlController.cs index f524146..091d27f 100644 --- a/Control/ControlController.cs +++ b/Control/ControlController.cs @@ -259,7 +259,7 @@ private static (int, string) RunCommand(string body) if (req == null || string.IsNullOrWhiteSpace(req.command)) return (400, Json(false, "缺少 command 字段")); - // 层 1:SLDataAPI 自身的管理 CLI 一律不经远程控制通道执行(所有子命令,不只 apikey)。 + // SLDataAPI 自身的管理 CLI 一律不经远程控制通道执行(所有子命令,不只 apikey)。 // 远程通道只能证明"持有某把 Key",无法证明操作者身份;放行等于任何拿到 console 授权的 Key // 都能无限增发新 Key 并从同一条通道取回明文。这里连命令都不派发,直接回控制面错误。 if (RemoteCommandGuard.IsManagementCommand(req.command)) @@ -308,8 +308,8 @@ private static (int, string) RunCommand(string body) /// private static string ExecuteConsoleCommand(string command) { - // 层 1 兜底:任何走到这里的命令都来自远程控制通道(HTTP /control/* 与 WS call 同源), - // 管理 CLI 在此二次硬拦;同时标记执行上下文,让层 2 的人工确认永远无法被远程"确认"通过。 + // 兜底:任何走到这里的命令都来自远程控制通道(HTTP /control/* 与 WS call 同源), + // 管理 CLI 在此二次硬拦,并标记远程执行上下文。 if (RemoteCommandGuard.IsManagementCommand(command)) { Log.Warn($"[SLDataAPI][Control] 已拒绝远程执行 SLDataAPI 管理命令(兜底): {command}"); diff --git a/README.md b/README.md index 5314ded..fae4113 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,6 @@ voice_port: 8082 voice_record_enabled: false report_enabled: false control_log_enabled: true -api_key_confirm_timeout_seconds: 20 # create/revoke Key 的确认窗口等待秒数 ``` 完整字段、YAML 坑、token 写法见 Wiki [[Configuration]](https://github.com/DNTOF/SLDataAPI/wiki/Configuration)。 @@ -103,12 +102,9 @@ X-SLDataAPI-Key: sldataapi apikey create [note] sldataapi apikey list sldataapi apikey revoke -sldataapi apikey confirmtest # 自检确认窗口,不创建也不吊销任何 Key ``` -`create` / `revoke` 需在服务器本机确认:执行后会在服务器桌面**新弹出一个 cmd 窗口**显示确认面板(Y 确认 / N 取消,默认取消,超时拒绝),LocalAdmin 窗口不会被接管;弹不出窗口时退化为 LocalAdmin 的 `[y/N]` 行提示。`duty` 偏只读;`admin` 按端点 catalog 授权,**不会**自动开放 catalog 为 `false` 的路径(控制台、插件、文件等),可用 `endpoints_override` 单独放开。 - -装好后先跑一次 `sldataapi apikey confirmtest` 验证确认窗口:应在服务器桌面弹出一个**新的** cmd 窗口(标题 `SLDataAPI Security Confirm …`,蓝底面板、逐秒倒计时),按 `Y` 回显"自检通过"、按 `N` 或等到倒计时结束回显"未通过",全程 LocalAdmin 窗口的输出不被清屏也不被接管。 +`sldataapi` / `slda` 管理 CLI 已被远程控制通道(HTTP + WS 的 `/control/console/command`)硬拒绝;本地控制台(LocalAdmin / RemoteAdmin / 游戏内控制台)执行 `create` / `revoke` 会立即生效,不再弹出确认窗口或 `[y/N]` 提示。`duty` 偏只读;`admin` 按端点 catalog 授权,**不会**自动开放 catalog 为 `false` 的路径(控制台、插件、文件等),可用 `endpoints_override` 单独放开。 路径与 curl 示例:Wiki [[Preview-HTTP-API]](https://github.com/DNTOF/SLDataAPI/wiki/Preview-HTTP-API)。稳定 2.5 仍用 [[HTTP-API]](https://github.com/DNTOF/SLDataAPI/wiki/HTTP-API)。 diff --git a/SECURITY.md b/SECURITY.md index 08602a9..f93de09 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -42,7 +42,7 @@ SLDataAPI 提供服务器数据查询和远程控制能力(含执行控制台 **不在范围内:** -- 已知需要控制面凭据(2.6 起为 API Key,早期为 `control_token`)才能触发的行为——持有凭据本身就等同于拥有服务器控制台权限,这是设计如此,不是漏洞(例如 `/control/console/command` 能执行任意命令)。**例外**:借远程控制通道增发/吊销 API Key 属越权提升,在范围内——`sldataapi` / `slda` 管理 CLI 已被远程执行路径硬拒绝,`create` / `revoke` 另需服务器本地控制台人工确认;任何绕过这两层的路径都请报告。 +- 已知需要控制面凭据(2.6 起为 API Key,早期为 `control_token`)才能触发的行为——持有凭据本身就等同于拥有服务器控制台权限,这是设计如此,不是漏洞(例如 `/control/console/command` 能执行任意命令)。**例外**:借远程控制通道增发/吊销 API Key 属越权提升,在范围内——`sldataapi` / `slda` 管理 CLI 已被远程执行路径硬拒绝;任何绕过该硬拒绝的路径都请报告。 - 纯粹的资源消耗类拒绝服务(比如无限制发包把带宽打满),除非能绕过已有的连接数/请求体大小限制 - 依赖社会工程学(骗管理员泄露 token)的攻击路径 - 对已经过期不再维护的旧版本的报告 diff --git a/Services/ConfirmPanel.cs b/Services/ConfirmPanel.cs deleted file mode 100644 index aa69e17..0000000 --- a/Services/ConfirmPanel.cs +++ /dev/null @@ -1,413 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; - -namespace SLDataAPI.Services; - -/// 确认面板要确认的操作类型。 -public enum ConfirmAction -{ - Create, - Revoke, - - /// 确认通道自检:走完整条通道但不碰任何 Key(`sldataapi apikey confirmtest`)。 - SelfTest, -} - -/// 面板行的语义样式(由具体的控制台实现映射成颜色)。 -public enum PanelRowStyle -{ - Blank, - Frame, - Separator, - Title, - Label, - Warning, - Hint, - Choice, -} - -/// -/// 边框字符集:Unicode 制表符(默认)与纯 ASCII 兜底 -/// (旧代码页 / 不支持制表符的终端下 Unicode 边框会变成乱码)。 -/// -public enum PanelCharset -{ - Unicode, - Ascii, -} - -/// 确认窗口里的一行:已按目标宽度补齐的文本 + 语义样式。 -public sealed class PanelRow -{ - public PanelRow(string text, PanelRowStyle style) - { - Text = text ?? ""; - Style = style; - } - - public string Text { get; } - public PanelRowStyle Style { get; } - - public override string ToString() => Text; -} - -/// 待确认操作的数据(面板上展示的字段)。 -public sealed class ConfirmPanelModel -{ - public ConfirmAction Action { get; set; } = ConfirmAction.Create; - public string KeyId { get; set; } = ""; - public string Template { get; set; } = ""; - public string Note { get; set; } = ""; - - /// 行模式(弹不出确认窗口时)与日志用的单行描述。 - public string OneLinePrompt() => Action switch - { - ConfirmAction.Create => $"Confirm create API key id={KeyId} template={Template} ?", - ConfirmAction.Revoke => $"Confirm revoke API key id={KeyId} ?", - _ => "Confirm channel self-test (no API key will be created or revoked) ?", - }; -} - -/// -/// 确认面板的纯排版逻辑:把待确认操作渲染成"一屏 × 每行定宽"的行列表 -/// (新开的 cmd 确认窗口按秒各取一屏打印,见 ConfirmWindowProtocol)。 -/// 不碰任何控制台 API,宽度按终端显示列计算(CJK 全角字符占 2 列), -/// 因此可脱离游戏 DLL 直接单元测试。 -/// -public static class ConfirmPanelLayout -{ - /// 低于此尺寸不足以放下面板(调用方退化到行模式提示)。 - public const int MinWidth = 44; - public const int MinHeight = 14; - - private const int MaxPanelWidth = 78; - - private readonly struct Glyphs - { - public Glyphs(char h, char v, char tl, char tr, char bl, char br, char ml, char mr, string warn) - { - H = h; V = v; TL = tl; TR = tr; BL = bl; BR = br; ML = ml; MR = mr; Warn = warn; - } - - public char H { get; } - public char V { get; } - public char TL { get; } - public char TR { get; } - public char BL { get; } - public char BR { get; } - public char ML { get; } - public char MR { get; } - public string Warn { get; } - } - - private static Glyphs GlyphsFor(PanelCharset charset) => - charset == PanelCharset.Ascii - ? new Glyphs('-', '|', '+', '+', '+', '+', '+', '+', "[!]") - : new Glyphs('─', '│', '┌', '┐', '└', '┘', '├', '┤', "[!]"); - - /// - /// 渲染一屏。返回恰好 行,每行恰好 显示列, - /// 面板在屏幕中水平与垂直居中,其余区域为空白行(整屏重画,不残留旧内容)。 - /// 标出默认选项:确认窗口只收 Y/N,始终传 false, - /// 于是"取消"带着尖括号,一眼能看出默认是不放行。 - /// - public static IReadOnlyList Render( - ConfirmPanelModel model, - int width, - int height, - PanelCharset charset, - int secondsLeft, - bool confirmSelected) - { - if (model == null) throw new ArgumentNullException(nameof(model)); - - width = Math.Max(MinWidth, width); - height = Math.Max(MinHeight, height); - - Glyphs g = GlyphsFor(charset); - int panelWidth = Math.Min(width - 2, MaxPanelWidth); - int inner = panelWidth - 4; // 左右边框各 1 列 + 内缩进各 1 列 - - // 屏幕不够高时整条整条地少画风险提示(至少留一条),而不是把句子截一半 - List panel; - int warningCount = Warnings(model).Count(); - while (true) - { - panel = Frame(BuildBody(model, g, secondsLeft, confirmSelected, inner, warningCount), panelWidth, inner, g); - if (panel.Count <= height || warningCount <= 1) break; - warningCount--; - } - - TrimToHeight(panel, height); - - int top = Math.Max(0, (height - panel.Count) / 2); - int left = Math.Max(0, (width - panelWidth) / 2); - string indent = new string(' ', left); - - var screen = new List(height); - for (int y = 0; y < height; y++) - { - int idx = y - top; - if (idx < 0 || idx >= panel.Count) - { - screen.Add(new PanelRow(FitToWidth("", width), PanelRowStyle.Blank)); - continue; - } - screen.Add(new PanelRow(FitToWidth(indent + panel[idx].Text, width), panel[idx].Style)); - } - return screen; - } - - private static bool IsCentered(PanelRowStyle style) => - style is PanelRowStyle.Title or PanelRowStyle.Hint or PanelRowStyle.Choice; - - /// 给内容行套上边框(分隔行换成框线,居中样式的行居中)。 - private static List Frame(List body, int panelWidth, int inner, Glyphs g) - { - var framed = new List(body.Count + 2); - framed.Add(new PanelRow(TopBorder(panelWidth, g), PanelRowStyle.Frame)); - foreach (var item in body) - { - if (item.Style == PanelRowStyle.Separator) - { - framed.Add(new PanelRow(MidBorder(panelWidth, g), PanelRowStyle.Separator)); - continue; - } - - string text = IsCentered(item.Style) ? Center(item.Text, inner) : item.Text; - framed.Add(new PanelRow($"{g.V} {FitToWidth(text, inner)} {g.V}", item.Style)); - } - framed.Add(new PanelRow(BottomBorder(panelWidth, g), PanelRowStyle.Frame)); - return framed; - } - - private static List BuildBody( - ConfirmPanelModel model, Glyphs g, int secondsLeft, bool confirmSelected, int inner, int maxWarnings) - { - bool create = model.Action == ConfirmAction.Create; - var body = new List - { - new PanelRow("", PanelRowStyle.Blank), - new PanelRow(TitleFor(model.Action), PanelRowStyle.Title), - new PanelRow("", PanelRowStyle.Blank), - }; - - AddWrapped(body, $"id:{model.KeyId}", PanelRowStyle.Label, inner); - if (create && !string.IsNullOrWhiteSpace(model.Template)) - AddWrapped(body, $"模板:{model.Template}", PanelRowStyle.Label, inner); - if (!string.IsNullOrWhiteSpace(model.Note)) - AddWrapped(body, $"备注:{model.Note}", PanelRowStyle.Label, inner); - - body.Add(new PanelRow("", PanelRowStyle.Blank)); - foreach (string warning in Warnings(model).Take(Math.Max(1, maxWarnings))) - AddWrapped(body, $"{g.Warn} {warning}", PanelRowStyle.Warning, inner); - - body.Add(new PanelRow("", PanelRowStyle.Separator)); - body.Add(new PanelRow($"{Math.Max(0, secondsLeft)} 秒内未确认将自动拒绝(不创建 / 不吊销)", PanelRowStyle.Hint)); - body.Add(new PanelRow("", PanelRowStyle.Blank)); - body.Add(new PanelRow(ChoiceLine(confirmSelected), PanelRowStyle.Choice)); - body.Add(new PanelRow("", PanelRowStyle.Blank)); - AddWrapped(body, "按 Y 确认 · 按 N 取消 · 关闭本窗口或倒计时结束都按取消处理", PanelRowStyle.Hint, inner); - body.Add(new PanelRow("", PanelRowStyle.Blank)); - return body; - } - - private static string TitleFor(ConfirmAction action) => action switch - { - ConfirmAction.Create => "SLDataAPI 安全确认 · 创建 API Key", - ConfirmAction.Revoke => "SLDataAPI 安全确认 · 吊销 API Key", - _ => "SLDataAPI 安全确认 · 确认通道自检", - }; - - private static IEnumerable Warnings(ConfirmPanelModel model) - { - if (model.Action == ConfirmAction.Create) - { - yield return "新 Key 的明文只显示这一次,关闭后无法找回,只能吊销重建。"; - if (string.Equals(model.Template, "admin", StringComparison.OrdinalIgnoreCase)) - yield return "admin 模板可调用控制面全部已授权端点,等同管理权限。"; - yield return "若这次创建不是你本人在操作,请立刻选择取消并检查控制面日志。"; - } - else if (model.Action == ConfirmAction.Revoke) - { - yield return "吊销后该 Key 立即失效,正在使用它的平台 / 面板会断连。"; - yield return "此操作不可撤销,恢复需重新 create 并重新分发明文。"; - yield return "若这次吊销不是你本人在操作,请立刻选择取消并检查控制面日志。"; - } - else - { - yield return "这是确认通道自检:按 Y 或 N 都不会创建或吊销任何 API Key。"; - yield return "请确认本面板是新弹出的 cmd 窗口,且 LocalAdmin 窗口没有被清屏或接管。"; - } - } - - /// 选择行:左"确认"右"取消",选中项用尖括号标出(不依赖颜色也能看清)。 - public static string ChoiceLine(bool confirmSelected) - { - const string yes = "[ 确认 (Y) ]"; - const string no = "[ 取消 (N) ]"; - string left = confirmSelected ? $"> {yes} <" : $" {yes} "; - string right = confirmSelected ? $" {no} " : $"> {no} <"; - return left + " " + right; - } - - private static void AddWrapped(List body, string text, PanelRowStyle style, int inner) - { - foreach (string line in Wrap(text, inner)) - body.Add(new PanelRow(line, style)); - } - - /// - /// 面板仍高于屏幕时先削空白行,最后才从尾部硬裁(底部边框始终保留)。 - /// 风险提示的条数已在 Render 里按高度递减,这里不再动它。 - /// - private static void TrimToHeight(List panel, int height) - { - for (int i = panel.Count - 2; i > 0 && panel.Count > height; i--) - { - if (panel[i].Style == PanelRowStyle.Blank) - panel.RemoveAt(i); - } - while (panel.Count > height) - panel.RemoveAt(panel.Count - 2); - } - - private static string TopBorder(int panelWidth, Glyphs g) => - g.TL + new string(g.H, Math.Max(0, panelWidth - 2)) + g.TR; - - private static string MidBorder(int panelWidth, Glyphs g) => - g.ML + new string(g.H, Math.Max(0, panelWidth - 2)) + g.MR; - - private static string BottomBorder(int panelWidth, Glyphs g) => - g.BL + new string(g.H, Math.Max(0, panelWidth - 2)) + g.BR; - - // ────────────── 显示宽度工具(终端按显示列排版,不能按 char 数) ────────────── - - /// 字符是否占两个终端显示列(CJK 及全角标点)。 - public static bool IsWide(char c) => - (c >= 0x1100 && c <= 0x115F) || - (c >= 0x2E80 && c <= 0x303E) || - (c >= 0x3041 && c <= 0x33FF) || - (c >= 0x3400 && c <= 0x4DBF) || - (c >= 0x4E00 && c <= 0x9FFF) || - (c >= 0xA000 && c <= 0xA4CF) || - (c >= 0xAC00 && c <= 0xD7A3) || - (c >= 0xF900 && c <= 0xFAFF) || - (c >= 0xFE30 && c <= 0xFE4F) || - (c >= 0xFF00 && c <= 0xFF60) || - (c >= 0xFFE0 && c <= 0xFFE6); - - /// 字符串占用的终端显示列数。 - public static int DisplayWidth(string? text) - { - if (string.IsNullOrEmpty(text)) return 0; - int w = 0; - foreach (char c in text!) - { - if (char.IsControl(c)) continue; - w += IsWide(c) ? 2 : 1; - } - return w; - } - - /// - /// 把文本裁剪 / 补齐到恰好 显示列。 - /// 裁剪位置落在全角字符中间时不劈开该字符,改用空格补位。 - /// - public static string FitToWidth(string? text, int width) - { - if (width <= 0) return ""; - var sb = new StringBuilder(width); - int used = 0; - foreach (char c in text ?? "") - { - if (char.IsControl(c)) continue; - int cw = IsWide(c) ? 2 : 1; - if (used + cw > width) break; - sb.Append(c); - used += cw; - } - if (used < width) sb.Append(' ', width - used); - return sb.ToString(); - } - - /// 按显示列宽度折行;无空格可断(中文)时按字符硬断。 - public static IReadOnlyList Wrap(string? text, int width) - { - var lines = new List(); - if (width <= 0) return lines; - if (string.IsNullOrEmpty(text)) - { - lines.Add(""); - return lines; - } - - var current = new StringBuilder(); - int used = 0; - int lastBreak = -1; // current 中最后一个空格的位置(含其显示宽度) - int widthAtBreak = 0; - - foreach (char c in text!) - { - if (char.IsControl(c)) continue; - int cw = IsWide(c) ? 2 : 1; - if (used + cw > width) - { - // 空格断行只在断点已经填满半行以上时才用;否则硬断。 - // 中英混排("[!] 新 Key 的明文……")里最后一个空格往往在很靠前的位置, - // 一律按空格断会留下一行几个字、下一行超长的难看结果。 - if (lastBreak > 0 && lastBreak <= current.Length && widthAtBreak * 2 >= width) - { - string head = current.ToString(0, lastBreak).TrimEnd(); - string tail = current.ToString(lastBreak, current.Length - lastBreak); - lines.Add(head); - current.Clear(); - current.Append(tail); - used -= widthAtBreak; - } - else - { - // 硬断:避让行首禁则字符(句读、收尾括号),把前一个字一起带到下一行 - string lineText = current.ToString(); - string carry = ""; - if (IsNoBreakBefore(c) && lineText.Length > 1) - { - carry = lineText.Substring(lineText.Length - 1); - lineText = lineText.Substring(0, lineText.Length - 1); - } - lines.Add(lineText); - current.Clear(); - current.Append(carry); - used = DisplayWidth(carry); - } - lastBreak = -1; - } - current.Append(c); - used += cw; - if (c == ' ') - { - lastBreak = current.Length; - widthAtBreak = used; - } - } - - if (current.Length > 0 || lines.Count == 0) - lines.Add(current.ToString()); - return lines; - } - - /// 行首禁则:这些字符不应落在折行后的行首(中文排版习惯)。 - public static bool IsNoBreakBefore(char c) => - "。,、;:?!)】」』》”’·%".IndexOf(c) >= 0; - - /// 按显示列居中。 - public static string Center(string? text, int width) - { - int w = DisplayWidth(text); - if (w >= width) return FitToWidth(text, width); - int left = (width - w) / 2; - return FitToWidth(new string(' ', left) + text, width); - } -} diff --git a/Services/ConfirmWindowChannel.cs b/Services/ConfirmWindowChannel.cs deleted file mode 100644 index 875e3b1..0000000 --- a/Services/ConfirmWindowChannel.cs +++ /dev/null @@ -1,368 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using SLDataAPI.Auth; - -namespace SLDataAPI.Services; - -/// -/// 【主通道】在服务器桌面上新开一个 cmd.exe 窗口做人工确认。 -/// -/// 这里刻意不碰 LocalAdmin 的控制台:不 AttachConsole、不 FreeConsole、不清屏、不恢复。 -/// 新窗口用 CREATE_NEW_CONSOLE 拉起(ShellExecute 兜底,它默认同样给控制台程序开新窗口), -/// 拥有自己的屏幕缓冲区与输入队列,关掉即走,LocalAdmin 的输出全程不受影响。 -/// -/// 结论只认"退出码 + 一次性校验串"两者同时对上;其余一切情况(窗口起不来、被 X 掉、 -/// 脚本异常退出)要么按拒绝处理,要么把通道判为不可用交给下一条通道,绝不放行。 -/// -internal static class ConfirmWindowChannel -{ - /// 子进程自己会在倒计时结束时退出;这是父进程多等的宽限,防止卡住命令线程。 - private const int GraceMs = 8000; - - private const string ScratchPrefix = "confirm-ui-"; - - /// - /// 弹出确认窗口并等待结论。返回 false 表示该通道不可用(调用方继续尝试行模式 / 对话框)。 - /// - public static bool TryConfirm( - ConfirmPanelModel model, int timeoutSeconds, out ConfirmOutcome outcome, out string detail) - { - outcome = ConfirmOutcome.Denied; - detail = ""; - - if (model == null) throw new ArgumentNullException(nameof(model)); - if (!IsSupportedHost(out string comSpec)) - return false; - - string? dir = null; - try - { - dir = CreateScratchDirectory(); - if (dir == null) - return false; - - string nonce = ConfirmWindowProtocol.NewNonce(); - WritePayload(dir, model, timeoutSeconds, nonce); - - // 先说一声再阻塞:命令是在 LocalAdmin 里敲的,操作者得知道要去看新窗口 - Log.Info($"[SLDataAPI] 正在服务器桌面弹出确认窗口(独立 cmd 窗口)," + - $"请在该窗口按 Y 确认 / N 取消({timeoutSeconds} 秒后自动拒绝)"); - - if (!TryRunConfirmWindow(comSpec, dir, timeoutSeconds, out int exitCode)) - return false; - - string resultPath = Path.Combine(dir, ConfirmWindowProtocol.ResultFileName); - string? resultText = null; - try { if (File.Exists(resultPath)) resultText = File.ReadAllText(resultPath); } - catch (Exception ex) { Log.Debug($"[SLDataAPI] 读取确认窗口结果失败: {ex.Message}"); } - - if (!ConfirmWindowProtocol.TryParseOutcome(exitCode, resultText, nonce, out outcome, out detail)) - { - Log.Debug($"[SLDataAPI] 确认窗口未给出有效结论(退出码 {exitCode}),改用下一条确认通道"); - return false; - } - return true; - } - catch (Exception ex) - { - Log.Warn($"[SLDataAPI] 新开确认窗口失败: {ex.Message}"); - return false; - } - finally - { - TryDeleteDirectory(dir); - } - } - - /// - /// 只在"Windows + 有交互桌面 + 找得到 cmd.exe"时可用。 - /// 以服务方式运行(会话 0,无桌面)时新窗口没人看得见,直接判不可用,让行模式接手。 - /// - private static bool IsSupportedHost(out string comSpec) - { - comSpec = ""; - try - { - if (Environment.OSVersion.Platform != PlatformID.Win32NT) - return false; - if (!Environment.UserInteractive) - { - Log.Debug("[SLDataAPI] 当前会话没有交互桌面,跳过新开确认窗口"); - return false; - } - - string candidate = Environment.GetEnvironmentVariable("ComSpec") ?? ""; - if (string.IsNullOrWhiteSpace(candidate) || !File.Exists(candidate)) - candidate = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe"); - if (!File.Exists(candidate)) - return false; - - comSpec = candidate; - return true; - } - catch (Exception ex) - { - Log.Debug($"[SLDataAPI] 确认窗口宿主环境判定失败: {ex.Message}"); - return false; - } - } - - // ────────────── 临时目录 ────────────── - - /// - /// 面板与结论文件放在插件配置目录下(与 apikey.config 同一信任边界:能写这里的人 - /// 本来就能改密钥库),配置目录不可用时退回系统临时目录。目录名随机,用完即删。 - /// - private static string? CreateScratchDirectory() - { - foreach (string? baseDir in new[] { PluginConfigDirectory(), SafeTempDirectory() }) - { - if (string.IsNullOrWhiteSpace(baseDir)) - continue; - try - { - SweepStaleDirectories(baseDir!); - string dir = Path.Combine(baseDir!, ScratchPrefix + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(dir); - return dir; - } - catch (Exception ex) - { - Log.Debug($"[SLDataAPI] 无法在 {baseDir} 建立确认窗口临时目录: {ex.Message}"); - } - } - return null; - } - - private static string? PluginConfigDirectory() - { - try - { - string path = ApiKeyService.ConfigPath; - if (string.IsNullOrWhiteSpace(path)) - return null; - string? dir = Path.GetDirectoryName(path); - return Directory.Exists(dir) ? dir : null; - } - catch { return null; } - } - - private static string? SafeTempDirectory() - { - try { return Path.GetTempPath(); } - catch { return null; } - } - - /// 清掉上次进程崩溃残留的目录,避免越堆越多。 - private static void SweepStaleDirectories(string baseDir) - { - try - { - foreach (string stale in Directory.GetDirectories(baseDir, ScratchPrefix + "*")) - { - try - { - if (DateTime.UtcNow - Directory.GetCreationTimeUtc(stale) > TimeSpan.FromHours(1)) - Directory.Delete(stale, recursive: true); - } - catch { /* 残留目录删不掉不影响本次确认 */ } - } - } - catch { /* 枚举失败忽略 */ } - } - - private static void TryDeleteDirectory(string? dir) - { - if (string.IsNullOrEmpty(dir)) return; - try { if (Directory.Exists(dir)) Directory.Delete(dir!, recursive: true); } - catch (Exception ex) { Log.Debug($"[SLDataAPI] 确认窗口临时目录清理失败: {ex.Message}"); } - } - - /// 写入脚本(ASCII)与每一秒对应的一屏面板(UTF-8 无 BOM,给 cmd 的 type 用)。 - private static void WritePayload(string dir, ConfirmPanelModel model, int timeoutSeconds, string nonce) - { - var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - for (int seconds = timeoutSeconds; seconds >= 1; seconds--) - { - File.WriteAllText( - Path.Combine(dir, ConfirmWindowProtocol.PanelFileName(seconds)), - ConfirmWindowProtocol.RenderPanel(model, seconds), - utf8NoBom); - } - - File.WriteAllText( - Path.Combine(dir, ConfirmWindowProtocol.ScriptFileName), - ConfirmWindowProtocol.BuildScript(timeoutSeconds, nonce), - Encoding.ASCII); - } - - // ────────────── 起窗口 ────────────── - - private static bool TryRunConfirmWindow(string comSpec, string dir, int timeoutSeconds, out int exitCode) - { - int waitMs = timeoutSeconds * 1000 + GraceMs; - // 工作目录就是脚本目录,命令行里只出现不含空格的脚本文件名,省掉 cmd 的引号陷阱 - string commandLine = $"\"{comSpec}\" /c {ConfirmWindowProtocol.ScriptFileName}"; - - if (TryRunViaCreateProcess(commandLine, dir, waitMs, out exitCode)) - return true; - return TryRunViaShellExecute(comSpec, dir, waitMs, out exitCode); - } - - private static bool TryRunViaCreateProcess(string commandLine, string dir, int waitMs, out int exitCode) - { - exitCode = 0; - var startupInfo = new StartupInfo { cb = Marshal.SizeOf(typeof(StartupInfo)) }; - startupInfo.dwFlags = StartfUseShowWindow; - startupInfo.wShowWindow = SwShowNormal; - - bool created; - ProcessInformation info; - try - { - created = CreateProcessW(null, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, - bInheritHandles: false, dwCreationFlags: CreateNewConsole, lpEnvironment: IntPtr.Zero, - lpCurrentDirectory: dir, lpStartupInfo: ref startupInfo, lpProcessInformation: out info); - } - catch (Exception ex) - { - Log.Debug($"[SLDataAPI] CreateProcess 起确认窗口失败: {ex.Message}"); - return false; - } - - if (!created) - { - Log.Debug($"[SLDataAPI] CreateProcess 起确认窗口失败(Win32 错误 {Marshal.GetLastWin32Error()})"); - return false; - } - - try - { - uint wait = WaitForSingleObject(info.hProcess, (uint)waitMs); - if (wait != WaitObject0) - { - // 子进程自己应该已经超时退出;没退就关掉窗口,按超时处理 - try { TerminateProcess(info.hProcess, (uint)ConfirmWindowProtocol.ExitTimedOut); } - catch { /* 已退出 */ } - WaitForSingleObject(info.hProcess, 2000); - } - - if (!GetExitCodeProcess(info.hProcess, out uint code)) - return false; - exitCode = unchecked((int)code); - return true; - } - finally - { - if (info.hThread != IntPtr.Zero) CloseHandle(info.hThread); - if (info.hProcess != IntPtr.Zero) CloseHandle(info.hProcess); - } - } - - /// - /// 兜底:ShellExecute(UseShellExecute=true)默认也会给控制台程序开新窗口 - /// (不带 SEE_MASK_NO_CONSOLE 就等同 CREATE_NEW_CONSOLE),同样不会借用 LocalAdmin 的窗口。 - /// - private static bool TryRunViaShellExecute(string comSpec, string dir, int waitMs, out int exitCode) - { - exitCode = 0; - try - { - var psi = new ProcessStartInfo(comSpec, "/c " + ConfirmWindowProtocol.ScriptFileName) - { - UseShellExecute = true, - CreateNoWindow = false, - WindowStyle = ProcessWindowStyle.Normal, - WorkingDirectory = dir, - }; - - using (Process? process = Process.Start(psi)) - { - if (process == null) - return false; - if (!process.WaitForExit(waitMs)) - { - // 子进程本该自己倒计时结束退出;没退就关掉窗口,按超时处理 - try { process.Kill(); } catch { /* 已退出 */ } - try { process.WaitForExit(2000); } catch { /* 忽略 */ } - exitCode = ConfirmWindowProtocol.ExitTimedOut; - return true; - } - exitCode = process.ExitCode; - return true; - } - } - catch (Exception ex) - { - Log.Debug($"[SLDataAPI] ShellExecute 起确认窗口失败: {ex.Message}"); - return false; - } - } - - // ────────────── P/Invoke ────────────── - - private const uint CreateNewConsole = 0x00000010; - private const int StartfUseShowWindow = 0x00000001; - private const short SwShowNormal = 1; - private const uint WaitObject0 = 0x00000000; - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - private struct StartupInfo - { - public int cb; - public IntPtr lpReserved; - public IntPtr lpDesktop; - public IntPtr lpTitle; - public int dwX; - public int dwY; - public int dwXSize; - public int dwYSize; - public int dwXCountChars; - public int dwYCountChars; - public int dwFillAttribute; - public int dwFlags; - public short wShowWindow; - public short cbReserved2; - public IntPtr lpReserved2; - public IntPtr hStdInput; - public IntPtr hStdOutput; - public IntPtr hStdError; - } - - [StructLayout(LayoutKind.Sequential)] - private struct ProcessInformation - { - public IntPtr hProcess; - public IntPtr hThread; - public int dwProcessId; - public int dwThreadId; - } - - [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern bool CreateProcessW( - string? lpApplicationName, - StringBuilder lpCommandLine, - IntPtr lpProcessAttributes, - IntPtr lpThreadAttributes, - bool bInheritHandles, - uint dwCreationFlags, - IntPtr lpEnvironment, - string? lpCurrentDirectory, - ref StartupInfo lpStartupInfo, - out ProcessInformation lpProcessInformation); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool GetExitCodeProcess(IntPtr handle, out uint exitCode); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool TerminateProcess(IntPtr handle, uint exitCode); - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool CloseHandle(IntPtr handle); -} diff --git a/Services/ConfirmWindowProtocol.cs b/Services/ConfirmWindowProtocol.cs deleted file mode 100644 index 41edce0..0000000 --- a/Services/ConfirmWindowProtocol.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using System.Security.Cryptography; -using System.Text; - -namespace SLDataAPI.Services; - -/// 确认会话的结果。 -public enum ConfirmOutcome -{ - Confirmed, - Denied, - TimedOut, -} - -/// -/// 新开 cmd 窗口确认通道的"协议"层(纯逻辑,无 Win32 / 游戏依赖,可直接单元测试): -/// 往临时目录里写什么、批处理脚本长什么样、子进程退出后怎么判定结论。 -/// -/// 为什么是一个独立进程而不是接管当前控制台:LocalAdmin 的控制台由 LocalAdmin 自己持有 -/// (它转发游戏进程的输出),外部 AttachConsole / 整屏接管在真实服务器上不生效; -/// 用 CREATE_NEW_CONSOLE 拉起的 cmd.exe 拥有自己的控制台缓冲区与输入队列, -/// 既不需要借 LocalAdmin 的窗口,也不会改动它的任何状态。 -/// -/// 进程间只传三样东西,都不含密钥明文: -/// 1. 面板正文(panel-N.txt,每个倒计时秒数一屏,UTF-8 无 BOM,交给 cmd 的 type 打印); -/// 2. 批处理脚本(confirm.cmd,纯 ASCII——避免代码页差异把脚本本身解析坏); -/// 3. 结论(进程退出码 + result.txt 里的一次性校验串,二者都对上才算"确认通过")。 -/// 命令行上只有脚本文件名,面板字段不经命令行,也就不会出现在其它进程的命令行快照里。 -/// -public static class ConfirmWindowProtocol -{ - // 退出码刻意避开 cmd / choice 自己会用到的小数值与 9009(命令不存在) - public const int ExitConfirmed = 10; - public const int ExitDenied = 11; - public const int ExitTimedOut = 12; - - public const string ScriptFileName = "confirm.cmd"; - public const string ResultFileName = "result.txt"; - public const string PanelFilePrefix = "panel-"; - public const string PanelFileSuffix = ".txt"; - - /// 确认窗口的排版尺寸。 - public const int ScreenWidth = 80; - - /// - /// 一屏画多少行。24 行 + 末尾换行 = 25 行,正好是 conhost 默认窗口高度: - /// 即使 mode con 调整窗口失败,面板也不会把自己顶得滚屏。 - /// - public const int ScreenHeight = 24; - - /// 脚本请求的窗口尺寸(比一屏多两行余量)。 - public const int WindowLines = ScreenHeight + 2; - - private const string ConfirmTag = "CONFIRM"; - private const string DenyTag = "DENY"; - private const string TimeoutTag = "TIMEOUT"; - - public static string PanelFileName(int secondsLeft) => - PanelFilePrefix + secondsLeft.ToString(CultureInfo.InvariantCulture) + PanelFileSuffix; - - /// 一次性校验串:确认窗口把它写回 result.txt,父进程逐字比对。 - public static string NewNonce() - { - var bytes = new byte[16]; - using (var rng = RandomNumberGenerator.Create()) - rng.GetBytes(bytes); - var sb = new StringBuilder(bytes.Length * 2); - foreach (byte b in bytes) - sb.Append(b.ToString("x2", CultureInfo.InvariantCulture)); - return sb.ToString(); - } - - /// 确认窗口写回 result.txt 的整行内容。 - public static string ResultLine(ConfirmOutcome outcome, string nonce) => - $"{TagFor(outcome)} {nonce}"; - - private static string TagFor(ConfirmOutcome outcome) => outcome switch - { - ConfirmOutcome.Confirmed => ConfirmTag, - ConfirmOutcome.TimedOut => TimeoutTag, - _ => DenyTag, - }; - - /// - /// 渲染倒计时到 秒时的一屏正文(末行是 ASCII 摘要)。 - /// 行尾空白裁掉:cmd 里写满整行会触发自动换行,把画面顶上去。 - /// - public static string RenderPanel( - ConfirmPanelModel model, int secondsLeft, PanelCharset charset = PanelCharset.Unicode) - { - if (model == null) throw new ArgumentNullException(nameof(model)); - - IReadOnlyList rows = ConfirmPanelLayout.Render( - model, ScreenWidth, ScreenHeight - 1, charset, secondsLeft, confirmSelected: false); - - var lines = rows.Select(r => r.Text.TrimEnd()).ToList(); - lines.Add(AsciiSummary(model, secondsLeft)); - return string.Join("\r\n", lines); - } - - /// - /// 屏幕最下面的纯 ASCII 摘要行:万一操作者的控制台字体 / 代码页画不出中文, - /// 这一行仍能说清在确认什么、按哪个键、还剩几秒。 - /// - public static string AsciiSummary(ConfirmPanelModel model, int secondsLeft) - { - if (model == null) throw new ArgumentNullException(nameof(model)); - - string action = model.Action switch - { - ConfirmAction.Create => "CREATE", - ConfirmAction.Revoke => "REVOKE", - _ => "SELF-TEST", - }; - string template = model.Action == ConfirmAction.Create && !string.IsNullOrWhiteSpace(model.Template) - ? " template=" + ToAscii(model.Template, 24) - : ""; - // 按键与倒计时放最前面:行尾被裁掉时,最要紧的信息也还在 - string text = $"Y=allow N=cancel | {Math.Max(0, secondsLeft).ToString(CultureInfo.InvariantCulture)}s" + - $" | {action} api key id={ToAscii(model.KeyId, 32)}{template}"; - if (text.Length > ScreenWidth - 1) - text = text.Substring(0, ScreenWidth - 1); - return text.TrimEnd(); - } - - /// 非 ASCII 字符(含控制字符)统一换成 ?,并限长。 - private static string ToAscii(string? text, int maxLength) - { - if (string.IsNullOrEmpty(text)) return ""; - var sb = new StringBuilder(Math.Min(text!.Length, maxLength)); - foreach (char c in text!) - { - if (sb.Length >= maxLength) { sb.Append('~'); break; } - sb.Append(c >= 0x20 && c <= 0x7E ? c : '?'); - } - return sb.ToString(); - } - - /// - /// 生成确认窗口跑的批处理。纯 ASCII:中文只出现在 type 打印的面板文件里, - /// 脚本本身在任何代码页下都解析得一样。 - /// - /// 交互靠 choice(Vista 起随系统自带):每秒一次 1 秒超时的按键读取, - /// 超时就重画下一秒的面板,于是倒计时会真的走。choice 缺失时退化成一次 set /p 行输入 - /// (无倒计时,父进程等到超时会结束掉窗口,按拒绝处理)。 - /// - public static string BuildScript(int timeoutSeconds, string nonce) - { - if (timeoutSeconds <= 0) throw new ArgumentOutOfRangeException(nameof(timeoutSeconds)); - if (string.IsNullOrEmpty(nonce)) throw new ArgumentException("nonce 不能为空", nameof(nonce)); - if (!IsAsciiWord(nonce)) throw new ArgumentException("nonce 必须是 ASCII 字母数字", nameof(nonce)); - - string seconds = timeoutSeconds.ToString(CultureInfo.InvariantCulture); - var lines = new List - { - "@echo off", - "setlocal enableextensions", - "title SLDataAPI Security Confirm - press Y to allow / N to cancel", - "chcp 65001 >nul 2>&1", - $"mode con: cols={ScreenWidth.ToString(CultureInfo.InvariantCulture)} " + - $"lines={WindowLines.ToString(CultureInfo.InvariantCulture)} >nul 2>&1", - "color 1F", - "set \"PANELDIR=%~dp0\"", - $"set \"RESULT=%PANELDIR%{ResultFileName}\"", - $"set \"LEFT={seconds}\"", - "where choice >nul 2>&1 || goto noChoice", - "", - ":loop", - "cls", - $"type \"%PANELDIR%{PanelFilePrefix}%LEFT%{PanelFileSuffix}\" 2>nul", - // choice 的 /C 里 T 只是"计时片用完"的默认答案,按到它等同于一次静默重画 - "choice /C YNT /N /T 1 /D T >nul 2>&1", - "if errorlevel 3 goto tick", - "if errorlevel 2 goto deny", - "if errorlevel 1 goto allow", - "goto deny", - "", - ":tick", - "set /a LEFT-=1", - "if %LEFT% GTR 0 goto loop", - "goto expire", - "", - ":noChoice", - "cls", - $"type \"%PANELDIR%{PanelFilePrefix}%LEFT%{PanelFileSuffix}\" 2>nul", - "echo.", - "set \"ANSWER=\"", - // 提示语里不放 > :即便被引号保护,也不给 cmd 的重定向解析留想象空间 - "set /p \"ANSWER=Y = allow / N = cancel : \"", - "if /i \"%ANSWER%\"==\"y\" goto allow", - "if /i \"%ANSWER%\"==\"yes\" goto allow", - "goto deny", - "", - ":allow", - $">\"%RESULT%\" echo {ResultLine(ConfirmOutcome.Confirmed, nonce)}", - $"exit {ExitConfirmed.ToString(CultureInfo.InvariantCulture)}", - "", - ":deny", - $">\"%RESULT%\" echo {ResultLine(ConfirmOutcome.Denied, nonce)}", - $"exit {ExitDenied.ToString(CultureInfo.InvariantCulture)}", - "", - ":expire", - $">\"%RESULT%\" echo {ResultLine(ConfirmOutcome.TimedOut, nonce)}", - $"exit {ExitTimedOut.ToString(CultureInfo.InvariantCulture)}", - "", - }; - - string script = string.Join("\r\n", lines); - if (!IsAscii(script)) - throw new InvalidOperationException("确认脚本必须是纯 ASCII"); - return script; - } - - /// - /// 判定确认窗口给出的结论。返回 false 表示这一路没有给出有效结论 - /// (窗口没起来 / 被 X 掉 / 脚本本身跑挂),调用方应继续尝试下一条通道。 - /// 任何"看不懂"的情况都不会被当成确认通过。 - /// - public static bool TryParseOutcome( - int exitCode, string? resultText, string nonce, out ConfirmOutcome outcome, out string detail) - { - outcome = ConfirmOutcome.Denied; - detail = ""; - string token = (resultText ?? "").Trim(); - - switch (exitCode) - { - case ExitConfirmed: - if (string.Equals(token, ResultLine(ConfirmOutcome.Confirmed, nonce), StringComparison.Ordinal)) - { - outcome = ConfirmOutcome.Confirmed; - return true; - } - // 退出码说"确认"但校验串对不上:只能按拒绝处理 - detail = "确认窗口返回的一次性校验串不匹配"; - outcome = ConfirmOutcome.Denied; - return true; - - case ExitDenied: - outcome = ConfirmOutcome.Denied; - return true; - - case ExitTimedOut: - outcome = ConfirmOutcome.TimedOut; - return true; - - default: - return false; - } - } - - private static bool IsAscii(string text) => text.All(c => c <= 0x7F); - - private static bool IsAsciiWord(string text) => - text.Length > 0 && text.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')); -} diff --git a/Services/OperatorConfirmService.cs b/Services/OperatorConfirmService.cs deleted file mode 100644 index dcab4d3..0000000 --- a/Services/OperatorConfirmService.cs +++ /dev/null @@ -1,314 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Runtime.InteropServices; -using System.Threading; -using SLDataAPI.Auth; - -namespace SLDataAPI.Services; - -/// -/// 服务端敏感操作(API Key 创建 / 吊销)的人工确认(层 2)。 -/// -/// 通道优先级: -/// 0. 远程控制通道下发的命令直接拒绝——层 1 已在 /control/console/command 处硬拦, -/// 这里在触碰任何界面之前再兜一层,保证层 2 永远不可能被远程"确认"通过。 -/// 1. 【主通道】新开一个 cmd.exe 窗口显示确认面板(操作、id、模板、备注、风险提示、 -/// 倒计时、Y/N),在那个窗口里读按键,结论用退出码 + 一次性校验串回传。 -/// 实现见 ConfirmWindowChannel:全程不接管、不 Attach、不还原 LocalAdmin 的控制台。 -/// 2. 行模式:弹不出新窗口但标准输入可读时,打一行提示 + 读一行(无头/管道方式运行)。 -/// 3. MessageBox:仅当上面两条都不可用且明确存在交互桌面时的最后兜底。 -/// 拒绝、超时、无可用通道一律按拒绝处理,调用方不得执行变更。 -/// -/// 注意:行模式的标准输入读取线程在首次用到时才启动(此后常驻)并持续消费本进程标准输入; -/// 提示前会丢弃队列里的历史输入行,避免"提前敲 y"预先应答。 -/// -public static class OperatorConfirmService -{ - private const int DefaultTimeoutSeconds = 20; - private const int MinTimeoutSeconds = 5; - private const int MaxTimeoutSeconds = 120; - private const int PollSliceMs = 200; - - // 同一时刻只允许一个确认会话:两个确认窗口/行提示并存时,操作者分不清自己放行的是哪一次 - private static readonly object Gate = new object(); - private static readonly BlockingCollection PendingLines = - new BlockingCollection(new ConcurrentQueue()); - - private static Thread? _stdinReader; - private static volatile bool _stdinUnavailable; - - /// - /// 向服务端操作者索取一次确认。返回 false 即调用方必须中止操作, - /// 给出可直接回显给命令发送者的原因。 - /// - public static bool Confirm(ConfirmPanelModel model, out string reason) - { - if (model == null) throw new ArgumentNullException(nameof(model)); - reason = ""; - - // 远程执行上下文:在画任何界面之前就失败,远程通道不可能自我确认 - if (RemoteCommandGuard.IsRemoteExecution) - { - reason = "远程控制通道不允许该操作(只能在服务器本地控制台执行)"; - return false; - } - - int timeoutSeconds = ResolveTimeoutSeconds(); - string prompt = model.OneLinePrompt(); - - lock (Gate) - { - if (TryConfirmViaWindow(model, timeoutSeconds, out bool windowAnswer, out string windowReason)) - { - reason = windowReason; - LogDecision(prompt, windowAnswer, windowAnswer ? "确认窗口确认" : windowReason); - return windowAnswer; - } - - if (TryConfirmViaStdin(prompt, timeoutSeconds, out bool lineAnswer, out string lineReason)) - { - reason = lineReason; - LogDecision(prompt, lineAnswer, lineAnswer ? "行模式确认" : lineReason); - return lineAnswer; - } - - if (TryConfirmViaMessageBox(prompt, timeoutSeconds, out bool dialogAnswer)) - { - if (!dialogAnswer) - reason = "服务端操作者在确认对话框中选择了否"; - LogDecision(prompt, dialogAnswer, dialogAnswer ? "对话框确认" : reason); - return dialogAnswer; - } - - reason = "无可用的人工确认通道(弹不出新的 cmd 确认窗口、标准输入不可读且无交互桌面),已按拒绝处理"; - Log.Warn($"[SLDataAPI] 人工确认通道不可用,已拒绝敏感操作:{prompt}"); - return false; - } - } - - private static void LogDecision(string prompt, bool confirmed, string detail) - { - string text = $"[SLDataAPI] 人工确认{(confirmed ? "通过" : "未通过")}:{prompt}" + - (string.IsNullOrEmpty(detail) ? "" : $"({detail})"); - if (confirmed) Log.Info(text); - else Log.Warn(text); - } - - private static int ResolveTimeoutSeconds() - { - int configured = Plugin.Instance?.Config.ApiKeyConfirmTimeoutSeconds ?? DefaultTimeoutSeconds; - if (configured <= 0) configured = DefaultTimeoutSeconds; - return Math.Max(MinTimeoutSeconds, Math.Min(MaxTimeoutSeconds, configured)); - } - - // ────────────── 通道 1:新开的 cmd 确认窗口 ────────────── - - private static bool TryConfirmViaWindow( - ConfirmPanelModel model, int timeoutSeconds, out bool answer, out string reason) - { - answer = false; - reason = ""; - - if (!ConfirmWindowChannel.TryConfirm(model, timeoutSeconds, out ConfirmOutcome outcome, out string detail)) - return false; - - switch (outcome) - { - case ConfirmOutcome.Confirmed: - answer = true; - return true; - case ConfirmOutcome.TimedOut: - reason = $"{timeoutSeconds} 秒内未在弹出的确认窗口中确认,已按拒绝处理"; - return true; - default: - reason = string.IsNullOrEmpty(detail) - ? "服务端操作者在弹出的确认窗口中选择了取消" - : $"确认窗口未放行:{detail}"; - return true; - } - } - - // ────────────── 通道 2:行模式 ────────────── - - /// - /// 打一行提示并读一行标准输入。返回 false 表示该通道不可用(应尝试下一个通道)。 - /// - private static bool TryConfirmViaStdin( - string prompt, int timeoutSeconds, out bool answer, out string reason) - { - answer = false; - reason = ""; - - if (!EnsureStdinReader()) - return false; - - DrainPendingLines(); - WriteConsoleLine($"[SLDataAPI][安全确认] {prompt} [y/N]({timeoutSeconds} 秒内在服务器控制台回答,超时按拒绝处理)"); - - DateTime deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds); - while (DateTime.UtcNow < deadline) - { - if (PendingLines.TryTake(out string? line, PollSliceMs)) - { - answer = RemoteCommandGuard.IsAffirmative(line); - if (!answer) - reason = "服务端操作者未确认(回答不是 y/yes)"; - return true; - } - if (_stdinUnavailable) - return false; - } - - if (_stdinUnavailable) - return false; - - reason = $"{timeoutSeconds} 秒内未在服务器控制台收到确认,已按拒绝处理"; - return true; - } - - private static bool EnsureStdinReader() - { - if (_stdinUnavailable) - return false; - if (_stdinReader != null) - return true; - - try - { - _stdinReader = new Thread(ReadStdinLoop) - { - IsBackground = true, - Name = "SLDataAPI-Confirm-Stdin", - }; - _stdinReader.Start(); - return true; - } - catch (Exception ex) - { - _stdinUnavailable = true; - Log.Warn($"[SLDataAPI] 无法启动控制台确认读取线程: {ex.Message}"); - return false; - } - } - - private static void ReadStdinLoop() - { - try - { - while (true) - { - string? line = Console.In.ReadLine(); - if (line == null) - { - // EOF:本进程没有可读的控制台输入(服务方式运行 / stdin 重定向到空设备) - _stdinUnavailable = true; - return; - } - PendingLines.Add(line); - } - } - catch (Exception ex) - { - _stdinUnavailable = true; - Log.Debug($"[SLDataAPI] 控制台确认读取线程结束: {ex.Message}"); - } - } - - /// 丢弃提示出现之前积压的输入行,防止预先敲入的 y 被当成本次回答。 - private static void DrainPendingLines() - { - while (PendingLines.TryTake(out _)) { } - } - - // ────────────── 通道 3:图形对话框(最后兜底) ────────────── - - private const uint MbYesNo = 0x00000004; - private const uint MbIconWarning = 0x00000030; - private const uint MbDefButton2 = 0x00000100; - private const uint MbSystemModal = 0x00001000; - private const uint MbSetForeground = 0x00010000; - private const int IdYes = 6; - - [DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "MessageBoxW")] - private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type); - - private static bool TryConfirmViaMessageBox(string prompt, int timeoutSeconds, out bool answer) - { - answer = false; - if (!IsInteractiveDesktopAvailable()) - return false; - - int result = 0; - Exception? failure = null; - var done = new ManualResetEventSlim(false); - - var dialog = new Thread(() => - { - try - { - result = MessageBoxW(IntPtr.Zero, prompt + "\n\n确认执行该操作?", - "SLDataAPI 安全确认", - MbYesNo | MbIconWarning | MbDefButton2 | MbSystemModal | MbSetForeground); - } - catch (Exception ex) - { - failure = ex; - } - finally - { - done.Set(); - } - }) - { - IsBackground = true, - Name = "SLDataAPI-Confirm-Dialog", - }; - - try { dialog.Start(); } - catch (Exception ex) - { - Log.Debug($"[SLDataAPI] 无法启动确认对话框线程: {ex.Message}"); - return false; - } - - // MessageBox 没有原生超时;超时后不再等待(残留窗口由操作者自行关闭),按拒绝处理 - if (!done.Wait(timeoutSeconds * 1000)) - return false; - - if (failure != null) - { - Log.Debug($"[SLDataAPI] 确认对话框不可用: {failure.Message}"); - return false; - } - - answer = result == IdYes; - return true; - } - - private static bool IsInteractiveDesktopAvailable() - { - try - { - return Environment.OSVersion.Platform == PlatformID.Win32NT && Environment.UserInteractive; - } - catch - { - return false; - } - } - - // ────────────── 控制台输出 ────────────── - - private static void WriteConsoleLine(string text) - { - try { ServerConsole.AddLog(text, ConsoleColor.Yellow); } - catch { /* 控制台不可用时退化到标准输出 */ } - - try - { - Console.Out.WriteLine(text); - Console.Out.Flush(); - } - catch { /* 无标准输出时忽略 */ } - } -} diff --git a/tests/SLDataAPI.Auth.Tests/ConfirmPanelTests.cs b/tests/SLDataAPI.Auth.Tests/ConfirmPanelTests.cs deleted file mode 100644 index ba7942f..0000000 --- a/tests/SLDataAPI.Auth.Tests/ConfirmPanelTests.cs +++ /dev/null @@ -1,298 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using SLDataAPI.Services; -using Xunit; - -namespace SLDataAPI.Auth.Tests; - -public class DisplayWidthTests -{ - [Theory] - [InlineData("", 0)] - [InlineData("abc", 3)] - [InlineData("id:foo", 7)] // 全角冒号占 2 列 - [InlineData("确认", 4)] - [InlineData("[ 确认 (Y) ]", 12)] - public void DisplayWidth_CountsWideCharsAsTwo(string text, int expected) - { - Assert.Equal(expected, ConfirmPanelLayout.DisplayWidth(text)); - } - - [Fact] - public void DisplayWidth_IgnoresControlChars() - { - Assert.Equal(3, ConfirmPanelLayout.DisplayWidth("a\r\nbc")); - } - - [Fact] - public void FitToWidth_PadsShortText() - { - string fit = ConfirmPanelLayout.FitToWidth("ab", 6); - Assert.Equal("ab ", fit); - Assert.Equal(6, ConfirmPanelLayout.DisplayWidth(fit)); - } - - [Fact] - public void FitToWidth_TruncatesLongText() - { - Assert.Equal("abcd", ConfirmPanelLayout.FitToWidth("abcdef", 4)); - } - - [Fact] - public void FitToWidth_NeverSplitsWideChar() - { - // "确认" 占 4 列,裁到 3 列时只能放下一个全角字 + 一个补位空格 - string fit = ConfirmPanelLayout.FitToWidth("确认", 3); - Assert.Equal("确 ", fit); - Assert.Equal(3, ConfirmPanelLayout.DisplayWidth(fit)); - } - - [Fact] - public void FitToWidth_AlwaysExactWidth() - { - foreach (string sample in new[] { "", "a", "确认取消", "mixed 混排 text", new string('x', 200) }) - { - for (int w = 1; w <= 20; w++) - Assert.Equal(w, ConfirmPanelLayout.DisplayWidth(ConfirmPanelLayout.FitToWidth(sample, w))); - } - } - - [Fact] - public void Center_PutsTextInMiddle() - { - Assert.Equal(" ab ", ConfirmPanelLayout.Center("ab", 6)); - } - - [Fact] - public void Wrap_HardBreaksCjkWithoutSpaces() - { - var lines = ConfirmPanelLayout.Wrap("确认取消确认取消", 4); - Assert.Equal(4, lines.Count); - Assert.All(lines, l => Assert.True(ConfirmPanelLayout.DisplayWidth(l) <= 4)); - } - - [Fact] - public void Wrap_BreaksOnSpacesWhenAvailable() - { - var lines = ConfirmPanelLayout.Wrap("alpha beta gamma", 11); - Assert.Equal(2, lines.Count); - Assert.Equal("alpha beta", lines[0]); - Assert.Equal("gamma", lines[1]); - } - - [Fact] - public void Wrap_EmptyTextYieldsOneEmptyLine() - { - Assert.Single(ConfirmPanelLayout.Wrap("", 10)); - } - - [Fact] - public void Wrap_PrefersHardBreak_WhenSpaceBreakWouldLeaveStubLine() - { - // 中英混排:最后一个空格在第 10 列,按空格断会留下一行只有几个字 - var lines = ConfirmPanelLayout.Wrap("[!] 新 Key 的明文只显示这一次,关闭后无法找回。", 40); - Assert.True(ConfirmPanelLayout.DisplayWidth(lines[0]) >= 20, - $"首行过短:\"{lines[0]}\"({ConfirmPanelLayout.DisplayWidth(lines[0])} 列)"); - Assert.All(lines, l => Assert.True(ConfirmPanelLayout.DisplayWidth(l) <= 40)); - } - - [Theory] - [InlineData('。')] - [InlineData(',')] - [InlineData(')')] - public void IsNoBreakBefore_CoversClosingPunctuation(char c) - { - Assert.True(ConfirmPanelLayout.IsNoBreakBefore(c)); - } - - [Fact] - public void IsNoBreakBefore_AllowsOrdinaryChars() - { - Assert.False(ConfirmPanelLayout.IsNoBreakBefore('新')); - Assert.False(ConfirmPanelLayout.IsNoBreakBefore('a')); - } - - [Fact] - public void Wrap_DoesNotOrphanClosingPunctuation() - { - // 恰好在句号前断行时,把前一个字一起带下来,句号不会独占一行 - var lines = ConfirmPanelLayout.Wrap("关闭后无法找回。", 14); - Assert.Equal(2, lines.Count); - Assert.NotEqual("。", lines[1]); - Assert.StartsWith("回", lines[1]); - } -} - -public class ConfirmPanelRenderTests -{ - private static ConfirmPanelModel CreateModel() => new ConfirmPanelModel - { - Action = ConfirmAction.Create, - KeyId = "platform-a", - Template = "admin", - Note = "上游平台", - }; - - private static ConfirmPanelModel RevokeModel() => new ConfirmPanelModel - { - Action = ConfirmAction.Revoke, - KeyId = "platform-a", - }; - - private static string Flatten(IReadOnlyList rows) => - string.Join("\n", rows.Select(r => r.Text)); - - [Theory] - [InlineData(80, 25)] - [InlineData(120, 30)] - [InlineData(44, 14)] - [InlineData(200, 60)] - public void Render_FillsWholeScreenExactly(int width, int height) - { - var rows = ConfirmPanelLayout.Render(CreateModel(), width, height, PanelCharset.Unicode, 20, false); - Assert.Equal(height, rows.Count); - Assert.All(rows, r => Assert.Equal(width, ConfirmPanelLayout.DisplayWidth(r.Text))); - } - - [Theory] - [InlineData(10, 4)] - [InlineData(0, 0)] - public void Render_ClampsToMinimumSize(int width, int height) - { - var rows = ConfirmPanelLayout.Render(CreateModel(), width, height, PanelCharset.Unicode, 20, false); - Assert.Equal(ConfirmPanelLayout.MinHeight, rows.Count); - Assert.All(rows, r => Assert.Equal(ConfirmPanelLayout.MinWidth, ConfirmPanelLayout.DisplayWidth(r.Text))); - } - - [Fact] - public void Render_ShowsKeyFieldsAndCountdown() - { - string screen = Flatten(ConfirmPanelLayout.Render(CreateModel(), 80, 25, PanelCharset.Unicode, 17, false)); - Assert.Contains("创建 API Key", screen); - Assert.Contains("platform-a", screen); - Assert.Contains("admin", screen); - Assert.Contains("上游平台", screen); - Assert.Contains("17 秒", screen); - Assert.Contains("[ 确认 (Y) ]", screen); - Assert.Contains("[ 取消 (N) ]", screen); - } - - [Fact] - public void Render_CreateWarnsAboutOneTimePlaintext() - { - string screen = Flatten(ConfirmPanelLayout.Render(CreateModel(), 80, 25, PanelCharset.Unicode, 20, false)); - Assert.Contains("明文只显示这一次", screen); - Assert.Contains("admin 模板", screen); - } - - [Fact] - public void Render_DutyTemplateDoesNotShowAdminWarning() - { - var model = CreateModel(); - model.Template = "duty"; - string screen = Flatten(ConfirmPanelLayout.Render(model, 80, 25, PanelCharset.Unicode, 20, false)); - Assert.Contains("明文只显示这一次", screen); - Assert.DoesNotContain("admin 模板", screen); - } - - [Fact] - public void Render_RevokeWarnsDestructive() - { - string screen = Flatten(ConfirmPanelLayout.Render(RevokeModel(), 80, 25, PanelCharset.Unicode, 20, false)); - Assert.Contains("吊销 API Key", screen); - Assert.Contains("立即失效", screen); - Assert.Contains("不可撤销", screen); - Assert.DoesNotContain("模板:", screen); - } - - [Fact] - public void Render_SelectionMarkerFollowsSelection() - { - string denied = Flatten(ConfirmPanelLayout.Render(CreateModel(), 80, 25, PanelCharset.Unicode, 20, false)); - string confirmed = Flatten(ConfirmPanelLayout.Render(CreateModel(), 80, 25, PanelCharset.Unicode, 20, true)); - Assert.Contains("> [ 取消 (N) ] <", denied); - Assert.DoesNotContain("> [ 确认 (Y) ] <", denied); - Assert.Contains("> [ 确认 (Y) ] <", confirmed); - Assert.DoesNotContain("> [ 取消 (N) ] <", confirmed); - } - - [Fact] - public void ChoiceLine_DefaultsToCancelHighlighted() - { - Assert.Contains("> [ 取消 (N) ] <", ConfirmPanelLayout.ChoiceLine(confirmSelected: false)); - Assert.Contains("> [ 确认 (Y) ] <", ConfirmPanelLayout.ChoiceLine(confirmSelected: true)); - } - - [Fact] - public void Render_AsciiCharsetAvoidsBoxDrawingGlyphs() - { - var rows = ConfirmPanelLayout.Render(CreateModel(), 80, 25, PanelCharset.Ascii, 20, false); - string screen = Flatten(rows); - Assert.DoesNotContain('─', screen); - Assert.DoesNotContain('│', screen); - Assert.DoesNotContain('┌', screen); - Assert.Contains('+', screen); - Assert.Contains('|', screen); - Assert.All(rows, r => Assert.Equal(80, ConfirmPanelLayout.DisplayWidth(r.Text))); - } - - [Fact] - public void Render_UnicodeCharsetDrawsBoxFrame() - { - string screen = Flatten(ConfirmPanelLayout.Render(CreateModel(), 80, 25, PanelCharset.Unicode, 20, false)); - Assert.Contains('┌', screen); - Assert.Contains('┘', screen); - Assert.Contains('├', screen); - } - - [Theory] - [InlineData(80, 14)] - [InlineData(60, 16)] - [InlineData(44, 14)] - public void Render_ShortScreen_KeepsInteractiveRowsAndWholeWarnings(int width, int height) - { - var rows = ConfirmPanelLayout.Render(CreateModel(), width, height, PanelCharset.Unicode, 20, false); - string screen = Flatten(rows); - - Assert.Equal(height, rows.Count); - Assert.Contains("platform-a", screen); - Assert.Contains("[ 确认 (Y) ]", screen); - Assert.Contains("[ 取消 (N) ]", screen); - Assert.Contains("秒内未确认", screen); - Assert.Contains(rows, r => r.Style == PanelRowStyle.Frame); - - // 风险提示按整条删减:留下来的最后一条必须是完整句子(以句号结尾) - var warnings = rows.Where(r => r.Style == PanelRowStyle.Warning).ToList(); - Assert.NotEmpty(warnings); - Assert.EndsWith("。", warnings[^1].Text.TrimEnd().TrimEnd('│').TrimEnd()); - } - - [Fact] - public void Render_LongNoteWrapsInsideFrame() - { - var model = CreateModel(); - model.Note = new string('长', 120); - var rows = ConfirmPanelLayout.Render(model, 80, 40, PanelCharset.Unicode, 20, false); - Assert.All(rows, r => Assert.Equal(80, ConfirmPanelLayout.DisplayWidth(r.Text))); - // 每一行仍然是完整的框线包裹(左右竖线成对出现) - foreach (var row in rows.Where(r => r.Style is PanelRowStyle.Label or PanelRowStyle.Warning)) - Assert.Equal(2, row.Text.Count(c => c == '│')); - } - - [Fact] - public void Render_NullModelThrows() - { - Assert.Throws(() => - ConfirmPanelLayout.Render(null!, 80, 25, PanelCharset.Unicode, 20, false)); - } - - [Fact] - public void OneLinePrompt_MatchesActionShape() - { - Assert.Equal("Confirm create API key id=platform-a template=admin ?", CreateModel().OneLinePrompt()); - Assert.Equal("Confirm revoke API key id=platform-a ?", RevokeModel().OneLinePrompt()); - Assert.Equal("Confirm channel self-test (no API key will be created or revoked) ?", - new ConfirmPanelModel { Action = ConfirmAction.SelfTest }.OneLinePrompt()); - } -} diff --git a/tests/SLDataAPI.Auth.Tests/ConfirmWindowProtocolTests.cs b/tests/SLDataAPI.Auth.Tests/ConfirmWindowProtocolTests.cs deleted file mode 100644 index 00614ed..0000000 --- a/tests/SLDataAPI.Auth.Tests/ConfirmWindowProtocolTests.cs +++ /dev/null @@ -1,315 +0,0 @@ -using System; -using System.Linq; -using SLDataAPI.Services; -using Xunit; - -namespace SLDataAPI.Auth.Tests; - -public class ConfirmWindowPanelTests -{ - private static ConfirmPanelModel CreateModel() => new ConfirmPanelModel - { - Action = ConfirmAction.Create, - KeyId = "platform-a", - Template = "admin", - Note = "上游平台", - }; - - private static string[] Lines(string panel) => panel.Split(new[] { "\r\n" }, StringSplitOptions.None); - - [Fact] - public void RenderPanel_UsesCrlfAndOneLinePerScreenRow() - { - string panel = ConfirmWindowProtocol.RenderPanel(CreateModel(), 20); - Assert.Equal(ConfirmWindowProtocol.ScreenHeight, Lines(panel).Length); - Assert.DoesNotContain('\n', panel.Replace("\r\n", "")); - } - - [Fact] - public void RenderPanel_NeverFillsTheLastColumn() - { - // 在 80 列的 cmd 窗口里写满整行会自动换行,把画面顶上去滚屏 - foreach (string line in Lines(ConfirmWindowProtocol.RenderPanel(CreateModel(), 20))) - Assert.True(ConfirmPanelLayout.DisplayWidth(line) < ConfirmWindowProtocol.ScreenWidth, - $"行过宽:\"{line}\""); - } - - [Fact] - public void RenderPanel_TrimsTrailingSpaces() - { - foreach (string line in Lines(ConfirmWindowProtocol.RenderPanel(CreateModel(), 20))) - Assert.Equal(line.TrimEnd(), line); - } - - [Fact] - public void RenderPanel_FitsDefaultConhostWindow() - { - // mode con 调整窗口失败时也要放得下:conhost 默认 80×25,末行换行占掉第 25 行 - Assert.True(ConfirmWindowProtocol.ScreenHeight + 1 <= 25); - } - - [Fact] - public void RenderPanel_ShowsFieldsCountdownAndKeys() - { - string panel = ConfirmWindowProtocol.RenderPanel(CreateModel(), 17); - Assert.Contains("创建 API Key", panel); - Assert.Contains("platform-a", panel); - Assert.Contains("admin", panel); - Assert.Contains("上游平台", panel); - Assert.Contains("17 秒", panel); - Assert.Contains("[ 确认 (Y) ]", panel); - Assert.Contains("[ 取消 (N) ]", panel); - Assert.Contains("按 Y 确认", panel); - } - - [Fact] - public void RenderPanel_KeepsAllCreateWarnings() - { - string panel = ConfirmWindowProtocol.RenderPanel(CreateModel(), 20); - Assert.Contains("明文只显示这一次", panel); - Assert.Contains("admin 模板", panel); - Assert.Contains("不是你本人在操作", panel); - } - - [Fact] - public void RenderPanel_DefaultsToCancelHighlighted() - { - Assert.Contains("> [ 取消 (N) ] <", ConfirmWindowProtocol.RenderPanel(CreateModel(), 20)); - } - - [Fact] - public void RenderPanel_RevokeShowsDestructiveWarnings() - { - var model = new ConfirmPanelModel { Action = ConfirmAction.Revoke, KeyId = "platform-a" }; - string panel = ConfirmWindowProtocol.RenderPanel(model, 20); - Assert.Contains("吊销 API Key", panel); - Assert.Contains("立即失效", panel); - Assert.DoesNotContain("模板:", panel); - } - - [Fact] - public void RenderPanel_SelfTestSaysNothingChanges() - { - var model = new ConfirmPanelModel - { - Action = ConfirmAction.SelfTest, - KeyId = "self-test", - Template = "admin", - }; - string panel = ConfirmWindowProtocol.RenderPanel(model, 20); - Assert.Contains("确认通道自检", panel); - Assert.Contains("不会创建或吊销任何 API Key", panel); - Assert.DoesNotContain("明文只显示这一次", panel); - Assert.DoesNotContain("模板:", panel); - Assert.StartsWith("Y=allow N=cancel | 20s | SELF-TEST api key id=self-test", Lines(panel)[^1]); - } - - [Fact] - public void RenderPanel_LongNoteStaysInsideTheWindow() - { - var model = CreateModel(); - model.Note = new string('长', 200); - string panel = ConfirmWindowProtocol.RenderPanel(model, 20); - Assert.Equal(ConfirmWindowProtocol.ScreenHeight, Lines(panel).Length); - Assert.All(Lines(panel), l => - Assert.True(ConfirmPanelLayout.DisplayWidth(l) < ConfirmWindowProtocol.ScreenWidth)); - } - - [Fact] - public void RenderPanel_LastLineIsAnAsciiSummary() - { - // 中文画不出来(字体 / 代码页不给力)时,这一行仍然说得清在确认什么 - string last = Lines(ConfirmWindowProtocol.RenderPanel(CreateModel(), 9))[^1]; - Assert.All(last, c => Assert.True(c <= 0x7F)); - Assert.StartsWith("Y=allow N=cancel | 9s", last); - Assert.Contains("CREATE api key id=platform-a", last); - Assert.Contains("template=admin", last); - } - - [Fact] - public void AsciiSummary_ReplacesNonAsciiAndStaysInsideTheWindow() - { - var model = new ConfirmPanelModel - { - Action = ConfirmAction.Revoke, - KeyId = new string('键', 80), - }; - string summary = ConfirmWindowProtocol.AsciiSummary(model, 20); - Assert.All(summary, c => Assert.True(c <= 0x7F)); - Assert.True(summary.Length < ConfirmWindowProtocol.ScreenWidth); - Assert.Contains("REVOKE api key", summary); - Assert.DoesNotContain("template=", summary); - } - - [Fact] - public void RenderPanel_AsciiCharsetDropsBoxDrawing() - { - string panel = ConfirmWindowProtocol.RenderPanel(CreateModel(), 20, PanelCharset.Ascii); - Assert.DoesNotContain('│', panel); - Assert.Contains('|', panel); - } - - [Fact] - public void RenderPanel_NullModelThrows() - { - Assert.Throws(() => ConfirmWindowProtocol.RenderPanel(null!, 20)); - } - - [Fact] - public void PanelFileName_IsPerSecond() - { - Assert.Equal("panel-7.txt", ConfirmWindowProtocol.PanelFileName(7)); - Assert.NotEqual(ConfirmWindowProtocol.PanelFileName(7), ConfirmWindowProtocol.PanelFileName(8)); - } -} - -public class ConfirmWindowScriptTests -{ - private const string Nonce = "0123456789abcdef0123456789abcdef"; - - [Fact] - public void BuildScript_IsPureAscii() - { - // 脚本在新窗口的默认代码页下被 cmd 逐行解析:非 ASCII 会随代码页变味 - Assert.All(ConfirmWindowProtocol.BuildScript(20, Nonce), c => Assert.True(c <= 0x7F)); - } - - [Fact] - public void BuildScript_UsesCrlfLineEndings() - { - string script = ConfirmWindowProtocol.BuildScript(20, Nonce); - Assert.DoesNotContain('\n', script.Replace("\r\n", "")); - Assert.StartsWith("@echo off\r\n", script); - } - - [Fact] - public void BuildScript_CountsDownFromTheConfiguredTimeout() - { - string script = ConfirmWindowProtocol.BuildScript(45, Nonce); - Assert.Contains("set \"LEFT=45\"", script); - Assert.Contains("choice /C YNT /N /T 1 /D T", script); - Assert.Contains("set /a LEFT-=1", script); - Assert.Contains("type \"%PANELDIR%panel-%LEFT%.txt\"", script); - } - - [Fact] - public void BuildScript_MapsEveryBranchToItsExitCodeAndToken() - { - string script = ConfirmWindowProtocol.BuildScript(20, Nonce); - Assert.Contains($"echo CONFIRM {Nonce}", script); - Assert.Contains($"echo DENY {Nonce}", script); - Assert.Contains($"echo TIMEOUT {Nonce}", script); - Assert.Contains($"exit {ConfirmWindowProtocol.ExitConfirmed}", script); - Assert.Contains($"exit {ConfirmWindowProtocol.ExitDenied}", script); - Assert.Contains($"exit {ConfirmWindowProtocol.ExitTimedOut}", script); - } - - [Fact] - public void BuildScript_FallsBackToLineInputWhenChoiceIsMissing() - { - string script = ConfirmWindowProtocol.BuildScript(20, Nonce); - Assert.Contains("where choice >nul 2>&1 || goto noChoice", script); - Assert.Contains(":noChoice", script); - Assert.Contains("set /p \"ANSWER=", script); - } - - [Fact] - public void BuildScript_OnlyYAnswersAllow() - { - string script = ConfirmWindowProtocol.BuildScript(20, Nonce); - // 行输入兜底里除了 y / yes,其余(含空回车)都落到 :deny - Assert.Contains("if /i \"%ANSWER%\"==\"y\" goto allow", script); - Assert.Contains("if /i \"%ANSWER%\"==\"yes\" goto allow", script); - int denyFallThrough = script.IndexOf("if /i \"%ANSWER%\"==\"yes\" goto allow", StringComparison.Ordinal); - Assert.Contains("goto deny", script.Substring(denyFallThrough)); - } - - [Fact] - public void BuildScript_RejectsUnusableArguments() - { - Assert.Throws(() => ConfirmWindowProtocol.BuildScript(0, Nonce)); - Assert.Throws(() => ConfirmWindowProtocol.BuildScript(20, "")); - Assert.Throws(() => ConfirmWindowProtocol.BuildScript(20, "bad nonce & echo pwn")); - Assert.Throws(() => ConfirmWindowProtocol.BuildScript(20, "校验串")); - } - - [Fact] - public void NewNonce_IsHexAndUnique() - { - string a = ConfirmWindowProtocol.NewNonce(); - string b = ConfirmWindowProtocol.NewNonce(); - Assert.Equal(32, a.Length); - Assert.All(a, c => Assert.True(Uri.IsHexDigit(c))); - Assert.NotEqual(a, b); - } -} - -public class ConfirmWindowOutcomeTests -{ - private const string Nonce = "0123456789abcdef0123456789abcdef"; - - private static bool Parse(int exitCode, string? resultText, out ConfirmOutcome outcome) => - ConfirmWindowProtocol.TryParseOutcome(exitCode, resultText, Nonce, out outcome, out _); - - [Fact] - public void ExitConfirmedWithMatchingToken_Confirms() - { - Assert.True(Parse(ConfirmWindowProtocol.ExitConfirmed, $"CONFIRM {Nonce}\r\n", out ConfirmOutcome outcome)); - Assert.Equal(ConfirmOutcome.Confirmed, outcome); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("CONFIRM deadbeef")] - [InlineData("DENY 0123456789abcdef0123456789abcdef")] - public void ExitConfirmedWithoutMatchingToken_Denies(string? resultText) - { - Assert.True(Parse(ConfirmWindowProtocol.ExitConfirmed, resultText, out ConfirmOutcome outcome)); - Assert.Equal(ConfirmOutcome.Denied, outcome); - } - - [Fact] - public void ExitConfirmedWithoutMatchingToken_ExplainsWhy() - { - ConfirmWindowProtocol.TryParseOutcome( - ConfirmWindowProtocol.ExitConfirmed, "CONFIRM nope", Nonce, out _, out string detail); - Assert.Contains("校验串", detail); - } - - [Fact] - public void ExitDenied_Denies() - { - Assert.True(Parse(ConfirmWindowProtocol.ExitDenied, $"DENY {Nonce}", out ConfirmOutcome outcome)); - Assert.Equal(ConfirmOutcome.Denied, outcome); - } - - [Fact] - public void ExitTimedOut_TimesOut() - { - Assert.True(Parse(ConfirmWindowProtocol.ExitTimedOut, $"TIMEOUT {Nonce}", out ConfirmOutcome outcome)); - Assert.Equal(ConfirmOutcome.TimedOut, outcome); - } - - [Theory] - [InlineData(0)] - [InlineData(1)] - [InlineData(9009)] // cmd: 命令不存在 - [InlineData(-1073741510)] // 0xC000013A: 窗口被关掉 / Ctrl+C - public void UnknownExitCode_YieldsNoVerdict(int exitCode) - { - Assert.False(Parse(exitCode, $"CONFIRM {Nonce}", out ConfirmOutcome outcome)); - Assert.NotEqual(ConfirmOutcome.Confirmed, outcome); - } - - [Fact] - public void ResultLine_MatchesWhatTheScriptWrites() - { - Assert.Equal($"CONFIRM {Nonce}", ConfirmWindowProtocol.ResultLine(ConfirmOutcome.Confirmed, Nonce)); - Assert.Equal($"DENY {Nonce}", ConfirmWindowProtocol.ResultLine(ConfirmOutcome.Denied, Nonce)); - Assert.Equal($"TIMEOUT {Nonce}", ConfirmWindowProtocol.ResultLine(ConfirmOutcome.TimedOut, Nonce)); - Assert.Contains( - ConfirmWindowProtocol.ResultLine(ConfirmOutcome.Confirmed, Nonce), - ConfirmWindowProtocol.BuildScript(20, Nonce)); - } -} diff --git a/tests/SLDataAPI.Auth.Tests/RemoteCommandGuardTests.cs b/tests/SLDataAPI.Auth.Tests/RemoteCommandGuardTests.cs index 4590a06..9f12070 100644 --- a/tests/SLDataAPI.Auth.Tests/RemoteCommandGuardTests.cs +++ b/tests/SLDataAPI.Auth.Tests/RemoteCommandGuardTests.cs @@ -52,27 +52,11 @@ public void RemoteExecutionScope_TracksNesting() Assert.False(RemoteCommandGuard.IsRemoteExecution); } - [Theory] - [InlineData("y")] - [InlineData("Y")] - [InlineData(" yes ")] - [InlineData("YES")] - public void Affirmative_Answers(string answer) - { - Assert.True(RemoteCommandGuard.IsAffirmative(answer)); - } - - [Theory] - [InlineData("")] - [InlineData(" ")] - [InlineData(null)] - [InlineData("n")] - [InlineData("no")] - [InlineData("yeah")] - [InlineData("ok")] - [InlineData("1")] - public void NonAffirmative_Answers(string? answer) + [Fact] + public void RemoteDenyMessage_DoesNotRequireLocalConfirm() { - Assert.False(RemoteCommandGuard.IsAffirmative(answer)); + Assert.Contains("不允许通过远程控制通道执行", RemoteCommandGuard.RemoteDenyMessage); + Assert.DoesNotContain("人工确认", RemoteCommandGuard.RemoteDenyMessage); + Assert.DoesNotContain("确认窗口", RemoteCommandGuard.RemoteDenyMessage); } } \ No newline at end of file diff --git a/tests/SLDataAPI.Auth.Tests/SLDataAPI.Auth.Tests.csproj b/tests/SLDataAPI.Auth.Tests/SLDataAPI.Auth.Tests.csproj index d4e4154..08ac8c0 100644 --- a/tests/SLDataAPI.Auth.Tests/SLDataAPI.Auth.Tests.csproj +++ b/tests/SLDataAPI.Auth.Tests/SLDataAPI.Auth.Tests.csproj @@ -18,7 +18,5 @@ - - \ No newline at end of file