From 8c37db15773cde2aa5226045b5f3dd18fd71c026 Mon Sep 17 00:00:00 2001 From: JustMe Date: Thu, 8 Oct 2026 06:04:04 +0800 Subject: [PATCH] feat: every agent and probe must have an owner passkey, enforced by the panel, the installers and fleet-enroll [CI] --- .../EnrollmentClient.cs | 6 + backend/fleet/tools/fleet-enroll/Program.cs | 6 + .../Admin/AgentEndpoints.cs | 6 +- .../Admin/OwnerPolicy.cs | 32 +++++ .../BackendOptions.cs | 6 + .../MoShiro.Backend.Tests/BackendFixture.cs | 1 + .../tests/MoShiro.Backend.Tests/OwnerTests.cs | 126 ++++++++++++++++++ docs/access.md | 8 +- packaging/container/entrypoint.sh | 7 + packaging/install/install.ps1 | 5 + packaging/install/install.sh | 4 + web/admin/src/pages/Agents.tsx | 4 +- web/admin/src/pages/OwnerPicker.tsx | 4 +- web/admin/src/pages/Probes.tsx | 4 +- 14 files changed, 207 insertions(+), 12 deletions(-) create mode 100644 backend/src/MoShiro.Backend.Application/Admin/OwnerPolicy.cs create mode 100644 backend/tests/MoShiro.Backend.Tests/OwnerTests.cs diff --git a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs index 35cb796..0260cd1 100644 --- a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs +++ b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs @@ -46,6 +46,12 @@ public static class EnrollmentClient TrustBundle? bundle = null; if (genesis is not null) { + if (controllers is not { Count: > 0 }) + { + // 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作 + return Result.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)"); + } + Result trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct); if (!trusted.TryGet(out bundle, out FleetError? trustError)) { diff --git a/backend/fleet/tools/fleet-enroll/Program.cs b/backend/fleet/tools/fleet-enroll/Program.cs index 3e7ee3b..e563fb2 100644 --- a/backend/fleet/tools/fleet-enroll/Program.cs +++ b/backend/fleet/tools/fleet-enroll/Program.cs @@ -33,6 +33,12 @@ if (Arg("--meta") is { } metaText) string? genesis = Arg("--genesis"); string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +if (genesis is not null && controllers.Length == 0) +{ + Console.Error.WriteLine("--genesis needs --controllers: every node has an owner (the passkey credential ids from the panel's command)"); + return 2; +} + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; Result result = await EnrollmentClient.EnrollAsync( http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers); diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 8fe3026..d0530a6 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -137,10 +137,10 @@ public static class AgentEndpoints // 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把 OperatorSet? set = await trust.CurrentAsync(http.RequestAborted); string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis; - string[] controllers = set is null ? [] : [.. request.Controllers ?? [.. set.Keys.Select(k => k.CredentialId)]]; - if (set is not null && controllers.Any(c => set.Keys.All(k => k.CredentialId != c))) + Result owner = OwnerPolicy.Resolve(options.RequireOwner, set, request.Controllers); + if (!owner.TryGet(out string[]? controllers, out FleetError? ownerError)) { - return Problems.From(http, ValidationError.Single("controllers", "must be passkeys of the operator set")); + return Problems.From(http, ownerError); } var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1)); diff --git a/backend/src/MoShiro.Backend.Application/Admin/OwnerPolicy.cs b/backend/src/MoShiro.Backend.Application/Admin/OwnerPolicy.cs new file mode 100644 index 0000000..34c2bbe --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Admin/OwnerPolicy.cs @@ -0,0 +1,32 @@ +using Fleet.Core; +using Fleet.Identity; + +namespace MoShiro.Backend.Application; + +/// +/// 节点的属主:创建加入码时选定的 passkey 凭证 id,写进安装命令,节点注册时固定在本地。 +/// 属主必须是操作员集合里的凭证; 打开时(默认)必须已经有信任根,并且至少选一把—— +/// 不选属主的节点没有人能授权它的敏感操作,也就不该存在。没有明确指定时默认是操作员集合里的全部 passkey。 +/// +public static class OwnerPolicy +{ + public static Result Resolve(bool required, OperatorSet? set, IReadOnlyList? requested) + { + if (set is null) + { + return required + ? ValidationError.Fail("controllers", "set up the trust root first (System, Trust): every node needs an owner passkey") + : Result.Ok([]); + } + + string[] chosen = requested is null ? [.. set.Keys.Select(k => k.CredentialId)] : [.. requested.Distinct(StringComparer.Ordinal)]; + if (chosen.Any(c => set.Keys.All(k => k.CredentialId != c))) + { + return ValidationError.Fail("controllers", "must be passkeys of the operator set"); + } + + return required && chosen.Length == 0 + ? ValidationError.Fail("controllers", "pick at least one owner passkey: a node's owner is who its sensitive sessions and data are bound to") + : Result.Ok(chosen); + } +} diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 99d564c..12937ba 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -26,6 +26,12 @@ public sealed class BackendOptions /// public string BinaryPolicy { get; set; } = "enforce"; + /// + /// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。 + /// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。 + /// + public bool RequireOwner { get; set; } = true; + /// 面板的对外地址(用于生成安装命令)。 public string? PublicUrl { get; set; } diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index c36ce48..b7f5cd9 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -259,6 +259,7 @@ public class BackendFixture : IAsyncLifetime options.InitialAdminUser = AdminUser; options.InitialAdminPassword = AdminPassword; options.LoginAttemptsPerMinute = LoginAttemptsPerMinute; + options.RequireOwner = false; // 大部分测试不建立信任根;属主必选的测试自己打开(OwnerTests) options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务 options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒 }); diff --git a/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs new file mode 100644 index 0000000..ce0c71f --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Fleet.Identity; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Tests; + +/// +/// 每个 Agent 和 Probe 都必须有属主(passkey):属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地。 +/// 没有信任根、没选属主、选了操作员集合之外的凭证,都不能创建加入码;安装脚本和容器入口缺了属主也不装。 +/// +public sealed class OwnerPolicyTests +{ + private static OperatorSet Set(params string[] ids) => + new(1, OperatorTrust.Genesis, "example.test", ["https://example.test"], [.. ids.Select(id => new OperatorKey(id, [1, 2, 3]))]); + + [Fact] + public void WithoutATrustRoot_AnOwnerCannotBeChosen_WhenOwnersAreRequired() + { + Assert.False(OwnerPolicy.Resolve(required: true, null, null).IsSuccess); + Assert.False(OwnerPolicy.Resolve(required: true, null, ["a"]).IsSuccess); + Assert.Empty(OwnerPolicy.Resolve(required: false, null, null).Value); // 没有信任根的老部署:不固定属主 + } + + [Fact] + public void ByDefaultEveryOperatorIsAnOwner_AndAnExplicitChoiceMustBeNonEmptyAndKnown() + { + OperatorSet set = Set("a", "b"); + + Assert.Equal(["a", "b"], OwnerPolicy.Resolve(true, set, null).Value); + Assert.Equal(["b"], OwnerPolicy.Resolve(true, set, ["b", "b"]).Value); + Assert.False(OwnerPolicy.Resolve(true, set, []).IsSuccess); // 一把都不选:不行 + Assert.False(OwnerPolicy.Resolve(true, set, ["a", "stranger"]).IsSuccess); // 不是操作员集合里的凭证 + Assert.False(OwnerPolicy.Resolve(false, set, ["stranger"]).IsSuccess); + } +} + +[Collection("probe-e2e")] +public sealed class OwnerEndToEndTests : IDisposable +{ + private readonly BackendFixture _backend; + private readonly BackendOptions _options; + private readonly bool _before; + + public OwnerEndToEndTests(BackendFixture backend) + { + _backend = backend; + _options = backend.App.Services.GetRequiredService(); + _before = _options.RequireOwner; + _options.RequireOwner = true; + } + + public void Dispose() => _options.RequireOwner = _before; + + private static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); + + /// 建立信任根:一个有 passkey 的操作员给操作员集合签名。 + private async Task<(HttpClient Session, SoftwarePasskey Passkey)> EstablishTrustAsync() + { + (HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("owner"); + JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!; + if (!state["initialized"]!.GetValue()) + { + string statement = state["proposal"]!["statement"]!.GetValue(); + JsonObject assertion = passkey.Assert(Base64UrlOf(statement)); + var body = new JsonObject + { + ["statement"] = statement, + ["credential_id"] = assertion["credential_id"]!.DeepClone(), + ["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(), + ["client_data_json"] = assertion["client_data_json"]!.DeepClone(), + ["signature"] = assertion["signature"]!.DeepClone(), + }; + using HttpResponseMessage committed = await session.PostAsync("/api/admin/trust", BackendFixture.Json(body)); + Assert.Equal(HttpStatusCode.Created, committed.StatusCode); + } + + return (session, passkey); + } + + private async Task OperatorsAsync(HttpClient session) => + JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!["operators"]!; + + [Fact] + public async Task ANodeCannotBeAddedWithoutAnOwner() + { + (HttpClient session, SoftwarePasskey passkey) = await EstablishTrustAsync(); + using HttpClient sessionScope = session; + using SoftwarePasskey passkeyScope = passkey; + string known = (await OperatorsAsync(session))[0]!.GetValue(); + + using HttpResponseMessage none = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["controllers"] = new JsonArray() })); + Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode); + Assert.Contains("owner", await none.Content.ReadAsStringAsync(), StringComparison.Ordinal); + + using HttpResponseMessage stranger = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["controllers"] = new JsonArray("not-an-operator") })); + Assert.Equal(HttpStatusCode.BadRequest, stranger.StatusCode); + + // 明确选了一把,或者不指定(默认是全部操作员):可以,命令里带着创世指纹和属主 + using HttpResponseMessage chosen = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["controllers"] = new JsonArray(known) })); + Assert.Equal(HttpStatusCode.Created, chosen.StatusCode); + string linux = JsonNode.Parse(await chosen.Content.ReadAsStringAsync())!["install_commands"]!["linux"]!.GetValue(); + Assert.Contains("MOSHIRO_GENESIS=", linux, StringComparison.Ordinal); + Assert.Contains("MOSHIRO_CONTROLLERS=", linux, StringComparison.Ordinal); + Assert.Contains(known, linux, StringComparison.Ordinal); + + using HttpResponseMessage defaults = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe" })); + Assert.Equal(HttpStatusCode.Created, defaults.StatusCode); + } + + [Fact] + public async Task TheInstallScripts_RefuseANodeWithoutAnOwner() + { + // 脚本是静态的,所以可以直接检查:缺了信任根指纹或属主就不装,只有明确设置 MOSHIRO_ALLOW_NO_OWNER=1(没有信任根的老面板)才放行 + using HttpClient anonymous = _backend.NewClient(); + foreach (string path in new[] { "/install", "/install.ps1" }) + { + string script = await anonymous.GetStringAsync(path); + Assert.Contains("MOSHIRO_ALLOW_NO_OWNER", script, StringComparison.Ordinal); + Assert.Contains("this node has no owner", script, StringComparison.Ordinal); + } + } +} diff --git a/docs/access.md b/docs/access.md index d87f67b..2ed7332 100644 --- a/docs/access.md +++ b/docs/access.md @@ -284,12 +284,14 @@ ### 8.0 范围:这一节先固定协议和要求,extension 本身先不做 - **web ssh、文件、抓包等 extension 现在都不实现**,Agent 和 Probe 一样(Probe 是节点,同样要在本地执行下面的要求)。这里只把「谁来强制」和「怎么证明」定下来,以后实现时不改信任链,也不用重装节点。 -- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。默认是操作员集合里的全部凭证,空 = 不允许任何 extension 会话。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。 +- **已经固定在节点本地的字段**:`controllers`(允许授权本节点 extension 会话的 passkey 凭证 id,必须是操作员集合里的凭证)由安装命令带来,节点注册时保存在 `trust.json`,Backend 之后改不了;从集合里去掉的凭证不再有效。**每个 Agent 和 Probe 都必须有属主,至少一把**:属主是节点敏感内容(extension 会话、加密数据)绑定的人——这些内容只有属主的 passkey 能授权、解密,没有属主的节点没有人能用它的敏感能力,也就不该存在。接收者名单(`recipients`)等做加密时再加。节点还没有 extension,所以现在只是被固定和保存,不起别的作用。 ### 8.1 添加节点时选属主 -- API:`POST /api/admin/join-tokens` 带可选的 `controllers: [credential_id…]`;缺省 = 操作员集合里的全部凭证。校验:每个 id 必须是操作员集合里的凭证。 -- 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表;只是选,不用触碰。选定的写进安装命令,节点装上时固定。 +- API:`POST /api/admin/join-tokens` 的 `controllers: [credential_id…]`:不给就是操作员集合里的全部凭证;给了就必须非空,且每个 id 都是操作员集合里的凭证。**还没有建立信任根、或者一把属主都没有,都不能创建加入码**(400,字段 `controllers`)。`MoShiro__RequireOwner=false` 可以关掉(只给没有信任根的老部署)。 +- 界面:「添加 Agent / Probe」对话框里一个「属主(passkey)」多选列表,**必选**(一把都不选时创建按钮不可用);只是选,不用触碰。选定的写进安装命令,节点装上时固定。 +- 安装端也强制:`install.sh`、`install.ps1`、容器入口缺了 `MOSHIRO_GENESIS` 或 `MOSHIRO_CONTROLLERS` 就不装(只有没有信任根的老面板才用 `MOSHIRO_ALLOW_NO_OWNER=1`);`fleet-enroll --genesis` 不带 `--controllers` 也拒绝。 +- 设计意图(还没做):属主的 passkey 是敏感内容的钥匙。节点和 Probe 这一侧要在宿主侧拿到属主 passkey 的证明,才可以解密、才可以开 extension 会话;Backend 在这里只是搬运,见第 8.2、8.4 节。 - 以后改属主要现有属主的 passkey 签名(`ControllerUpdate`,没有实现);管理员改不了。 ### 8.2 谁来强制:节点本地,不是 Backend diff --git a/packaging/container/entrypoint.sh b/packaging/container/entrypoint.sh index 6fbc417..a583150 100644 --- a/packaging/container/entrypoint.sh +++ b/packaging/container/entrypoint.sh @@ -13,6 +13,13 @@ if [ ! -f "$identity/node.json" ]; then echo "first start: set MOSHIRO_SERVER, MOSHIRO_JOIN and MOSHIRO_NAME (the panel shows the complete command)" >&2 exit 1 fi + # 每个节点都必须有属主(passkey):信任根指纹和属主缺一个就不注册 + if [ -z "${MOSHIRO_GENESIS:-}" ] || [ -z "${MOSHIRO_CONTROLLERS:-}" ]; then + if [ "${MOSHIRO_ALLOW_NO_OWNER:-}" != 1 ]; then + echo "this node has no owner: set MOSHIRO_GENESIS and MOSHIRO_CONTROLLERS (the panel's command has them; set up the trust root first)" >&2 + exit 1 + fi + fi if [ -n "${MOSHIRO_GENESIS:-}" ]; then # 命令里带着信任根指纹和属主(公开值):先验证面板的操作员集合,并把它们固定在本地 /usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" \ diff --git a/packaging/install/install.ps1 b/packaging/install/install.ps1 index ba945c2..05b2803 100644 --- a/packaging/install/install.ps1 +++ b/packaging/install/install.ps1 @@ -113,6 +113,11 @@ YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA== # Docker Desktop 没有宿主网络:Agent 把端口发布出来(面板里的地址需要指向这台机器的公网地址) if ($role -eq 'agent') { $arguments += @('-p', '7443:7443/tcp', '-p', '7443:7443/udp', '-p', '7444:7444/tcp') } $arguments += @('-e', "MOSHIRO_SERVER=$server", '-e', "MOSHIRO_JOIN=$code", '-e', "MOSHIRO_NAME=$name") + # 每个节点都必须有属主(passkey):命令里的信任根指纹和属主缺一个就不装(面板的命令总是带着;只有没有信任根的老面板才需要 MOSHIRO_ALLOW_NO_OWNER=1) + if ((-not $env:MOSHIRO_GENESIS -or -not $env:MOSHIRO_CONTROLLERS) -and $env:MOSHIRO_ALLOW_NO_OWNER -ne '1') { + Write-Error 'this node has no owner: MOSHIRO_GENESIS and MOSHIRO_CONTROLLERS are required (copy the complete command from the panel; set up the trust root first)' + return + } # 命令里带着信任根指纹和属主(公开值):节点在注册时验证面板的操作员集合并把它们固定在本地 if ($env:MOSHIRO_GENESIS) { if (($env:MOSHIRO_GENESIS + $env:MOSHIRO_CONTROLLERS) -notmatch '^[A-Za-z0-9_,-]+$') { Write-Error 'MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters'; return } diff --git a/packaging/install/install.sh b/packaging/install/install.sh index 2a5659b..26584fb 100644 --- a/packaging/install/install.sh +++ b/packaging/install/install.sh @@ -109,6 +109,10 @@ elif [ "$ROLE" = agent ]; then set -- "$@" -p 7443:7443/tcp -p 7443:7443/udp -p 7444:7444/tcp fi set -- "$@" -e "MOSHIRO_SERVER=$SERVER" -e "MOSHIRO_JOIN=$CODE" -e "MOSHIRO_NAME=$name" +# 每个节点都必须有属主(passkey):命令里的信任根指纹和属主缺一个就不装(面板的命令总是带着;只有没有信任根的老面板才需要 MOSHIRO_ALLOW_NO_OWNER=1) +if [ -z "${MOSHIRO_GENESIS:-}" ] || [ -z "${MOSHIRO_CONTROLLERS:-}" ]; then + [ "${MOSHIRO_ALLOW_NO_OWNER:-}" = 1 ] || die "this node has no owner: MOSHIRO_GENESIS and MOSHIRO_CONTROLLERS are required (copy the complete command from the panel; set up the trust root first)" +fi if [ -n "${MOSHIRO_GENESIS:-}" ]; then case "$MOSHIRO_GENESIS${MOSHIRO_CONTROLLERS:-}" in *[!A-Za-z0-9_,-]*) die "MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters" ;; esac set -- "$@" -e "MOSHIRO_GENESIS=$MOSHIRO_GENESIS" -e "MOSHIRO_CONTROLLERS=${MOSHIRO_CONTROLLERS:-}" diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index 001e880..3942c23 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -50,7 +50,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated