Merge branch 'main' into dev
ci/woodpecker/push/verify Pipeline failed
ci/woodpecker/push/web-build unknown status

This commit is contained in:
justme committed 2026-10-08 06:47:15 +08:00
commit 4a15dba8de
54 files changed
+1328 -65

No files matched your search

+1 -1
View File
@@ -49,6 +49,6 @@ cd backend
dotnet run --project src/MoShiro.Backend -- --urls http://localhost:5000
```
首次启动时可用 `MoShiro__InitialAdminUser` 和 `MoShiro__InitialAdminPassword` 设置初始管理员。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
没有默认管理员:第一次打开面板时按页面引导自己创建管理员、注册 passkey、建立信任根,页面要的认领码写在后端日志里(或用 `MoShiro__ClaimCode` 指定)。`MoShiro__InitialAdminUser` / `MoShiro__InitialAdminPassword` 只给自动化用。数据目录、公开地址、Cookie 和 CORS 等设置通过 `MoShiro` 配置节传入;完整配置说明见 [`../docs/deployment.md`](../docs/deployment.md)。站点默认需要登录,可在管理端修改公开设置。
OpenAPI 文档由构建生成到 `openapi/v1.json`。API 设计和端点说明见 [`../docs/api.md`](../docs/api.md);项目总览见仓库根目录的 [`README.md`](../README.md)。
@@ -304,6 +304,7 @@ public sealed class AgentApp : IDisposable
{
var meta = new AgentHelloMeta
{
Owners = [.. _trust.Controllers],
Ipv6 = Ipv6Diagnostics.Sample(_ipv6Supported, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")),
Ipv6Supported = _ipv6Supported,
Addresses = [.. LocalAddresses.Sample()],
@@ -45,6 +45,10 @@ public sealed class FleetSection
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro";
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>。</summary>
[JsonPropertyName("require_owner")]
public bool RequireOwner { get; set; } = true;
}
public sealed class ListenSection
@@ -28,6 +28,11 @@ try
config = NodeConfig.Load(configPath, AgentConfigContext.Default.AgentConfig);
restart = new RestartController(RestartSchedule.Resolve(config.RestartAt));
identity = LoadedNodeIdentity.Load(config.Fleet.IdentityDir);
if (!OwnerGuard.Check(config.Fleet.IdentityDir, config.Fleet.RequireOwner, log))
{
identity.Dispose();
return 1;
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
{
@@ -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))
{
@@ -0,0 +1,48 @@
using Fleet.Core;
namespace Fleet.Hosting;
/// <summary>
/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。
/// <para>
/// 属主是安装命令带来、注册时固定在 <c>trust.json</c> 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上,
/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。
/// 这是节点本地的强制,不依赖面板:面板被攻破也改不了节点已经固定的属主。
/// </para>
/// </summary>
public static class OwnerGuard
{
/// <summary>设为 <c>1</c> 放行没有属主的节点(只给没有信任根的老面板)。</summary>
public const string AllowEnvironmentVariable = "MOSHIRO_ALLOW_NO_OWNER";
/// <summary>没问题返回 null;否则是要告诉运维的原因。</summary>
public static string? Problem(TrustAnchor trust, bool require)
{
if (!require || Environment.GetEnvironmentVariable(AllowEnvironmentVariable) == "1")
{
return null;
}
if (!trust.Pinned)
{
return "this node has no owner: it was not enrolled with a trust root and owner passkeys; enroll it again with the command from the panel (or set " + AllowEnvironmentVariable + "=1 for a panel without a trust root)";
}
return trust.Controllers.Count == 0
? "none of this node's owner passkeys is in the operator set any more; enroll it again with a command from the panel"
: null;
}
/// <summary>读身份目录里固定的信任根,检查属主;不行就记一条错误并返回 false(调用方应当退出)。</summary>
public static bool Check(string identityDirectory, bool require, INodeLog log)
{
TrustAnchor trust = TrustAnchor.Load(identityDirectory, log);
if (Problem(trust, require) is { } problem)
{
log.Error("startup failed", ("err", problem));
return false;
}
return true;
}
}
@@ -0,0 +1,70 @@
using Fleet.Core;
using Fleet.Hosting;
using Fleet.Identity;
namespace Fleet.Tests;
/// <summary>节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。</summary>
public sealed class OwnerGuardTests : IDisposable
{
private readonly TestAuthenticator _alice = new();
private readonly string _directory = Path.Combine(Path.GetTempPath(), "moshiro-owner-" + Guid.NewGuid().ToString("N"));
public OwnerGuardTests()
{
Directory.CreateDirectory(_directory);
}
public void Dispose()
{
_alice.Dispose();
Directory.Delete(_directory, recursive: true);
}
private void Pin(params string[] controllers)
{
var set = new OperatorSet(1, OperatorTrust.Genesis, TestAuthenticator.RpId, [TestAuthenticator.Origin], [_alice.Key]);
SignedStatement genesis = _alice.Sign(OperatorTrust.Encode(set));
TrustAnchor.Pin(_directory, OperatorTrust.Fingerprint(genesis.Statement), [genesis.ToDto()], controllers);
}
[Fact]
public void ANodeEnrolledWithoutATrustRoot_HasNoOwner_AndDoesNotRun()
{
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.NotNull(OwnerGuard.Problem(none, require: true));
Assert.False(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
}
[Fact]
public void ANodePinnedToAnOwnerPasskey_Runs()
{
Pin(_alice.CredentialId);
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Null(OwnerGuard.Problem(pinned, require: true));
Assert.True(OwnerGuard.Check(_directory, require: true, NullNodeLog.Instance));
Assert.Equal([_alice.CredentialId], pinned.Controllers);
}
[Fact]
public void APinnedNodeWhoseOwnerIsNoLongerInTheOperatorSet_DoesNotRun()
{
Pin("some-passkey-that-is-not-in-the-set");
TrustAnchor pinned = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Empty(pinned.Controllers);
Assert.NotNull(OwnerGuard.Problem(pinned, require: true));
}
[Fact]
public void TheRequirementCanBeTurnedOff_ForAPanelWithoutATrustRoot()
{
TrustAnchor none = TrustAnchor.Load(_directory, NullNodeLog.Instance);
Assert.Null(OwnerGuard.Problem(none, require: false));
}
}
@@ -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);
+179
View File
@@ -1187,6 +1187,96 @@
}
}
},
"/api/admin/auth/setup": {
"get": {
"tags": [
"admin-auth"
],
"summary": "Whether the panel has no users yet and still has to be claimed",
"operationId": "setup_status",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetupStatusDto"
}
}
}
}
}
},
"post": {
"tags": [
"admin-auth"
],
"summary": "Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists)",
"operationId": "setup_complete",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetupRequest"
}
}
},
"required": true
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoginResponse"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"409": {
"description": "Conflict",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
},
"429": {
"description": "Too Many Requests",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDto"
}
}
}
}
}
}
},
"/api/admin/auth/passkey/options": {
"post": {
"tags": [
@@ -7169,6 +7259,15 @@
"$ref": "#/components/schemas/NodeBinaryDto"
}
]
},
"owners": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/NodeOwnerDto"
}
}
}
},
@@ -7241,6 +7340,31 @@
}
}
},
"NodeOwnerDto": {
"required": [
"credential_id",
"passkey",
"account"
],
"type": "object",
"properties": {
"credential_id": {
"type": "string"
},
"passkey": {
"type": [
"null",
"string"
]
},
"account": {
"type": [
"null",
"string"
]
}
}
},
"NodeUpdateDto": {
"required": [
"state",
@@ -8739,6 +8863,52 @@
}
}
},
"SetupRequest": {
"required": [
"claim_code",
"username",
"password",
"return_session"
],
"type": "object",
"properties": {
"claim_code": {
"type": [
"null",
"string"
]
},
"username": {
"type": [
"null",
"string"
]
},
"password": {
"type": [
"null",
"string"
]
},
"return_session": {
"type": [
"null",
"boolean"
]
}
}
},
"SetupStatusDto": {
"required": [
"needs_setup"
],
"type": "object",
"properties": {
"needs_setup": {
"type": "boolean"
}
}
},
"SignedStatementDto": {
"type": "object",
"properties": {
@@ -9226,6 +9396,15 @@
"server_key_listed": {
"type": "boolean",
"default": false
},
"operator_details": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/NodeOwnerDto"
}
}
}
},
+1 -1
View File
@@ -44,7 +44,7 @@ public sealed class ProbeApp : IDisposable
NodeVersion = Version,
Capabilities = [MoShiroCapabilities.Base],
HeartbeatExtra = () => JsonSerializer.SerializeToElement(new ProbeHeartbeatExtra { Update = update.ReadStatus(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHeartbeatExtra),
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
HelloMeta = () => JsonSerializer.SerializeToElement(new ProbeHelloMeta { Owners = [.. trust.Controllers], Platform = PlatformDetector.Detect(), Ipv6 = Ipv6Diagnostics.Sample(null, Environment.GetEnvironmentVariable("MOSHIRO_NETWORK_SCOPE"), Environment.GetEnvironmentVariable("HOST_PROC")) }, MoShiroJsonContext.Default.ProbeHelloMeta),
};
var list = new StateHandler<ProbeAgentList>(
ProbeAgentList.StateType,
@@ -29,6 +29,10 @@ public sealed class ProbeFleetSection
/// <summary><c>fleet-enroll</c> 写出的身份目录:<c>node.json</c>、<c>node.key</c>(连面板的密钥)、<c>tls.pfx</c>(对等面的密钥)。</summary>
[JsonPropertyName("identity_dir")]
public string IdentityDir { get; set; } = "/etc/moshiro/probe-id";
/// <summary>节点自己要求有属主(passkey):没有就不运行。见 <c>OwnerGuard</c>(Agent 和 Probe 共用)。</summary>
[JsonPropertyName("require_owner")]
public bool RequireOwner { get; set; } = true;
}
public sealed class KnownAgent
@@ -38,6 +38,12 @@ try
identity.Dispose();
throw new InvalidOperationException($"the identity in {file.Fleet.IdentityDir} is not a probe's: enroll with a probe join token");
}
if (!OwnerGuard.Check(file.Fleet.IdentityDir, file.Fleet.RequireOwner, log))
{
identity.Dispose();
return 1;
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException or System.Text.Json.JsonException or System.Security.Cryptography.CryptographicException)
{
@@ -306,6 +306,10 @@ public sealed class ProbeHeartbeatExtra
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
public sealed class ProbeHelloMeta
{
/// <summary>这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
[JsonPropertyName("owners")]
public List<string>? Owners { get; set; }
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
@@ -316,6 +320,10 @@ public sealed class ProbeHelloMeta
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
public sealed class AgentHelloMeta
{
/// <summary>这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
[JsonPropertyName("owners")]
public List<string>? Owners { get; set; }
[JsonPropertyName("ipv6")]
public Ipv6Diagnostic? Ipv6 { get; set; }
@@ -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));
@@ -19,6 +19,14 @@ public static class AuthEndpoints
.AllowsAnonymous()
.Api("auth_login", "Sign in with a username and password", Tag)
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
open.MapGet("/auth/setup", SetupStatusAsync)
.AllowsAnonymous()
.Api("setup_status", "Whether the panel has no users yet and still has to be claimed", Tag)
.Returns<SetupStatusDto>();
open.MapPost("/auth/setup", SetupAsync)
.AllowsAnonymous()
.Api("setup_complete", "Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists)", Tag)
.Returns<LoginResponse>(StatusCodes.Status201Created).ProducesProblems(400, 401, 409, 429);
open.MapPost("/auth/passkey/options", PasskeyLoginOptions)
.AllowsAnonymous()
.Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag)
@@ -77,6 +85,32 @@ public static class AuthEndpoints
return IssueSession(http, login, request.ReturnSession == true, options);
}
private static async Task<IResult> SetupStatusAsync(SetupService setup, CancellationToken ct)
{
return TypedResults.Json(new SetupStatusDto(await setup.NeedsSetupAsync(ct)), ApiJsonContext.Default.SetupStatusDto);
}
private static async Task<IResult> SetupAsync(HttpContext http, SetupRequest request, SetupService setup, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
{
IResult limit = Limited(http, limiter, out bool ok, "setup:");
if (!ok)
{
return limit;
}
string remote = http.ClientAddress();
Result<LoginResult> result = await setup.CompleteAsync(request.ClaimCode, request.Username ?? "", request.Password ?? "", http.RequestAborted);
if (!result.TryGet(out LoginResult? login, out FleetError? error))
{
await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "setup.complete", null, null, remote, false, http.RequestAborted);
return Problems.From(http, error);
}
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "setup.complete", null, null, remote, true, http.RequestAborted);
Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> session = IssueSession(http, login, request.ReturnSession == true, options);
return TypedResults.Json(session.Value!, ApiJsonContext.Default.LoginResponse, statusCode: StatusCodes.Status201Created);
}
/// <summary>发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。</summary>
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options)
{
@@ -73,13 +73,14 @@ public static class BuildEndpoints
return await PublishReleaseAsync(http, releases, request);
}
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, CancellationToken ct)
private static async Task<IResult> GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, NodeOwnerService owners, CancellationToken ct)
{
IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>> ownerOf = await owners.ListAsync(ct);
Dictionary<string, NodeBinding> bound = (await bindings.ListAsync(ct)).ToDictionary(b => b.NodeId, StringComparer.Ordinal);
var items = (await nodes.ListAsync(ct))
.OrderBy(n => n.Kind, StringComparer.Ordinal)
.ThenBy(n => n.NodeId, StringComparer.Ordinal)
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null))
.Select(n => new NodeBuildDto(n.NodeId, n.Kind, n.Os, n.OsName, n.Arch, n.Version, n.Tag, n.Commit, n.BuiltAt, ToDto(releases.StatusOf(new NodeId(n.NodeId))), bound.TryGetValue(n.NodeId, out NodeBinding? binding) ? ToDto(binding) : null, ownerOf.TryGetValue(n.NodeId, out IReadOnlyList<NodeOwner>? list) ? [.. list.Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))] : null))
.ToList();
http.Response.Headers.CacheControl = "no-store";
return TypedResults.Json(new BuildInfoDto(NodeEndpoints.Backend(withCommit: true), items, ToDto(await backendUpdates.GetAsync(ct))), ApiJsonContext.Default.BuildInfoDto);
@@ -156,7 +156,7 @@ public static class Mappers
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed);
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed, [.. (s.OperatorDetails ?? []).Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))]);
public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success);
@@ -75,6 +75,12 @@ public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string>
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
/// <summary>面板是不是还没有任何用户(需要首次初始化)。</summary>
public sealed record SetupStatusDto(bool NeedsSetup);
/// <param name="ClaimCode">首次启动时写在后端日志里的认领码(或者部署时指定的 <c>MoShiro__ClaimCode</c>)。</param>
public sealed record SetupRequest(string? ClaimCode, string? Username, string? Password, bool? ReturnSession);
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
/// <summary>下一版里新增的 passkey。</summary>
@@ -89,9 +95,10 @@ public sealed record TrustProposalDto(string Statement, int Version, string Chal
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
/// <param name="OperatorDetails">每个操作员是哪个账户的哪把 passkey(对不上已注册的 passkey 时名称和账户为空)。</param>
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</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);
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);
@@ -181,7 +188,10 @@ public sealed record RecomputeDto(string State, IReadOnlyList<string> Metrics, l
/// <summary>节点二进制的绑定:第一次握手成功时绑定的 SHA-256。<c>Signed</c> 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);<c>Violation</c> 是最近一次不符的原因,下一次握手符合了就清掉。</summary>
public sealed record NodeBinaryDto(string Sha256, bool Signed, long Seq, DateTimeOffset BoundAt, string? Violation, DateTimeOffset? ViolationAt);
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, NodeBinaryDto? Binary = null);
/// <summary>节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
public sealed record NodeOwnerDto(string CredentialId, string? Passkey, string? Account);
public sealed record NodeBuildDto(string Id, string Kind, string? Os, string? OsName, string? Arch, string? Version, string? Tag, string? Commit, string? BuiltAt, NodeUpdateDto? Update = null, NodeBinaryDto? Binary = null, IReadOnlyList<NodeOwnerDto>? Owners = null);
/// <summary>节点宿主机上的更新器报告的状态(<c>up_to_date</c>、<c>available</c>、<c>applying</c>、<c>failed</c>、<c>rolled_back</c>、<c>invalid</c>);没装更新器的节点没有这个字段。</summary>
public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At);
@@ -48,6 +48,7 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(AboutDto))]
[JsonSerializable(typeof(NodeBuildDto))]
[JsonSerializable(typeof(NodeBinaryDto))]
[JsonSerializable(typeof(NodeOwnerDto))]
[JsonSerializable(typeof(BuildInfoDto))]
[JsonSerializable(typeof(BackendUpdateDto))]
[JsonSerializable(typeof(ConfirmBackendUpdateRequest))]
@@ -82,6 +83,8 @@ namespace MoShiro.Backend.Api;
[JsonSerializable(typeof(PasskeyLoginRequest))]
[JsonSerializable(typeof(Fleet.Core.SignedStatementDto), TypeInfoPropertyName = "FleetSignedStatement")]
[JsonSerializable(typeof(TrustStateDto))]
[JsonSerializable(typeof(SetupStatusDto))]
[JsonSerializable(typeof(SetupRequest))]
[JsonSerializable(typeof(ProposedCredentialDto))]
[JsonSerializable(typeof(PasskeyDto))]
[JsonSerializable(typeof(PageDto<PasskeyDto>))]
@@ -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);
}
}
@@ -27,10 +27,12 @@ public sealed partial class AgentService
private readonly TrustService _trust;
private readonly BinaryBindingService _bindings;
private readonly NodeOwnerService _owners;
public AgentService(BinaryBindingService bindings, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
public AgentService(BinaryBindingService bindings, NodeOwnerService owners, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TaskPlanner planner, ISettingsStore settings, NodeInfoService nodes, LinkService links, TrustService trust)
{
_bindings = bindings;
_owners = owners;
_trust = trust;
_links = links;
_nodes = nodes;
@@ -151,6 +153,7 @@ public sealed partial class AgentService
await _agents.DeleteAsync(id, ct);
await _nodes.ForgetAsync(id.Value, ct);
await _bindings.ForgetAsync(id.Value, ct);
_owners.Forget(id.Value);
await _links.ForgetNodeAsync(id.Value, ct);
_runtime.Remove(id);
await AfterAgentSetChangedAsync(ct);
@@ -69,9 +69,11 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
private readonly ReleaseService _releases;
private readonly ScheduleService _schedule;
private readonly IProbeStore _probes;
private readonly NodeOwnerService _owners;
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes)
public MoShiroNodeEventSink(IAgentStore agents, AgentRuntime runtime, IEventStore events, LiveHub live, AgentService service, NodeInfoService nodes, ReleaseService releases, ScheduleService schedule, IProbeStore probes, NodeOwnerService owners)
{
_owners = owners;
_releases = releases;
_schedule = schedule;
_probes = probes;
@@ -148,6 +150,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
{
ProbeHelloMeta? meta = raw.Deserialize(MoShiroJsonContext.Default.ProbeHelloMeta);
await _nodes.SetPlatformAsync(id.Value, NodeKinds.Probe, meta?.Platform, ct);
_owners.Report(id.Value, meta?.Owners);
if (meta?.Ipv6 is { } ipv6)
{
await _probes.SetIpv6DiagnosticsAsync(id.Value, JsonSerializer.Serialize(ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic), ct);
@@ -229,6 +232,7 @@ public sealed class MoShiroNodeEventSink : INodeEventSink
}
}
_owners.Report(id.Value, meta.Owners);
AgentRecord? before = await _agents.GetAsync(id, ct);
string? limitsJson = meta.LocalLimits is null ? null : JsonSerializer.Serialize(meta.LocalLimits, LocalLimitsJson.Default.LocalLimits);
string? ipv6Json = meta.Ipv6 is null ? null : JsonSerializer.Serialize(meta.Ipv6, MoShiroJsonContext.Default.Ipv6Diagnostic);
@@ -0,0 +1,68 @@
using System.Collections.Concurrent;
namespace MoShiro.Backend.Application;
/// <summary>节点的一个属主:它报上来的 passkey 凭证 id,以及面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。</summary>
public sealed record NodeOwner(string CredentialId, string? PasskeyName, string? Username);
/// <summary>
/// 节点的属主:Agent 和 Probe 握手时报上来的、安装时固定在节点本地的 passkey 凭证 id。
/// <b>属主的强制在节点本地</b>(没有属主的节点不运行,见 <c>OwnerGuard</c>),面板这边只是顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上——
/// 以后给节点更多的接口和操作(extension 会话、加密内容)时,据此判断谁有资格用。上报的只是自述,真正的授权由节点自己对 passkey 签名的校验把关。
/// 在内存里:节点每次连上来都会重新报。
/// </summary>
public sealed class NodeOwnerService
{
private const int MaxOwners = 16;
private const int MaxCredentialIdLength = 256;
private readonly ConcurrentDictionary<string, string[]> _reported = new(StringComparer.Ordinal);
private readonly IAuthStore _auth;
public NodeOwnerService(IAuthStore auth)
{
_auth = auth;
}
public void Report(string nodeId, IReadOnlyList<string>? credentialIds)
{
string[] clean = credentialIds is null ? [] : [.. credentialIds.Where(id => id.Length is > 0 and <= MaxCredentialIdLength).Distinct(StringComparer.Ordinal).Take(MaxOwners)];
if (clean.Length == 0)
{
_reported.TryRemove(nodeId, out _);
}
else
{
_reported[nodeId] = clean;
}
}
public void Forget(string nodeId) => _reported.TryRemove(nodeId, out _);
/// <summary>每个节点的属主(只含报过的节点)。</summary>
public async ValueTask<IReadOnlyDictionary<string, IReadOnlyList<NodeOwner>>> ListAsync(CancellationToken ct)
{
var result = new Dictionary<string, IReadOnlyList<NodeOwner>>(StringComparer.Ordinal);
var users = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach ((string nodeId, string[] ids) in _reported)
{
var owners = new List<NodeOwner>();
foreach (string id in ids)
{
PasskeyRecord? passkey = await _auth.FindPasskeyAsync(id, ct);
string? username = null;
if (passkey is not null && !users.TryGetValue(passkey.UserId, out username))
{
username = (await _auth.GetUserAsync(passkey.UserId, ct))?.Username;
users[passkey.UserId] = username;
}
owners.Add(new NodeOwner(id, passkey?.Name, username));
}
result[nodeId] = owners;
}
return result;
}
}
@@ -30,10 +30,12 @@ public sealed partial class ProbeService
private readonly LinkService _links;
private readonly BinaryBindingService _bindings;
private readonly NodeOwnerService _owners;
public ProbeService(BinaryBindingService bindings, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
public ProbeService(BinaryBindingService bindings, NodeOwnerService owners, IProbeStore probes, IAgentStore agents, AgentRuntime runtime, IAgentGateway gateway, TimeProvider time, NodeInfoService nodes, LinkService links)
{
_bindings = bindings;
_owners = owners;
_links = links;
_nodes = nodes;
_probes = probes;
@@ -98,6 +100,7 @@ public sealed partial class ProbeService
{
await _nodes.ForgetAsync(id, ct);
await _bindings.ForgetAsync(id, ct);
_owners.Forget(id);
await _links.ForgetNodeAsync(id, ct);
_gateway.NotifyAllTaskSetsChanged();
}
@@ -38,7 +38,9 @@ public static class ApplicationServiceCollectionExtensions
services.AddSingleton<PasskeyService>();
services.AddSingleton<AuditService>();
services.AddSingleton<ServerIdentityService>();
services.AddSingleton<SetupService>();
services.AddSingleton<BinaryBindingService>();
services.AddSingleton<NodeOwnerService>();
services.AddSingleton<Fleet.Server.IBinaryAttestor>(sp => sp.GetRequiredService<BinaryBindingService>());
services.AddSingleton<Fleet.Server.IServerIdentity>(sp => sp.GetRequiredService<ServerIdentityService>());
services.AddSingleton<TrustService>();
@@ -0,0 +1,99 @@
using System.Security.Cryptography;
using Fleet.Core;
using Microsoft.Extensions.Logging;
using MoShiro.Protocol;
namespace MoShiro.Backend.Application;
/// <summary>
/// 首次认领:面板里还没有任何用户时,由部署的人自己填管理员的用户名和密码——没有默认账户,也没有默认密码。
/// <para>
/// 谁先打开页面谁就能当管理员,所以要有一个只有部署的人才拿得到的<b>认领码</b>:首次启动时随机生成并写进后端的日志
/// (<c>journalctl -u moshiro-backend</c> / <c>podman logs</c>),或者用 <c>MoShiro__ClaimCode</c> 自己指定。
/// 创建完第一个管理员,认领码立刻作废,这个入口也就关闭了。
/// </para>
/// </summary>
public sealed partial class SetupService : IDisposable
{
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
private const int CodeLength = 16;
private readonly AuthService _auth;
private readonly IAuthStore _store;
private readonly BackendOptions _options;
private readonly ILogger<SetupService> _log;
private readonly SemaphoreSlim _gate = new(1, 1);
private string? _code;
public SetupService(AuthService auth, IAuthStore store, BackendOptions options, ILogger<SetupService> log)
{
_auth = auth;
_store = store;
_options = options;
_log = log;
}
public void Dispose() => _gate.Dispose();
/// <summary>面板里还没有任何用户。</summary>
public async ValueTask<bool> NeedsSetupAsync(CancellationToken ct) => await _store.CountUsersAsync(ct) == 0;
/// <summary>启动时调用:需要初始化就准备好认领码,并写进日志(只有能看到日志的人才拿得到)。</summary>
public async ValueTask AnnounceAsync(CancellationToken ct)
{
if (await NeedsSetupAsync(ct))
{
LogClaimCode(_log, _options.PublicUrl ?? "the panel's address", Code());
}
}
public async ValueTask<Result<LoginResult>> CompleteAsync(string? claimCode, string username, string password, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
if (!await NeedsSetupAsync(ct))
{
return Result<LoginResult>.Fail(MoShiroErrors.NameConflict, "the panel has already been claimed");
}
if (claimCode is null || !AuthService.FixedTimeEquals(Normalize(claimCode), Normalize(Code())))
{
return Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "the claim code is not valid (see the backend's log)");
}
Result<UserRecord> created = await _auth.CreateUserAsync(username, password, ct);
if (!created.TryGet(out _, out FleetError? error))
{
return Result<LoginResult>.Fail(error);
}
_code = null; // 一次有效:第一个管理员创建完,这个入口就关了
return await _auth.LoginAsync(username, password, ct);
}
finally
{
_gate.Release();
}
}
private string Code() => _code ??= string.IsNullOrWhiteSpace(_options.ClaimCode) ? Generate() : _options.ClaimCode.Trim();
private static string Generate()
{
char[] chars = new char[CodeLength];
for (int i = 0; i < chars.Length; i++)
{
chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
}
string raw = new(chars);
return string.Join('-', Enumerable.Range(0, CodeLength / 4).Select(g => raw.Substring(g * 4, 4)));
}
/// <summary>比较时不分大小写、忽略分隔符和空格。</summary>
private static string Normalize(string code) => new(code.Where(char.IsLetterOrDigit).Select(char.ToUpperInvariant).ToArray());
[LoggerMessage(Level = LogLevel.Warning, Message = "First start: no administrator exists yet. Open {Address} and finish the setup there with this claim code: {Code}")]
private static partial void LogClaimCode(ILogger logger, string address, string code);
}
@@ -15,7 +15,7 @@ public sealed record ProposedCredential(string Id, string Name, long CreatedAtMs
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
/// <param name="ServerKey">这个面板的服务器身份密钥的指纹;<c>ServerKeyListed</c>:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。</param>
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false);
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwner>? OperatorDetails = null);
/// <summary>
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
@@ -56,7 +56,16 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
TrustProposal? proposal = await ProposeAsync(chain, current, context, ct);
OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement);
bool inSync = current is not null && proposed is not null && Same(current, proposed);
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true);
// 每个操作员是哪个账户的哪把 passkey:添加节点时选属主、信任页都要显示
var details = new List<NodeOwner>();
foreach (OperatorKey key in current?.Keys ?? [])
{
PasskeyRecord? passkey = await _auth.FindPasskeyAsync(key.CredentialId, ct);
string? account = passkey is null ? null : (await _auth.GetUserAsync(passkey.UserId, ct))?.Username;
details.Add(new NodeOwner(key.CredentialId, passkey?.Name, account));
}
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true, details);
}
private async ValueTask<TrustProposal?> ProposeAsync(IReadOnlyList<SignedStatementDto> chain, OperatorSet? current, PasskeyContext context, CancellationToken ct)
@@ -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; }
@@ -102,6 +108,12 @@ public sealed class BackendOptions
/// </summary>
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
/// <summary>
/// 首次认领的认领码(可选):面板里还没有任何用户时,第一个管理员由部署的人自己在页面上填,需要这个码才能创建。
/// 不设置就在首次启动时随机生成并写进后端的日志。创建完第一个管理员后作废。
/// </summary>
public string? ClaimCode { get; set; }
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
public string? InitialAdminUser { get; set; }
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Hosting;
namespace MoShiro.Backend.Application;
/// <summary>
/// 启动时最先执行:截断没有索引记录的段文件尾部、创建初始管理员、创建默认的线路任务模板。
/// 启动时最先执行:截断没有索引记录的段文件尾部、(用配置里的凭据创建管理员,没有的话把首次认领码写进日志)创建默认的线路任务模板。
/// 宿主按注册顺序依次等待各个 IHostedService 启动,所以它需要最先注册。
/// </summary>
public sealed class StartupTasks : IHostedService
@@ -12,9 +12,11 @@ public sealed class StartupTasks : IHostedService
private readonly AuthService _auth;
private readonly TaskService _tasks;
private readonly ServerIdentityService _identity;
private readonly SetupService _setup;
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity)
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity, SetupService setup)
{
_setup = setup;
_identity = identity;
_raw = raw;
_auth = auth;
@@ -25,6 +27,7 @@ public sealed class StartupTasks : IHostedService
{
await _raw.RecoverAsync(cancellationToken);
await _auth.EnsureInitialAdminAsync(cancellationToken);
await _setup.AnnounceAsync(cancellationToken);
await _tasks.SeedDefaultsAsync(cancellationToken);
await _identity.EnsureAsync(cancellationToken);
}
@@ -1,3 +1,4 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json.Nodes;
@@ -25,6 +26,9 @@ public class BackendFixture : IAsyncLifetime
public const string AdminUser = "admin";
public const string AdminPassword = "correct horse battery";
/// <summary>首次认领码:和线上一样,第一个管理员通过 <c>/api/admin/auth/setup</c> 创建,不再用配置里的默认账户。</summary>
public const string ClaimCode = "TEST-CLAIM-CODE-0001";
private readonly List<AgentSimulator> _agents = [];
private string _adminToken = "";
private int _counter;
@@ -256,9 +260,9 @@ public class BackendFixture : IAsyncLifetime
options.SecureCookies = false;
options.WindowGrace = TimeSpan.Zero;
options.AnalysisPollInterval = TimeSpan.FromMilliseconds(100);
options.InitialAdminUser = AdminUser;
options.InitialAdminPassword = AdminPassword;
options.ClaimCode = ClaimCode;
options.LoginAttemptsPerMinute = LoginAttemptsPerMinute;
options.RequireOwner = false; // 大部分测试不建立信任根;属主必选的测试自己打开(OwnerTests)
options.GeoLookupUrl = string.Empty; // 测试里不访问外部服务
options.PhaseDurationScale = 0.2; // 端到端测试里 connect、hold 这些固定 30 秒的阶段缩成 6 秒
});
@@ -281,8 +285,8 @@ public class BackendFixture : IAsyncLifetime
{
using HttpClient session = NewClient();
using HttpResponseMessage login = await session.PostAsync(
"/api/admin/auth/login", Json(new JsonObject { ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
login.EnsureSuccessStatusCode();
"/api/admin/auth/setup", Json(new JsonObject { ["claim_code"] = ClaimCode, ["username"] = AdminUser, ["password"] = AdminPassword, ["return_session"] = true }));
Assert.Equal(HttpStatusCode.Created, login.StatusCode);
session.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", JsonNode.Parse(await login.Content.ReadAsStringAsync())!["session_token"]!.GetValue<string>());
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
@@ -36,7 +36,7 @@ public sealed class CapabilityTests
}
// 只有登录(密码和 passkey)是匿名的
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options", "/api/admin/auth/setup"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
}
[Fact]
@@ -122,7 +122,7 @@ public sealed partial class OpenApiConformanceTests
public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures()
{
JsonObject doc = await DocumentAsync();
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options")))
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options" or "/api/admin/auth/setup")))
{
JsonObject responses = operation["responses"]!.AsObject();
Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401");
@@ -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);
}
}
}
@@ -0,0 +1,149 @@
using System.Net;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using MoShiro.Backend.Application;
namespace MoShiro.Backend.Tests;
/// <summary>
/// 首次认领:面板里没有任何用户时,部署的人自己填管理员的用户名和密码——没有默认账户、没有默认密码。
/// 谁先打开页面谁就能当管理员,所以要有只有部署的人才拿得到的认领码(写在后端日志里,或者部署时指定);创建完第一个管理员,入口就关了。
/// </summary>
public sealed class SetupTests : IAsyncLifetime
{
private const string Code = "ABCD-EFGH-JKLM-NPQR";
private WebApplication _app = null!;
private HttpClient _http = null!;
private string _directory = "";
public async ValueTask InitializeAsync()
{
_directory = Path.Combine(Path.GetTempPath(), "moshiro-setup-" + Guid.NewGuid().ToString("N"));
_app = BackendHost.Build(
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
options =>
{
options.DataDirectory = _directory;
options.Pbkdf2Iterations = 1000;
options.SecureCookies = false;
options.ClaimCode = Code;
options.LoginAttemptsPerMinute = 1000;
options.GeoLookupUrl = string.Empty;
});
await _app.StartAsync();
_http = new HttpClient { BaseAddress = new Uri(_app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
}
public async ValueTask DisposeAsync()
{
_http.Dispose();
await _app.StopAsync();
await _app.DisposeAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); // 连接池还拿着数据库文件
Directory.Delete(_directory, recursive: true);
}
private Task<HttpResponseMessage> SetupAsync(string? code, string username, string password, bool returnSession = false) =>
_http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = code, ["username"] = username, ["password"] = password, ["return_session"] = returnSession }));
[Fact]
public async Task ABrandNewPanel_HasNoUsers_AndNoDefaultAccountToSignInWith()
{
JsonNode status = JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!;
Assert.True(status["needs_setup"]!.GetValue<bool>());
foreach (string username in new[] { "admin", "administrator", "root" })
{
using HttpResponseMessage login = await _http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = username, ["password"] = "admin" }));
Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode);
}
}
[Fact]
public async Task TheClaimCode_IsRequired_SoTheFirstVisitorCannotTakeThePanel()
{
using HttpResponseMessage none = await SetupAsync(null, "owner", "correct horse battery");
using HttpResponseMessage wrong = await SetupAsync("AAAA-BBBB-CCCC-DDDD", "owner", "correct horse battery");
using HttpResponseMessage empty = await SetupAsync("", "owner", "correct horse battery");
Assert.Equal(HttpStatusCode.Unauthorized, none.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized, empty.StatusCode);
Assert.True(JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>()); // 还是没人
}
[Fact]
public async Task TheCredentialsAreValidated_AndAMistakeDoesNotBurnTheCode()
{
using HttpResponseMessage shortPassword = await SetupAsync(Code, "owner", "short");
using HttpResponseMessage badName = await SetupAsync(Code, "x", "correct horse battery");
Assert.Equal(HttpStatusCode.BadRequest, shortPassword.StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, badName.StatusCode);
// 填错了还能再来
using HttpResponseMessage ok = await SetupAsync(Code, "owner", "correct horse battery");
Assert.Equal(HttpStatusCode.Created, ok.StatusCode);
}
[Fact]
public async Task TheFirstAdministrator_IsCreatedByTheDeployer_SignedInRightAway_AndTheDoorCloses()
{
// 认领码不分大小写、不看分隔符
using HttpResponseMessage created = await SetupAsync("abcd efgh-jklm npqr", "owner", "correct horse battery", returnSession: true);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
Assert.Equal("owner", body["username"]!.GetValue<string>());
Assert.False(string.IsNullOrEmpty(body["session_token"]?.GetValue<string>())); // 马上登录,接着引导 passkey 和信任根
// 入口关了:再用同一个码、别的用户名也不行,状态也变了
using HttpResponseMessage again = await SetupAsync(Code, "intruder", "correct horse battery");
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
Assert.False(JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>());
// 管理员能用自己填的账号密码正常登录(密码登录的会话有能力,passkey 之后再加)
using HttpResponseMessage login = await _http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "owner", ["password"] = "correct horse battery" }));
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
}
[Fact]
public async Task WithoutAConfiguredCode_TheBackendGeneratesOne_AndNothingElseUnlocksTheSetup()
{
// 另起一个没有指定认领码的后端:随机生成并写进日志,猜不到;任何别的码都进不去
string directory = Path.Combine(Path.GetTempPath(), "moshiro-setup-" + Guid.NewGuid().ToString("N"));
await using WebApplication generated = BackendHost.Build(
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
options =>
{
options.DataDirectory = directory;
options.Pbkdf2Iterations = 1000;
options.SecureCookies = false;
options.LoginAttemptsPerMinute = 1000;
options.GeoLookupUrl = string.Empty;
});
await generated.StartAsync();
try
{
using var http = new HttpClient { BaseAddress = new Uri(generated.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
foreach (string guess in new[] { Code, "0000-0000-0000-0000", "admin", "TEST-CLAIM-CODE-0001" })
{
using HttpResponseMessage response = await http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = guess, ["username"] = "owner", ["password"] = "correct horse battery" }));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
Assert.True(JsonNode.Parse(await http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>());
}
finally
{
await generated.StopAsync();
await generated.DisposeAsync();
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
Directory.Delete(directory, recursive: true);
}
}
}
@@ -250,6 +250,44 @@ public sealed class TrustTests
Assert.Equal(HttpStatusCode.Created, code.StatusCode);
}
[Fact]
public async Task TheTrustState_SaysWhichAccountsPasskeyEachOperatorIs()
{
// 添加节点时选属主、信任页都要显示「哪个账户的哪把 passkey」,不只是一串凭证 id
Operator op = await OperatorAsync();
JsonNode state = JsonNode.Parse(await op.Session.GetStringAsync("/api/admin/trust"))!;
JsonNode detail = state["operator_details"]!.AsArray().First(d => d!["credential_id"]!.GetValue<string>() == op.Passkey.Id)!;
Assert.False(string.IsNullOrEmpty(detail["passkey"]?.GetValue<string>()));
Assert.False(string.IsNullOrEmpty(detail["account"]?.GetValue<string>()));
Assert.Equal(state["operators"]!.AsArray().Count, state["operator_details"]!.AsArray().Count);
}
[Fact]
public async Task APinnedAgent_ReportsItsOwner_AndThePanelFindsTheAccountAndPasskey()
{
// 宿主(属主)要求在节点这一侧:节点把安装时固定的属主报上来,面板顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上
Operator op = await OperatorAsync();
(string code, _, _) = await CreateCodeAsync(role: "agent", controllers: [op.Passkey.Id]);
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, pin: (code, op.Genesis, [op.Passkey.Id]));
JsonNode? owner = null;
await BackendFixture.EventuallyAsync(
async () =>
{
JsonNode info = await _backend.GetJsonAsync(_backend.Admin, "/api/admin/build-info");
owner = info["nodes"]!.AsArray().FirstOrDefault(n => n!["id"]!.GetValue<string>() == agent.Id)?["owners"]?.AsArray().FirstOrDefault();
return owner is not null;
},
TimeSpan.FromSeconds(30),
"the agent reports its owner in hello");
Assert.Equal(op.Passkey.Id, owner!["credential_id"]!.GetValue<string>());
Assert.False(string.IsNullOrEmpty(owner["passkey"]?.GetValue<string>()));
Assert.False(string.IsNullOrEmpty(owner["account"]?.GetValue<string>()));
}
[Fact]
public async Task TheProposal_ListsEveryNewPasskey_SoTheSignerSeesWhatTheyAreAuthorizing()
{
+2 -2
View File
@@ -11,8 +11,8 @@ services:
MoShiro__DataDirectory: /data
MoShiro__UpdateChannel: ${MOSHIRO_UPDATE_CHANNEL:-auto}
MoShiro__ImageNamespace: ${MOSHIRO_IMAGE_NAMESPACE:-moshiro}
MoShiro__InitialAdminUser: ${MOSHIRO_ADMIN_USER:-admin}
MoShiro__InitialAdminPassword: "${MOSHIRO_ADMIN_PASSWORD:?Set MOSHIRO_ADMIN_PASSWORD in the environment}"
# 没有默认管理员:第一次打开面板时按页面引导自己创建(需要后端日志里的认领码,也可以在这里指定 MOSHIRO_CLAIM_CODE)
MoShiro__ClaimCode: "${MOSHIRO_CLAIM_CODE:-}"
volumes:
- moshiro-backend-data:/data
- moshiro-backend-status:/data/update-status:ro
+7 -4
View File
@@ -97,7 +97,7 @@
| OIDC | L1 会话 | 账户绑定 `iss + sub`,登录时按它找账户,不自动建账户。放在 passkey 之后做 |
- 密码和 OIDC 会话可以管理(有能力),但**进不了信任域**:节点验证不了 IdP 的断言,所以它们改不了操作员集合。
- **引导**:初始管理员用密码登录,注册第一个 passkey,再在「系统 → 信任」用它给操作员集合签名建立信任根。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认(见第 7 节)。
- **引导(认领向导,没有默认账户)**:面板里还没有任何用户时,打开 `/admin/` 进向导——用后端日志里的认领码(或 `MoShiro__ClaimCode`)创建第一个管理员(用户名和密码自己填),注册第一个 passkey,再用它给操作员集合签名建立信任根。谁先打开页面谁就能当管理员,所以要认领码;创建完码作废、入口关闭(`POST /api/admin/auth/setup` 之后返回 409)。后两步是引导不是门槛,可以稍后补,没有 passkey 也能用密码登录管理。之后新增的 passkey 不会自动成为操作员:要由现有操作员在信任页看清「新增了哪些」再签新版本,节点才认(见第 7 节)。
- 建议每个账户至少注册两个 passkey(手机加硬件密钥)。
- **离线恢复密钥**:创建 owner 时同时生成一把离线的 ECDSA 恢复密钥,作为 `type=ecdsa`、`recovery=true` 的 owner 凭证写进操作员集合;私钥打印或放进密码管理器,平时不用。节点强制它**只能签操作员集合更新**,不能签委托、入网券和吊销表,所以即使被偷走,也加不了节点;它的用处是 passkey 全部丢失时签一版新的操作员集合、重新加 passkey,节点不用重装。
@@ -284,12 +284,15 @@
### 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 都必须有属主,至少一把——而且这是节点自己的要求,不是 Backend 的要求**:节点启动时检查本地固定的属主,没有(或者属主的 passkey 已经不在操作员集合里)就不运行(Agent 和 Probe 共用 `Fleet.Hosting` 里的 `OwnerGuard`;配置 `fleet.require_owner`,`MOSHIRO_ALLOW_NO_OWNER=1` 放行)。面板被攻破也改不了节点本地的属主。属主是节点敏感内容(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` 可以关掉这个提前检查(只给没有信任根的老部署)。
- **逆推出绑定关系**:节点握手时把本地固定的属主(passkey 凭证 id)报给面板(hello 的 `owners`),面板顺着凭证 id 反查出它绑定在**哪个账户的哪把 passkey** 上,显示在「系统 → 版本」的「属主」列(对不上任何已注册 passkey 的显示「未知凭证」)。上报只是自述,真正的授权由节点自己校验 passkey 签名;以后给节点更多的接口和操作(extension 会话、加密内容)时,据此决定谁有资格用,不会越权到别人的节点。
- 界面:「添加 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
+7 -1
View File
@@ -42,12 +42,18 @@ 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_ADMIN_PASSWORD`(可选设置 `MOSHIRO_ADMIN_USER`、`MOSHIRO_VERSION`),然后启动:
在部署目录保存此仓库的 `compose.yaml`,可选设置 `MOSHIRO_CLAIM_CODE`(认领码,不设置就随机生成并写进后端日志)和 `MOSHIRO_VERSION`,然后启动:
```sh
docker compose up -d
```
**首次认领(没有默认管理员)**:面板里还没有任何用户时,打开 `/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` 也可以用同一份文件)。首次启动成功创建管理员后,改密应在管理端完成。
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 文档在普通开发构建中生成。
+8 -8
View File
@@ -4,7 +4,8 @@
#
# 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1
# 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --server https://panel --join jt_xxx --name home-1
# 首次部署 Backend:sudo sh deploy-node.sh backend --channel dev --admin-password '…' [--public-url https://panel] [--port 29181] [--env KEY=VALUE]...
# 首次部署 Backend:sudo sh deploy-node.sh backend --channel dev [--public-url https://panel] [--port 29181] [--env KEY=VALUE]...
# 没有默认管理员:部署完打开面板,按页面引导自己创建管理员、注册 passkey、建立信任根;页面要的认领码在后端日志里(journalctl -u moshiro-backend)。
# 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置)
# 旧版 Probe(probe.json 里没有 fleet 段):加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old
#
@@ -16,7 +17,7 @@ set -eu
usage() {
echo "usage: deploy-node.sh <agent|probe|backend> [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2
echo " [--server URL --join TOKEN --name NAME] [--policy auto|manual] [--authfile F]" >&2
echo " backend: [--channel dev|release] [--port N] [--admin-user U] [--admin-password P] [--public-url URL] [--env KEY=VALUE]..." >&2
echo " backend: [--channel dev|release] [--port N] [--admin-user U --admin-password P:只给自动化用,正常不要] [--public-url URL] [--env KEY=VALUE]..." >&2
exit 2
}
@@ -143,18 +144,17 @@ if [ "$ROLE" = backend ]; then
# Backend 的环境文件放在宿主机上(单元用 --env-file 读取);已有的不会被覆盖
install -d -m 755 /etc/moshiro
if [ ! -f /etc/moshiro/backend.env ]; then
if [ -z "$ADMIN_PASSWORD" ]; then
ADMIN_PASSWORD="$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 20)"
echo "generated admin password (shown once): $ADMIN_PASSWORD"
fi
(
umask 077
{
echo "MoShiro__DataDirectory=/data"
echo "MoShiro__UpdateChannel=$CHANNEL"
echo "MoShiro__ImageNamespace=$NAMESPACE"
echo "MoShiro__InitialAdminUser=${ADMIN_USER:-admin}"
echo "MoShiro__InitialAdminPassword=$ADMIN_PASSWORD"
# 没有默认管理员:第一个管理员由部署的人在首次打开的页面上自己填,认领码在后端日志里(--admin-password 只给自动化用)
if [ -n "$ADMIN_PASSWORD" ]; then
echo "MoShiro__InitialAdminUser=${ADMIN_USER:-admin}"
echo "MoShiro__InitialAdminPassword=$ADMIN_PASSWORD"
fi
[ -z "$PUBLIC_URL" ] || echo "MoShiro__PublicUrl=$PUBLIC_URL"
# 其他设置原样写入,例如 --env MoShiro__CorsOrigins=http://localhost:5173 --env MoShiro__SecureCookies=false
[ -z "$EXTRA_ENV" ] || printf '%s' "$EXTRA_ENV"
+7
View File
@@ -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" \
+5
View File
@@ -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 }
+4
View File
@@ -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:-}"
+3 -1
View File
@@ -8,6 +8,7 @@ import { describeError, Field, Segmented, SourceButton, Spinner, ThemeToggle } f
import { useAuth } from './auth'
import { BackendUpdateNotice } from './BackendUpdateNotice'
import { PasskeyNotice } from './PasskeyNotice'
import { Setup } from './Setup'
import { AgentDetail } from './pages/AgentDetail'
import { Agents } from './pages/Agents'
import { Probes } from './pages/Probes'
@@ -232,7 +233,7 @@ const NAV = [
]
export function App() {
const { me, ready, logout } = useAuth()
const { me, ready, logout, setupActive } = useAuth()
if (!ready) {
return (
<div className="login">
@@ -240,6 +241,7 @@ export function App() {
</div>
)
}
if (setupActive) return <Setup />
if (!me) return <Login />
// 密码登录的会话没有任何能力:只给它添加 Passkey 的页面
+1 -1
View File
@@ -54,7 +54,7 @@ export function PasskeyNotice() {
<div className="row wrap">
<KeyRound size={16} />
<span>
<b>建议添加 Passkey</b>:用指纹、人脸或安全密钥登录,不用输密码;以后加节点这类敏感操作也需要它。
<b>还没有 Passkey</b>:不注册 Passkey、不建立信任根就无法添加 Agent 和 Probe,现在只能做基础管理(架构如此:节点只信任由 Passkey 签名的信任根)。Passkey 也可以用来登录,不用输密码。
</span>
<span className="right row">
<button type="button" className="btn small text" onClick={later}>
+125
View File
@@ -0,0 +1,125 @@
import { Check, KeyRound, ShieldCheck, UserPlus } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import { ApiError } from '@shared/api/client'
import { passkeySupported } from '@shared/api/webauthn'
import { describeError, Field } from '@shared/ui'
import { useAuth } from './auth'
import { Trust } from './pages/Trust'
import { PasskeyAddForm } from './pages/PasskeyAdd'
const STEPS = [
{ icon: UserPlus, label: '认领面板' },
{ icon: KeyRound, label: '注册 Passkey' },
{ icon: ShieldCheck, label: '建立信任根' },
]
/**
* 认领向导:面板里还没有任何用户时出现。没有默认账户,管理员的用户名和密码由部署的人自己填;
* 谁先打开页面谁就能当管理员,所以第一步要后端日志里的认领码(只有部署的人拿得到)。
* 后两步是引导,不是门槛:之后随时可以在「用户 → 我的 Passkey」和「系统 → 信任」里补,没有 passkey 也能用密码登录管理。
*/
export function Setup() {
const { me, completeSetup, finishSetup } = useAuth()
const [step, setStep] = useState(me ? 1 : 0)
const [trusted, setTrusted] = useState(false)
return (
<div className="login">
<div className="card stack" style={{ maxWidth: 640, width: '100%' }}>
<div>
<h1>MoShiro</h1>
<p className="muted">第一次打开:先认领这个面板——建好管理员、Passkey 和信任根。</p>
</div>
<ol className="row" style={{ gap: 16, listStyle: 'none', padding: 0, margin: 0, flexWrap: 'wrap' }}>
{STEPS.map((s, i) => (
<li key={s.label} className="row" style={{ gap: 6, opacity: i === step ? 1 : 0.55, fontWeight: i === step ? 600 : 400 }}>
{i < step ? <Check size={16} /> : <s.icon size={16} />}
{i + 1}. {s.label}
</li>
))}
</ol>
{step === 0 ? <Account onDone={() => setStep(1)} complete={completeSetup} /> : null}
{step === 1 ? (
<div className="stack">
<p className="muted">
Passkey 是整个系统的根:它给「操作员集合」签名,节点只认从它出发的信任根,以后 web ssh 等敏感操作也只认它。
<b>不注册 Passkey、不建立信任根就无法使用这些功能:添加不了 Agent 和 Probe,只能进入面板做基础管理</b>,这是架构如此,面板自己没法替你代签。
{passkeySupported() ? '' : ' 这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。'}
</p>
<PasskeyAddForm compact onAdded={() => setStep(2)} />
<div>
<button type="button" className="btn text" onClick={finishSetup}>
跳过,只进入面板做基础管理
</button>
</div>
</div>
) : null}
{step === 2 ? (
<div className="stack">
<Trust onInitialized={() => setTrusted(true)} />
<div>
<button type="button" className="btn primary" onClick={finishSetup}>
{trusted ? '完成,进入面板' : '跳过,只进入面板做基础管理(之后到「系统 → 信任」建立)'}
</button>
</div>
</div>
) : null}
</div>
</div>
)
}
function Account({ complete, onDone }: { complete: (code: string, username: string, password: string) => Promise<void>; onDone: () => void }) {
const [code, setCode] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (e: FormEvent) => {
e.preventDefault()
if (password !== confirm) {
setError('两次输入的密码不一样')
return
}
setBusy(true)
setError('')
try {
await complete(code, username, password)
onDone()
} catch (err) {
if (err instanceof ApiError && err.status === 401) setError('认领码不对:它写在后端的日志里(journalctl -u moshiro-backend,或 docker logs)')
else if (err instanceof ApiError && err.status === 409) setError('这个面板已经被认领过了,请直接登录(刷新页面)')
else setError(describeError(err))
} finally {
setBusy(false)
}
}
return (
<form className="stack" onSubmit={submit}>
<Field label="认领码" hint="用来证明你是部署这个面板的人(防止别人抢先认领它)。首次启动时写在后端日志里(journalctl -u moshiro-backend 或 docker logs),或者部署时用 MoShiro__ClaimCode 指定。只有部署的人拿得到。">
<input className="input mono" value={code} onChange={(e) => setCode(e.target.value)} placeholder="XXXX-XXXX-XXXX-XXXX" autoComplete="off" autoFocus required />
</Field>
<Field label="管理员用户名" hint="3–32 个字符:字母、数字、. _ -">
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required />
</Field>
<Field label="密码" hint="至少 12 个字符">
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" required />
</Field>
<Field label="再输一遍密码">
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" required />
</Field>
{error ? <div className="notice bad">{error}</div> : null}
<div>
<button className="btn primary" type="submit" disabled={busy}>
认领面板
</button>
</div>
</form>
)
}
+31 -4
View File
@@ -1,7 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
import { api, ApiError, loginTo, setCsrf } from '@shared/api/client'
import { getBase, normalizeBase, setSource, setToken } from '@shared/api/source'
import type { LoginResponse, MeDto } from '@shared/api/types'
import type { LoginResponse, MeDto, SetupStatusDto } from '@shared/api/types'
import { signInWithPasskey } from '@shared/api/webauthn'
interface Auth {
@@ -12,6 +12,12 @@ interface Auth {
/** passkey 登录(只对页面所在的后端;passkey 绑定在后端的域名上)。 */
loginWithPasskey: (opts?: { conditional?: boolean; signal?: AbortSignal }) => Promise<void>
logout: () => Promise<void>
/** 面板里还没有任何用户:显示认领向导(创建管理员 → 注册 passkey → 建立信任根)。向导走完(或离开)之前一直为真。 */
setupActive: boolean
/** 向导第一步:用后端日志里的认领码创建第一个管理员,并马上以他登录。 */
completeSetup: (claimCode: string, username: string, password: string) => Promise<void>
/** 离开向导,进入面板。 */
finishSetup: () => void
}
const AuthContext = createContext<Auth | null>(null)
@@ -19,6 +25,7 @@ const AuthContext = createContext<Auth | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [me, setMe] = useState<MeDto | null>(null)
const [ready, setReady] = useState(false)
const [setupActive, setSetupActive] = useState(false)
useEffect(() => {
api
@@ -27,8 +34,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setCsrf(m.csrf_token ?? null)
setMe(m)
})
.catch((e: unknown) => {
if (!(e instanceof ApiError) || (e.status !== 401 && e.status !== 403)) console.error(e)
.catch(async (e: unknown) => {
if (!(e instanceof ApiError) || (e.status !== 401 && e.status !== 403)) {
console.error(e)
return
}
// 没登录:如果面板里连一个用户都没有,就是第一次打开,进认领向导(只对页面所在的这个后端)
if (!getBase()) {
try {
const status = await api.get<SetupStatusDto>('/api/admin/auth/setup')
if (status.needs_setup) setSetupActive(true)
} catch {
// 取不到就当作普通登录页
}
}
})
.finally(() => setReady(true))
}, [])
@@ -61,6 +80,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setMe(await api.get<MeDto>('/api/admin/auth/me'))
}, [])
const completeSetup = useCallback(async (claimCode: string, username: string, password: string) => {
const r = await api.post<LoginResponse>('/api/admin/auth/setup', { claim_code: claimCode, username, password })
setCsrf(r.csrf_token)
setMe(await api.get<MeDto>('/api/admin/auth/me'))
}, [])
const finishSetup = useCallback(() => setSetupActive(false), [])
const logout = useCallback(async () => {
try {
await api.post('/api/admin/auth/logout')
@@ -73,7 +100,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, [])
return <AuthContext.Provider value={{ me, ready, login, loginWithPasskey, logout }}>{children}</AuthContext.Provider>
return <AuthContext.Provider value={{ me, ready, login, loginWithPasskey, logout, setupActive, completeSetup, finishSetup }}>{children}</AuthContext.Provider>
}
export function useAuth(): Auth {
+2 -2
View File
@@ -50,7 +50,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
<button
type="button"
className="btn primary"
disabled={busy}
disabled={busy || !controllers?.length}
onClick={async () => {
await run(async () => {
const token = await api.post<CreatedJoinTokenDto>('/api/admin/join-tokens', {
@@ -92,7 +92,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
</Field>
<div className="wide">
<Field label="属主(passkey)" hint="允许授权这个节点 web ssh 等敏感操作的 Passkey;只是选,不用触碰。装上后固定在节点本地,面板改不了。">
<Field label="属主(passkey)" hint="必选(至少一把):这个节点绑定到哪几个账户的哪几把 Passkey 上,它的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
<OwnerPicker value={controllers} onChange={setControllers} />
</Field>
</div>
+5 -5
View File
@@ -21,21 +21,21 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
<div className="stack">
<div className="notice warn">命令里的代码只显示这一次,请现在就复制。命令里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。</div>
<Field
label={`在要加入的机器上运行(${kind})`}
label={`容器安装(推荐):在要加入的机器上运行(${kind})`}
hint="需要 Docker 或 Podman。命令会先下载安装脚本并核对它的 SHA-256,不一致就不执行;节点名字默认取主机名;身份和配置存在命名卷里,重启、换新镜像重建容器都不用再给参数。名字想自己定,或者提示名字被占用,在命令里加上 MOSHIRO_NAME=名字(Linux 和 macOS 写在 bash 或 zsh 前面,Windows 先执行 $env:MOSHIRO_NAME='名字')。"
>
<CommandTabs
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 },
{ id: 'linux', label: 'Linux · 容器', command: commands.linux },
{ id: 'macos', label: 'macOS · 容器', command: commands.macos },
{ id: 'windows', label: 'Windows · 容器', command: commands.windows },
]}
/>
</Field>
<p className="muted">
{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
容器方式是首选:镜像按 CI 签名的清单固定到 digest,宿主机更新器能自己更新它,属主和信任根在注册时固定。{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
</p>
</div>
+13 -9
View File
@@ -1,15 +1,14 @@
import { useEffect, useState } from 'react'
import { api } from '@shared/api/client'
import type { Page, PasskeyDto, TrustStateDto } from '@shared/api/types'
import type { TrustStateDto } from '@shared/api/types'
/**
* 选这个节点的属主:允许授权它 extension 会话(web ssh 等,以后)的 passkey。只是选,不需要触碰 Passkey:
* 选这个节点的属主(必选,至少一把):节点绑定在哪几个账户的哪几把 passkey 上,它的 extension 会话(web ssh 等,以后)和加密内容只认它们。只是选,不需要触碰 Passkey:
* 选定的凭证 id 会写进安装命令,节点装上时固定在本地,之后面板改不了。默认是操作员集合里的全部。
* 还没有建立信任根时不显示选项,只提示去建立。
*/
export function OwnerPicker({ value, onChange }: { value: string[] | null; onChange: (next: string[]) => void }) {
const [trust, setTrust] = useState<TrustStateDto>()
const [own, setOwn] = useState<PasskeyDto[]>([])
useEffect(() => {
let alive = true
@@ -21,10 +20,6 @@ export function OwnerPicker({ value, onChange }: { value: string[] | null; onCha
if (value === null) onChange(t.operators ?? [])
})
.catch(() => undefined)
api
.get<Page<PasskeyDto>>('/api/admin/auth/passkeys')
.then((list) => alive && setOwn(list.items))
.catch(() => undefined)
return () => {
alive = false
}
@@ -33,11 +28,20 @@ export function OwnerPicker({ value, onChange }: { value: string[] | null; onCha
if (!trust) return null
if (!trust.initialized) {
return <div className="notice warn">还没有建立信任根:这个节点装上后不会固定属主,也不会校验面板的操作员集合。先到「系统 → 信任」用 Passkey 建立。</div>
return (
<div className="notice warn">
还没有建立信任根,所以现在无法添加 Agent 和 Probe:每个节点都要绑定到属主的 Passkey 上,节点只信任由 Passkey 签名的信任根。没有信任根时只能进入面板做基础管理,这是架构如此。请先到「系统 → 信任」注册 Passkey 并建立信任根。
</div>
)
}
const chosen = new Set(value ?? [])
const name = (id: string) => own.find((p) => p.id === id)?.name ?? `其他账号的 Passkey(${id.slice(0, 8)}…)`
const details = new Map((trust.operator_details ?? []).map((d) => [d.credential_id, d] as const))
// 绑定到哪个账户的哪把 Passkey:「Passkey 名称 · 账户」;对不上任何已注册的 Passkey 时标明
const name = (id: string) => {
const d = details.get(id)
return d?.passkey ? `${d.passkey}${d.account ? ` · ${d.account}` : ''}` : `未知 Passkey(${id.slice(0, 8)}…)`
}
return (
<div className="stack" style={{ gap: 4 }}>
{(trust.operators ?? []).map((id) => (
+2 -2
View File
@@ -113,7 +113,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated
<button
type="button"
className="btn primary"
disabled={busy}
disabled={busy || !controllers?.length}
onClick={async () => {
await run(async () => {
const token = await api.post<CreatedJoinTokenDto>('/api/admin/join-tokens', {
@@ -147,7 +147,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
</Field>
<div className="wide">
<Field label="属主(passkey)" hint="允许授权这个节点 web ssh 等敏感操作的 Passkey;只是选,不用触碰。装上后固定在节点本地,面板改不了。">
<Field label="属主(passkey)" hint="必选(至少一把):这个节点绑定到哪几个账户的哪几把 Passkey 上,它的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
<OwnerPicker value={controllers} onChange={setControllers} />
</Field>
</div>
+23 -1
View File
@@ -1,7 +1,7 @@
import { Download, KeyRound, Plus, Trash2 } from 'lucide-react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { api } from '@shared/api/client'
import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeBinaryDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import type { AgentDto, ApiTokenDto, BuildInfoDto, NodeBinaryDto, NodeOwnerDto, CapabilityCatalogDto, MeDto, PasskeyDto, NodeUpdateDto, ReleaseDto, CreatedApiTokenDto, AuditDto, EventDto, Page, ProbeDto, RawChunkMetaDto, RecomputeDto, SettingDto, UserDto } from '@shared/api/types'
import { fmtBuiltAt, frontendBuild } from '@shared/buildInfo'
import { fmtNum, fmtTime } from '@shared/format'
import { translateSetting } from '@shared/i18n'
@@ -192,6 +192,7 @@ function BuildRow({ name, kind, version, tag, commit, builtAt, platform, update
<td className="muted">{platform || dash}</td>
<td>{update ?? dash}</td>
<td>{dash}</td>
<td>{dash}</td>
</tr>
)
}
@@ -210,6 +211,23 @@ const UPDATE_STATE: Record<string, { text: string; tone: '' | 'ok' | 'warn' | 'b
* 节点二进制的绑定:第一次握手成功时绑定它正在运行的二进制的哈希,之后对不上又不在 CI 签名的更新清单里就拒绝连接。
* 「已签名」表示哈希出现在一份 CI 签名的清单里;否则只是首次握手时记下的。不符时可以确认当前的二进制是自己要的(比如手工升级了)。
*/
/**
* 节点的属主:节点自己要求有属主(passkey),安装时固定在本地;面板顺着它报上来的凭证 id 反查出绑定在哪个账户的哪把 passkey 上。
* 以后给节点更多接口和操作(web ssh、加密内容)时,只认这些 passkey。
*/
function OwnerCell({ owners }: { owners?: NodeOwnerDto[] | null }) {
if (!owners || owners.length === 0) return <span className="faint">未报告</span>
return (
<span className="row" style={{ gap: 6, flexWrap: 'wrap' }}>
{owners.map((o) => (
<Chip key={o.credential_id} tone={o.passkey ? '' : 'warn'}>
<span title={o.credential_id}>{o.passkey ? `${o.passkey}${o.account ? ` · ${o.account}` : ''}` : '未知凭证'}</span>
</Chip>
))}
</span>
)
}
function BinaryCell({ binary, busy, onAccept }: { binary?: NodeBinaryDto | null; busy: boolean; onAccept: () => void }) {
if (!binary) return <span className="faint">未绑定</span>
const short = binary.sha256.slice(0, 10)
@@ -293,6 +311,7 @@ function Versions() {
<th>系统</th>
<th>更新</th>
<th>二进制</th>
<th>属主</th>
</tr>
</thead>
<tbody>
@@ -317,6 +336,9 @@ function Versions() {
<td>
<BinaryCell binary={n.binary} busy={busy} onAccept={() => void run(async () => { await api.del(`/api/admin/nodes/${n.id}/binary`); info.reload() }, '已清除绑定,节点下一次连接时重新绑定')} />
</td>
<td>
<OwnerCell owners={n.owners} />
</td>
</tr>
))}
</tbody>
+7 -2
View File
@@ -1,4 +1,5 @@
import { ShieldCheck } from 'lucide-react'
import { useEffect } from 'react'
import { api } from '@shared/api/client'
import type { TrustStateDto } from '@shared/api/types'
import { signStatement } from '@shared/api/webauthn'
@@ -49,11 +50,15 @@ function Changes({ proposal }: { proposal: Proposal }) {
* 信任根:你的 Passkey 是整个系统的根。节点安装时把下面这个指纹和属主(passkey)固定在本地,之后只认从它出发、由你的 Passkey 签过名的
* 操作员集合,面板自己改不了,也签不出新的版本。
*/
export function Trust() {
export function Trust({ onInitialized }: { onInitialized?: () => void } = {}) {
const trust = useQuery(() => api.get<TrustStateDto>('/api/admin/trust'), [])
const { busy, run } = useAction()
const toast = useToast()
const state = trust.data
useEffect(() => {
if (state?.initialized) onInitialized?.()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state?.initialized])
const sign = async () => {
if (!state?.proposal) return
@@ -80,7 +85,7 @@ export function Trust() {
<b>还没有建立信任根</b>
</div>
<p className="muted">
建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。
<b>不建立信任根就无法添加 Agent 和 Probe,只能进入面板做基础管理——这是架构如此:节点只信任由你的 Passkey 签名的信任根,面板自己没法代签。</b>建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。
这一步会把下面列出的 Passkey 登记为操作员,需要你触碰一次 Passkey 给集合签名。
</p>
{state.proposal ? (
+114
View File
@@ -327,6 +327,24 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/auth/setup": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Whether the panel has no users yet and still has to be claimed */
get: operations["setup_status"];
put?: never;
/** Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists) */
post: operations["setup_complete"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/passkey/options": {
parameters: {
query?: never;
@@ -1720,6 +1738,7 @@ export interface components {
built_at: null | string;
update?: null | components["schemas"]["NodeUpdateDto"];
binary?: null | components["schemas"]["NodeBinaryDto"];
owners?: null | components["schemas"]["NodeOwnerDto"][];
};
NodeInfoDto: {
id: string;
@@ -1733,6 +1752,11 @@ export interface components {
version?: null | string;
tag?: null | string;
};
NodeOwnerDto: {
credential_id: string;
passkey: null | string;
account: null | string;
};
NodeUpdateDto: {
state: string;
current_digest: null | string;
@@ -2067,6 +2091,15 @@ export interface components {
default: string;
description: string;
};
SetupRequest: {
claim_code: null | string;
username: null | string;
password: null | string;
return_session: null | boolean;
};
SetupStatusDto: {
needs_setup: boolean;
};
SignedStatementDto: {
statement?: string;
credential_id?: string;
@@ -2188,6 +2221,7 @@ export interface components {
server_key: string;
/** @default false */
server_key_listed: boolean;
operator_details?: null | components["schemas"]["NodeOwnerDto"][];
};
UserDto: {
id: string;
@@ -3070,6 +3104,86 @@ export interface operations {
};
};
};
setup_status: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SetupStatusDto"];
};
};
};
};
setup_complete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SetupRequest"];
};
};
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LoginResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Too Many Requests */
429: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
auth_passkey_options: {
parameters: {
query?: never;
+2
View File
@@ -20,6 +20,7 @@ export type BackendUpdateDto = Narrow<S['BackendUpdateDto']>
export type NodeBuildDto = Narrow<S['NodeBuildDto']>
export type NodeUpdateDto = Narrow<S['NodeUpdateDto']>
export type NodeBinaryDto = Narrow<S['NodeBinaryDto']>
export type NodeOwnerDto = Narrow<S['NodeOwnerDto']>
export type ReleaseDto = Narrow<S['ReleaseDto']>
export type LinkDto = Narrow<S['LinkDto']>
export type LinkMatrixDto = Narrow<S['LinkMatrixDto']>
@@ -55,6 +56,7 @@ export type GapDto = Narrow<S['GapDto']>
export type RecomputeDto = Narrow<S['RecomputeDto']>
export type RawChunkMetaDto = Narrow<S['RawChunkMetaDto']>
export type LoginResponse = Narrow<S['LoginResponse']>
export type SetupStatusDto = Narrow<S['SetupStatusDto']>
export interface Page<T> {
items: T[]