feat!: genesis-pinned operator set, server identity with an encrypted control link, key-less join codes; release key continuity
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]
This commit is contained in:
1 parent
9bb6841dce
commit
a2f88a344b
89 files changed
+2145
-1195
No files matched your search
@@ -1,4 +1,5 @@
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
@@ -129,12 +130,23 @@ public static class AgentEndpoints
|
||||
private static async Task<IResult> ListTokensAsync(JoinTokenService tokens, CancellationToken ct) =>
|
||||
TypedResults.Json(new PageDto<JoinTokenDto>([.. (await tokens.ListAsync(ct)).Select(Mappers.ToDto)], null), ApiJsonContext.Default.PageDtoJoinTokenDto);
|
||||
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, CreateJoinTokenRequest request, BackendOptions options)
|
||||
private static async Task<IResult> CreateTokenAsync(HttpContext http, JoinTokenService tokens, TrustService trust, CreateJoinTokenRequest request, BackendOptions options)
|
||||
{
|
||||
string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}";
|
||||
|
||||
// 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把
|
||||
OperatorSet? set = await trust.CurrentAsync(http.RequestAborted);
|
||||
string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis;
|
||||
string[] controllers = set is null ? [] : [.. request.Controllers ?? [.. set.Keys.Select(k => k.CredentialId)]];
|
||||
if (set is not null && controllers.Any(c => set.Keys.All(k => k.CredentialId != c)))
|
||||
{
|
||||
return Problems.From(http, ValidationError.Single("controllers", "must be passkeys of the operator set"));
|
||||
}
|
||||
|
||||
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1));
|
||||
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
|
||||
Result<CreatedJoinToken> result = await tokens.CreateAsync(
|
||||
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, http.RequestAborted);
|
||||
new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted);
|
||||
if (!result.TryGet(out CreatedJoinToken? created, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
|
||||
@@ -320,7 +320,7 @@ public static class AuthEndpoints
|
||||
{
|
||||
Principal principal = http.GetPrincipal();
|
||||
return TypedResults.Json(
|
||||
new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken),
|
||||
new MeDto(principal.Kind.ToString().ToLowerInvariant(), [.. principal.Capabilities.Order(StringComparer.Ordinal)], principal.UserId, principal.Username, principal.CsrfToken, principal.AuthMethod.Length > 0 ? principal.AuthMethod : null),
|
||||
ApiJsonContext.Default.MeDto);
|
||||
}
|
||||
}
|
||||
@@ -26,18 +26,35 @@ public static class TrustEndpoints
|
||||
.Returns<TrustStateDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.AccountsManage)
|
||||
.ElevationExempt();
|
||||
guarded.MapPost("/join-tokens/signed", CreateSignedTokenAsync)
|
||||
.Api("join_tokens_create_signed", "Create a join code from a grant the operator signed with a passkey; the browser derives the code from a seed the panel never sees", Tag)
|
||||
.Returns<JoinTokenDto>(StatusCodes.Status201Created).ProducesProblems(400, 401, 403, 409)
|
||||
.Requires(Capability.FleetEnroll)
|
||||
.ElevationExempt();
|
||||
}
|
||||
|
||||
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, BackendOptions options)
|
||||
private static async Task<IResult> StateAsync(HttpContext http, TrustService trust, ReleaseService releases, BackendOptions options)
|
||||
{
|
||||
TrustState state = await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted);
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto);
|
||||
string sh = InstallScripts.Sha256(InstallScripts.Sh);
|
||||
string ps1 = InstallScripts.Sha256(InstallScripts.Ps1);
|
||||
return TypedResults.Json(Mappers.ToDto(state, options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}", sh, ps1, await MatchesReleaseAsync(releases, sh, ps1, http.RequestAborted)), ApiJsonContext.Default.TrustStateDto);
|
||||
}
|
||||
|
||||
/// <summary>面板提供的脚本和 CI 签名的发布清单里记的哈希是否一致(清单由 CI 签名,节点和浏览器之外的人改不了)。没有清单或没记哈希时返回 null。</summary>
|
||||
private static async Task<bool?> MatchesReleaseAsync(ReleaseService releases, string sh, string ps1, CancellationToken ct)
|
||||
{
|
||||
foreach (string component in new[] { JoinTokenService.AgentRole, JoinTokenService.ProbeRole })
|
||||
{
|
||||
if (await releases.GetAsync(component, ct) is not { } offer)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(offer.Manifest);
|
||||
if (document.RootElement.TryGetProperty("install_sh", out System.Text.Json.JsonElement recordedSh) && document.RootElement.TryGetProperty("install_ps1", out System.Text.Json.JsonElement recordedPs1))
|
||||
{
|
||||
return string.Equals(recordedSh.GetString(), sh, StringComparison.Ordinal) && string.Equals(recordedPs1.GetString(), ps1, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CommitAsync(HttpContext http, SignedStatementDto signed, TrustService trust, AgentService agents, BackendOptions options)
|
||||
@@ -59,17 +76,4 @@ public static class TrustEndpoints
|
||||
http.SetDetail($"operator set v{state.Version}");
|
||||
return TypedResults.Json(Mappers.ToDto(state), ApiJsonContext.Default.TrustStateDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateSignedTokenAsync(HttpContext http, CreateSignedJoinTokenRequest request, JoinTokenService tokens)
|
||||
{
|
||||
Result<JoinTokenInfo> result = await tokens.CreateSignedAsync(request.Grant, request.Note, request.Labels, request.AutoApprove ?? true, http.GetPrincipal().Label, http.RequestAborted);
|
||||
if (!result.TryGet(out JoinTokenInfo? info, out FleetError? error))
|
||||
{
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
http.SetDetail($"{JoinTokenService.RoleOf(info.Kind)}: {request.Note}");
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
return TypedResults.Json(Mappers.ToDto(info), ApiJsonContext.Default.JoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
}
|
||||
@@ -154,9 +154,9 @@ public static class Mappers
|
||||
new(t.Id.Value, t.Scope, t.AgentId?.Value, t.BaseTaskId?.Value, t.Kind, t.Target, t.Params, t.Schedule, t.Enabled, Time(t.UpdatedAtMs));
|
||||
|
||||
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy, t.Grant is not null);
|
||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
|
||||
|
||||
public static TrustStateDto ToDto(TrustState s) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge));
|
||||
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed);
|
||||
|
||||
public static ElevationDto ToDto(ElevationWindow w) => new(Elevation.IdOf(w.Class), Time(w.ExpiresAtMs));
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ public sealed record PasskeyDto(string Id, string Name, DateTimeOffset CreatedAt
|
||||
|
||||
public sealed record LoginResponse(string UserId, string Username, string CsrfToken, DateTimeOffset ExpiresAt, string? SessionToken);
|
||||
|
||||
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken);
|
||||
/// <param name="AuthMethod">会话是怎么登录的:<c>password</c>(没有任何能力,只能注册 passkey)或 <c>passkey</c>;API 令牌为空。</param>
|
||||
public sealed record MeDto(string Kind, IReadOnlyList<string> Capabilities, string? UserId, string? Username, string? CsrfToken, string? AuthMethod = null);
|
||||
|
||||
public sealed record LocalLimitsDto(double MaxTotalMbps, long MaxTotalPps, IReadOnlyList<string> AllowedTargetCidrs, int MaxConcurrentTests);
|
||||
|
||||
@@ -74,22 +75,23 @@ public sealed record Ipv6DiagnosticDto(bool? AddressConfigured, bool SocketSuppo
|
||||
public sealed record AgentPatchRequest(string? Name, IReadOnlyList<string>? Labels, string? PublicHost, bool? PublicVisible, string? Ipv6Host = null, bool? PublicHostAuto = null, bool? Ipv6HostAuto = null);
|
||||
|
||||
/// <param name="Role"><c>agent</c> 或 <c>probe</c>:用这个令牌注册出来的是哪种节点。</param>
|
||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null, bool Signed = false);
|
||||
public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string> Labels, bool AutoApprove, int MaxUses, int Uses, DateTimeOffset ExpiresAt, DateTimeOffset? RevokedAt, DateTimeOffset CreatedAt, string Role = "agent", string? CreatedBy = null);
|
||||
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null);
|
||||
/// <param name="Controllers">属主:允许授权这个节点 extension 会话的 passkey 凭证 id(必须是操作员集合里的);缺省是集合里的全部。没有建立信任根时忽略。</param>
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
|
||||
|
||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||
|
||||
/// <param name="Grant">操作员用 passkey 对这个码签的委托(语句加断言)。</param>
|
||||
public sealed record CreateSignedJoinTokenRequest(SignedStatementDto Grant, string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove);
|
||||
|
||||
public sealed record TrustProposalDto(string Statement, int Version, string Challenge);
|
||||
|
||||
/// <param name="Genesis">创世版本的指纹:安装命令里固定到节点上的就是它。还没建立信任根时为空。</param>
|
||||
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
||||
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
|
||||
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal);
|
||||
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
|
||||
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</param>
|
||||
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</param>
|
||||
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false);
|
||||
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||
|
||||
|
||||
@@ -80,7 +80,6 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(RegisterPasskeyRequest))]
|
||||
[JsonSerializable(typeof(PasskeyLoginRequest))]
|
||||
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
|
||||
[JsonSerializable(typeof(CreateSignedJoinTokenRequest))]
|
||||
[JsonSerializable(typeof(TrustStateDto))]
|
||||
[JsonSerializable(typeof(ElevationRequest))]
|
||||
[JsonSerializable(typeof(ElevationAssertionRequest))]
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
using System.Reflection;
|
||||
using MoShiro.Backend.Application;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MoShiro.Backend.Api;
|
||||
|
||||
/// <summary>
|
||||
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,
|
||||
/// 这里把角色、网关、代码和镜像填进去。Agent 和 Probe 共用同一份,不分开维护。
|
||||
/// 一行安装脚本:<c>packaging/install/</c> 下的 <c>install.sh</c> 和 <c>install.ps1</c> 作为嵌入资源打进 Backend,<b>原样返回,不替换任何值</b>。
|
||||
/// 静态,所以它的 SHA-256 在一个版本内固定:CI 构建时算出来写进签名的发布清单,面板给的命令先核对哈希再执行(docs/access.md 第 15 节)。
|
||||
/// 角色、面板地址、代码都走命令里的环境变量。
|
||||
/// </summary>
|
||||
public static class InstallScripts
|
||||
{
|
||||
/// <summary>CI 的发布公钥(<c>packaging/update/release-key.pub.pem</c>):脚本用它验发布清单的签名。</summary>
|
||||
public static string ReleaseKey()
|
||||
{
|
||||
using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream("release-key.pub.pem")
|
||||
?? throw new InvalidOperationException("the embedded release key is missing");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
public const string Sh = "install.sh";
|
||||
public const string Ps1 = "install.ps1";
|
||||
|
||||
public static string Render(string resource, InstallTarget target)
|
||||
/// <summary>CI 的发布公钥(<c>packaging/update/release-key.pub.pem</c>):脚本里内置的就是这一把,测试核对两边一致。</summary>
|
||||
public static string ReleaseKey() => Read("release-key.pub.pem").Trim();
|
||||
|
||||
public static string Script(string resource) => Read(resource);
|
||||
|
||||
/// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
|
||||
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
|
||||
|
||||
private static string Read(string resource)
|
||||
{
|
||||
using Stream stream = typeof(InstallScripts).Assembly.GetManifestResourceStream(resource)
|
||||
?? throw new InvalidOperationException($"the embedded resource {resource} is missing");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd()
|
||||
.Replace("@@RELEASE_KEY@@", ReleaseKey().Trim(), StringComparison.Ordinal)
|
||||
.Replace("@@ROLE@@", target.Role, StringComparison.Ordinal)
|
||||
.Replace("@@SERVER@@", target.Server, StringComparison.Ordinal)
|
||||
.Replace("@@CODE@@", target.Code, StringComparison.Ordinal)
|
||||
.Replace("@@IMAGE@@", target.Image, StringComparison.Ordinal);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,12 @@ public static class OpsEndpoints
|
||||
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"))
|
||||
// 一行安装:面板给的命令先下载这份静态脚本、核对 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))
|
||||
@@ -33,9 +33,9 @@ public static class OpsEndpoints
|
||||
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);
|
||||
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)
|
||||
@@ -52,18 +52,10 @@ public static class OpsEndpoints
|
||||
return signature ? TypedResults.Text(offer.Signature, "text/plain; charset=utf-8") : TypedResults.Text(offer.Manifest, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
private static async Task<IResult> InstallAsync(HttpContext http, JoinTokenService tokens, BackendOptions options, string? code, string resource, string contentType)
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.ContentHttpResult Static(HttpContext http, 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");
|
||||
return TypedResults.Text(InstallScripts.Script(resource), contentType + "; charset=utf-8");
|
||||
}
|
||||
|
||||
private static async Task<IResult> HealthAsync(HttpContext http, OpsService ops)
|
||||
|
||||
Reference in new issue
Block a user