feat: every agent and probe must have an owner passkey, enforced by the panel, the installers and fleet-enroll [CI]

This commit is contained in:
justme committed 2026-10-08 06:04:04 +08:00
1 parent 0b384a58f5
commit 8c37db1577
14 files changed
+207 -12

No files matched your search

@@ -46,6 +46,12 @@ public static class EnrollmentClient
TrustBundle? bundle = null;
if (genesis is not null)
{
if (controllers is not { Count: > 0 })
{
// 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作
return Result<EnrollmentResult>.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)");
}
Result<TrustBundle> trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct);
if (!trusted.TryGet(out bundle, out FleetError? trustError))
{
@@ -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<EnrollmentResult> result = await EnrollmentClient.EnrollAsync(
http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers);
@@ -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<string[]> 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));
@@ -0,0 +1,32 @@
using Fleet.Core;
using Fleet.Identity;
namespace MoShiro.Backend.Application;
/// <summary>
/// 节点的属主:创建加入码时选定的 passkey 凭证 id,写进安装命令,节点注册时固定在本地。
/// 属主必须是操作员集合里的凭证;<see cref="BackendOptions.RequireOwner"/> 打开时(默认)必须已经有信任根,并且至少选一把——
/// 不选属主的节点没有人能授权它的敏感操作,也就不该存在。没有明确指定时默认是操作员集合里的全部 passkey。
/// </summary>
public static class OwnerPolicy
{
public static Result<string[]> Resolve(bool required, OperatorSet? set, IReadOnlyList<string>? requested)
{
if (set is null)
{
return required
? ValidationError.Fail<string[]>("controllers", "set up the trust root first (System, Trust): every node needs an owner passkey")
: Result<string[]>.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<string[]>("controllers", "must be passkeys of the operator set");
}
return required && chosen.Length == 0
? ValidationError.Fail<string[]>("controllers", "pick at least one owner passkey: a node's owner is who its sensitive sessions and data are bound to")
: Result<string[]>.Ok(chosen);
}
}
@@ -26,6 +26,12 @@ public sealed class BackendOptions
/// </summary>
public string BinaryPolicy { get; set; } = "enforce";
/// <summary>
/// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。
/// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。
/// </summary>
public bool RequireOwner { get; set; } = true;
/// <summary>面板的对外地址(用于生成安装命令)。</summary>
public string? PublicUrl { get; set; }
@@ -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 秒
});
@@ -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;
/// <summary>
/// 每个 Agent 和 Probe 都必须有属主(passkey):属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地。
/// 没有信任根、没选属主、选了操作员集合之外的凭证,都不能创建加入码;安装脚本和容器入口缺了属主也不装。
/// </summary>
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<BackendOptions>();
_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)));
/// <summary>建立信任根:一个有 passkey 的操作员给操作员集合签名。</summary>
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<bool>())
{
string statement = state["proposal"]!["statement"]!.GetValue<string>();
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<JsonNode> 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<string>();
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<string>();
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);
}
}
}