feat: install nodes and the backend from a static compose file; remove dead code [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
Agent, Probe and Backend now start from one static compose.yaml plus a local
.env: the panel (or the release assets, for the very first Backend) provides
the file, the panel's command writes the public values into .env, checks the
file's SHA-256 and runs compose up. Updating is compose pull && compose up -d.
The image is chosen by the CI channel (:latest, or MOSHIRO_VERSION in .env),
not by the panel; the previous one-line script stays as a fallback.
- packaging/compose/{agent,probe,backend}/compose.yaml are the single source,
embedded in the Backend and served at /install/compose/{file}; the root
compose.yaml moved there. Releases also carry them and list them in the
signed SHA256SUMS.
- The container entrypoint defaults the node name to the host name.
- Join token creation returns a compose block (.env, url, hash, per-platform
commands); the admin dialog leads with it.
- Remove code with no references (HotLayer members, LiveHub.SubscriberCount,
QueryService.ParseParams, ReleaseService.Forget, a few constants) and reduce
the install-code lookup to the role it is for.
This commit is contained in:
1 parent
e014a19554
commit
12be44645f
31 files changed
+492
-176
No files matched your search
@@ -213,8 +213,6 @@ public sealed class PeerHub
|
||||
|
||||
private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion };
|
||||
|
||||
public IReadOnlyList<string> ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)];
|
||||
|
||||
/// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
|
||||
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -51,16 +51,4 @@ public static class VarInt
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryReadUInt32LittleEndian(ReadOnlySpan<byte> source, out uint value)
|
||||
{
|
||||
if (source.Length < 4)
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = BinaryPrimitives.ReadUInt32LittleEndian(source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,6 @@ public static class FleetMethodTable
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>二进制通道:0–15 保留给框架,产品从 16 开始。</summary>
|
||||
public static class FleetChannels
|
||||
{
|
||||
public const byte FirstProductChannel = 16;
|
||||
}
|
||||
|
||||
public sealed class ChallengeParams
|
||||
{
|
||||
[JsonPropertyName("nonce")]
|
||||
|
||||
+70
-1
@@ -4966,6 +4966,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/install/compose/{file}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"ops"
|
||||
],
|
||||
"summary": "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)",
|
||||
"operationId": "install_compose",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "file",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/install.ps1": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -5796,6 +5830,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ComposeInstallDto": {
|
||||
"required": [
|
||||
"env",
|
||||
"url",
|
||||
"sha256",
|
||||
"linux",
|
||||
"macos",
|
||||
"windows"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"env": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string"
|
||||
},
|
||||
"linux": {
|
||||
"type": "string"
|
||||
},
|
||||
"macos": {
|
||||
"type": "string"
|
||||
},
|
||||
"windows": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ConfirmBackendUpdateRequest": {
|
||||
"required": [
|
||||
"seq"
|
||||
@@ -6029,7 +6094,8 @@
|
||||
"required": [
|
||||
"token",
|
||||
"plaintext",
|
||||
"install_commands"
|
||||
"install_commands",
|
||||
"compose"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -6041,6 +6107,9 @@
|
||||
},
|
||||
"install_commands": {
|
||||
"$ref": "#/components/schemas/InstallCommandsDto"
|
||||
},
|
||||
"compose": {
|
||||
"$ref": "#/components/schemas/ComposeInstallDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,7 +3,6 @@ namespace MoShiro.Protocol;
|
||||
public static class ProtocolVersion
|
||||
{
|
||||
public const int Container = 1;
|
||||
public const int Wire = 1;
|
||||
}
|
||||
|
||||
public static class ProtocolLimits
|
||||
@@ -39,5 +38,4 @@ public static class MoShiroCapabilities
|
||||
{
|
||||
public const string Base = "moshiro.v1";
|
||||
public const string KindsPrefix = "moshiro.kinds:";
|
||||
public const string BlocksPrefix = "moshiro.blocks:";
|
||||
}
|
||||
@@ -143,7 +143,7 @@ public static class AgentEndpoints
|
||||
return Problems.From(http, ownerError);
|
||||
}
|
||||
|
||||
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1));
|
||||
var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1), InstallScripts.ComposeSha256(request.Role ?? JoinTokenService.AgentRole));
|
||||
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, plan, http.RequestAborted);
|
||||
@@ -154,7 +154,7 @@ public static class AgentEndpoints
|
||||
|
||||
http.Response.Headers.CacheControl = "no-store";
|
||||
http.Response.Headers.Location = "/api/admin/join-tokens";
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows), new ComposeInstallDto(created.Compose.Env, created.Compose.Url, created.Compose.Sha256, created.Compose.Linux, created.Compose.Macos, created.Compose.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct)
|
||||
|
||||
@@ -83,6 +83,14 @@ public sealed record SetupRequest(string? ClaimCode, string? Username, string? P
|
||||
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||
|
||||
/// <param name="Env">要存成 <c>.env</c> 的内容(只有公开的值)。</param>
|
||||
/// <param name="Url">compose 文件的下载地址。</param>
|
||||
/// <param name="Sha256">compose 文件的 SHA-256:命令下载之后先核对它。</param>
|
||||
/// <param name="Linux">Linux(Podman):建目录、写 .env、下载、核对哈希、启动,合在一起的一段命令。</param>
|
||||
/// <param name="Macos">macOS(Docker Desktop);Agent 需要 Linux 宿主网络,所以是空的。</param>
|
||||
/// <param name="Windows">Windows(Docker Desktop,PowerShell);Agent 同样是空的。</param>
|
||||
public sealed record ComposeInstallDto(string Env, string Url, string Sha256, string Linux, string Macos, string Windows);
|
||||
|
||||
/// <summary>下一版里新增的 passkey。</summary>
|
||||
public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt);
|
||||
|
||||
@@ -100,7 +108,7 @@ public sealed record TrustProposalDto(string Statement, int Version, string Chal
|
||||
/// <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, IReadOnlyList<NodeOwnerDto>? OperatorDetails = null);
|
||||
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands, ComposeInstallDto Compose);
|
||||
|
||||
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
||||
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
||||
|
||||
@@ -86,6 +86,7 @@ namespace MoShiro.Backend.Api;
|
||||
[JsonSerializable(typeof(TrustStateDto))]
|
||||
[JsonSerializable(typeof(SetupStatusDto))]
|
||||
[JsonSerializable(typeof(SetupRequest))]
|
||||
[JsonSerializable(typeof(ComposeInstallDto))]
|
||||
[JsonSerializable(typeof(ProposedCredentialDto))]
|
||||
[JsonSerializable(typeof(PasskeyDto))]
|
||||
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
||||
|
||||
@@ -19,6 +19,12 @@ public static class InstallScripts
|
||||
|
||||
public static string Script(string resource) => Read(resource);
|
||||
|
||||
/// <summary>compose 文件的资源名(<c>agent.yaml</c>、<c>probe.yaml</c>、<c>backend.yaml</c> 对应的);不是这三个就是 null。</summary>
|
||||
public static string? ComposeResource(string file) => file is "agent.yaml" or "probe.yaml" or "backend.yaml" ? "compose-" + file : null;
|
||||
|
||||
/// <summary>某个角色(agent、probe)的 compose 文件的 SHA-256;其他角色返回空。</summary>
|
||||
public static string ComposeSha256(string? role) => role is "agent" or "probe" ? Sha256("compose-" + role + ".yaml") : string.Empty;
|
||||
|
||||
/// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
|
||||
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<EmbeddedResource Include="..\..\..\packaging\install\install.sh" LogicalName="install.sh" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\update\release-key.pub.pem" LogicalName="release-key.pub.pem" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\compose\agent\compose.yaml" LogicalName="compose-agent.yaml" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\compose\probe\compose.yaml" LogicalName="compose-probe.yaml" />
|
||||
<EmbeddedResource Include="..\..\..\packaging\compose\backend\compose.yaml" LogicalName="compose-backend.yaml" />
|
||||
</ItemGroup>
|
||||
<!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API -->
|
||||
<ItemGroup Condition="Exists('..\..\..\web\display\dist\index.html')">
|
||||
|
||||
@@ -33,6 +33,11 @@ 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);
|
||||
// compose 文件:Agent、Probe、Backend 各一份,静态(镜像用 :latest 或 .env 里的 MOSHIRO_VERSION,不含任何一次性的值),
|
||||
// 面板的命令把参数放在本地的 .env 里,下载后先核对 SHA-256。
|
||||
app.MapGet("/install/compose/{file}", (HttpContext http, string file) => InstallScripts.ComposeResource(file) is { } resource ? Static(http, resource, "text/yaml") : Problems.Create(http, MoShiroErrors.NotFound, "unknown compose file"))
|
||||
.Api("install_compose", "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)", Tag)
|
||||
.Produces(StatusCodes.Status200OK, contentType: "text/yaml").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");
|
||||
@@ -40,9 +45,8 @@ public static class OpsEndpoints
|
||||
|
||||
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.GetOrFetchAsync(target.Role, http.RequestAborted);
|
||||
string? role = string.IsNullOrEmpty(code) ? null : await tokens.RoleOfCodeAsync(code, http.RequestAborted);
|
||||
ReleaseOffer? offer = role is null ? null : await releases.GetOrFetchAsync(role, http.RequestAborted);
|
||||
if (offer is null)
|
||||
{
|
||||
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
|
||||
|
||||
@@ -14,12 +14,16 @@ public sealed record InstallCommands(string Linux, string Macos, string Windows)
|
||||
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
|
||||
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
|
||||
/// </summary>
|
||||
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256);
|
||||
/// <param name="ComposeSha256">面板提供的这个角色的 compose 文件的 SHA-256(命令下载之后先核对)。</param>
|
||||
public sealed record InstallPlan(string? Genesis, IReadOnlyList<string> Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = "");
|
||||
|
||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands);
|
||||
/// <summary>
|
||||
/// compose 安装:<c>Env</c> 是要存成 <c>.env</c> 的内容(只有公开的值),<c>Url</c> 和 <c>Sha256</c> 是 compose 文件的下载地址和哈希,
|
||||
/// 三条命令(Linux 用 Podman,macOS、Windows 用 Docker Desktop)把 .env、下载、核对哈希和启动合在一起;Agent 的 compose 需要 Linux 宿主网络,所以它的 macOS、Windows 命令是空的。
|
||||
/// </summary>
|
||||
public sealed record ComposeInstall(string Env, string Url, string Sha256, string Linux, string Macos, string Windows);
|
||||
|
||||
/// <summary>安装脚本里要填的四个值。</summary>
|
||||
public sealed record InstallTarget(string Role, string Server, string Code, string Image);
|
||||
public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands, ComposeInstall Compose);
|
||||
|
||||
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
||||
public sealed class JoinTokenService
|
||||
@@ -82,7 +86,7 @@ public sealed class JoinTokenService
|
||||
Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role),
|
||||
request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct);
|
||||
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
|
||||
return Result<CreatedJoinToken>.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Compose(BaseUrl(requestBaseUrl), request.Role, plaintext, plan)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -101,13 +105,67 @@ public sealed class JoinTokenService
|
||||
+ $"$env:MOSHIRO_SERVER='{baseUrl}'; $env:MOSHIRO_ROLE='{role}'; $env:MOSHIRO_CODE='{code}'; {psPin}iex (Get-Content -Raw $f)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把一次性码和公开的值写进本地的 <c>.env</c>,下载静态的 compose 文件、核对哈希、<c>compose up -d</c>:以后更新就是
|
||||
/// <c>compose pull && compose up -d</c>。镜像来自 CI 渠道(<c>:latest</c>,也可以在 .env 里固定版本),不由面板决定。
|
||||
/// </summary>
|
||||
public ComposeInstall Compose(string baseUrl, string role, string code, InstallPlan plan)
|
||||
{
|
||||
string url = $"{baseUrl}/install/compose/{role}.yaml";
|
||||
string dir = $"moshiro-{role}";
|
||||
string Env(bool desktop)
|
||||
{
|
||||
var lines = new List<string> { $"MOSHIRO_SERVER={baseUrl}", $"MOSHIRO_JOIN={code}" };
|
||||
if (plan.Genesis is null)
|
||||
{
|
||||
lines.Add("MOSHIRO_ALLOW_NO_OWNER=1");
|
||||
}
|
||||
else
|
||||
{
|
||||
lines.Add($"MOSHIRO_GENESIS={plan.Genesis}");
|
||||
lines.Add($"MOSHIRO_CONTROLLERS={string.Join(',', plan.Controllers)}");
|
||||
}
|
||||
|
||||
lines.Add($"MOSHIRO_IMAGE={ImageRepository(role)}");
|
||||
if (desktop)
|
||||
{
|
||||
// Docker Desktop 没有宿主网络
|
||||
lines.Add("MOSHIRO_NETWORK_MODE=bridge");
|
||||
lines.Add("MOSHIRO_NETWORK_SCOPE=container");
|
||||
}
|
||||
|
||||
lines.Add("# MOSHIRO_NAME=节点名字(不写就用主机名;名字被占用时在这里改)");
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
string linux = $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(false)}\nEOF\n"
|
||||
+ $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | sha256sum -c - && podman compose up -d";
|
||||
string macos = role == ProbeRole
|
||||
? $"mkdir -p {dir} && cd {dir} && cat > .env <<'EOF'\n{Env(true)}\nEOF\n"
|
||||
+ $"chmod 600 .env && curl -fsSL '{url}' -o compose.yaml && echo \"{plan.ComposeSha256} compose.yaml\" | shasum -a 256 -c - && docker compose up -d"
|
||||
: "";
|
||||
string windows = role == ProbeRole
|
||||
? $"New-Item -ItemType Directory -Force {dir} | Out-Null; Set-Location {dir}\n@'\n{Env(true)}\n'@ | Set-Content -Encoding ascii .env\n"
|
||||
+ $"irm '{url}' -OutFile compose.yaml; if ((Get-FileHash compose.yaml -Algorithm SHA256).Hash -ne '{plan.ComposeSha256}') {{ throw 'compose.yaml does not match its published hash' }}\ndocker compose up -d"
|
||||
: "";
|
||||
return new ComposeInstall(Env(false), url, plan.ComposeSha256, linux, macos, windows);
|
||||
}
|
||||
|
||||
/// <summary>这个角色的镜像仓库地址(不带 tag):节点镜像仓库加上命名空间。</summary>
|
||||
private string ImageRepository(string role)
|
||||
{
|
||||
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
|
||||
string ns = _options.ImageNamespace.Trim('/');
|
||||
return $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}";
|
||||
}
|
||||
|
||||
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
||||
|
||||
/// <summary>
|
||||
/// 安装脚本要填的值:由代码找到令牌(不消耗),角色、网关、镜像都在这里决定,Agent 和 Probe 共用同一份脚本。
|
||||
/// 代码不存在、已吊销、已过期或用完了返回 null。网关和镜像要是含有会破坏脚本引号的字符也返回 null。
|
||||
/// 这个一次性码是给哪个角色(agent 或 probe)的:安装脚本据此向面板取该角色已签名的发布清单。
|
||||
/// 代码不存在、已吊销、已过期或用完了返回 null(不消耗它)。
|
||||
/// </summary>
|
||||
public async ValueTask<InstallTarget?> ResolveInstallAsync(string code, string? requestBaseUrl, CancellationToken ct)
|
||||
public async ValueTask<string?> RoleOfCodeAsync(string code, CancellationToken ct)
|
||||
{
|
||||
if (code.Length is 0 or > 200)
|
||||
{
|
||||
@@ -115,22 +173,11 @@ public sealed class JoinTokenService
|
||||
}
|
||||
|
||||
JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct);
|
||||
if (token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string role = RoleOf(token.Kind);
|
||||
string registry = (_options.NodeImageRegistry ?? "").Trim('/');
|
||||
string ns = _options.ImageNamespace.Trim('/');
|
||||
string image = $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}:latest";
|
||||
string server = BaseUrl(requestBaseUrl);
|
||||
return IsSafe(server) && IsSafe(image) && IsSafe(code) ? new InstallTarget(role, server, code, image) : null;
|
||||
return token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses
|
||||
? null
|
||||
: RoleOf(token.Kind);
|
||||
}
|
||||
|
||||
/// <summary>值会被放进脚本的单引号里:只允许 URL 和镜像名里正常出现的字符。</summary>
|
||||
private static bool IsSafe(string value) => value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' or ':' or '/' or '[' or ']' or '~' or '%');
|
||||
|
||||
public ValueTask<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||
|
||||
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct);
|
||||
|
||||
@@ -102,8 +102,6 @@ public static class CapabilityCatalog
|
||||
|
||||
public static bool IsKnown(string id) => s_byId.ContainsKey(id);
|
||||
|
||||
public static CapabilityInfo? Find(string id) => s_byId.GetValueOrDefault(id);
|
||||
|
||||
/// <summary>补上蕴含的能力(写蕴含读),去重并排序,结果稳定。</summary>
|
||||
public static IReadOnlyList<string> Expand(IEnumerable<string> ids)
|
||||
{
|
||||
|
||||
@@ -28,8 +28,6 @@ public sealed class HotLayer
|
||||
|
||||
public void MarkReady() => _ready = true;
|
||||
|
||||
public IEnumerable<(NodeId Agent, string Series)> Keys => _rings.Keys;
|
||||
|
||||
/// <summary>评估一个块并合并进热层。同一个块重复合并(例如回填与实时竞争)会被忽略。</summary>
|
||||
public void Merge(NodeId agent, string series, RawChunk chunk)
|
||||
{
|
||||
@@ -94,27 +92,6 @@ public sealed class HotLayer
|
||||
return result;
|
||||
}
|
||||
|
||||
public int BucketCount(NodeId agent, string series)
|
||||
{
|
||||
if (!_rings.TryGetValue((agent, series), out Ring? ring))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
lock (ring)
|
||||
{
|
||||
return ring.Buckets.Count;
|
||||
}
|
||||
}
|
||||
|
||||
public void Remove(NodeId agent)
|
||||
{
|
||||
foreach ((NodeId Agent, string Series) key in _rings.Keys.Where(k => k.Agent == agent).ToList())
|
||||
{
|
||||
_rings.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Prune(Ring ring, long cutoff)
|
||||
{
|
||||
while (ring.Buckets.Count > 0)
|
||||
|
||||
@@ -45,8 +45,6 @@ public sealed class LiveHub
|
||||
{
|
||||
private readonly ConcurrentDictionary<LiveSubscription, byte> _subscriptions = new();
|
||||
|
||||
public int SubscriberCount => _subscriptions.Count;
|
||||
|
||||
public LiveSubscription Subscribe()
|
||||
{
|
||||
var subscription = new LiveSubscription(this);
|
||||
|
||||
@@ -422,8 +422,4 @@ public sealed class QueryService
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
/// <summary>从 JSON 文本读取参数(供端点之外的调用方使用)。</summary>
|
||||
public static Dictionary<string, JsonElement> ParseParams(string json) =>
|
||||
JsonSerializer.Deserialize(json, TestJson.Default.ParamMap) ?? [];
|
||||
}
|
||||
@@ -194,8 +194,6 @@ public sealed partial class ReleaseService : IDisposable
|
||||
|
||||
public UpdateStatus? StatusOf(NodeId node) => _status.TryGetValue(node.Value, out UpdateStatus? status) ? status : null;
|
||||
|
||||
public void Forget(NodeId node) => _status.TryRemove(node.Value, out _);
|
||||
|
||||
private IEnumerable<NodeId> OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component);
|
||||
|
||||
internal static string? ComponentOf(NodeId node) =>
|
||||
|
||||
@@ -48,6 +48,78 @@ public sealed class InstallTests
|
||||
Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("agent")]
|
||||
[InlineData("probe")]
|
||||
public async Task ComposeInstall_WritesThePublicValuesToAnEnvFile_AndChecksTheComposeHash(string role)
|
||||
{
|
||||
JsonNode created = await CreateAsync(role);
|
||||
string code = created["plaintext"]!.GetValue<string>();
|
||||
JsonNode compose = created["compose"]!;
|
||||
string env = compose["env"]!.GetValue<string>();
|
||||
string url = compose["url"]!.GetValue<string>();
|
||||
string linux = compose["linux"]!.GetValue<string>();
|
||||
|
||||
Assert.EndsWith($"/install/compose/{role}.yaml", url, StringComparison.Ordinal);
|
||||
Assert.Contains($"MOSHIRO_JOIN={code}", env, StringComparison.Ordinal);
|
||||
Assert.Contains("MOSHIRO_SERVER=", env, StringComparison.Ordinal);
|
||||
Assert.Contains($"/moshiro-{role}", env, StringComparison.Ordinal); // MOSHIRO_IMAGE:仓库和命名空间来自后端的配置,不是写死的
|
||||
Assert.StartsWith($"mkdir -p moshiro-{role} && cd moshiro-{role} && cat > .env <<'EOF'\n", linux, StringComparison.Ordinal);
|
||||
Assert.Contains(env, linux, StringComparison.Ordinal);
|
||||
Assert.Contains("chmod 600 .env", linux, StringComparison.Ordinal);
|
||||
Assert.Contains($"echo \"{compose["sha256"]!.GetValue<string>()} compose.yaml\" | sha256sum -c -", linux, StringComparison.Ordinal);
|
||||
Assert.EndsWith("podman compose up -d", linux, StringComparison.Ordinal);
|
||||
|
||||
// 面板提供的就是命令里核对的那份
|
||||
using HttpResponseMessage served = await _backend.Anonymous.GetAsync($"/install/compose/{role}.yaml");
|
||||
Assert.Equal(HttpStatusCode.OK, served.StatusCode);
|
||||
Assert.Equal("text/yaml", served.Content.Headers.ContentType?.MediaType);
|
||||
byte[] body = await served.Content.ReadAsByteArrayAsync();
|
||||
Assert.Equal(compose["sha256"]!.GetValue<string>(), Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(body)));
|
||||
|
||||
if (role == "probe")
|
||||
{
|
||||
// Docker Desktop 没有宿主网络:macOS 和 Windows 的 .env 里改成 bridge
|
||||
string macos = compose["macos"]!.GetValue<string>();
|
||||
string windows = compose["windows"]!.GetValue<string>();
|
||||
Assert.Contains("MOSHIRO_NETWORK_MODE=bridge", macos + windows, StringComparison.Ordinal);
|
||||
Assert.Contains("docker compose up -d", macos, StringComparison.Ordinal);
|
||||
Assert.Contains("Get-FileHash compose.yaml", windows, StringComparison.Ordinal);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Agent 需要 Linux 的宿主网络
|
||||
Assert.Equal(string.Empty, compose["macos"]!.GetValue<string>());
|
||||
Assert.Equal(string.Empty, compose["windows"]!.GetValue<string>());
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("agent.yaml")]
|
||||
[InlineData("probe.yaml")]
|
||||
[InlineData("backend.yaml")]
|
||||
public async Task TheComposeFiles_AreStatic_PinNothingToABuild_AndKeepTheNodeHardening(string file)
|
||||
{
|
||||
string body = await _backend.Anonymous.GetStringAsync("/install/compose/" + file);
|
||||
|
||||
Assert.Contains("${MOSHIRO_VERSION:-latest}", body, StringComparison.Ordinal); // 更新就是 pull 加 up,想固定版本在 .env 里写
|
||||
Assert.Contains("read_only: true", body, StringComparison.Ordinal);
|
||||
Assert.Contains("no-new-privileges:true", body, StringComparison.Ordinal);
|
||||
Assert.Contains("- ALL", body, StringComparison.Ordinal); // cap_drop
|
||||
Assert.DoesNotContain("jt_", body, StringComparison.Ordinal); // 没有任何一次性的值
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("evil.yaml")]
|
||||
[InlineData("agent")]
|
||||
[InlineData("..%2Finstall.sh")]
|
||||
public async Task OnlyTheThreeComposeFiles_AreServed(string file)
|
||||
{
|
||||
using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install/compose/" + file);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheScripts_AreStatic_TheSameBytesForEveryone_AndNeedNoCode()
|
||||
{
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed partial class OpenApiConformanceTests
|
||||
foreach ((string path, _) in doc["paths"]!.AsObject())
|
||||
{
|
||||
string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"];
|
||||
string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/api/release/offer"];
|
||||
string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/install/compose/{file}", "/api/release/offer"];
|
||||
Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers");
|
||||
foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user