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 MiGu.Server.Persistence; using Yarp.ReverseProxy.Transforms; 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 出发向上逐级找前端产物: // 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 即生效; // 也不再需要 commit wwwroot/index.html。 // - 部署:CI/CD 走 build-platform-frontend.bat 把 dist 同步到 wwwroot/,dist 此时不存在 // 于发布产物中 → 自动 fallback 到 wwwroot/。行为与改造前一致。 { static string? FindFrontendWebRoot(string startDir) { var dir = new DirectoryInfo(startDir); while (dir != null) { 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 webRoot = FindFrontendWebRoot(builder.Environment.ContentRootPath); if (webRoot != null) { builder.Environment.WebRootPath = webRoot; builder.Environment.WebRootFileProvider = new PhysicalFileProvider(webRoot); Console.WriteLine($"[MiGu.Server] WebRoot -> {webRoot}"); } else { Console.WriteLine($"[MiGu.Server] WebRoot -> default wwwroot (frontend index not found): {builder.Environment.WebRootPath}"); } } // 控制器 + JSON 默认大小写、忽略 null builder.Services.AddControllers() .AddJsonOptions(opt => { opt.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; opt.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull; opt.JsonSerializerOptions.WriteIndented = false; }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new() { Title = "MiGu.Server", Version = "v1", Description = "Simple-FR 平台后端骨架(含 YARP 反代 SimpleLite 8222)。" }); // Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。 c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { Name = "Authorization", Description = "JWT bearer。值: \"Bearer {token}\"", In = ParameterLocation.Header, Type = SecuritySchemeType.ApiKey, Scheme = "Bearer" }); c.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" } }, Array.Empty() } }); }); // CORS(Vite dev 5173 与 MiGu.Server 8080 跨域) var origins = builder.Configuration.GetSection("Cors:Origins").Get() ?? Array.Empty(); builder.Services.AddCors(opts => opts.AddDefaultPolicy(p => p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials())); // ─── AR-3 / AR-5:JWT + Cookie 双轨鉴权 ───────────────────────────────────────── // // 设计: // - access token 同时通过 Authorization: Bearer header 与 httpOnly Cookie 两路传输; // - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受; // - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量 // PLATFORM__JWT__SECRET,占位值会被运行时随机化并强制告警。 // - InternalTokenStore 管理 SimpleLite 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token // 共享密钥(YARP transform 自动追加)。 builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => { var config = sp.GetRequiredService(); var logger = sp.GetRequiredService>(); var secret = config["Jwt:Secret"] ?? JwtIssuer.PlaceholderSecret; var issuer = config["Jwt:Issuer"] ?? "MiGu.Server"; var audience = config["Jwt:Audience"] ?? "platform.client"; var lifetimeMinutes = int.TryParse(config["Jwt:LifetimeMinutes"], out var m) && m > 0 ? m : 24 * 60; return new JwtIssuer(secret, issuer, audience, TimeSpan.FromMinutes(lifetimeMinutes), logger); }); builder.Services.AddSingleton(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(opt => { // TokenValidationParameters 在第一次解析请求时从 JwtIssuer 拿,避免 ctor 顺序耦合。 opt.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters { // 完整参数在 OnMessageReceived 里替换为 JwtIssuer.BuildValidationParameters() ValidateIssuer = false, ValidateAudience = false, ValidateIssuerSigningKey = false, ValidateLifetime = false, }; opt.Events = new JwtBearerEvents { OnMessageReceived = ctx => { // 优先从 Authorization: Bearer 取;没有再从 Cookie 取。 if (string.IsNullOrEmpty(ctx.Token)) { var cookie = ctx.Request.Cookies["simple.auth.token"]; if (!string.IsNullOrEmpty(cookie)) ctx.Token = cookie; } // 用真实 JwtIssuer 参数替换占位 ValidationParameters。 var issuer = ctx.HttpContext.RequestServices.GetRequiredService(); ctx.Options.TokenValidationParameters = issuer.BuildValidationParameters(); return Task.CompletedTask; } }; }); builder.Services.AddAuthorization(opts => { // Platform scope:完整管理端权限,对应 admin 账号。 opts.AddPolicy("PlatformScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "Platform")); // RCSMonitor scope:运营白名单。 opts.AddPolicy("MonitorScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "RCSMonitor")); // 任一登录用户。 opts.AddPolicy("AnyAuthed", p => p.RequireAuthenticatedUser()); // RBAC 管理:JWT 的 ops claim(空格分隔)含 "*" 或 "auth.manage" 才放行。 // 用于 RbacController(用户 / 角色 / 权限页面管理),即「超级管理员」类账号专属。 opts.AddPolicy("RbacAdmin", p => p.RequireAuthenticatedUser().RequireAssertion(ctx => { var ops = ctx.User.FindFirst("ops")?.Value ?? string.Empty; var set = ops.Split(' ', StringSplitOptions.RemoveEmptyEntries); return set.Contains("*") || set.Contains("auth.manage"); })); }); // YARP + transform:把 Platform 内部 token 透传给 SimpleLite 8222(AR-1/AR-2 配套)。 builder.Services.AddReverseProxy() .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) .AddTransforms(tctx => { // 只对 sl-route 注入 internal token;vrender-route(webVRender iframe 静态资源)不需要。 if (tctx.Route.RouteId != "sl-route") return; tctx.AddRequestTransform(rt => { var store = rt.HttpContext.RequestServices.GetRequiredService(); rt.ProxyRequest.Headers.Remove("X-Platform-Internal-Token"); rt.ProxyRequest.Headers.Add("X-Platform-Internal-Token", store.Token); return ValueTask.CompletedTask; }); }); // 单例配置仓库(内存 + JSON 文件持久化占位) builder.Services.AddSingleton(); // 运维审计持久化存储(替代旧的纯内存队列,MiGu.Server 重启后审计不丢)。 builder.Services.AddSingleton(); // OpsController 真实转发 SimpleLite reflection execute 所需的 HttpClient 工厂。 builder.Services.AddHttpClient(); builder.Services.AddPlatformPersistence(builder.Configuration); // 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。 // 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。 builder.Services.Configure(builder.Configuration.GetSection("SimpleLite")); builder.Services.AddSingleton(); var app = builder.Build(); await app.Services.EnsurePlatformDatabaseAsync(); // 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。 _ = app.Services.GetRequiredService(); _ = app.Services.GetRequiredService(); // 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。 _ = app.Services.GetRequiredService(); // 主动实例化 SimpleLiteLauncher(FollowParent=true 时注册 ProcessExit 软关闭钩子)。 var simpleLiteLauncher = app.Services.GetRequiredService(); { var sl = simpleLiteLauncher.GetDiagnostics(); app.Logger.LogInformation( "[MiGu.Server] SimpleLite: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite", sl.Enabled, sl.FollowParent, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists, sl.ProjectionPort, sl.ProjectionPortReachable); } // FollowParent=true 时 MiGu.Server 退出会 kill SimpleLite;默认 false 时不注册停机清理(两进程独立)。 if (builder.Configuration.GetValue("SimpleLite:FollowParent", false)) { app.Lifetime.ApplicationStopping.Register(() => { try { app.Services.GetRequiredService().Dispose(); } catch { /* shutdown best-effort */ } }); } else { app.Logger.LogInformation("[MiGu.Server] SimpleLite: FollowParent=false — MiGu.Server 退出不会结束 SimpleLite"); } if (app.Environment.IsDevelopment()) { app.UseSwagger(); 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 app.UseDefaultFiles(); app.UseStaticFiles(); // 鉴权 / 授权管道必须放在 MapControllers 之前;CORS 之后。 app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); // YARP:/api/sl/* → :8222;/vr/* → :8223 app.MapReverseProxy(); // SPA fallback:单一合并 Vue 工程(vue-router 处理 /admin/* /monitor/* /login /status) // 所有非 API、非静态资源的路径都返回 wwwroot/index.html app.MapFallbackToFile("index.html"); app.Run();