feat: install nodes and the backend from a static compose file; remove dead code [CI]
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:
justme committed 2026-10-08 20:24:23 +08:00
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);
-12
View File
@@ -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
View File
@@ -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 &amp;&amp; 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))
{ {
+12 -1
View File
@@ -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"
-32
View File
@@ -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
View File
@@ -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 没有独立目标任务入口。
+6 -1
View File
@@ -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 需要证书,目前没有做。
+4 -3
View File
@@ -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 …
+17 -4
View File
@@ -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
+44
View File
@@ -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
+20 -6
View File
@@ -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
+5 -3
View File
@@ -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):信任根指纹和属主缺一个就不注册
+52 -16
View File
@@ -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 &amp;&amp; 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>
) )
+55
View File
@@ -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;
+1
View File
@@ -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']>