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 };
|
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>
|
/// <summary>向一个 Probe 下发它的线路任务集(Probe 连上时由 <see cref="OnSessionAsync"/> 先发一份)。</summary>
|
||||||
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
|
public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -51,16 +51,4 @@ public static class VarInt
|
|||||||
|
|
||||||
return false;
|
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
|
public sealed class ChallengeParams
|
||||||
{
|
{
|
||||||
[JsonPropertyName("nonce")]
|
[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": {
|
"/install.ps1": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"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": {
|
"ConfirmBackendUpdateRequest": {
|
||||||
"required": [
|
"required": [
|
||||||
"seq"
|
"seq"
|
||||||
@@ -6029,7 +6094,8 @@
|
|||||||
"required": [
|
"required": [
|
||||||
"token",
|
"token",
|
||||||
"plaintext",
|
"plaintext",
|
||||||
"install_commands"
|
"install_commands",
|
||||||
|
"compose"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6041,6 +6107,9 @@
|
|||||||
},
|
},
|
||||||
"install_commands": {
|
"install_commands": {
|
||||||
"$ref": "#/components/schemas/InstallCommandsDto"
|
"$ref": "#/components/schemas/InstallCommandsDto"
|
||||||
|
},
|
||||||
|
"compose": {
|
||||||
|
"$ref": "#/components/schemas/ComposeInstallDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ namespace MoShiro.Protocol;
|
|||||||
public static class ProtocolVersion
|
public static class ProtocolVersion
|
||||||
{
|
{
|
||||||
public const int Container = 1;
|
public const int Container = 1;
|
||||||
public const int Wire = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ProtocolLimits
|
public static class ProtocolLimits
|
||||||
@@ -39,5 +38,4 @@ public static class MoShiroCapabilities
|
|||||||
{
|
{
|
||||||
public const string Base = "moshiro.v1";
|
public const string Base = "moshiro.v1";
|
||||||
public const string KindsPrefix = "moshiro.kinds:";
|
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);
|
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}");
|
http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}");
|
||||||
Result<CreatedJoinToken> result = await tokens.CreateAsync(
|
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);
|
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.CacheControl = "no-store";
|
||||||
http.Response.Headers.Location = "/api/admin/join-tokens";
|
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)
|
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);
|
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>
|
/// <summary>下一版里新增的 passkey。</summary>
|
||||||
public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt);
|
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>
|
/// <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 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>
|
/// <summary>Agent 眼中连着的一个 Probe(Agent 上报)。</summary>
|
||||||
public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4");
|
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(TrustStateDto))]
|
||||||
[JsonSerializable(typeof(SetupStatusDto))]
|
[JsonSerializable(typeof(SetupStatusDto))]
|
||||||
[JsonSerializable(typeof(SetupRequest))]
|
[JsonSerializable(typeof(SetupRequest))]
|
||||||
|
[JsonSerializable(typeof(ComposeInstallDto))]
|
||||||
[JsonSerializable(typeof(ProposedCredentialDto))]
|
[JsonSerializable(typeof(ProposedCredentialDto))]
|
||||||
[JsonSerializable(typeof(PasskeyDto))]
|
[JsonSerializable(typeof(PasskeyDto))]
|
||||||
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ public static class InstallScripts
|
|||||||
|
|
||||||
public static string Script(string resource) => Read(resource);
|
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>
|
/// <summary>脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。</summary>
|
||||||
public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource))));
|
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.sh" LogicalName="install.sh" />
|
||||||
<EmbeddedResource Include="..\..\..\packaging\install\install.ps1" LogicalName="install.ps1" />
|
<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\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>
|
</ItemGroup>
|
||||||
<!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API -->
|
<!-- 前端构建产物(web 目录下 npm run build)内嵌进 Backend;没构建时不嵌入,Backend 只提供 API -->
|
||||||
<ItemGroup Condition="Exists('..\..\..\web\display\dist\index.html')">
|
<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))
|
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)
|
.Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag)
|
||||||
.Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404);
|
.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"))
|
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)
|
.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");
|
.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)
|
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}";
|
string? role = string.IsNullOrEmpty(code) ? null : await tokens.RoleOfCodeAsync(code, http.RequestAborted);
|
||||||
InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted);
|
ReleaseOffer? offer = role is null ? null : await releases.GetOrFetchAsync(role, http.RequestAborted);
|
||||||
ReleaseOffer? offer = target is null ? null : await releases.GetOrFetchAsync(target.Role, http.RequestAborted);
|
|
||||||
if (offer is null)
|
if (offer is null)
|
||||||
{
|
{
|
||||||
return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code");
|
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)、
|
/// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、
|
||||||
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
|
/// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 <c>Genesis</c> 为空,节点不固定信任根。
|
||||||
/// </summary>
|
/// </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 CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands, ComposeInstall Compose);
|
||||||
public sealed record InstallTarget(string Role, string Server, string Code, string Image);
|
|
||||||
|
|
||||||
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
/// <summary>加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。</summary>
|
||||||
public sealed class JoinTokenService
|
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),
|
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);
|
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>
|
/// <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)");
|
+ $"$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('/');
|
private string BaseUrl(string? requestBaseUrl) => (_options.PublicUrl ?? requestBaseUrl ?? "https://panel.example.com").TrimEnd('/');
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 安装脚本要填的值:由代码找到令牌(不消耗),角色、网关、镜像都在这里决定,Agent 和 Probe 共用同一份脚本。
|
/// 这个一次性码是给哪个角色(agent 或 probe)的:安装脚本据此向面板取该角色已签名的发布清单。
|
||||||
/// 代码不存在、已吊销、已过期或用完了返回 null。网关和镜像要是含有会破坏脚本引号的字符也返回 null。
|
/// 代码不存在、已吊销、已过期或用完了返回 null(不消耗它)。
|
||||||
/// </summary>
|
/// </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)
|
if (code.Length is 0 or > 200)
|
||||||
{
|
{
|
||||||
@@ -115,22 +173,11 @@ public sealed class JoinTokenService
|
|||||||
}
|
}
|
||||||
|
|
||||||
JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct);
|
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 token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses
|
||||||
{
|
? null
|
||||||
return null;
|
: RoleOf(token.Kind);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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<IReadOnlyList<JoinTokenInfo>> ListAsync(CancellationToken ct) => _store.ListAsync(ct);
|
||||||
|
|
||||||
public ValueTask<bool> RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), 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 bool IsKnown(string id) => s_byId.ContainsKey(id);
|
||||||
|
|
||||||
public static CapabilityInfo? Find(string id) => s_byId.GetValueOrDefault(id);
|
|
||||||
|
|
||||||
/// <summary>补上蕴含的能力(写蕴含读),去重并排序,结果稳定。</summary>
|
/// <summary>补上蕴含的能力(写蕴含读),去重并排序,结果稳定。</summary>
|
||||||
public static IReadOnlyList<string> Expand(IEnumerable<string> ids)
|
public static IReadOnlyList<string> Expand(IEnumerable<string> ids)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ public sealed class HotLayer
|
|||||||
|
|
||||||
public void MarkReady() => _ready = true;
|
public void MarkReady() => _ready = true;
|
||||||
|
|
||||||
public IEnumerable<(NodeId Agent, string Series)> Keys => _rings.Keys;
|
|
||||||
|
|
||||||
/// <summary>评估一个块并合并进热层。同一个块重复合并(例如回填与实时竞争)会被忽略。</summary>
|
/// <summary>评估一个块并合并进热层。同一个块重复合并(例如回填与实时竞争)会被忽略。</summary>
|
||||||
public void Merge(NodeId agent, string series, RawChunk chunk)
|
public void Merge(NodeId agent, string series, RawChunk chunk)
|
||||||
{
|
{
|
||||||
@@ -94,27 +92,6 @@ public sealed class HotLayer
|
|||||||
return result;
|
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)
|
private static void Prune(Ring ring, long cutoff)
|
||||||
{
|
{
|
||||||
while (ring.Buckets.Count > 0)
|
while (ring.Buckets.Count > 0)
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ public sealed class LiveHub
|
|||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<LiveSubscription, byte> _subscriptions = new();
|
private readonly ConcurrentDictionary<LiveSubscription, byte> _subscriptions = new();
|
||||||
|
|
||||||
public int SubscriberCount => _subscriptions.Count;
|
|
||||||
|
|
||||||
public LiveSubscription Subscribe()
|
public LiveSubscription Subscribe()
|
||||||
{
|
{
|
||||||
var subscription = new LiveSubscription(this);
|
var subscription = new LiveSubscription(this);
|
||||||
|
|||||||
@@ -422,8 +422,4 @@ public sealed class QueryService
|
|||||||
|
|
||||||
return gaps;
|
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 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);
|
private IEnumerable<NodeId> OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component);
|
||||||
|
|
||||||
internal static string? ComponentOf(NodeId node) =>
|
internal static string? ComponentOf(NodeId node) =>
|
||||||
|
|||||||
@@ -48,6 +48,78 @@ public sealed class InstallTests
|
|||||||
Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符
|
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]
|
[Fact]
|
||||||
public async Task TheScripts_AreStatic_TheSameBytesForEveryone_AndNeedNoCode()
|
public async Task TheScripts_AreStatic_TheSameBytesForEveryone_AndNeedNoCode()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public sealed partial class OpenApiConformanceTests
|
|||||||
foreach ((string path, _) in doc["paths"]!.AsObject())
|
foreach ((string path, _) in doc["paths"]!.AsObject())
|
||||||
{
|
{
|
||||||
string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"];
|
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");
|
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))
|
foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ trap 'rm -rf "$tmp"' EXIT
|
|||||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
install_sh="$(sha256sum "$root_dir/packaging/install/install.sh" | cut -d" " -f1)"
|
install_sh="$(sha256sum "$root_dir/packaging/install/install.sh" | cut -d" " -f1)"
|
||||||
install_ps1="$(sha256sum "$root_dir/packaging/install/install.ps1" | cut -d" " -f1)"
|
install_ps1="$(sha256sum "$root_dir/packaging/install/install.ps1" | cut -d" " -f1)"
|
||||||
release_body="Install script SHA-256 (compare with the panel before you run an install command): install.sh $install_sh, install.ps1 $install_ps1. Agent and Probe builds: Linux glibc and musl (x64, arm64), macOS (x64, arm64), and Windows (x64, arm64). Linux glibc Agent and Probe OCI images: registry.oeo.one/moshiro/moshiro-agent:$version and registry.oeo.one/moshiro/moshiro-probe:$version (linux/amd64 and linux/arm64). Backend OCI image: registry.oeo.one/moshiro/moshiro-backend:$version (linux/amd64 and linux/arm64). macOS binaries are not Developer ID signed or notarized."
|
release_body="Install script SHA-256 (compare with the panel before you run an install command): install.sh $install_sh, install.ps1 $install_ps1. Agent and Probe builds: Linux glibc and musl (x64, arm64), macOS (x64, arm64), and Windows (x64, arm64). Linux glibc Agent and Probe OCI images: registry.oeo.one/moshiro/moshiro-agent:$version and registry.oeo.one/moshiro/moshiro-probe:$version (linux/amd64 and linux/arm64). Backend OCI image: registry.oeo.one/moshiro/moshiro-backend:$version (linux/amd64 and linux/arm64). Compose files for Agent, Probe and Backend: moshiro-compose-<role>.yaml (images use :latest, or pin MOSHIRO_VERSION in .env). macOS binaries are not Developer ID signed or notarized."
|
||||||
payload="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"MoShiro %s","body":"%s","draft":false,"prerelease":false}' \
|
payload="$(printf '{"tag_name":"%s","target_commitish":"%s","name":"MoShiro %s","body":"%s","draft":false,"prerelease":false}' \
|
||||||
"$tag" "${CI_COMMIT_SHA:-}" "$tag" "$release_body")"
|
"$tag" "${CI_COMMIT_SHA:-}" "$tag" "$release_body")"
|
||||||
release_status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \
|
release_status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \
|
||||||
@@ -129,6 +129,13 @@ upload_local_asset() {
|
|||||||
echo "attached $name"
|
echo "attached $name"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# compose 文件(Agent、Probe、Backend 各一份,静态,镜像用 :latest 或 .env 里的 MOSHIRO_VERSION):作为发布资产,
|
||||||
|
# 第一次部署 Backend 时还没有面板,就从这里下载;它们也记进 SHA256SUMS(有签名密钥时一起签名)。
|
||||||
|
for role in agent probe backend; do
|
||||||
|
cp "$root_dir/packaging/compose/$role/compose.yaml" "$tmp/moshiro-compose-$role.yaml"
|
||||||
|
upload_local_asset "moshiro-compose-$role.yaml" "$tmp/moshiro-compose-$role.yaml"
|
||||||
|
done
|
||||||
|
|
||||||
if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then
|
if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then
|
||||||
sums="$tmp/SHA256SUMS"
|
sums="$tmp/SHA256SUMS"
|
||||||
: > "$sums"
|
: > "$sums"
|
||||||
@@ -138,6 +145,10 @@ if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then
|
|||||||
cat "$tmp/$archive.sha256" >> "$sums"
|
cat "$tmp/$archive.sha256" >> "$sums"
|
||||||
done
|
done
|
||||||
[[ "$(wc -l < "$sums")" -eq "${#assets[@]}" ]] || { echo "SHA256SUMS does not have one line per archive" >&2; exit 1; }
|
[[ "$(wc -l < "$sums")" -eq "${#assets[@]}" ]] || { echo "SHA256SUMS does not have one line per archive" >&2; exit 1; }
|
||||||
|
for role in agent probe backend; do
|
||||||
|
printf '%s %s
|
||||||
|
' "$(sha256sum "$tmp/moshiro-compose-$role.yaml" | cut -d' ' -f1)" "moshiro-compose-$role.yaml" >> "$sums"
|
||||||
|
done
|
||||||
signing_key="$(mktemp)"
|
signing_key="$(mktemp)"
|
||||||
chmod 600 "$signing_key"
|
chmod 600 "$signing_key"
|
||||||
printf '%s\n' "$RELEASE_SIGNING_KEY" > "$signing_key"
|
printf '%s\n' "$RELEASE_SIGNING_KEY" > "$signing_key"
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
services:
|
|
||||||
backend:
|
|
||||||
image: registry.oeo.one/${MOSHIRO_IMAGE_NAMESPACE:-moshiro}/moshiro-backend:${MOSHIRO_VERSION:-0.1.1}
|
|
||||||
container_name: moshiro-backend
|
|
||||||
restart: unless-stopped
|
|
||||||
init: true
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:8080:8080"
|
|
||||||
environment:
|
|
||||||
ASPNETCORE_HTTP_PORTS: "8080"
|
|
||||||
MoShiro__DataDirectory: /data
|
|
||||||
MoShiro__UpdateChannel: ${MOSHIRO_UPDATE_CHANNEL:-auto}
|
|
||||||
MoShiro__ImageNamespace: ${MOSHIRO_IMAGE_NAMESPACE:-moshiro}
|
|
||||||
# 没有默认管理员:第一次打开面板时按页面引导自己创建(需要后端日志里的认领码,也可以在这里指定 MOSHIRO_CLAIM_CODE)
|
|
||||||
MoShiro__ClaimCode: "${MOSHIRO_CLAIM_CODE:-}"
|
|
||||||
volumes:
|
|
||||||
- moshiro-backend-data:/data
|
|
||||||
- moshiro-backend-status:/data/update-status:ro
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp:rw,noexec,nosuid,size=64m,mode=1777
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
stop_grace_period: 30s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
moshiro-backend-data:
|
|
||||||
name: moshiro-backend-data
|
|
||||||
moshiro-backend-status:
|
|
||||||
name: moshiro-backend-status
|
|
||||||
+36
-25
@@ -42,19 +42,27 @@ Backend 使用 OCI 标准镜像,通过 `registry.oeo.one/moshiro/moshiro-backe
|
|||||||
|
|
||||||
**前端内嵌在 Backend 里**:CI 构建 Backend 前先构建 `web/display` 和 `web/admin`,`Backend.Api` 把两个 `dist` 嵌进程序,展示端在 `/`,管理端在 `/admin/`,所以面板的域名(如 `https://mo.oeo.one/admin/`)打开就是管理端,passkey 才能绑定在这个域名上(浏览器只允许页面的来源与 RP 域名一致,本机 `localhost` 上的页面注册不了绑定 `mo.oeo.one` 的 passkey)。本地没有构建前端时不嵌入,Backend 只提供 API。文件是启动时读进内存的白名单,请求时不访问文件系统;带 `..`、反斜杠、`%`、控制字符或空段的路径一律 404,缺失的文件(带扩展名)404,只有无扩展名的路径回退到入口页;`/api`、`/openapi`、`/install`、`/healthz` 下没有匹配到的路径仍是后端的 JSON 404。页面带 CSP(脚本只许同源)、`nosniff`、`X-Frame-Options: DENY`,入口页 `no-cache`,哈希文件名的资源 `immutable`,不嵌入 source map。部署时设置 `MoShiro__PublicUrl=https://<面板域名>`(`deploy-node.sh backend --public-url …`),WebAuthn 的域名和允许的来源由它派生。
|
**前端内嵌在 Backend 里**:CI 构建 Backend 前先构建 `web/display` 和 `web/admin`,`Backend.Api` 把两个 `dist` 嵌进程序,展示端在 `/`,管理端在 `/admin/`,所以面板的域名(如 `https://mo.oeo.one/admin/`)打开就是管理端,passkey 才能绑定在这个域名上(浏览器只允许页面的来源与 RP 域名一致,本机 `localhost` 上的页面注册不了绑定 `mo.oeo.one` 的 passkey)。本地没有构建前端时不嵌入,Backend 只提供 API。文件是启动时读进内存的白名单,请求时不访问文件系统;带 `..`、反斜杠、`%`、控制字符或空段的路径一律 404,缺失的文件(带扩展名)404,只有无扩展名的路径回退到入口页;`/api`、`/openapi`、`/install`、`/healthz` 下没有匹配到的路径仍是后端的 JSON 404。页面带 CSP(脚本只许同源)、`nosniff`、`X-Frame-Options: DENY`,入口页 `no-cache`,哈希文件名的资源 `immutable`,不嵌入 source map。部署时设置 `MoShiro__PublicUrl=https://<面板域名>`(`deploy-node.sh backend --public-url …`),WebAuthn 的域名和允许的来源由它派生。
|
||||||
|
|
||||||
在部署目录保存此仓库的 `compose.yaml`,可选设置 `MOSHIRO_CLAIM_CODE`(认领码,不设置就随机生成并写进后端日志)和 `MOSHIRO_VERSION`,然后启动:
|
**用 compose 部署**(Docker 和 Podman 通用,需要 `docker compose` 或 `podman compose`):compose 文件是静态的,在仓库的 `packaging/compose/backend/compose.yaml`,每个发布版本的 Gitea Release 里也有一份 `moshiro-compose-backend.yaml`(第一次部署时还没有面板,就从这里下载)。镜像用 `:${MOSHIRO_VERSION:-latest}`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up -d
|
mkdir moshiro-backend && cd moshiro-backend
|
||||||
|
curl -fsSL https://git.oeo.one/justme/MoShiro/releases/download/<版本>/moshiro-compose-backend.yaml -o compose.yaml
|
||||||
|
cat > .env <<'EOF'
|
||||||
|
MOSHIRO_PUBLIC_URL=https://panel.example.com # 面板对外的地址;passkey 绑定在这个域名上,改了会让已注册的 passkey 失效
|
||||||
|
# MOSHIRO_CLAIM_CODE= # 认领码,不写就随机生成并打印在日志里
|
||||||
|
EOF
|
||||||
|
podman compose up -d # Docker 用 docker compose
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**更新**:先备份数据卷,再 `podman compose pull && podman compose up -d`。新版启动时自动迁移数据库,但**旧版不认比自己新的数据库**:想回滚要先恢复备份,只改 `MOSHIRO_VERSION` 不够。Backend 能指挥所有节点,更新它尽量用宿主机更新器(见下文「Backend 签名更新」,清单有发布签名,更新前自动备份,不健康自动回滚),或者在 `.env` 里把 `MOSHIRO_VERSION` 固定到经过核对的版本或 digest。
|
||||||
|
|
||||||
**首次认领(没有默认管理员)**:面板里还没有任何用户时,打开 `/admin/` 会进入认领向导:
|
**首次认领(没有默认管理员)**:面板里还没有任何用户时,打开 `/admin/` 会进入认领向导:
|
||||||
1. 填认领码(首次启动时随机生成并写进后端日志,`journalctl -u moshiro-backend` 或 `docker logs`;也可以用 `MoShiro__ClaimCode` / `MOSHIRO_CLAIM_CODE` 指定)、管理员的用户名和密码;
|
1. 填认领码(首次启动时随机生成并写进后端日志,`journalctl -u moshiro-backend` 或 `docker logs`;也可以用 `MoShiro__ClaimCode` / `MOSHIRO_CLAIM_CODE` 指定)、管理员的用户名和密码;
|
||||||
2. 注册你的第一把 passkey;
|
2. 注册你的第一把 passkey;
|
||||||
3. 用它给操作员集合签名,建立信任根。
|
3. 用它给操作员集合签名,建立信任根。
|
||||||
谁先打开页面谁就能当管理员,所以认领码只有能看到后端日志的人拿得到;创建完第一个管理员,码作废、入口关闭(再提交返回 409)。认领码比较不分大小写、忽略分隔符;失败按来源地址限流。向导可以中途离开,passkey 和信任根之后随时在「用户 → 我的 Passkey」和「系统 → 信任」里补;没有 passkey 也能用密码登录管理。
|
谁先打开页面谁就能当管理员,所以认领码只有能看到后端日志的人拿得到;创建完第一个管理员,码作废、入口关闭(再提交返回 409)。认领码比较不分大小写、忽略分隔符;失败按来源地址限流。向导可以中途离开,passkey 和信任根之后随时在「用户 → 我的 Passkey」和「系统 → 信任」里补;没有 passkey 也能用密码登录管理。
|
||||||
|
|
||||||
Compose 将容器端口只绑定到宿主机 `127.0.0.1:8080`,适合由同机 HTTPS 反向代理转发;数据保存在命名卷 `moshiro-backend-data`(Docker 和 Podman 通用,`podman compose` 也可以用同一份文件)。首次启动成功创建管理员后,改密应在管理端完成。
|
Compose 将容器端口只绑定到宿主机 `127.0.0.1:8080`,适合由同机 HTTPS 反向代理转发;数据保存在命名卷 `moshiro-backend-data`(Docker 和 Podman 通用)。首次启动成功创建管理员后,改密应在管理端完成。
|
||||||
|
|
||||||
Backend 的 NativeAOT 发布默认启用 `SqliteStaticLink`,支持 Linux glibc/musl、macOS、Windows 的 x64/arm64 RID;跨平台构建使用 `-p:UseAotAnywhere=true`。SQLite 源码版本、下载年份和 SHA-256 统一配置在根目录 `Directory.Packages.props`,构建会校验下载文件并按 RID 缓存原生对象。普通 JIT 开发构建仍使用 NuGet 原生库。发布 Backend 时设置 `-p:OpenApiGenerateDocuments=false`,API 文档在普通开发构建中生成。
|
Backend 的 NativeAOT 发布默认启用 `SqliteStaticLink`,支持 Linux glibc/musl、macOS、Windows 的 x64/arm64 RID;跨平台构建使用 `-p:UseAotAnywhere=true`。SQLite 源码版本、下载年份和 SHA-256 统一配置在根目录 `Directory.Packages.props`,构建会校验下载文件并按 RID 缓存原生对象。普通 JIT 开发构建仍使用 NuGet 原生库。发布 Backend 时设置 `-p:OpenApiGenerateDocuments=false`,API 文档在普通开发构建中生成。
|
||||||
|
|
||||||
@@ -215,7 +223,28 @@ OpenWrt 默认不带 OpenSSL:`opkg install libopenssl3`(较新的快照和 2
|
|||||||
|
|
||||||
镜像和 Compose 配置共用。普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 `keep-id` 或手动 `chown`。Agent 的配置、原有身份和缓存保存在 `moshiro-agent-config`,Probe 保存在 `moshiro-probe-config`,更新状态使用独立的 `moshiro-<role>-status` volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要使用 `compose down -v`,该参数会删除数据。
|
镜像和 Compose 配置共用。普通用户执行 Podman 即为 rootless,使用命名 volume 不需要 `keep-id` 或手动 `chown`。Agent 的配置、原有身份和缓存保存在 `moshiro-agent-config`,Probe 保存在 `moshiro-probe-config`,更新状态使用独立的 `moshiro-<role>-status` volume,在业务容器内只读。不会重新生成已有身份。更新/重建容器不删除 volume;不要使用 `compose down -v`,该参数会删除数据。
|
||||||
|
|
||||||
**一行安装(Docker 或 Podman,Agent 和 Probe 共用)**:管理端「添加 Agent」「添加 Probe」生成加入令牌之后,给出一行命令,面板地址、一次性代码、信任根指纹和属主都已经填好,没有要自己替换的占位符;节点名字默认取主机名。命令先下载静态安装脚本、核对它的 SHA-256,一致才执行:
|
**compose 安装(Agent 和 Probe,推荐)**:管理端「添加 Agent」「添加 Probe」生成加入令牌之后,给出一段命令(面板地址、一次性码、信任根指纹和属主都是公开的值,没有私钥):建一个目录,把参数写进 `.env`(权限 600),下载面板提供的静态 compose 文件、核对 SHA-256,一致才启动:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p moshiro-probe && cd moshiro-probe && cat > .env <<'EOF'
|
||||||
|
MOSHIRO_SERVER=https://panel.example.com
|
||||||
|
MOSHIRO_JOIN=jt_…
|
||||||
|
MOSHIRO_GENESIS=…
|
||||||
|
MOSHIRO_CONTROLLERS=…
|
||||||
|
MOSHIRO_IMAGE=registry.oeo.one/moshiro/moshiro-probe
|
||||||
|
# MOSHIRO_NAME=节点名字(不写就用主机名)
|
||||||
|
EOF
|
||||||
|
chmod 600 .env && curl -fsSL 'https://panel.example.com/install/compose/probe.yaml' -o compose.yaml && echo "<sha256> compose.yaml" | sha256sum -c - && podman compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
- compose 文件是静态的(`packaging/compose/agent|probe|backend/compose.yaml`,作为嵌入资源打进 Backend,由 `/install/compose/{agent,probe,backend}.yaml` 原样提供,不含任何一次性的值)。镜像是 `${MOSHIRO_IMAGE}:${MOSHIRO_VERSION:-latest}`,由 CI 渠道决定,**不由面板决定**;`MOSHIRO_IMAGE` 取自面板的 `MoShiro__NodeImageRegistry` 和 `MoShiro__ImageNamespace`(默认 `registry.oeo.one/moshiro`,开发镜像 `moshiro-dev`)。
|
||||||
|
- 只读根文件系统、丢掉全部能力、`no-new-privileges`、命名卷;Linux 上用宿主网络(Agent 对外暴露端口,也靠它识别本机公网地址,并只读挂载 `/proc`、`/sys`、`/` 读主机指标)。**Agent 的 compose 只适用于 Linux**;Probe 在 macOS 和 Windows(Docker Desktop)上没有宿主网络,面板给的命令会在 `.env` 里写 `MOSHIRO_NETWORK_MODE=bridge`。
|
||||||
|
- 身份和配置在命名卷 `moshiro-<role>-config` 里:镜像入口脚本(`packaging/container/entrypoint.sh`)第一次启动用 `MOSHIRO_SERVER`、`MOSHIRO_JOIN`(和信任根指纹、属主)注册并写配置,之后重启、换新镜像重建容器都不需要再给参数,所以注册成功之后可以把 `.env` 里的 `MOSHIRO_JOIN` 删掉。缺了信任根指纹或属主就不注册(只有还没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`)。
|
||||||
|
- Podman 的开机自启要靠 `podman-restart.service`(`systemctl enable --now podman-restart`;rootless 用 `--user`,并 `loginctl enable-linger`);Docker 用 compose 里的 `restart: unless-stopped`。
|
||||||
|
- **更新**:`podman compose pull && podman compose up -d`(Docker 用 `docker compose`),只重建镜像变了的容器,身份和配置不动,节点几十秒内重连。想固定版本,在 `.env` 里写 `MOSHIRO_VERSION=0.3.3`;想要更严,写成 `MOSHIRO_VERSION=0.3.3@sha256:<digest>`,digest 取自面板信任页或签名清单(那里有发布签名)。回滚就是改回旧版本再 `up -d`。
|
||||||
|
- **信任**:compose 来自面板或 CI 渠道,面板被攻破也改不了镜像地址;但客户端不再验发布签名,拉取靠 TLS 加「注册表可信」这个假设(文档里本来就把 CI、Gitea、注册表放在可信范围内)。这条路没有管理端的「更新」列和确认更新按钮,那些依赖宿主机更新器;要有签名验证的更新,用下面的部署脚本。
|
||||||
|
|
||||||
|
**一行安装脚本(备用,Docker 或 Podman,Agent 和 Probe 共用)**:管理端的弹窗里折叠着一条旧的一行命令,先核对脚本哈希再执行,脚本向面板取签名清单、用内置发布公钥验签、按 digest 拉镜像:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
f=$(mktemp) && curl -fsSL 'https://panel.example.com/install' -o "$f" && echo "<sha256> $f" | sha256sum -c - >/dev/null \
|
f=$(mktemp) && curl -fsSL 'https://panel.example.com/install' -o "$f" && echo "<sha256> $f" | sha256sum -c - >/dev/null \
|
||||||
@@ -223,12 +252,7 @@ f=$(mktemp) && curl -fsSL 'https://panel.example.com/install' -o "$f" && echo "<
|
|||||||
# Windows(PowerShell):irm 'https://panel.example.com/install.ps1' 下载后用 Get-FileHash 核对,不一致就 throw,再带同样的环境变量运行
|
# Windows(PowerShell):irm 'https://panel.example.com/install.ps1' 下载后用 Get-FileHash 核对,不一致就 throw,再带同样的环境变量运行
|
||||||
```
|
```
|
||||||
|
|
||||||
- 脚本是静态的同一份(`packaging/install/install.sh` 和 `install.ps1`,作为嵌入资源打进 Backend,原样返回,不替换任何值),所以它的 SHA-256 在一个版本内固定;角色、面板地址、代码和属主走环境变量。脚本向面板取该角色已签名的发布清单(凭一次性代码),用内置的发布公钥验签,再按清单里的 digest 拉镜像;代码不存在、已吊销、已过期或用完了取清单都是 404。缺了 `MOSHIRO_GENESIS` 或 `MOSHIRO_CONTROLLERS` 就不装(只有还没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`)。
|
脚本是静态的(`packaging/install/install.sh` 和 `install.ps1`,嵌入 Backend,不替换任何值),所以它的 SHA-256 在一个版本内固定;角色、面板地址、代码和属主走环境变量。环境变量 `MOSHIRO_NAME`、`MOSHIRO_ENGINE=docker|podman`、`MOSHIRO_DRY_RUN=1`(只打印 run 命令);Podman 会顺便启用 `podman-restart`。
|
||||||
- 脚本检测 Docker 或 Podman(连不上就自动用 sudo),拉镜像,起容器 `moshiro-<role>`:只读根文件系统、丢掉全部能力、Linux 上用宿主网络(Agent 对外暴露端口,也靠它识别本机公网地址),macOS 和 Windows 上 Agent 发布 7443、7444 端口;注册成功(日志出现 `link up`)才算装好。
|
|
||||||
- 身份和配置在命名卷 `moshiro-<role>-config` 里(持久化):镜像入口脚本(`packaging/container/entrypoint.sh`)第一次启动用 `MOSHIRO_SERVER`、`MOSHIRO_JOIN`、`MOSHIRO_NAME` 注册并写配置,之后重启、换新镜像重建容器都不需要再给参数。卷名和部署脚本一致,以后可以直接切到脚本方式。
|
|
||||||
- 环境变量:`MOSHIRO_NAME=名字`(想自己定,或者提示名字被占用)、`MOSHIRO_ENGINE=docker|podman`、`MOSHIRO_DRY_RUN=1`(只打印 run 命令)。Podman 会顺便启用 `podman-restart`,容器开机自启。
|
|
||||||
- 镜像地址由 Backend 的 `MoShiro__NodeImageRegistry`(默认 `registry.oeo.one`)和 `MoShiro__ImageNamespace`(默认 `moshiro`,开发镜像 `moshiro-dev`)拼出;私有仓库先 `docker login` 或 `podman login`。
|
|
||||||
- 更新:拉新镜像、删掉旧容器、重新执行安装命令(卷保留);要自动更新(签名清单)用下面的部署脚本。
|
|
||||||
|
|
||||||
**用脚本部署和更新(rootful Podman 或 Docker,推荐)**:`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。
|
**用脚本部署和更新(rootful Podman 或 Docker,推荐)**:`packaging/container/deploy-node.sh` 可以重复运行,每次都会拉取镜像并固定到 digest、把配置迁移到当前版本需要的样子(例如旧的 `listen.bind` 为 `0.0.0.0` 的 Agent 改成 `::`,这样 IPv6 线路才能建立)、重装单元和更新器,然后重启节点;已有的身份不会动,更新策略沿用上次的。
|
||||||
|
|
||||||
@@ -247,24 +271,11 @@ sudo sh deploy-node.sh backend --channel dev --public-url https://<面板域名>
|
|||||||
sudo sh deploy-node.sh agent
|
sudo sh deploy-node.sh agent
|
||||||
```
|
```
|
||||||
|
|
||||||
Agent 的 `public_host` 和 `ipv6_host` 由 Agent 上报自动识别(需要宿主网络,脚本和 Compose 默认如此,监听 `::`),IPv6 不用额外配置。下面是不用脚本的手动步骤。
|
Agent 的 `public_host` 和 `ipv6_host` 由 Agent 上报自动识别(需要宿主网络,脚本和 Compose 默认如此,监听 `::`),IPv6 不用额外配置。
|
||||||
|
|
||||||
在 `packaging/compose/agent` 下首次注册,并写配置(下列 `podman` 可替换为 `docker`):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
podman compose run --rm --entrypoint /usr/local/bin/fleet-enroll agent \
|
|
||||||
--server https://<backend> --join <token> --name <name> --identity-dir /etc/moshiro/agent-id
|
|
||||||
podman compose run --rm -T --entrypoint /bin/sh agent -c 'cat > /etc/moshiro/agent.json' <<'JSON'
|
|
||||||
{"fleet":{"server":"https://<backend>","identity_dir":"/etc/moshiro/agent-id"},"listen":{"bind":"::"}}
|
|
||||||
JSON
|
|
||||||
podman compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
Probe 在 `packaging/compose/probe` 下,和 Agent 一样先用 `fleet-enroll` 注册:`podman compose run --rm --entrypoint /usr/local/bin/fleet-enroll probe --server https://<backend> --join <probe 令牌> --name <name> --identity-dir /etc/moshiro/probe-id`,再写 `probe.json`(`{"fleet":{"server":"https://<backend>","identity_dir":"/etc/moshiro/probe-id"}}`),然后 `podman compose up -d`。已有部署迁移时,应把原配置与身份原样复制进 volume,不重新注册。要换密钥就在面板吊销再重新注册。Docker 与 Podman 各自管理 volume,切换引擎不会自动共享数据。
|
|
||||||
|
|
||||||
纯 Compose 使用 `compose pull && compose up -d` 更新镜像;面板自动更新使用下述 systemd 单元和更新器。Rootless Podman 使用 `moshiro-<role>-rootless.service`,安装到 `~/.config/systemd/user/`,镜像文件为 `~/.config/moshiro/<role>.image`;rootful Podman 使用 `moshiro-<role>-container.service`,Docker 使用 `moshiro-<role>-docker.service`,安装到 `/etc/systemd/system/`,镜像文件为 `/etc/moshiro-update/<role>.image`。所有单元均使用相同的配置和状态 volume 名称。自动启动分别使用 `systemctl --user enable --now <unit>` 或 `sudo systemctl enable --now <unit>`。Rootless 在退出登录后继续运行需管理员执行一次 `loginctl enable-linger <用户名>`。
|
纯 Compose 使用 `compose pull && compose up -d` 更新镜像;面板自动更新使用下述 systemd 单元和更新器。Rootless Podman 使用 `moshiro-<role>-rootless.service`,安装到 `~/.config/systemd/user/`,镜像文件为 `~/.config/moshiro/<role>.image`;rootful Podman 使用 `moshiro-<role>-container.service`,Docker 使用 `moshiro-<role>-docker.service`,安装到 `/etc/systemd/system/`,镜像文件为 `/etc/moshiro-update/<role>.image`。所有单元均使用相同的配置和状态 volume 名称。自动启动分别使用 `systemctl --user enable --now <unit>` 或 `sudo systemctl enable --now <unit>`。Rootless 在退出登录后继续运行需管理员执行一次 `loginctl enable-linger <用户名>`。
|
||||||
|
|
||||||
Backend 用根目录的 `compose.yaml`(见上文「Backend 容器」),数据库和原始数据在命名卷 `moshiro-backend-data` 里。首次安装 Podman 与用户 subuid/subgid 映射需要管理员权限,之后普通用户运行无需 sudo。
|
Backend 用 `packaging/compose/backend/compose.yaml`(见上文「Backend 容器」),数据库和原始数据在命名卷 `moshiro-backend-data` 里。首次安装 Podman 与用户 subuid/subgid 映射需要管理员权限,之后普通用户运行无需 sudo。
|
||||||
|
|
||||||
已在 Fedora CoreOS 的 SELinux Enforcing 环境、Podman 6.0.2 rootless 下用同一份 Compose 实测:Agent 注册、宿主指标、Probe 登录和 TLS 连接正常,IPv4/IPv6 ICMP 目标任务收到有效回包,容器保持 `cap_drop: ALL`,未添加 NET_RAW。Linux ICMP 优先使用 datagram ping socket,不依赖外部 ping 程序;需要 `net.ipv4.ping_group_range` 包含容器映射后的 GID(测试环境为 `0 2147483647`)。宿主禁止普通用户 ping 时,需管理员配置该范围;例如 rootful Docker 的用户 GID 1654 可设置 `sysctl -w "net.ipv4.ping_group_range=1654 1654"`。rootless 应使用映射后的宿主 GID,不能直接套用容器 GID。单加 `cap_add: NET_RAW` 不保证非 root 用户获得有效 capability。有原始套接字权限的部署仍保留原有 Ping 回退路径。其他操作系统继续使用 System.Net.Ping。ICMP 目标任务当前由 Agent 执行,Probe 没有独立目标任务入口。
|
已在 Fedora CoreOS 的 SELinux Enforcing 环境、Podman 6.0.2 rootless 下用同一份 Compose 实测:Agent 注册、宿主指标、Probe 登录和 TLS 连接正常,IPv4/IPv6 ICMP 目标任务收到有效回包,容器保持 `cap_drop: ALL`,未添加 NET_RAW。Linux ICMP 优先使用 datagram ping socket,不依赖外部 ping 程序;需要 `net.ipv4.ping_group_range` 包含容器映射后的 GID(测试环境为 `0 2147483647`)。宿主禁止普通用户 ping 时,需管理员配置该范围;例如 rootful Docker 的用户 GID 1654 可设置 `sysctl -w "net.ipv4.ping_group_range=1654 1654"`。rootless 应使用映射后的宿主 GID,不能直接套用容器 GID。单加 `cap_add: NET_RAW` 不保证非 root 用户获得有效 capability。有原始套接字权限的部署仍保留原有 Ping 回退路径。其他操作系统继续使用 System.Net.Ping。ICMP 目标任务当前由 Agent 执行,Probe 没有独立目标任务入口。
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,12 @@ macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就
|
|||||||
- 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用**脚本里内置的发布公钥**验签,再按清单里的 digest 拉镜像(`repo@sha256:…`)。Backend 改不了 digest,最多让节点装到一个旧的合法版本。
|
- 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用**脚本里内置的发布公钥**验签,再按清单里的 digest 拉镜像(`repo@sha256:…`)。Backend 改不了 digest,最多让节点装到一个旧的合法版本。
|
||||||
- 下载压缩包安装的人,用 Release 里签名的 `SHA256SUMS` 核对(见 [deployment.md](../ops/deployment.md))。
|
- 下载压缩包安装的人,用 Release 里签名的 `SHA256SUMS` 核对(见 [deployment.md](../ops/deployment.md))。
|
||||||
|
|
||||||
**③ 其余**
|
**③ compose 安装(默认)**
|
||||||
|
|
||||||
|
- 管理端的容器安装是:建目录,把参数写进本地 `.env`(面板地址、一次性码、创世指纹、属主,都是公开的值),下载面板提供的静态 compose 文件、核对 SHA-256,`compose up -d`。compose 文件是静态的(和安装脚本一样嵌在 Backend 里,也作为 Release 资产发布),镜像是 `:latest` 或 `.env` 里固定的版本,**镜像地址由 CI 渠道决定,不由面板决定**,面板被攻破也改不了;Backend 第一次部署时没有面板,就从 Release 资产下载。
|
||||||
|
- 代价:客户端不再验发布签名,更新(`compose pull && compose up -d`)靠 TLS 加「注册表可信」这个假设;想要更严,在 `.env` 里把 `MOSHIRO_VERSION` 固定到 digest,或者用宿主机更新器(清单有发布签名,不健康自动回滚)。详见 [deployment.md](../ops/deployment.md)。
|
||||||
|
|
||||||
|
**④ 其余**
|
||||||
|
|
||||||
- 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。
|
- 角色(Agent 还是 Probe)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。
|
||||||
- macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要证书,目前没有做。
|
- macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要证书,目前没有做。
|
||||||
|
|||||||
@@ -16,10 +16,11 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
管理员 ── 创建加入令牌 {标签, max_uses, 过期时间, auto_approve, controllers} ──> 面板(Fleet.Server.Enrollment)
|
管理员 ── 创建加入令牌 {标签, max_uses, 过期时间, auto_approve, controllers} ──> 面板(Fleet.Server.Enrollment)
|
||||||
<── 一行安装命令(Agent 和 Probe 共用):先下载静态安装脚本、核对它的 SHA-256,一致才执行;
|
<── 一段 compose 安装命令(默认):建目录、把面板地址、一次性码、创世指纹和属主写进本地 .env,下载面板提供的静态
|
||||||
面板地址、角色、一次性码、创世指纹和属主都走环境变量,命令里只有公开的值(见 access.md 第 10 节)
|
compose.yaml 并核对 SHA-256,再 `compose up -d`;命令里只有公开的值(见 access.md 第 10 节)。
|
||||||
|
备用:一行安装脚本(先核对脚本哈希再执行),参数走环境变量
|
||||||
|
|
||||||
install.sh / install.ps1(packaging/install/,嵌入 Backend,静态文件,不替换任何值):
|
两条路径做的事一样:compose 的入口脚本,或 install.sh / install.ps1(packaging/install/,嵌入 Backend,静态文件,不替换任何值):
|
||||||
├─ 向面板取 CI 签名的发布清单,用脚本里内置的发布公钥验签,按清单里的 digest 拉镜像
|
├─ 向面板取 CI 签名的发布清单,用脚本里内置的发布公钥验签,按清单里的 digest 拉镜像
|
||||||
├─ 用 Docker 或 Podman 起容器,身份和配置存在命名卷里;节点名字默认取主机名
|
├─ 用 Docker 或 Podman 起容器,身份和配置存在命名卷里;节点名字默认取主机名
|
||||||
└─ 容器入口用代码注册:fleet-enroll --server … --join jt_… --genesis … --controllers …
|
└─ 容器入口用代码注册:fleet-enroll --server … --join jt_… --genesis … --controllers …
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
# Docker 和 rootless/rootful Podman 共用。配置、身份和缓存保存在命名 volume 中。
|
# MoShiro Agent(Linux,需要宿主网络)。Docker 和 rootless/rootful Podman 共用。
|
||||||
# docker compose up -d / podman compose up -d(Podman 以普通用户执行即可 rootless)。
|
#
|
||||||
# 首次注册、配置导入和自动更新见 docs/ops/deployment.md。
|
# 用法(面板「添加 Agent」会给出带好参数的 .env 内容):
|
||||||
|
# mkdir moshiro-agent && cd moshiro-agent
|
||||||
|
# 把面板给的 .env 内容存成 .env,再下载本文件为 compose.yaml
|
||||||
|
# podman compose up -d # Docker 用 docker compose
|
||||||
|
# 更新:podman compose pull && podman compose up -d (身份和配置在命名卷里,不会动)
|
||||||
|
# 固定版本:在 .env 里写 MOSHIRO_VERSION=0.3.3;想要更严,写成 MOSHIRO_VERSION=0.3.3@sha256:<digest>。
|
||||||
|
# 说明见 docs/ops/deployment.md。
|
||||||
services:
|
services:
|
||||||
agent:
|
agent:
|
||||||
image: ${MOSHIRO_IMAGE:-registry.oeo.one/moshiro/moshiro-agent}:${MOSHIRO_VERSION:-0.1.1}
|
image: ${MOSHIRO_IMAGE:-registry.oeo.one/moshiro/moshiro-agent}:${MOSHIRO_VERSION:-latest}
|
||||||
container_name: moshiro-agent
|
container_name: moshiro-agent
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
@@ -18,6 +24,13 @@ services:
|
|||||||
- /sys:/host/sys:ro
|
- /sys:/host/sys:ro
|
||||||
- /:/host/root:ro
|
- /:/host/root:ro
|
||||||
environment:
|
environment:
|
||||||
|
# 只在第一次启动时用来注册(身份写进 agent-config 卷之后,.env 里的 MOSHIRO_JOIN 就可以删掉)
|
||||||
|
MOSHIRO_SERVER: ${MOSHIRO_SERVER:-}
|
||||||
|
MOSHIRO_JOIN: ${MOSHIRO_JOIN:-}
|
||||||
|
MOSHIRO_NAME: ${MOSHIRO_NAME:-}
|
||||||
|
MOSHIRO_GENESIS: ${MOSHIRO_GENESIS:-}
|
||||||
|
MOSHIRO_CONTROLLERS: ${MOSHIRO_CONTROLLERS:-}
|
||||||
|
MOSHIRO_ALLOW_NO_OWNER: ${MOSHIRO_ALLOW_NO_OWNER:-}
|
||||||
MOSHIRO_NETWORK_SCOPE: host
|
MOSHIRO_NETWORK_SCOPE: host
|
||||||
HOST_PROC: /host/proc
|
HOST_PROC: /host/proc
|
||||||
HOST_SYS: /host/sys
|
HOST_SYS: /host/sys
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# MoShiro Backend(面板)。Docker 和 rootless/rootful Podman 共用。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# mkdir moshiro-backend && cd moshiro-backend
|
||||||
|
# 下载本文件为 compose.yaml;写一个 .env(可选):
|
||||||
|
# MOSHIRO_PUBLIC_URL=https://panel.example.com # 面板对外的地址;passkey 绑定在这个域名上,改了会让已注册的 passkey 失效
|
||||||
|
# MOSHIRO_CLAIM_CODE= # 认领码,不写就随机生成并打印在日志里(podman logs moshiro-backend)
|
||||||
|
# podman compose up -d # Docker 用 docker compose
|
||||||
|
# 第一次打开面板按页面引导认领:没有默认管理员。面板监听 127.0.0.1:8080,对外请放在反向代理(HTTPS)后面。
|
||||||
|
# 更新:先备份数据卷,再 podman compose pull && podman compose up -d。新版会自动迁移数据库,
|
||||||
|
# 但旧版不认比自己新的数据库:想回滚要先恢复备份,只改 MOSHIRO_VERSION 不够。
|
||||||
|
# 说明见 docs/ops/deployment.md。
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: registry.oeo.one/${MOSHIRO_IMAGE_NAMESPACE:-moshiro}/moshiro-backend:${MOSHIRO_VERSION:-latest}
|
||||||
|
container_name: moshiro-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
|
environment:
|
||||||
|
ASPNETCORE_HTTP_PORTS: "8080"
|
||||||
|
MoShiro__DataDirectory: /data
|
||||||
|
MoShiro__UpdateChannel: ${MOSHIRO_UPDATE_CHANNEL:-auto}
|
||||||
|
MoShiro__ImageNamespace: ${MOSHIRO_IMAGE_NAMESPACE:-moshiro}
|
||||||
|
MoShiro__PublicUrl: "${MOSHIRO_PUBLIC_URL:-}"
|
||||||
|
MoShiro__ClaimCode: "${MOSHIRO_CLAIM_CODE:-}"
|
||||||
|
volumes:
|
||||||
|
- moshiro-backend-data:/data
|
||||||
|
- moshiro-backend-status:/data/update-status:ro
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:rw,noexec,nosuid,size=64m,mode=1777
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
stop_grace_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
moshiro-backend-data:
|
||||||
|
name: moshiro-backend-data
|
||||||
|
moshiro-backend-status:
|
||||||
|
name: moshiro-backend-status
|
||||||
@@ -1,15 +1,29 @@
|
|||||||
# Docker 和 rootless/rootful Podman 共用。配置、身份和缓存保存在命名 volume 中。
|
# MoShiro Probe。Docker 和 rootless/rootful Podman 共用。
|
||||||
# docker compose up -d / podman compose up -d(Podman 以普通用户执行即可 rootless)。
|
#
|
||||||
# 首次注册、配置导入和自动更新见 docs/ops/deployment.md。
|
# 用法(面板「添加 Probe」会给出带好参数的 .env 内容):
|
||||||
|
# mkdir moshiro-probe && cd moshiro-probe
|
||||||
|
# 把面板给的 .env 内容存成 .env,再下载本文件为 compose.yaml
|
||||||
|
# podman compose up -d # Docker 用 docker compose
|
||||||
|
# 更新:podman compose pull && podman compose up -d (身份和配置在命名卷里,不会动)
|
||||||
|
# 固定版本:在 .env 里写 MOSHIRO_VERSION=0.3.3;想要更严,写成 MOSHIRO_VERSION=0.3.3@sha256:<digest>。
|
||||||
|
# macOS、Windows 的 Docker Desktop 没有宿主网络:面板给的 .env 里已经写了 MOSHIRO_NETWORK_MODE=bridge。
|
||||||
|
# 说明见 docs/ops/deployment.md。
|
||||||
services:
|
services:
|
||||||
probe:
|
probe:
|
||||||
image: ${MOSHIRO_IMAGE:-registry.oeo.one/moshiro/moshiro-probe}:${MOSHIRO_VERSION:-0.1.1}
|
image: ${MOSHIRO_IMAGE:-registry.oeo.one/moshiro/moshiro-probe}:${MOSHIRO_VERSION:-latest}
|
||||||
container_name: moshiro-probe
|
container_name: moshiro-probe
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
init: true
|
init: true
|
||||||
network_mode: host
|
network_mode: ${MOSHIRO_NETWORK_MODE:-host}
|
||||||
environment:
|
environment:
|
||||||
MOSHIRO_NETWORK_SCOPE: host
|
# 只在第一次启动时用来注册(身份写进 probe-config 卷之后,.env 里的 MOSHIRO_JOIN 就可以删掉)
|
||||||
|
MOSHIRO_SERVER: ${MOSHIRO_SERVER:-}
|
||||||
|
MOSHIRO_JOIN: ${MOSHIRO_JOIN:-}
|
||||||
|
MOSHIRO_NAME: ${MOSHIRO_NAME:-}
|
||||||
|
MOSHIRO_GENESIS: ${MOSHIRO_GENESIS:-}
|
||||||
|
MOSHIRO_CONTROLLERS: ${MOSHIRO_CONTROLLERS:-}
|
||||||
|
MOSHIRO_ALLOW_NO_OWNER: ${MOSHIRO_ALLOW_NO_OWNER:-}
|
||||||
|
MOSHIRO_NETWORK_SCOPE: ${MOSHIRO_NETWORK_SCOPE:-host}
|
||||||
volumes:
|
volumes:
|
||||||
- probe-config:/etc/moshiro
|
- probe-config:/etc/moshiro
|
||||||
- probe-status:/etc/moshiro/update-status:ro
|
- probe-status:/etc/moshiro/update-status:ro
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# 容器入口(Agent 和 Probe 共用):第一次启动用 MOSHIRO_SERVER / MOSHIRO_JOIN / MOSHIRO_NAME 自己注册并写配置,
|
# 容器入口(Agent 和 Probe 共用):第一次启动用 MOSHIRO_SERVER / MOSHIRO_JOIN(MOSHIRO_NAME 不写就用主机名)自己注册并写配置,
|
||||||
# 身份和配置都在命名卷 /etc/moshiro 里,之后重启(或换新镜像重建容器)直接启动,不需要再给参数。
|
# 身份和配置都在命名卷 /etc/moshiro 里,之后重启(或换新镜像重建容器)直接启动,不需要再给参数。
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
@@ -9,8 +9,10 @@ identity="$dir/$role-id"
|
|||||||
config="$dir/$role.json"
|
config="$dir/$role.json"
|
||||||
|
|
||||||
if [ ! -f "$identity/node.json" ]; then
|
if [ ! -f "$identity/node.json" ]; then
|
||||||
if [ -z "${MOSHIRO_SERVER:-}" ] || [ -z "${MOSHIRO_JOIN:-}" ] || [ -z "${MOSHIRO_NAME:-}" ]; then
|
# 节点名字不写就用主机名(宿主网络下容器的主机名就是宿主机的)
|
||||||
echo "first start: set MOSHIRO_SERVER, MOSHIRO_JOIN and MOSHIRO_NAME (the panel shows the complete command)" >&2
|
MOSHIRO_NAME="${MOSHIRO_NAME:-$(hostname -s 2>/dev/null || hostname 2>/dev/null || echo node)}"
|
||||||
|
if [ -z "${MOSHIRO_SERVER:-}" ] || [ -z "${MOSHIRO_JOIN:-}" ]; then
|
||||||
|
echo "first start: set MOSHIRO_SERVER and MOSHIRO_JOIN in .env (the panel shows the complete content)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# 每个节点都必须有属主(passkey):信任根指纹和属主缺一个就不注册
|
# 每个节点都必须有属主(passkey):信任根指纹和属主缺一个就不注册
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
import type { CreatedJoinTokenDto } from '@shared/api/types'
|
import type { CreatedJoinTokenDto } from '@shared/api/types'
|
||||||
import { fmtTime } from '@shared/format'
|
import { fmtTime } from '@shared/format'
|
||||||
import { CommandTabs, Dialog, Field, guessPlatform } from '@shared/ui'
|
import { CommandTabs, CopyButton, Dialog, Field, guessPlatform, type CommandTab } from '@shared/ui'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 加入令牌创建之后的对话框:Agent 和 Probe 共用同一份,一行命令装好。
|
* 加入令牌创建之后的对话框:Agent 和 Probe 共用同一份。
|
||||||
* 命令里只有一次性代码,角色由代码决定;节点名字默认取主机名。
|
* 首选 compose:把面板给的 .env 存下来,下载 compose.yaml,`compose up -d`,以后更新就是 `compose pull && compose up -d`。
|
||||||
|
* .env 里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥;脚本一行命令留作备用。
|
||||||
*/
|
*/
|
||||||
export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJoinTokenDto; kind: 'Agent' | 'Probe'; onClose: () => void }) {
|
export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJoinTokenDto; kind: 'Agent' | 'Probe'; onClose: () => void }) {
|
||||||
const commands = created.install_commands
|
const compose = created.compose
|
||||||
|
const script = created.install_commands
|
||||||
|
const composeTabs: CommandTab[] = [
|
||||||
|
{ id: 'linux', label: 'Linux · Podman', command: compose.linux },
|
||||||
|
...(compose.macos ? [{ id: 'macos', label: 'macOS · Docker', command: compose.macos }] : []),
|
||||||
|
...(compose.windows ? [{ id: 'windows', label: 'Windows · Docker', command: compose.windows }] : []),
|
||||||
|
]
|
||||||
|
const initial = composeTabs.some((t) => t.id === guessPlatform()) ? guessPlatform() : 'linux'
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title="加入令牌已创建"
|
title="加入令牌已创建"
|
||||||
@@ -19,25 +27,53 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<div className="notice warn">命令里的代码只显示这一次,请现在就复制。命令里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。</div>
|
<div className="notice warn">一次性码只显示这一次,请现在就复制。下面只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。</div>
|
||||||
<Field
|
<Field
|
||||||
label={`容器安装(推荐):在要加入的机器上运行(${kind})`}
|
label={`容器安装(推荐):在要加入的机器上运行(${kind})`}
|
||||||
hint="需要 Docker 或 Podman。命令会先下载安装脚本并核对它的 SHA-256,不一致就不执行;节点名字默认取主机名;身份和配置存在命名卷里,重启、换新镜像重建容器都不用再给参数。名字想自己定,或者提示名字被占用,在命令里加上 MOSHIRO_NAME=名字(Linux 和 macOS 写在 bash 或 zsh 前面,Windows 先执行 $env:MOSHIRO_NAME='名字')。"
|
hint="需要 podman compose 或 docker compose。命令建一个目录,把参数写进 .env(权限 600),下载 compose.yaml 并核对 SHA-256,一致才启动。节点名字默认取主机名,想自己定就改 .env 里的 MOSHIRO_NAME。"
|
||||||
>
|
>
|
||||||
<CommandTabs
|
<CommandTabs storageKey="moshiro.compose_tab" initial={initial} tabs={composeTabs} />
|
||||||
storageKey="moshiro.install_tab"
|
|
||||||
initial={guessPlatform()}
|
|
||||||
tabs={[
|
|
||||||
{ id: 'linux', label: 'Linux · 容器', command: commands.linux },
|
|
||||||
{ id: 'macos', label: 'macOS · 容器', command: commands.macos },
|
|
||||||
{ id: 'windows', label: 'Windows · 容器', command: commands.windows },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
<details>
|
||||||
|
<summary>分步做:.env 内容、compose 下载地址和哈希</summary>
|
||||||
|
<div className="stack" style={{ marginTop: 8 }}>
|
||||||
|
<Field label=".env(存成当前目录下的 .env)">
|
||||||
|
<div className="row" style={{ alignItems: 'flex-start', gap: 8 }}>
|
||||||
|
<pre className="code" style={{ flex: 1, whiteSpace: 'pre-wrap', wordBreak: 'break-all', margin: 0 }}>{compose.env}</pre>
|
||||||
|
<CopyButton text={compose.env} />
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="compose.yaml">
|
||||||
|
<div className="stack">
|
||||||
|
<div className="row" style={{ gap: 8 }}>
|
||||||
|
<span className="mono" style={{ wordBreak: 'break-all' }}>{compose.url}</span>
|
||||||
|
<CopyButton text={compose.url} />
|
||||||
|
</div>
|
||||||
|
<div className="muted mono" style={{ wordBreak: 'break-all' }}>SHA-256 {compose.sha256}</div>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
容器方式是首选:镜像按 CI 签名的清单固定到 digest,宿主机更新器能自己更新它,属主和信任根在注册时固定。{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
|
以后更新:<span className="mono">podman compose pull && podman compose up -d</span>(Docker 把 podman 换成 docker);身份和配置在命名卷里,不会动。
|
||||||
|
想固定版本,在 .env 里写 <span className="mono">MOSHIRO_VERSION=版本号</span>。注册成功之后,可以把 .env 里的 <span className="mono">MOSHIRO_JOIN</span> 一行删掉。
|
||||||
|
{kind === 'Agent' ? (created.token.auto_approve ? ' 使用它注册的 Agent 会自动启用。' : ' 使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
|
||||||
可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
|
可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
|
||||||
</p>
|
</p>
|
||||||
|
<details>
|
||||||
|
<summary>备用:一行安装脚本(先核对脚本哈希再执行,需要 Docker 或 Podman)</summary>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<CommandTabs
|
||||||
|
storageKey="moshiro.install_tab"
|
||||||
|
initial={guessPlatform()}
|
||||||
|
tabs={[
|
||||||
|
{ id: 'linux', label: 'Linux · 脚本', command: script.linux },
|
||||||
|
{ id: 'macos', label: 'macOS · 脚本', command: script.macos },
|
||||||
|
{ id: 'windows', label: 'Windows · 脚本', command: script.windows },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1178,6 +1178,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/install/compose/{file}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone) */
|
||||||
|
get: operations["install_compose"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/install.ps1": {
|
"/install.ps1": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1407,6 +1424,14 @@ export interface components {
|
|||||||
[key: string]: null | number | string;
|
[key: string]: null | number | string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
ComposeInstallDto: {
|
||||||
|
env: string;
|
||||||
|
url: string;
|
||||||
|
sha256: string;
|
||||||
|
linux: string;
|
||||||
|
macos: string;
|
||||||
|
windows: string;
|
||||||
|
};
|
||||||
ConfirmBackendUpdateRequest: {
|
ConfirmBackendUpdateRequest: {
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
seq: number | string;
|
seq: number | string;
|
||||||
@@ -1459,6 +1484,7 @@ export interface components {
|
|||||||
token: components["schemas"]["JoinTokenDto"];
|
token: components["schemas"]["JoinTokenDto"];
|
||||||
plaintext: string;
|
plaintext: string;
|
||||||
install_commands: components["schemas"]["InstallCommandsDto"];
|
install_commands: components["schemas"]["InstallCommandsDto"];
|
||||||
|
compose: components["schemas"]["ComposeInstallDto"];
|
||||||
};
|
};
|
||||||
DimensionDto: {
|
DimensionDto: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -6179,6 +6205,35 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
install_compose: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
file: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/problem+json": components["schemas"]["ProblemDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
install_ps1: {
|
install_ps1: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export type ParamDto = Narrow<S['ParamDto']>
|
|||||||
export type MetricDto = Narrow<S['MetricDto']>
|
export type MetricDto = Narrow<S['MetricDto']>
|
||||||
export type JoinTokenDto = Narrow<S['JoinTokenDto']>
|
export type JoinTokenDto = Narrow<S['JoinTokenDto']>
|
||||||
export type CreatedJoinTokenDto = Narrow<S['CreatedJoinTokenDto']>
|
export type CreatedJoinTokenDto = Narrow<S['CreatedJoinTokenDto']>
|
||||||
|
export type ComposeInstallDto = Narrow<S['ComposeInstallDto']>
|
||||||
export type ApiTokenDto = Narrow<S['ApiTokenDto']>
|
export type ApiTokenDto = Narrow<S['ApiTokenDto']>
|
||||||
export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']>
|
export type CreatedApiTokenDto = Narrow<S['CreatedApiTokenDto']>
|
||||||
export type CapabilityCatalogDto = Narrow<S['CapabilityCatalogDto']>
|
export type CapabilityCatalogDto = Narrow<S['CapabilityCatalogDto']>
|
||||||
|
|||||||
Reference in new issue
Block a user