using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using MoShiro.Backend.Application; using MoShiro.Protocol; namespace MoShiro.Backend.Api; /// 运维端点:/healthz、/metrics(admin 令牌)、一行安装脚本(/install、/install.ps1)。 public static class OpsEndpoints { private const string Tag = "ops"; public static void Map(IEndpointRouteBuilder app) { app.MapGet("/healthz", HealthAsync) .Api("health_get", "SQLite and the raw data directory are both writable", Tag) .Returns().ProducesProblems(503); app.MapGet("/metrics", MetricsAsync) .Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403); // 一行安装:curl -fsSL 'https://panel/install?code=…' | bash(macOS 用 zsh),irm 'https://panel/install.ps1?code=…' | iex(Windows)。 // Agent 和 Probe 是同一份脚本,角色、网关和镜像由一次性代码决定;代码不能用就是 404。 app.MapGet("/install", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript")) .Api("install_sh", "One-line installer for Linux and macOS nodes (agent or probe, decided by the code)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript").ProducesProblems(404); app.MapGet("/install.sh", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.sh", "text/x-shellscript")) .ExcludeFromDescription(); // 脚本要装的版本:CI 签名的发布清单(原样的字节)和签名。脚本用内置的发布公钥验签,再按清单里的 digest 拉镜像。 app.MapGet("/install/manifest", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: false)) .Api("install_manifest", "The signed release manifest of the component an install code is for (exact signed bytes)", Tag) .Produces(StatusCodes.Status200OK, contentType: "application/json").ProducesProblems(404); app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true)) .Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); app.MapGet("/install.ps1", (HttpContext http, JoinTokenService tokens, BackendOptions options, string? code) => InstallAsync(http, tokens, options, code, "install.ps1", "text/plain")) .Api("install_ps1", "One-line installer for Windows nodes (agent or probe, decided by the code)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); } private static async Task ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature) { string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted); ReleaseOffer? offer = target is null ? null : await releases.GetAsync(target.Role, http.RequestAborted); if (offer is null) { return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code"); } http.Response.Headers.CacheControl = "no-store"; return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8"); } private static async Task InstallAsync(HttpContext http, JoinTokenService tokens, BackendOptions options, string? code, string resource, string contentType) { string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted); if (target is null) { return Problems.Create(http, MoShiroErrors.NotFound, "the install code is invalid, expired or already used"); } string script = InstallScripts.Render(resource, target); http.Response.Headers.CacheControl = "no-store"; return TypedResults.Text(script, contentType + "; charset=utf-8"); } private static async Task HealthAsync(HttpContext http, OpsService ops) { StorageHealth health = await ops.HealthAsync(http.RequestAborted); bool ok = health.Database && health.RawDirectory; HealthDto dto = new(ok ? "ok" : "unavailable", health.Database, health.RawDirectory); http.Response.Headers.CacheControl = "no-store"; return TypedResults.Json(dto, ApiJsonContext.Default.HealthDto, statusCode: ok ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable); } private static async Task MetricsAsync(HttpContext http, OpsService ops) { Principal principal = http.GetPrincipal(); if (!principal.Has(Capability.OpsMetrics)) { return principal.Kind != PrincipalKind.Anonymous ? Problems.Create(http, MoShiroErrors.Forbidden, "the ops.metrics capability is required") : Problems.Create(http, MoShiroErrors.Unauthorized, "authentication is required"); } return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4"); } }