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:
1 parent
0b384a58f5
commit
8c37db1577
14 files changed
+207
-12
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user