Join codes need no passkey touch and carry only public values (genesis fingerprint, owner credentials, installer hashes). Nodes pin the genesis and owners at enrollment and only follow passkey-signed operator set versions. The panel's server key is listed in the set; pinned nodes require its signature and run the control link under AES-256-GCM. Password sessions carry no capabilities. The updater adopts a next_key from a signed manifest. Bump version to 0.2.0. [CI SKIP]
83 lines
5.4 KiB
C#
83 lines
5.4 KiB
C#
using Microsoft.AspNetCore.Builder;
|
||
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.AspNetCore.Routing;
|
||
using MoShiro.Backend.Application;
|
||
using MoShiro.Protocol;
|
||
|
||
namespace MoShiro.Backend.Api;
|
||
|
||
/// <summary>运维端点:<c>/healthz</c>、<c>/metrics</c>(admin 令牌)、一行安装脚本(<c>/install</c>、<c>/install.ps1</c>)。</summary>
|
||
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<HealthDto>().ProducesProblems(503);
|
||
app.MapGet("/metrics", MetricsAsync)
|
||
.Api("metrics_get", "Prometheus metrics (requires ops.metrics)", Tag)
|
||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(401, 403);
|
||
// 一行安装:面板给的命令先下载这份静态脚本、核对 SHA-256,再用环境变量里的面板地址、角色和一次性代码执行。
|
||
// 脚本不含任何一次性的值,所以不需要代码,也可以直接拿去和发布页公布的哈希核对。
|
||
app.MapGet("/install", (HttpContext http) => Static(http, InstallScripts.Sh, "text/x-shellscript"))
|
||
.Api("install_sh", "The static one-line installer for Linux and macOS nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||
.Produces(StatusCodes.Status200OK, contentType: "text/x-shellscript");
|
||
app.MapGet("/install.sh", (HttpContext http) => Static(http, InstallScripts.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) => Static(http, InstallScripts.Ps1, "text/plain"))
|
||
.Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag)
|
||
.Produces(StatusCodes.Status200OK, contentType: "text/plain");
|
||
}
|
||
|
||
private static async Task<IResult> 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 Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult Static(HttpContext http, string resource, string contentType)
|
||
{
|
||
http.Response.Headers.CacheControl = "no-store";
|
||
return TypedResults.Text(InstallScripts.Script(resource), contentType + "; charset=utf-8");
|
||
}
|
||
|
||
private static async Task<IResult> 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<IResult> 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");
|
||
}
|
||
}
|