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

383 lines
18 KiB
C#
Raw Normal View History

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;
2026-06-23 17:22:40 +08:00
using MiGu.Server.OpenApi;
using MiGu.Server.Ota;
using MiGu.Server.Signal;
using MiGu.DB.Kernel.Hosting;
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
});
// 管理面默认 :8080WatchDog 拉包回传写死 :8000/upload-mdcs/*,必须额外监听 ReceivePort。
{
var receivePort = builder.Configuration.GetValue("Ota:ReceivePort", 8000);
var urls = builder.Configuration["urls"]
?? Environment.GetEnvironmentVariable("ASPNETCORE_URLS")
?? Environment.GetEnvironmentVariable("DOTNET_URLS");
if (string.IsNullOrWhiteSpace(urls))
urls = "http://0.0.0.0:8080";
var parts = urls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var hasReceive = parts.Any(u =>
u.Contains($":{receivePort}", StringComparison.OrdinalIgnoreCase)
|| u.EndsWith($":{receivePort}/", StringComparison.OrdinalIgnoreCase));
if (!hasReceive)
{
urls = string.Join(';', parts.Append($"http://0.0.0.0:{receivePort}"));
// 安全提示:该端口挂匿名 upload-mdcs/upload-historyWatchDog 写死回传)。
// upload-mdcs 仅在有进行中的拉取会话时可写入,其余管理端点仍需 JWT。请确保本机处于可信内网。
Console.WriteLine($"[MiGu.Server] OTA 回传端口 {receivePort} 已监听(0.0.0.0):匿名接收车辆包,仅限可信内网。");
}
builder.WebHost.UseUrls(urls);
}
// 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;
});
// WatchDog 回传 M/D/C 可较大;放宽 multipart 默认 128MB 限制
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
{
o.MultipartBodyLengthLimit = 512_000_000;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new()
{
Title = "MiGu.Server + Simple3",
Version = "v1",
Description = "咪咕平台后端 API,以及经 YARP 反代的 Simple3 数据 WebApi(标签 Simple3/*)。详见 Simple/Simple3/Docs/MIGU-API.md。"
});
c.DocumentFilter<Simple3OpenApiDocumentFilter>();
// 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 管理 Simple3 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 =>
{
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;
}
return Task.CompletedTask;
}
};
});
// TokenValidationParameters 由 JwtIssuerDI 单例)启动期一次性提供,
// 替代旧的「每请求在 OnMessageReceived 里改写共享 Options」写法(并发坏味道)。
builder.Services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
.Configure<JwtIssuer>((opt, issuer) => opt.TokenValidationParameters = issuer.BuildValidationParameters());
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 透传给 Simple3 8222AR-1/AR-2 配套)。
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(tctx =>
{
// 对全部 sl-* 路由(兜底 + map-edit/ai-config/reflection 管理面拆分路由)注入
// internal tokenvrender-routewebVRender iframe 静态资源)不需要。
if (!tctx.Route.RouteId.StartsWith("sl-", StringComparison.Ordinal)) 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);
var user = rt.HttpContext.User;
var username = user.FindFirst("unique_name")?.Value
?? user.Identity?.Name
?? user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
?? user.FindFirst("sub")?.Value;
var userId = user.FindFirst("sub")?.Value
?? user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
var scope = user.FindFirst("scope")?.Value;
rt.ProxyRequest.Headers.Remove("X-Platform-User");
rt.ProxyRequest.Headers.Remove("X-Platform-User-Id");
rt.ProxyRequest.Headers.Remove("X-Platform-Scope");
if (!string.IsNullOrWhiteSpace(username))
rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-User", username);
if (!string.IsNullOrWhiteSpace(userId))
rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-User-Id", userId);
if (!string.IsNullOrWhiteSpace(scope))
rt.ProxyRequest.Headers.TryAddWithoutValidation("X-Platform-Scope", scope);
return ValueTask.CompletedTask;
});
});
// 单例配置仓库(内存 + JSON 文件持久化占位)
builder.Services.AddSingleton<ConfigStore>();
builder.Services.AddSingleton<MiGu.Server.Logs.LogCleanupService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<MiGu.Server.Logs.LogCleanupService>());
// 运维审计持久化存储(替代旧的纯内存队列,MiGu.Server 重启后审计不丢)。
builder.Services.AddSingleton<OpsAuditStore>();
// OpsController 真实转发 Simple3 reflection execute 所需的 HttpClient 工厂。
builder.Services.AddHttpClient();
builder.Services.AddPlatformPersistence(builder.Configuration);
// 会话 N+1(启动反转):把 Simple3 子进程拉起器接入 DIAuthController 登录成功后按 LaunchMode 调 MaybeStart。
// 配置段绑定 appsettings.json:Simple3,可被环境变量 SIMPLE3__XXX 覆盖。
builder.Services.Configure<Simple3Options>(builder.Configuration.GetSection("Simple3"));
builder.Services.AddSingleton<Simple3Launcher>();
builder.Services.AddSingleton<SignalDataStore>();
// OTAWatchDog 编排):包库 / 任务 / 出站客户端
builder.Services.Configure<OtaOptions>(builder.Configuration.GetSection("Ota"));
builder.Services.AddSingleton<OtaStore>();
builder.Services.AddSingleton<InternalTokenStoreAccessor>();
builder.Services.AddSingleton<OtaVehicleSource>();
builder.Services.AddSingleton<WatchDogClient>();
builder.Services.AddSingleton<OtaJobRunner>();
builder.Services.AddSingleton<MiGu.Server.Fleet.FleetHealthService>();
builder.Services.AddSingleton<MiGu.Server.Fleet.CdmTaskSyncer>();
builder.Services.AddHostedService<MiGu.Server.Fleet.CdmTaskSyncService>();
builder.Services.AddSingleton<MiGu.Server.Fleet.AlarmCollector>();
builder.Services.AddHostedService<MiGu.Server.Fleet.AlarmCollectorService>();
builder.Services.AddHttpClient(nameof(WatchDogClient));
var app = builder.Build();
await app.Services.MigrateMiGuDbAsync();
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
_ = app.Services.GetRequiredService<JwtIssuer>();
_ = app.Services.GetRequiredService<InternalTokenStore>();
// 主动构造 RbacStore:首启时尽早 seed 默认用户 / 角色并打印 data/rbac.json 载入日志。
_ = app.Services.GetRequiredService<RbacStore>();
// 主动实例化 Simple3LauncherFollowParent=true 时注册 ProcessExit 软关闭钩子)。
var simple3Launcher = app.Services.GetRequiredService<Simple3Launcher>();
{
var sl = simple3Launcher.GetDiagnostics();
app.Logger.LogInformation(
"[MiGu.Server] Simple3: Enabled={Enabled}, FollowParent={FollowParent}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → Simple3",
sl.Enabled, sl.FollowParent, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists,
sl.ProjectionPort, sl.ProjectionPortReachable);
}
// FollowParent=true 时 MiGu.Server 退出会 kill Simple3;默认 false 时不注册停机清理(两进程独立)。
if (builder.Configuration.GetValue("Simple3:FollowParent", false))
{
app.Lifetime.ApplicationStopping.Register(() =>
{
try { app.Services.GetRequiredService<Simple3Launcher>().Dispose(); }
catch { /* shutdown best-effort */ }
});
}
else
{
app.Logger.LogInformation("[MiGu.Server] Simple3: FollowParent=false — MiGu.Server 退出不会结束 Simple3");
}
app.UseSwagger();
app.UseSwaggerUI();
2026-07-27 15:12:14 +08:00
// OTA 回传会话按 TCP 对端 IP 校验;必须在 ForwardedHeaders 改写 RemoteIpAddress 之前捕获。
app.Use(async (ctx, next) =>
{
ctx.Items[MiGu.Server.Controllers.OtaReceiveController.TcpRemoteIpItemKey] =
ctx.Connection.RemoteIpAddress?.ToString();
await next();
});
// 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.UseMiddleware<HttpActorContextMiddleware>();
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();