Files
Migu2.0/MiGu.Server/Program.cs
T

318 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.htmldotnet 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<string>()
}
});
});
// CORSVite dev 5173 与 MiGu.Server 8080 跨域)
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
// ─── AR-3 / AR-5JWT + 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<RbacStore>();
builder.Services.AddSingleton<JwtIssuer>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
var logger = sp.GetRequiredService<ILogger<JwtIssuer>>();
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<InternalTokenStore>();
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<JwtIssuer>();
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 8222AR-1/AR-2 配套)。
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(tctx =>
{
// 只对 sl-route 注入 internal tokenvrender-routewebVRender iframe 静态资源)不需要。
if (tctx.Route.RouteId != "sl-route") return;
tctx.AddRequestTransform(rt =>
{
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
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<ConfigStore>();
// 运维审计持久化存储(替代旧的纯内存队列,MiGu.Server 重启后审计不丢)。
builder.Services.AddSingleton<OpsAuditStore>();
// OpsController 真实转发 SimpleLite reflection execute 所需的 HttpClient 工厂。
builder.Services.AddHttpClient();
builder.Services.AddPlatformPersistence(builder.Configuration);
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DIAuthController 登录成功后按 LaunchMode 调 MaybeStart。
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
builder.Services.AddSingleton<SimpleLiteLauncher>();
var app = builder.Build();
await app.Services.EnsurePlatformDatabaseAsync();
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
_ = app.Services.GetRequiredService<JwtIssuer>();
_ = app.Services.GetRequiredService<InternalTokenStore>();
// 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。
_ = app.Services.GetRequiredService<RbacStore>();
// 主动实例化 SimpleLiteLauncherFollowParent=true 时注册 ProcessExit 软关闭钩子)。
var simpleLiteLauncher = app.Services.GetRequiredService<SimpleLiteLauncher>();
{
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<SimpleLiteLauncher>().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<string[]>()
?? Array.Empty<string>();
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();