From 655d50e04d8babad9bb3eeb438907bc77b42e5e7 Mon Sep 17 00:00:00 2001 From: "zhaowei.huang" <228127304@qq.com> Date: Mon, 8 Jun 2026 16:10:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E8=BD=AC=E5=8F=91=E5=A4=B4?= =?UTF-8?q?=E5=8F=AF=E4=BF=A1=E4=BB=A3=E7=90=86=E5=8F=AF=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E3=80=81=E7=99=BB=E5=BD=95=E5=86=B7=E5=90=AF=E5=8A=A8=E9=9D=9E?= =?UTF-8?q?=E9=98=BB=E5=A1=9E=E4=B8=8E=E5=90=AF=E5=8A=A8=E8=B7=AF=E5=BE=84?= =?UTF-8?q?/=E6=8E=88=E6=9D=83=E5=81=A5=E5=A3=AE=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Program.cs 的 ForwardedHeaders 改为读 ForwardedHeaders:KnownProxies 显式收紧可信代理 (未配置则维持信任所有,适合同机/内网;配置后按数量收紧 ForwardLimit,解析全失败 fail-closed), 并补 AnyAuthed 授权策略、内容根/前端目录三级探测、OpsAuditStore 与 HttpClientFactory 注册; AuthController 登录改 MaybeStart(waitForReady:false) 避免 SimpleLite 冷启动阻塞登录; SimpleLiteLauncher 增强相对 ContentRoot 的文件/目录解析,兼容从 bin 目录运行。 Co-authored-by: Cursor --- MiGu.Server/Controllers/AuthController.cs | 4 +- MiGu.Server/Launcher/SimpleLiteLauncher.cs | 57 ++++++++++-- MiGu.Server/Program.cs | 100 ++++++++++++++++++--- 3 files changed, 143 insertions(+), 18 deletions(-) diff --git a/MiGu.Server/Controllers/AuthController.cs b/MiGu.Server/Controllers/AuthController.cs index ebbb0d6..d1acc1f 100644 --- a/MiGu.Server/Controllers/AuthController.cs +++ b/MiGu.Server/Controllers/AuthController.cs @@ -96,7 +96,9 @@ public class AuthController : ControllerBase SimpleLiteLauncher.LaunchResult? launchResult = null; try { - launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode)); + // M1:waitForReady=false —— 拉起 SimpleLite 后立即返回,不在登录请求里同步等端口 + // 就绪(冷启动可能十几秒)。前端拿 LaunchStatus=Starting 即可,必要时轮询健康检查。 + launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode, waitForReady: false)); _log.LogInformation("SimpleLite launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}", user.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail); } diff --git a/MiGu.Server/Launcher/SimpleLiteLauncher.cs b/MiGu.Server/Launcher/SimpleLiteLauncher.cs index 81da09f..7038752 100644 --- a/MiGu.Server/Launcher/SimpleLiteLauncher.cs +++ b/MiGu.Server/Launcher/SimpleLiteLauncher.cs @@ -61,7 +61,7 @@ public sealed class SimpleLiteLauncher : IDisposable /// /// "WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。 /// 本次调用产生的状态摘要,可写入登录响应或日志。 - public LaunchResult MaybeStart(string launchMode) + public LaunchResult MaybeStart(string launchMode, bool waitForReady = true) { var displayMode = NormalizeDisplayMode(launchMode); @@ -150,7 +150,7 @@ public sealed class SimpleLiteLauncher : IDisposable var workdir = string.IsNullOrWhiteSpace(_opts.WorkingDirectory) ? Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory - : Path.GetFullPath(_opts.WorkingDirectory); + : ResolveConfiguredDirectory(_opts.WorkingDirectory) ?? Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory; var arguments = BuildArguments(displayMode, _opts.Arguments); @@ -182,6 +182,17 @@ public sealed class SimpleLiteLauncher : IDisposable } } + // M1:登录路径传 waitForReady=false —— 进程拉起后立即返回,不再同步阻塞最长 + // ReadinessTimeoutMs 等端口就绪(避免冷启动登录干等十几秒)。前端可轮询 + // GET /api/health/simplelite 获知就绪状态。 + if (!waitForReady) + { + return new LaunchResult(true, "Starting", + $"projection :{_opts.ProjectionPort} readiness wait skipped (async), displayMode={displayMode}", + DisplayMode: displayMode, + Warning: null); + } + var ready = WaitForProjectionReady(); return new LaunchResult(true, ready ? "Ready" : "StartedButNotReady", ready ? $"projection :{_opts.ProjectionPort} reachable, displayMode={displayMode}" @@ -262,7 +273,7 @@ public sealed class SimpleLiteLauncher : IDisposable public string? ResolveWorkingDirectory() { if (!string.IsNullOrWhiteSpace(_opts.WorkingDirectory)) - return Path.GetFullPath(_opts.WorkingDirectory); + return ResolveConfiguredDirectory(_opts.WorkingDirectory); var resolved = ResolveExecutable(_opts.ExecutablePath); return resolved == null ? null : (Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory); } @@ -518,8 +529,8 @@ public sealed class SimpleLiteLauncher : IDisposable { if (!string.IsNullOrWhiteSpace(configured)) { - var p = Path.IsPathRooted(configured) ? configured : Path.GetFullPath(configured, _env.ContentRootPath); - return File.Exists(p) ? p : null; + var p = ResolveConfiguredFile(configured); + if (p != null) return p; } var cwd = _env.ContentRootPath; @@ -562,6 +573,42 @@ public sealed class SimpleLiteLauncher : IDisposable return null; } + private string? ResolveConfiguredFile(string configured) + { + foreach (var baseDir in EnumeratePathBases()) + { + var p = Path.IsPathRooted(configured) + ? configured + : Path.GetFullPath(configured, baseDir); + if (File.Exists(p)) return p; + if (Path.IsPathRooted(configured)) break; + } + return null; + } + + private string? ResolveConfiguredDirectory(string configured) + { + foreach (var baseDir in EnumeratePathBases()) + { + var p = Path.IsPathRooted(configured) + ? configured + : Path.GetFullPath(configured, baseDir); + if (Directory.Exists(p)) return p; + if (Path.IsPathRooted(configured)) break; + } + return null; + } + + private IEnumerable EnumeratePathBases() + { + var dir = new DirectoryInfo(_env.ContentRootPath); + while (dir != null) + { + yield return dir.FullName; + dir = dir.Parent; + } + } + private bool WaitForProjectionReady() { if (_opts.ReadinessTimeoutMs == 0) return false; diff --git a/MiGu.Server/Program.cs b/MiGu.Server/Program.cs index ac559df..1cea473 100644 --- a/MiGu.Server/Program.cs +++ b/MiGu.Server/Program.cs @@ -1,20 +1,51 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.FileProviders; using Microsoft.OpenApi.Models; using MiGu.Server.Auth; using MiGu.Server.Configs; using MiGu.Server.Launcher; using Yarp.ReverseProxy.Transforms; -var builder = WebApplication.CreateBuilder(args); +static string? FindSourceContentRoot(string startDir) +{ + var dir = new DirectoryInfo(startDir); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "MiGu.Server.csproj"))) + return dir.FullName; + dir = dir.Parent; + } + return null; +} + +var builder = WebApplication.CreateBuilder(new WebApplicationOptions +{ + Args = args, + // 直接运行 bin/Debug/net8.0/MiGu.Server.exe 时,默认 ContentRoot 会落到 bin 目录, + // 导致 appsettings/data/wwwroot 与 dotnet run 不一致。源码构建输出中统一回到项目目录; + // 发布包没有 csproj,则使用 exe 所在目录作为常规内容根。 + ContentRootPath = FindSourceContentRoot(AppContext.BaseDirectory) ?? AppContext.BaseDirectory +}); + +if (string.IsNullOrWhiteSpace(builder.Configuration["urls"]) + && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("ASPNETCORE_URLS")) + && string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("DOTNET_URLS"))) +{ + // 直接运行 MiGu.Server.exe 不读取 launchSettings.json;保持与 dotnet run / 文档一致默认监听 8080。 + builder.WebHost.UseUrls("http://0.0.0.0:8080"); +} // S2 根治 (会话45):让 MiGu.Server 启动时自动优先把 frontends/apps/simple-platform-vue/dist/ // 作为 WebRoot —— 解决「每次 vite build → wwwroot/index.html 都被 hash 漂移 → 提一个噪音 commit」 // 的死循环。 // -// 探测策略:从 ContentRootPath 出发向上逐级找 frontends/apps/simple-platform-vue/dist/index.html, -// 找到则将 WebRootPath 改指到 dist;找不到则保持默认 wwwroot/(用于「克隆后没跑 build」或 -// 生产部署机不带 node 的场景,wwwroot/ 由 build-platform-frontend.bat 的 robocopy /MIR 兜底)。 +// 探测策略:从 ContentRootPath 出发向上逐级找前端产物: +// 1) frontends/apps/simple-platform-vue/dist/index.html(开发态刚跑完 pnpm build); +// 2) MiGu.Server/wwwroot/index.html(直接运行 bin/Debug/net8.0/MiGu.Server.exe 时的源码区兜底); +// 3) 当前目录 wwwroot/index.html(dotnet run / 发布包常规布局)。 +// 找到则将 WebRootPath 改指到对应目录;找不到才保持默认 wwwroot/。 // // 影响: // - dev 模式:跑过一次 `pnpm build` 后无需 robocopy,重启 MiGu.Server 即生效; @@ -22,27 +53,35 @@ var builder = WebApplication.CreateBuilder(args); // - 部署:CI/CD 走 build-platform-frontend.bat 把 dist 同步到 wwwroot/,dist 此时不存在 // 于发布产物中 → 自动 fallback 到 wwwroot/。行为与改造前一致。 { - static string? FindDistRoot(string startDir) + static string? FindFrontendWebRoot(string startDir) { var dir = new DirectoryInfo(startDir); while (dir != null) { - var candidate = Path.Combine(dir.FullName, "frontends", "apps", "simple-platform-vue", "dist", "index.html"); - if (File.Exists(candidate)) return Path.GetDirectoryName(candidate); + var distIndex = Path.Combine(dir.FullName, "frontends", "apps", "simple-platform-vue", "dist", "index.html"); + if (File.Exists(distIndex)) return Path.GetDirectoryName(distIndex); + + var sourceWwwrootIndex = Path.Combine(dir.FullName, "MiGu.Server", "wwwroot", "index.html"); + if (File.Exists(sourceWwwrootIndex)) return Path.GetDirectoryName(sourceWwwrootIndex); + + var localWwwrootIndex = Path.Combine(dir.FullName, "wwwroot", "index.html"); + if (File.Exists(localWwwrootIndex)) return Path.GetDirectoryName(localWwwrootIndex); + dir = dir.Parent; } return null; } - var dist = FindDistRoot(builder.Environment.ContentRootPath); - if (dist != null) + var webRoot = FindFrontendWebRoot(builder.Environment.ContentRootPath); + if (webRoot != null) { - builder.Environment.WebRootPath = dist; - Console.WriteLine($"[MiGu.Server] WebRoot -> dist: {dist}"); + builder.Environment.WebRootPath = webRoot; + builder.Environment.WebRootFileProvider = new PhysicalFileProvider(webRoot); + Console.WriteLine($"[MiGu.Server] WebRoot -> {webRoot}"); } else { - Console.WriteLine($"[MiGu.Server] WebRoot -> wwwroot (dist not found): {builder.Environment.WebRootPath}"); + Console.WriteLine($"[MiGu.Server] WebRoot -> default wwwroot (frontend index not found): {builder.Environment.WebRootPath}"); } } @@ -173,6 +212,10 @@ builder.Services.AddReverseProxy() // 单例配置仓库(内存 + JSON 文件持久化占位) builder.Services.AddSingleton(); +// 运维审计持久化存储(替代旧的纯内存队列,MiGu.Server 重启后审计不丢)。 +builder.Services.AddSingleton(); +// OpsController 真实转发 SimpleLite reflection execute 所需的 HttpClient 工厂。 +builder.Services.AddHttpClient(); // 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。 // 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。 @@ -216,6 +259,39 @@ if (app.Environment.IsDevelopment()) app.UseSwaggerUI(); } +// M6:在反向代理 / 负载均衡后运行时,根据 X-Forwarded-Proto 还原真实 scheme,让 +// Request.IsHttps 正确 → 登录 Cookie 的 Secure 标志在生产 HTTPS 下能正确置位。 +// 默认(未配置 KnownProxies):清空 Known* 表 = 信任所有前置转发头,适合「反代与本服务同机 / +// 可信内网」部署。生产若可能被不可信网络直连,请在 appsettings 配置 ForwardedHeaders:KnownProxies +// (可信代理 IP 列表)收紧:仅接受来自这些代理的 X-Forwarded-* 头,防止外部伪造 Proto/For。 +var forwardedOptions = new ForwardedHeadersOptions +{ + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto +}; +forwardedOptions.KnownNetworks.Clear(); +forwardedOptions.KnownProxies.Clear(); +var trustedProxies = app.Configuration.GetSection("ForwardedHeaders:KnownProxies").Get() + ?? Array.Empty(); +if (trustedProxies.Length > 0) +{ + // 转发链深度按可信代理数 +1 收紧,避免外部多塞一层伪造头被采信。 + forwardedOptions.ForwardLimit = trustedProxies.Length + 1; + foreach (var ip in trustedProxies) + { + if (System.Net.IPAddress.TryParse(ip.Trim(), out var addr)) + forwardedOptions.KnownProxies.Add(addr); + else + app.Logger.LogWarning("ForwardedHeaders:KnownProxies 含无法解析的地址,已忽略:{Ip}", ip); + } + // 全部解析失败时 KnownProxies 为空 → 中间件将拒绝所有转发头(fail-closed,安全方向)。 + app.Logger.LogInformation("ForwardedHeaders 已限定 {Count} 个可信代理", forwardedOptions.KnownProxies.Count); +} +else +{ + app.Logger.LogInformation("ForwardedHeaders 未配置 KnownProxies:信任所有前置转发头(适合同机 / 可信内网)"); +} +app.UseForwardedHeaders(forwardedOptions); + app.UseCors(); // 静态资源:MiGu.Server/wwwroot 下可同时存放 admin / monitor / index 三份 SPA