diff --git a/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs b/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs index 5a4a1da..69812ac 100644 --- a/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs +++ b/backend/agent/src/MoShiro.Agent/Peer/PeerHub.cs @@ -213,8 +213,6 @@ public sealed class PeerHub private static ConnectedProbe Describe(ProbeSession p) => new() { Id = new ProbeId(p.ProbeId), Version = p.Version, Remote = p.RemoteAddress, SinceUtcMs = p.ConnectedAtUtcMs, IpVersion = p.IpVersion }; - public IReadOnlyList ConnectedProbeIds() => [.. _sessions.All.Select(p => p.ProbeId)]; - /// 向一个 Probe 下发它的线路任务集(Probe 连上时由 先发一份)。 public async ValueTask PushToAsync(ProbeSession probe, LineTaskSet set) => await PushAsync(probe, set, CancellationToken.None); diff --git a/backend/fleet/src/Fleet.Core/VarInt.cs b/backend/fleet/src/Fleet.Core/VarInt.cs index 1b9654b..46fa7f4 100644 --- a/backend/fleet/src/Fleet.Core/VarInt.cs +++ b/backend/fleet/src/Fleet.Core/VarInt.cs @@ -51,16 +51,4 @@ public static class VarInt return false; } - - public static bool TryReadUInt32LittleEndian(ReadOnlySpan source, out uint value) - { - if (source.Length < 4) - { - value = 0; - return false; - } - - value = BinaryPrimitives.ReadUInt32LittleEndian(source); - return true; - } } diff --git a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs index a1c5e61..65b92f1 100644 --- a/backend/fleet/src/Fleet.Rpc/FleetMethods.cs +++ b/backend/fleet/src/Fleet.Rpc/FleetMethods.cs @@ -40,12 +40,6 @@ public static class FleetMethodTable ]); } -/// 二进制通道:0–15 保留给框架,产品从 16 开始。 -public static class FleetChannels -{ - public const byte FirstProductChannel = 16; -} - public sealed class ChallengeParams { [JsonPropertyName("nonce")] diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index ae49dbf..671e1fe 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -4966,6 +4966,40 @@ } } }, + "/install/compose/{file}": { + "get": { + "tags": [ + "ops" + ], + "summary": "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)", + "operationId": "install_compose", + "parameters": [ + { + "name": "file", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDto" + } + } + } + } + } + } + }, "/install.ps1": { "get": { "tags": [ @@ -5796,6 +5830,37 @@ } } }, + "ComposeInstallDto": { + "required": [ + "env", + "url", + "sha256", + "linux", + "macos", + "windows" + ], + "type": "object", + "properties": { + "env": { + "type": "string" + }, + "url": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "linux": { + "type": "string" + }, + "macos": { + "type": "string" + }, + "windows": { + "type": "string" + } + } + }, "ConfirmBackendUpdateRequest": { "required": [ "seq" @@ -6029,7 +6094,8 @@ "required": [ "token", "plaintext", - "install_commands" + "install_commands", + "compose" ], "type": "object", "properties": { @@ -6041,6 +6107,9 @@ }, "install_commands": { "$ref": "#/components/schemas/InstallCommandsDto" + }, + "compose": { + "$ref": "#/components/schemas/ComposeInstallDto" } } }, diff --git a/backend/shared/MoShiro.Protocol/ProtocolLimits.cs b/backend/shared/MoShiro.Protocol/ProtocolLimits.cs index 90f3c86..271cfd0 100644 --- a/backend/shared/MoShiro.Protocol/ProtocolLimits.cs +++ b/backend/shared/MoShiro.Protocol/ProtocolLimits.cs @@ -3,7 +3,6 @@ namespace MoShiro.Protocol; public static class ProtocolVersion { public const int Container = 1; - public const int Wire = 1; } public static class ProtocolLimits @@ -39,5 +38,4 @@ public static class MoShiroCapabilities { public const string Base = "moshiro.v1"; public const string KindsPrefix = "moshiro.kinds:"; - public const string BlocksPrefix = "moshiro.blocks:"; } diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index d0530a6..741b792 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -143,7 +143,7 @@ public static class AgentEndpoints return Problems.From(http, ownerError); } - var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1)); + var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1), InstallScripts.ComposeSha256(request.Role ?? JoinTokenService.AgentRole)); http.SetDetail($"{request.Role ?? JoinTokenService.AgentRole}: {request.Note}"); Result result = await tokens.CreateAsync( new Application.CreateJoinTokenRequest(request.Note, request.Labels, request.AutoApprove ?? true, request.MaxUses ?? 1, request.ExpiresInHours ?? 24, request.Role ?? JoinTokenService.AgentRole, http.GetPrincipal().Label), baseUrl, plan, http.RequestAborted); @@ -154,7 +154,7 @@ public static class AgentEndpoints http.Response.Headers.CacheControl = "no-store"; http.Response.Headers.Location = "/api/admin/join-tokens"; - return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created); + return TypedResults.Json(new CreatedJoinTokenDto(Mappers.ToDto(created.Info), created.Plaintext, new InstallCommandsDto(created.InstallCommands.Linux, created.InstallCommands.Macos, created.InstallCommands.Windows), new ComposeInstallDto(created.Compose.Env, created.Compose.Url, created.Compose.Sha256, created.Compose.Linux, created.Compose.Macos, created.Compose.Windows)), ApiJsonContext.Default.CreatedJoinTokenDto, statusCode: StatusCodes.Status201Created); } private static async Task RevokeTokenAsync(JoinTokenService tokens, string id, bool? purge, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index 4acf36e..def5dba 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -83,6 +83,14 @@ public sealed record SetupRequest(string? ClaimCode, string? Username, string? P public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); +/// 要存成 .env 的内容(只有公开的值)。 +/// compose 文件的下载地址。 +/// compose 文件的 SHA-256:命令下载之后先核对它。 +/// Linux(Podman):建目录、写 .env、下载、核对哈希、启动,合在一起的一段命令。 +/// macOS(Docker Desktop);Agent 需要 Linux 宿主网络,所以是空的。 +/// Windows(Docker Desktop,PowerShell);Agent 同样是空的。 +public sealed record ComposeInstallDto(string Env, string Url, string Sha256, string Linux, string Macos, string Windows); + /// 下一版里新增的 passkey。 public sealed record ProposedCredentialDto(string Id, string Name, DateTimeOffset CreatedAt); @@ -100,7 +108,7 @@ public sealed record TrustProposalDto(string Statement, int Version, string Chal /// 和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。 public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList? OperatorDetails = null); -public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands); +public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands, ComposeInstallDto Compose); /// Agent 眼中连着的一个 Probe(Agent 上报)。 public sealed record ConnectedProbeDto(string ProbeId, string? Version, string? Remote, DateTimeOffset ConnectedSince, string IpVersion = "ipv4"); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 0c46eb0..468c6a7 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -86,6 +86,7 @@ namespace MoShiro.Backend.Api; [JsonSerializable(typeof(TrustStateDto))] [JsonSerializable(typeof(SetupStatusDto))] [JsonSerializable(typeof(SetupRequest))] +[JsonSerializable(typeof(ComposeInstallDto))] [JsonSerializable(typeof(ProposedCredentialDto))] [JsonSerializable(typeof(PasskeyDto))] [JsonSerializable(typeof(PageDto))] diff --git a/backend/src/MoShiro.Backend.Api/InstallScripts.cs b/backend/src/MoShiro.Backend.Api/InstallScripts.cs index 4f042a5..f4a67bb 100644 --- a/backend/src/MoShiro.Backend.Api/InstallScripts.cs +++ b/backend/src/MoShiro.Backend.Api/InstallScripts.cs @@ -19,6 +19,12 @@ public static class InstallScripts public static string Script(string resource) => Read(resource); + /// compose 文件的资源名(agent.yaml、probe.yaml、backend.yaml 对应的);不是这三个就是 null。 + public static string? ComposeResource(string file) => file is "agent.yaml" or "probe.yaml" or "backend.yaml" ? "compose-" + file : null; + + /// 某个角色(agent、probe)的 compose 文件的 SHA-256;其他角色返回空。 + public static string ComposeSha256(string? role) => role is "agent" or "probe" ? Sha256("compose-" + role + ".yaml") : string.Empty; + /// 脚本内容的 SHA-256(小写十六进制,按 UTF-8 字节)。 public static string Sha256(string resource) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(Read(resource)))); diff --git a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj index f824f56..99b7951 100644 --- a/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj +++ b/backend/src/MoShiro.Backend.Api/MoShiro.Backend.Api.csproj @@ -10,6 +10,9 @@ + + + diff --git a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs index 603775d..734b669 100644 --- a/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/OpsEndpoints.cs @@ -33,6 +33,11 @@ public static class OpsEndpoints app.MapGet("/install/manifest.sig", (HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code) => ManifestAsync(http, tokens, releases, options, code, signature: true)) .Api("install_manifest_signature", "Base64 DER ECDSA P-256 signature of the manifest", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain").ProducesProblems(404); + // compose 文件:Agent、Probe、Backend 各一份,静态(镜像用 :latest 或 .env 里的 MOSHIRO_VERSION,不含任何一次性的值), + // 面板的命令把参数放在本地的 .env 里,下载后先核对 SHA-256。 + app.MapGet("/install/compose/{file}", (HttpContext http, string file) => InstallScripts.ComposeResource(file) is { } resource ? Static(http, resource, "text/yaml") : Problems.Create(http, MoShiroErrors.NotFound, "unknown compose file")) + .Api("install_compose", "The static compose file of agent, probe or backend (agent.yaml, probe.yaml, backend.yaml; same bytes for everyone)", Tag) + .Produces(StatusCodes.Status200OK, contentType: "text/yaml").ProducesProblems(404); app.MapGet("/install.ps1", (HttpContext http) => Static(http, InstallScripts.Ps1, "text/plain")) .Api("install_ps1", "The static one-line installer for Windows nodes (same bytes for everyone; its SHA-256 is published with each release)", Tag) .Produces(StatusCodes.Status200OK, contentType: "text/plain"); @@ -40,9 +45,8 @@ public static class OpsEndpoints private static async Task ManifestAsync(HttpContext http, JoinTokenService tokens, ReleaseService releases, BackendOptions options, string? code, bool signature) { - string baseUrl = options.PublicUrl ?? $"{http.Request.Scheme}://{http.Request.Host}"; - InstallTarget? target = string.IsNullOrEmpty(code) ? null : await tokens.ResolveInstallAsync(code, baseUrl, http.RequestAborted); - ReleaseOffer? offer = target is null ? null : await releases.GetOrFetchAsync(target.Role, http.RequestAborted); + string? role = string.IsNullOrEmpty(code) ? null : await tokens.RoleOfCodeAsync(code, http.RequestAborted); + ReleaseOffer? offer = role is null ? null : await releases.GetOrFetchAsync(role, http.RequestAborted); if (offer is null) { return Problems.Create(http, MoShiroErrors.NotFound, "no signed release is registered for this install code"); diff --git a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs index 05c40b6..108c128 100644 --- a/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs +++ b/backend/src/MoShiro.Backend.Application/Admin/JoinTokenService.cs @@ -14,12 +14,16 @@ public sealed record InstallCommands(string Linux, string Macos, string Windows) /// 安装命令里除了一次性的码,还带的公开值:信任根的创世指纹(节点把它固定下来)、属主(允许授权这个节点 extension 的 passkey 凭证 id)、 /// 和面板提供的静态安装脚本的 SHA-256(命令先核对哈希再执行)。没有建立信任根时 Genesis 为空,节点不固定信任根。 /// -public sealed record InstallPlan(string? Genesis, IReadOnlyList Controllers, string ScriptShSha256, string ScriptPs1Sha256); +/// 面板提供的这个角色的 compose 文件的 SHA-256(命令下载之后先核对)。 +public sealed record InstallPlan(string? Genesis, IReadOnlyList Controllers, string ScriptShSha256, string ScriptPs1Sha256, string ComposeSha256 = ""); -public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands); +/// +/// compose 安装:Env 是要存成 .env 的内容(只有公开的值),Url 和 Sha256 是 compose 文件的下载地址和哈希, +/// 三条命令(Linux 用 Podman,macOS、Windows 用 Docker Desktop)把 .env、下载、核对哈希和启动合在一起;Agent 的 compose 需要 Linux 宿主网络,所以它的 macOS、Windows 命令是空的。 +/// +public sealed record ComposeInstall(string Env, string Url, string Sha256, string Linux, string Macos, string Windows); -/// 安装脚本里要填的四个值。 -public sealed record InstallTarget(string Role, string Server, string Code, string Image); +public sealed record CreatedJoinToken(JoinTokenInfo Info, string Plaintext, InstallCommands InstallCommands, ComposeInstall Compose); /// 加入令牌:明文只在创建时返回一次,服务端只保存 SHA-256。 public sealed class JoinTokenService @@ -82,7 +86,7 @@ public sealed class JoinTokenService Ulid.NewPrefixed("jtk", _time), TokenHash.Sha256(plaintext), request.Note, request.Labels ?? [], request.AutoApprove, KindOf(request.Role), request.MaxUses, now + (request.ExpiresInHours * 3_600_000L), now, request.CreatedBy, ct); - return Result.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan))); + return Result.Ok(new CreatedJoinToken(info, plaintext, Commands(BaseUrl(requestBaseUrl), request.Role, plaintext, plan), Compose(BaseUrl(requestBaseUrl), request.Role, plaintext, plan))); } /// @@ -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,下载静态的 compose 文件、核对哈希、compose up -d:以后更新就是 + /// compose pull && compose up -d。镜像来自 CI 渠道(:latest,也可以在 .env 里固定版本),不由面板决定。 + /// + 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 { $"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); + } + + /// 这个角色的镜像仓库地址(不带 tag):节点镜像仓库加上命名空间。 + 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('/'); /// - /// 安装脚本要填的值:由代码找到令牌(不消耗),角色、网关、镜像都在这里决定,Agent 和 Probe 共用同一份脚本。 - /// 代码不存在、已吊销、已过期或用完了返回 null。网关和镜像要是含有会破坏脚本引号的字符也返回 null。 + /// 这个一次性码是给哪个角色(agent 或 probe)的:安装脚本据此向面板取该角色已签名的发布清单。 + /// 代码不存在、已吊销、已过期或用完了返回 null(不消耗它)。 /// - public async ValueTask ResolveInstallAsync(string code, string? requestBaseUrl, CancellationToken ct) + public async ValueTask RoleOfCodeAsync(string code, CancellationToken ct) { if (code.Length is 0 or > 200) { @@ -115,22 +173,11 @@ public sealed class JoinTokenService } JoinTokenInfo? token = await _store.FindAsync(TokenHash.Sha256(code), ct); - if (token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses) - { - return null; - } - - string role = RoleOf(token.Kind); - string registry = (_options.NodeImageRegistry ?? "").Trim('/'); - string ns = _options.ImageNamespace.Trim('/'); - string image = $"{(registry.Length > 0 ? registry + "/" : "")}{(ns.Length > 0 ? ns : "moshiro")}/moshiro-{role}:latest"; - string server = BaseUrl(requestBaseUrl); - return IsSafe(server) && IsSafe(image) && IsSafe(code) ? new InstallTarget(role, server, code, image) : null; + return token is null || token.RevokedAtMs is not null || token.ExpiresAtMs <= _time.GetUtcNow().ToUnixTimeMilliseconds() || token.Uses >= token.MaxUses + ? null + : RoleOf(token.Kind); } - /// 值会被放进脚本的单引号里:只允许 URL 和镜像名里正常出现的字符。 - private static bool IsSafe(string value) => value.All(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' or ':' or '/' or '[' or ']' or '~' or '%'); - public ValueTask> ListAsync(CancellationToken ct) => _store.ListAsync(ct); public ValueTask RevokeAsync(string id, CancellationToken ct) => _store.RevokeAsync(id, _time.GetUtcNow().ToUnixTimeMilliseconds(), ct); diff --git a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs index 767c5d3..b287a56 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/Capabilities.cs @@ -102,8 +102,6 @@ public static class CapabilityCatalog public static bool IsKnown(string id) => s_byId.ContainsKey(id); - public static CapabilityInfo? Find(string id) => s_byId.GetValueOrDefault(id); - /// 补上蕴含的能力(写蕴含读),去重并排序,结果稳定。 public static IReadOnlyList Expand(IEnumerable ids) { diff --git a/backend/src/MoShiro.Backend.Application/Live/HotLayer.cs b/backend/src/MoShiro.Backend.Application/Live/HotLayer.cs index 747cf7d..1d58506 100644 --- a/backend/src/MoShiro.Backend.Application/Live/HotLayer.cs +++ b/backend/src/MoShiro.Backend.Application/Live/HotLayer.cs @@ -28,8 +28,6 @@ public sealed class HotLayer public void MarkReady() => _ready = true; - public IEnumerable<(NodeId Agent, string Series)> Keys => _rings.Keys; - /// 评估一个块并合并进热层。同一个块重复合并(例如回填与实时竞争)会被忽略。 public void Merge(NodeId agent, string series, RawChunk chunk) { @@ -94,27 +92,6 @@ public sealed class HotLayer return result; } - public int BucketCount(NodeId agent, string series) - { - if (!_rings.TryGetValue((agent, series), out Ring? ring)) - { - return 0; - } - - lock (ring) - { - return ring.Buckets.Count; - } - } - - public void Remove(NodeId agent) - { - foreach ((NodeId Agent, string Series) key in _rings.Keys.Where(k => k.Agent == agent).ToList()) - { - _rings.TryRemove(key, out _); - } - } - private static void Prune(Ring ring, long cutoff) { while (ring.Buckets.Count > 0) diff --git a/backend/src/MoShiro.Backend.Application/Live/LiveHub.cs b/backend/src/MoShiro.Backend.Application/Live/LiveHub.cs index 7f5a1d2..963cb3c 100644 --- a/backend/src/MoShiro.Backend.Application/Live/LiveHub.cs +++ b/backend/src/MoShiro.Backend.Application/Live/LiveHub.cs @@ -45,8 +45,6 @@ public sealed class LiveHub { private readonly ConcurrentDictionary _subscriptions = new(); - public int SubscriberCount => _subscriptions.Count; - public LiveSubscription Subscribe() { var subscription = new LiveSubscription(this); diff --git a/backend/src/MoShiro.Backend.Application/Query/QueryService.cs b/backend/src/MoShiro.Backend.Application/Query/QueryService.cs index 624b0a8..c97921f 100644 --- a/backend/src/MoShiro.Backend.Application/Query/QueryService.cs +++ b/backend/src/MoShiro.Backend.Application/Query/QueryService.cs @@ -422,8 +422,4 @@ public sealed class QueryService return gaps; } - - /// 从 JSON 文本读取参数(供端点之外的调用方使用)。 - public static Dictionary ParseParams(string json) => - JsonSerializer.Deserialize(json, TestJson.Default.ParamMap) ?? []; } diff --git a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs index 31304b4..bf9b081 100644 --- a/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs +++ b/backend/src/MoShiro.Backend.Application/Updates/ReleaseService.cs @@ -194,8 +194,6 @@ public sealed partial class ReleaseService : IDisposable public UpdateStatus? StatusOf(NodeId node) => _status.TryGetValue(node.Value, out UpdateStatus? status) ? status : null; - public void Forget(NodeId node) => _status.TryRemove(node.Value, out _); - private IEnumerable OnlineOf(string component) => _gateway.OnlineNodes.Where(n => ComponentOf(n) == component); internal static string? ComponentOf(NodeId node) => diff --git a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs index 0ab7f43..27be2e0 100644 --- a/backend/tests/MoShiro.Backend.Tests/InstallTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/InstallTests.cs @@ -48,6 +48,78 @@ public sealed class InstallTests Assert.DoesNotContain("<", linux + macos + windows, StringComparison.Ordinal); // 没有要用户自己替换的占位符 } + [Theory] + [InlineData("agent")] + [InlineData("probe")] + public async Task ComposeInstall_WritesThePublicValuesToAnEnvFile_AndChecksTheComposeHash(string role) + { + JsonNode created = await CreateAsync(role); + string code = created["plaintext"]!.GetValue(); + JsonNode compose = created["compose"]!; + string env = compose["env"]!.GetValue(); + string url = compose["url"]!.GetValue(); + string linux = compose["linux"]!.GetValue(); + + 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()} 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(), Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(body))); + + if (role == "probe") + { + // Docker Desktop 没有宿主网络:macOS 和 Windows 的 .env 里改成 bridge + string macos = compose["macos"]!.GetValue(); + string windows = compose["windows"]!.GetValue(); + 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()); + Assert.Equal(string.Empty, compose["windows"]!.GetValue()); + } + } + + [Theory] + [InlineData("agent.yaml")] + [InlineData("probe.yaml")] + [InlineData("backend.yaml")] + public async Task TheComposeFiles_AreStatic_PinNothingToABuild_AndKeepTheNodeHardening(string file) + { + string body = await _backend.Anonymous.GetStringAsync("/install/compose/" + file); + + Assert.Contains("${MOSHIRO_VERSION:-latest}", body, StringComparison.Ordinal); // 更新就是 pull 加 up,想固定版本在 .env 里写 + Assert.Contains("read_only: true", body, StringComparison.Ordinal); + Assert.Contains("no-new-privileges:true", body, StringComparison.Ordinal); + Assert.Contains("- ALL", body, StringComparison.Ordinal); // cap_drop + Assert.DoesNotContain("jt_", body, StringComparison.Ordinal); // 没有任何一次性的值 + } + + [Theory] + [InlineData("evil.yaml")] + [InlineData("agent")] + [InlineData("..%2Finstall.sh")] + public async Task OnlyTheThreeComposeFiles_AreServed(string file) + { + using HttpResponseMessage response = await _backend.Anonymous.GetAsync("/install/compose/" + file); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + [Fact] public async Task TheScripts_AreStatic_TheSameBytesForEveryone_AndNeedNoCode() { diff --git a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs index 116ac82..b5303e7 100644 --- a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs @@ -53,7 +53,7 @@ public sealed partial class OpenApiConformanceTests foreach ((string path, _) in doc["paths"]!.AsObject()) { string[] prefixes = ["/api/public/", "/api/admin/", "/api/agent/v1/"]; - string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/api/release/offer"]; + string[] roots = ["/healthz", "/metrics", "/install", "/install.ps1", "/install/manifest", "/install/manifest.sig", "/install/compose/{file}", "/api/release/offer"]; Assert.True(prefixes.Any(p => path.StartsWith(p, StringComparison.Ordinal)) || roots.Contains(path), $"{path} is outside the documented layers"); foreach (string segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries)) { diff --git a/ci/publish-gitea-release.sh b/ci/publish-gitea-release.sh index b96f49e..9929a29 100644 --- a/ci/publish-gitea-release.sh +++ b/ci/publish-gitea-release.sh @@ -29,7 +29,7 @@ trap 'rm -rf "$tmp"' EXIT root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" install_sh="$(sha256sum "$root_dir/packaging/install/install.sh" | 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-.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}' \ "$tag" "${CI_COMMIT_SHA:-}" "$tag" "$release_body")" release_status="$(curl --silent --show-error --retry 3 --netrc-file "$netrc" \ @@ -129,6 +129,13 @@ upload_local_asset() { 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 sums="$tmp/SHA256SUMS" : > "$sums" @@ -138,6 +145,10 @@ if [[ -n "${RELEASE_SIGNING_KEY:-}" ]]; then cat "$tmp/$archive.sha256" >> "$sums" done [[ "$(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)" chmod 600 "$signing_key" printf '%s\n' "$RELEASE_SIGNING_KEY" > "$signing_key" diff --git a/compose.yaml b/compose.yaml deleted file mode 100644 index 36d6ac2..0000000 --- a/compose.yaml +++ /dev/null @@ -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 diff --git a/docs/ops/deployment.md b/docs/ops/deployment.md index 31a5555..490f40d 100644 --- a/docs/ops/deployment.md +++ b/docs/ops/deployment.md @@ -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 的域名和允许的来源由它派生。 -在部署目录保存此仓库的 `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 -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/` 会进入认领向导: 1. 填认领码(首次启动时随机生成并写进后端日志,`journalctl -u moshiro-backend` 或 `docker logs`;也可以用 `MoShiro__ClaimCode` / `MOSHIRO_CLAIM_CODE` 指定)、管理员的用户名和密码; 2. 注册你的第一把 passkey; 3. 用它给操作员集合签名,建立信任根。 谁先打开页面谁就能当管理员,所以认领码只有能看到后端日志的人拿得到;创建完第一个管理员,码作废、入口关闭(再提交返回 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 文档在普通开发构建中生成。 @@ -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--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 " 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--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 取自面板信任页或签名清单(那里有发布签名)。回滚就是改回旧版本再 `up -d`。 +- **信任**:compose 来自面板或 CI 渠道,面板被攻破也改不了镜像地址;但客户端不再验发布签名,拉取靠 TLS 加「注册表可信」这个假设(文档里本来就把 CI、Gitea、注册表放在可信范围内)。这条路没有管理端的「更新」列和确认更新按钮,那些依赖宿主机更新器;要有签名验证的更新,用下面的部署脚本。 + +**一行安装脚本(备用,Docker 或 Podman,Agent 和 Probe 共用)**:管理端的弹窗里折叠着一条旧的一行命令,先核对脚本哈希再执行,脚本向面板取签名清单、用内置发布公钥验签、按 digest 拉镜像: ```sh f=$(mktemp) && curl -fsSL 'https://panel.example.com/install' -o "$f" && echo " $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,再带同样的环境变量运行 ``` -- 脚本是静态的同一份(`packaging/install/install.sh` 和 `install.ps1`,作为嵌入资源打进 Backend,原样返回,不替换任何值),所以它的 SHA-256 在一个版本内固定;角色、面板地址、代码和属主走环境变量。脚本向面板取该角色已签名的发布清单(凭一次性代码),用内置的发布公钥验签,再按清单里的 digest 拉镜像;代码不存在、已吊销、已过期或用完了取清单都是 404。缺了 `MOSHIRO_GENESIS` 或 `MOSHIRO_CONTROLLERS` 就不装(只有还没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`)。 -- 脚本检测 Docker 或 Podman(连不上就自动用 sudo),拉镜像,起容器 `moshiro-`:只读根文件系统、丢掉全部能力、Linux 上用宿主网络(Agent 对外暴露端口,也靠它识别本机公网地址),macOS 和 Windows 上 Agent 发布 7443、7444 端口;注册成功(日志出现 `link up`)才算装好。 -- 身份和配置在命名卷 `moshiro--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`。 -- 更新:拉新镜像、删掉旧容器、重新执行安装命令(卷保留);要自动更新(签名清单)用下面的部署脚本。 +脚本是静态的(`packaging/install/install.sh` 和 `install.ps1`,嵌入 Backend,不替换任何值),所以它的 SHA-256 在一个版本内固定;角色、面板地址、代码和属主走环境变量。环境变量 `MOSHIRO_NAME`、`MOSHIRO_ENGINE=docker|podman`、`MOSHIRO_DRY_RUN=1`(只打印 run 命令);Podman 会顺便启用 `podman-restart`。 **用脚本部署和更新(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 ``` -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:// --join --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://","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:// --join --name --identity-dir /etc/moshiro/probe-id`,再写 `probe.json`(`{"fleet":{"server":"https://","identity_dir":"/etc/moshiro/probe-id"}}`),然后 `podman compose up -d`。已有部署迁移时,应把原配置与身份原样复制进 volume,不重新注册。要换密钥就在面板吊销再重新注册。Docker 与 Podman 各自管理 volume,切换引擎不会自动共享数据。 +Agent 的 `public_host` 和 `ipv6_host` 由 Agent 上报自动识别(需要宿主网络,脚本和 Compose 默认如此,监听 `::`),IPv6 不用额外配置。 纯 Compose 使用 `compose pull && compose up -d` 更新镜像;面板自动更新使用下述 systemd 单元和更新器。Rootless Podman 使用 `moshiro--rootless.service`,安装到 `~/.config/systemd/user/`,镜像文件为 `~/.config/moshiro/.image`;rootful Podman 使用 `moshiro--container.service`,Docker 使用 `moshiro--docker.service`,安装到 `/etc/systemd/system/`,镜像文件为 `/etc/moshiro-update/.image`。所有单元均使用相同的配置和状态 volume 名称。自动启动分别使用 `systemctl --user enable --now ` 或 `sudo systemctl enable --now `。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 没有独立目标任务入口。 diff --git a/docs/security/access.md b/docs/security/access.md index 1a4bac4..8ce03af 100644 --- a/docs/security/access.md +++ b/docs/security/access.md @@ -175,7 +175,12 @@ macOS 用 `shasum -a 256 -c`,Windows 用 `Get-FileHash` 比较,不通过就 - 脚本向面板取已签名的清单(和更新用的是同一份,发布密钥签名、`seq` 防回滚),用**脚本里内置的发布公钥**验签,再按清单里的 digest 拉镜像(`repo@sha256:…`)。Backend 改不了 digest,最多让节点装到一个旧的合法版本。 - 下载压缩包安装的人,用 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)、镜像仓库地址仍由代码决定,但镜像身份由签名清单决定。 - macOS 的 Developer ID 签名和公证、Windows 的 Authenticode 需要证书,目前没有做。 diff --git a/docs/security/enrollment.md b/docs/security/enrollment.md index d342772..ced222e 100644 --- a/docs/security/enrollment.md +++ b/docs/security/enrollment.md @@ -16,10 +16,11 @@ ```text 管理员 ── 创建加入令牌 {标签, max_uses, 过期时间, auto_approve, controllers} ──> 面板(Fleet.Server.Enrollment) - <── 一行安装命令(Agent 和 Probe 共用):先下载静态安装脚本、核对它的 SHA-256,一致才执行; - 面板地址、角色、一次性码、创世指纹和属主都走环境变量,命令里只有公开的值(见 access.md 第 10 节) + <── 一段 compose 安装命令(默认):建目录、把面板地址、一次性码、创世指纹和属主写进本地 .env,下载面板提供的静态 + 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 拉镜像 ├─ 用 Docker 或 Podman 起容器,身份和配置存在命名卷里;节点名字默认取主机名 └─ 容器入口用代码注册:fleet-enroll --server … --join jt_… --genesis … --controllers … diff --git a/packaging/compose/agent/compose.yaml b/packaging/compose/agent/compose.yaml index 7947686..5bce5d0 100644 --- a/packaging/compose/agent/compose.yaml +++ b/packaging/compose/agent/compose.yaml @@ -1,9 +1,15 @@ -# Docker 和 rootless/rootful Podman 共用。配置、身份和缓存保存在命名 volume 中。 -# docker compose up -d / podman compose up -d(Podman 以普通用户执行即可 rootless)。 -# 首次注册、配置导入和自动更新见 docs/ops/deployment.md。 +# MoShiro Agent(Linux,需要宿主网络)。Docker 和 rootless/rootful Podman 共用。 +# +# 用法(面板「添加 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:。 +# 说明见 docs/ops/deployment.md。 services: 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 restart: unless-stopped init: true @@ -18,6 +24,13 @@ services: - /sys:/host/sys:ro - /:/host/root:ro 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 HOST_PROC: /host/proc HOST_SYS: /host/sys diff --git a/packaging/compose/backend/compose.yaml b/packaging/compose/backend/compose.yaml new file mode 100644 index 0000000..6e752ba --- /dev/null +++ b/packaging/compose/backend/compose.yaml @@ -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 diff --git a/packaging/compose/probe/compose.yaml b/packaging/compose/probe/compose.yaml index 05902b9..82fd9f8 100644 --- a/packaging/compose/probe/compose.yaml +++ b/packaging/compose/probe/compose.yaml @@ -1,15 +1,29 @@ -# Docker 和 rootless/rootful Podman 共用。配置、身份和缓存保存在命名 volume 中。 -# docker compose up -d / podman compose up -d(Podman 以普通用户执行即可 rootless)。 -# 首次注册、配置导入和自动更新见 docs/ops/deployment.md。 +# MoShiro Probe。Docker 和 rootless/rootful Podman 共用。 +# +# 用法(面板「添加 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:。 +# macOS、Windows 的 Docker Desktop 没有宿主网络:面板给的 .env 里已经写了 MOSHIRO_NETWORK_MODE=bridge。 +# 说明见 docs/ops/deployment.md。 services: 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 restart: unless-stopped init: true - network_mode: host + network_mode: ${MOSHIRO_NETWORK_MODE:-host} 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: - probe-config:/etc/moshiro - probe-status:/etc/moshiro/update-status:ro diff --git a/packaging/container/entrypoint.sh b/packaging/container/entrypoint.sh index a583150..b291fce 100644 --- a/packaging/container/entrypoint.sh +++ b/packaging/container/entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/sh -# 容器入口(Agent 和 Probe 共用):第一次启动用 MOSHIRO_SERVER / MOSHIRO_JOIN / MOSHIRO_NAME 自己注册并写配置, +# 容器入口(Agent 和 Probe 共用):第一次启动用 MOSHIRO_SERVER / MOSHIRO_JOIN(MOSHIRO_NAME 不写就用主机名)自己注册并写配置, # 身份和配置都在命名卷 /etc/moshiro 里,之后重启(或换新镜像重建容器)直接启动,不需要再给参数。 set -eu @@ -9,8 +9,10 @@ identity="$dir/$role-id" config="$dir/$role.json" 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 fi # 每个节点都必须有属主(passkey):信任根指纹和属主缺一个就不注册 diff --git a/web/admin/src/pages/JoinTokenCreated.tsx b/web/admin/src/pages/JoinTokenCreated.tsx index 0c18a61..0c719e7 100644 --- a/web/admin/src/pages/JoinTokenCreated.tsx +++ b/web/admin/src/pages/JoinTokenCreated.tsx @@ -1,13 +1,21 @@ import type { CreatedJoinTokenDto } from '@shared/api/types' 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 }) { - 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 (
-
命令里的代码只显示这一次,请现在就复制。命令里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。
+
一次性码只显示这一次,请现在就复制。下面只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。
- + +
+ 分步做:.env 内容、compose 下载地址和哈希 +
+ +
+
{compose.env}
+ +
+
+ +
+
+ {compose.url} + +
+
SHA-256 {compose.sha256}
+
+
+
+

- 容器方式是首选:镜像按 CI 签名的清单固定到 digest,宿主机更新器能自己更新它,属主和信任根在注册时固定。{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''} + 以后更新:podman compose pull && podman compose up -d(Docker 把 podman 换成 docker);身份和配置在命名卷里,不会动。 + 想固定版本,在 .env 里写 MOSHIRO_VERSION=版本号。注册成功之后,可以把 .env 里的 MOSHIRO_JOIN 一行删掉。 + {kind === 'Agent' ? (created.token.auto_approve ? ' 使用它注册的 Agent 会自动启用。' : ' 使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''} 可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。

+
+ 备用:一行安装脚本(先核对脚本哈希再执行,需要 Docker 或 Podman) +
+ +
+
) diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 392355c..003152b 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -1178,6 +1178,23 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -1407,6 +1424,14 @@ export interface components { [key: string]: null | number | string; }; }; + ComposeInstallDto: { + env: string; + url: string; + sha256: string; + linux: string; + macos: string; + windows: string; + }; ConfirmBackendUpdateRequest: { /** Format: int64 */ seq: number | string; @@ -1459,6 +1484,7 @@ export interface components { token: components["schemas"]["JoinTokenDto"]; plaintext: string; install_commands: components["schemas"]["InstallCommandsDto"]; + compose: components["schemas"]["ComposeInstallDto"]; }; DimensionDto: { 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: { parameters: { query?: never; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 48e962d..4eeb1c8 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -40,6 +40,7 @@ export type ParamDto = Narrow export type MetricDto = Narrow export type JoinTokenDto = Narrow export type CreatedJoinTokenDto = Narrow +export type ComposeInstallDto = Narrow export type ApiTokenDto = Narrow export type CreatedApiTokenDto = Narrow export type CapabilityCatalogDto = Narrow