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 (admin token)", 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();
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 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.IsAdmin)
{
return principal.CanRead
? Problems.Create(http, MoShiroErrors.Forbidden, "an admin token is required")
: Problems.Create(http, MoShiroErrors.Unauthorized, "an admin token is required");
}
return TypedResults.Text(await ops.RenderPrometheusAsync(http.RequestAborted), "text/plain; version=0.0.4");
}
}