diff --git a/backend/README.md b/backend/README.md index e50ea49..8aab4ff 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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)。 diff --git a/backend/agent/src/MoShiro.Agent/AgentApp.cs b/backend/agent/src/MoShiro.Agent/AgentApp.cs index 83c674d..9433a5f 100644 --- a/backend/agent/src/MoShiro.Agent/AgentApp.cs +++ b/backend/agent/src/MoShiro.Agent/AgentApp.cs @@ -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()], diff --git a/backend/agent/src/MoShiro.Agent/AgentConfig.cs b/backend/agent/src/MoShiro.Agent/AgentConfig.cs index 9032de6..e27cabe 100644 --- a/backend/agent/src/MoShiro.Agent/AgentConfig.cs +++ b/backend/agent/src/MoShiro.Agent/AgentConfig.cs @@ -45,6 +45,10 @@ public sealed class FleetSection [JsonPropertyName("identity_dir")] public string IdentityDir { get; set; } = "/etc/moshiro"; + + /// 节点自己要求有属主(passkey):没有就不运行。见 OwnerGuard。 + [JsonPropertyName("require_owner")] + public bool RequireOwner { get; set; } = true; } public sealed class ListenSection diff --git a/backend/agent/src/MoShiro.Agent/Program.cs b/backend/agent/src/MoShiro.Agent/Program.cs index 493a37b..11fe862 100644 --- a/backend/agent/src/MoShiro.Agent/Program.cs +++ b/backend/agent/src/MoShiro.Agent/Program.cs @@ -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) { diff --git a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs index 35cb796..0260cd1 100644 --- a/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs +++ b/backend/fleet/src/Fleet.Enrollment.Client/EnrollmentClient.cs @@ -46,6 +46,12 @@ public static class EnrollmentClient TrustBundle? bundle = null; if (genesis is not null) { + if (controllers is not { Count: > 0 }) + { + // 每个节点都必须有属主:没有属主的节点没有人能授权它的敏感操作 + return Result.Fail(FleetErrors.InvalidParams, "an owner is required: pass the passkey credential ids from the panel's command (--controllers)"); + } + Result trusted = await FetchTrustAsync(http, server, joinToken, genesis, controllers ?? [], ct); if (!trusted.TryGet(out bundle, out FleetError? trustError)) { diff --git a/backend/fleet/src/Fleet.Hosting/OwnerGuard.cs b/backend/fleet/src/Fleet.Hosting/OwnerGuard.cs new file mode 100644 index 0000000..157622a --- /dev/null +++ b/backend/fleet/src/Fleet.Hosting/OwnerGuard.cs @@ -0,0 +1,48 @@ +using Fleet.Core; + +namespace Fleet.Hosting; + +/// +/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,启动时检查。 +/// +/// 属主是安装命令带来、注册时固定在 trust.json 里的 passkey 凭证 id(必须是操作员集合里的凭证)。节点据此知道自己绑定在哪个 passkey 上, +/// 才敢把更多的接口和操作交给它(extension 会话、加密内容都只认属主的 passkey);没有属主的节点没有人能授权它的敏感能力,所以不运行。 +/// 这是节点本地的强制,不依赖面板:面板被攻破也改不了节点已经固定的属主。 +/// +/// +public static class OwnerGuard +{ + /// 设为 1 放行没有属主的节点(只给没有信任根的老面板)。 + public const string AllowEnvironmentVariable = "MOSHIRO_ALLOW_NO_OWNER"; + + /// 没问题返回 null;否则是要告诉运维的原因。 + 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; + } + + /// 读身份目录里固定的信任根,检查属主;不行就记一条错误并返回 false(调用方应当退出)。 + 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; + } +} diff --git a/backend/fleet/tests/Fleet.Tests/OwnerGuardTests.cs b/backend/fleet/tests/Fleet.Tests/OwnerGuardTests.cs new file mode 100644 index 0000000..8cb373b --- /dev/null +++ b/backend/fleet/tests/Fleet.Tests/OwnerGuardTests.cs @@ -0,0 +1,70 @@ +using Fleet.Core; +using Fleet.Hosting; +using Fleet.Identity; + +namespace Fleet.Tests; + +/// 节点自己要求有属主(passkey):Agent 和 Probe 共用这一份,没有属主就不运行。 +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)); + } +} diff --git a/backend/fleet/tools/fleet-enroll/Program.cs b/backend/fleet/tools/fleet-enroll/Program.cs index 3e7ee3b..e563fb2 100644 --- a/backend/fleet/tools/fleet-enroll/Program.cs +++ b/backend/fleet/tools/fleet-enroll/Program.cs @@ -33,6 +33,12 @@ if (Arg("--meta") is { } metaText) string? genesis = Arg("--genesis"); string[] controllers = (Arg("--controllers") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +if (genesis is not null && controllers.Length == 0) +{ + Console.Error.WriteLine("--genesis needs --controllers: every node has an owner (the passkey credential ids from the panel's command)"); + return 2; +} + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; Result result = await EnrollmentClient.EnrollAsync( http, new Uri(server), join, name, directory, Arg("--version") ?? "0.0.0", meta, TimeProvider.System, CancellationToken.None, genesis, controllers); diff --git a/backend/openapi/v1.json b/backend/openapi/v1.json index 060bc21..1a86df0 100644 --- a/backend/openapi/v1.json +++ b/backend/openapi/v1.json @@ -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" + } } } }, diff --git a/backend/probe/src/MoShiro.Probe/ProbeApp.cs b/backend/probe/src/MoShiro.Probe/ProbeApp.cs index 2bd1e75..c9d1309 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeApp.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeApp.cs @@ -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.StateType, diff --git a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs index 4f4d6a2..262c33b 100644 --- a/backend/probe/src/MoShiro.Probe/ProbeConfig.cs +++ b/backend/probe/src/MoShiro.Probe/ProbeConfig.cs @@ -29,6 +29,10 @@ public sealed class ProbeFleetSection /// fleet-enroll 写出的身份目录:node.json、node.key(连面板的密钥)、tls.pfx(对等面的密钥)。 [JsonPropertyName("identity_dir")] public string IdentityDir { get; set; } = "/etc/moshiro/probe-id"; + + /// 节点自己要求有属主(passkey):没有就不运行。见 OwnerGuard(Agent 和 Probe 共用)。 + [JsonPropertyName("require_owner")] + public bool RequireOwner { get; set; } = true; } public sealed class KnownAgent diff --git a/backend/probe/src/MoShiro.Probe/Program.cs b/backend/probe/src/MoShiro.Probe/Program.cs index 6f375b3..85a0464 100644 --- a/backend/probe/src/MoShiro.Probe/Program.cs +++ b/backend/probe/src/MoShiro.Probe/Program.cs @@ -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) { diff --git a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs index 43c7b34..c063c88 100644 --- a/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs +++ b/backend/shared/MoShiro.Protocol/Rpc/MoShiroRpc.cs @@ -306,6 +306,10 @@ public sealed class ProbeHeartbeatExtra /// Probe 的 fleet.hello 附加字段:系统、架构和构建信息(只用于展示和更新)。 public sealed class ProbeHelloMeta { + /// 这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。 + [JsonPropertyName("owners")] + public List? Owners { get; set; } + [JsonPropertyName("ipv6")] public Ipv6Diagnostic? Ipv6 { get; set; } @@ -316,6 +320,10 @@ public sealed class ProbeHelloMeta /// fleet.hello 的附加字段。 public sealed class AgentHelloMeta { + /// 这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。 + [JsonPropertyName("owners")] + public List? Owners { get; set; } + [JsonPropertyName("ipv6")] public Ipv6Diagnostic? Ipv6 { get; set; } diff --git a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs index 8fe3026..d0530a6 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AgentEndpoints.cs @@ -137,10 +137,10 @@ public static class AgentEndpoints // 信任根建立之后,命令里带上创世指纹和属主;属主默认是操作员集合里的全部 passkey,也可以只选其中几把 OperatorSet? set = await trust.CurrentAsync(http.RequestAborted); string? genesis = set is null ? null : (await trust.StateAsync(AuthEndpoints.PasskeyContextOf(http, options), http.RequestAborted)).Genesis; - string[] controllers = set is null ? [] : [.. request.Controllers ?? [.. set.Keys.Select(k => k.CredentialId)]]; - if (set is not null && controllers.Any(c => set.Keys.All(k => k.CredentialId != c))) + Result owner = OwnerPolicy.Resolve(options.RequireOwner, set, request.Controllers); + if (!owner.TryGet(out string[]? controllers, out FleetError? ownerError)) { - return Problems.From(http, ValidationError.Single("controllers", "must be passkeys of the operator set")); + return Problems.From(http, ownerError); } var plan = new InstallPlan(genesis, controllers, InstallScripts.Sha256(InstallScripts.Sh), InstallScripts.Sha256(InstallScripts.Ps1)); diff --git a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs index f54390e..086dce8 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/AuthEndpoints.cs @@ -19,6 +19,14 @@ public static class AuthEndpoints .AllowsAnonymous() .Api("auth_login", "Sign in with a username and password", Tag) .Returns().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(); + 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(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 SetupStatusAsync(SetupService setup, CancellationToken ct) + { + return TypedResults.Json(new SetupStatusDto(await setup.NeedsSetupAsync(ct)), ApiJsonContext.Default.SetupStatusDto); + } + + private static async Task 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 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 session = IssueSession(http, login, request.ReturnSession == true, options); + return TypedResults.Json(session.Value!, ApiJsonContext.Default.LoginResponse, statusCode: StatusCodes.Status201Created); + } + /// 发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。 private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options) { diff --git a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs index 92b1785..499aae4 100644 --- a/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs +++ b/backend/src/MoShiro.Backend.Api/Admin/BuildEndpoints.cs @@ -73,13 +73,14 @@ public static class BuildEndpoints return await PublishReleaseAsync(http, releases, request); } - private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, CancellationToken ct) + private static async Task GetAsync(HttpContext http, NodeInfoService nodes, ReleaseService releases, BackendUpdates backendUpdates, BinaryBindingService bindings, NodeOwnerService owners, CancellationToken ct) { + IReadOnlyDictionary> ownerOf = await owners.ListAsync(ct); Dictionary 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? 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); diff --git a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs index c29f8ff..95d8b6e 100644 --- a/backend/src/MoShiro.Backend.Api/Common/Mappers.cs +++ b/backend/src/MoShiro.Backend.Api/Common/Mappers.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs index dc12c2c..1670290 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/AdminDtos.cs @@ -75,6 +75,12 @@ public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList? Controllers = null); /// 令牌明文只在创建的响应里出现一次。 +/// 面板是不是还没有任何用户(需要首次初始化)。 +public sealed record SetupStatusDto(bool NeedsSetup); + +/// 首次启动时写在后端日志里的认领码(或者部署时指定的 MoShiro__ClaimCode)。 +public sealed record SetupRequest(string? ClaimCode, string? Username, string? Password, bool? ReturnSession); + public sealed record InstallCommandsDto(string Linux, string Macos, string Windows); /// 下一版里新增的 passkey。 @@ -89,9 +95,10 @@ public sealed record TrustProposalDto(string Statement, int Version, string Chal /// 当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。 /// 下一个要签的版本(challenge 就是 WebAuthn 的挑战)。 /// 当前操作员集合里的 passkey 凭证 id:加入码的 controllers 只能从这里选。 +/// 每个操作员是哪个账户的哪把 passkey(对不上已注册的 passkey 时名称和账户为空)。 /// 面板提供的安装脚本的 SHA-256(命令里核对的就是它)。 /// 和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。 -public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false); +public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList? OperatorDetails = null); public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands); @@ -181,7 +188,10 @@ public sealed record RecomputeDto(string State, IReadOnlyList Metrics, l /// 节点二进制的绑定:第一次握手成功时绑定的 SHA-256。Signed 表示它出现在一份 CI 签名的清单里(否则只是首次握手时记下的);Violation 是最近一次不符的原因,下一次握手符合了就清掉。 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); +/// 节点的一个属主:节点报上来的(安装时固定在节点本地的)passkey 凭证 id,以及它在面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。 +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? Owners = null); /// 节点宿主机上的更新器报告的状态(up_to_date、available、applying、failed、rolled_back、invalid);没装更新器的节点没有这个字段。 public sealed record NodeUpdateDto(string State, string? CurrentDigest, string? AvailableTag, string? AvailableVersion, string? Error, DateTimeOffset? At); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index 334e91c..278049a 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -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))] diff --git a/backend/src/MoShiro.Backend.Application/Admin/OwnerPolicy.cs b/backend/src/MoShiro.Backend.Application/Admin/OwnerPolicy.cs new file mode 100644 index 0000000..34c2bbe --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Admin/OwnerPolicy.cs @@ -0,0 +1,32 @@ +using Fleet.Core; +using Fleet.Identity; + +namespace MoShiro.Backend.Application; + +/// +/// 节点的属主:创建加入码时选定的 passkey 凭证 id,写进安装命令,节点注册时固定在本地。 +/// 属主必须是操作员集合里的凭证; 打开时(默认)必须已经有信任根,并且至少选一把—— +/// 不选属主的节点没有人能授权它的敏感操作,也就不该存在。没有明确指定时默认是操作员集合里的全部 passkey。 +/// +public static class OwnerPolicy +{ + public static Result Resolve(bool required, OperatorSet? set, IReadOnlyList? requested) + { + if (set is null) + { + return required + ? ValidationError.Fail("controllers", "set up the trust root first (System, Trust): every node needs an owner passkey") + : Result.Ok([]); + } + + string[] chosen = requested is null ? [.. set.Keys.Select(k => k.CredentialId)] : [.. requested.Distinct(StringComparer.Ordinal)]; + if (chosen.Any(c => set.Keys.All(k => k.CredentialId != c))) + { + return ValidationError.Fail("controllers", "must be passkeys of the operator set"); + } + + return required && chosen.Length == 0 + ? ValidationError.Fail("controllers", "pick at least one owner passkey: a node's owner is who its sensitive sessions and data are bound to") + : Result.Ok(chosen); + } +} diff --git a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs index 925236d..9418a53 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/AgentService.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs index fa507d3..769a26e 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/MoShiroFleetHooks.cs @@ -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); diff --git a/backend/src/MoShiro.Backend.Application/Agents/NodeOwnerService.cs b/backend/src/MoShiro.Backend.Application/Agents/NodeOwnerService.cs new file mode 100644 index 0000000..a49d10c --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Agents/NodeOwnerService.cs @@ -0,0 +1,68 @@ +using System.Collections.Concurrent; + +namespace MoShiro.Backend.Application; + +/// 节点的一个属主:它报上来的 passkey 凭证 id,以及面板上对应的 passkey 名称和账户(对不上任何已注册的 passkey 时为空)。 +public sealed record NodeOwner(string CredentialId, string? PasskeyName, string? Username); + +/// +/// 节点的属主:Agent 和 Probe 握手时报上来的、安装时固定在节点本地的 passkey 凭证 id。 +/// 属主的强制在节点本地(没有属主的节点不运行,见 OwnerGuard),面板这边只是顺着凭证 id 反查出它绑定在哪个账户的哪把 passkey 上—— +/// 以后给节点更多的接口和操作(extension 会话、加密内容)时,据此判断谁有资格用。上报的只是自述,真正的授权由节点自己对 passkey 签名的校验把关。 +/// 在内存里:节点每次连上来都会重新报。 +/// +public sealed class NodeOwnerService +{ + private const int MaxOwners = 16; + private const int MaxCredentialIdLength = 256; + + private readonly ConcurrentDictionary _reported = new(StringComparer.Ordinal); + private readonly IAuthStore _auth; + + public NodeOwnerService(IAuthStore auth) + { + _auth = auth; + } + + public void Report(string nodeId, IReadOnlyList? 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 _); + + /// 每个节点的属主(只含报过的节点)。 + public async ValueTask>> ListAsync(CancellationToken ct) + { + var result = new Dictionary>(StringComparer.Ordinal); + var users = new Dictionary(StringComparer.Ordinal); + foreach ((string nodeId, string[] ids) in _reported) + { + var owners = new List(); + 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; + } +} diff --git a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs index cc9853a..0d945f6 100644 --- a/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs +++ b/backend/src/MoShiro.Backend.Application/Agents/ProbeService.cs @@ -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(); } diff --git a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index d8f69ec..d680d0e 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -38,7 +38,9 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(); diff --git a/backend/src/MoShiro.Backend.Application/Auth/SetupService.cs b/backend/src/MoShiro.Backend.Application/Auth/SetupService.cs new file mode 100644 index 0000000..9e96bf2 --- /dev/null +++ b/backend/src/MoShiro.Backend.Application/Auth/SetupService.cs @@ -0,0 +1,99 @@ +using System.Security.Cryptography; +using Fleet.Core; +using Microsoft.Extensions.Logging; +using MoShiro.Protocol; + +namespace MoShiro.Backend.Application; + +/// +/// 首次认领:面板里还没有任何用户时,由部署的人自己填管理员的用户名和密码——没有默认账户,也没有默认密码。 +/// +/// 谁先打开页面谁就能当管理员,所以要有一个只有部署的人才拿得到的认领码:首次启动时随机生成并写进后端的日志 +/// (journalctl -u moshiro-backend / podman logs),或者用 MoShiro__ClaimCode 自己指定。 +/// 创建完第一个管理员,认领码立刻作废,这个入口也就关闭了。 +/// +/// +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 _log; + private readonly SemaphoreSlim _gate = new(1, 1); + private string? _code; + + public SetupService(AuthService auth, IAuthStore store, BackendOptions options, ILogger log) + { + _auth = auth; + _store = store; + _options = options; + _log = log; + } + + public void Dispose() => _gate.Dispose(); + + /// 面板里还没有任何用户。 + public async ValueTask NeedsSetupAsync(CancellationToken ct) => await _store.CountUsersAsync(ct) == 0; + + /// 启动时调用:需要初始化就准备好认领码,并写进日志(只有能看到日志的人才拿得到)。 + public async ValueTask AnnounceAsync(CancellationToken ct) + { + if (await NeedsSetupAsync(ct)) + { + LogClaimCode(_log, _options.PublicUrl ?? "the panel's address", Code()); + } + } + + public async ValueTask> CompleteAsync(string? claimCode, string username, string password, CancellationToken ct) + { + await _gate.WaitAsync(ct); + try + { + if (!await NeedsSetupAsync(ct)) + { + return Result.Fail(MoShiroErrors.NameConflict, "the panel has already been claimed"); + } + + if (claimCode is null || !AuthService.FixedTimeEquals(Normalize(claimCode), Normalize(Code()))) + { + return Result.Fail(MoShiroErrors.Unauthorized, "the claim code is not valid (see the backend's log)"); + } + + Result created = await _auth.CreateUserAsync(username, password, ct); + if (!created.TryGet(out _, out FleetError? error)) + { + return Result.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))); + } + + /// 比较时不分大小写、忽略分隔符和空格。 + 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); +} diff --git a/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs index 21d6003..5e31245 100644 --- a/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs +++ b/backend/src/MoShiro.Backend.Application/Auth/TrustService.cs @@ -15,7 +15,7 @@ public sealed record ProposedCredential(string Id, string Name, long CreatedAtMs /// InSync:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。 /// 这个面板的服务器身份密钥的指纹;ServerKeyListed:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。 -public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList Chain, TrustProposal? Proposal, IReadOnlyList? Operators = null, string ServerKey = "", bool ServerKeyListed = false); +public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList Chain, TrustProposal? Proposal, IReadOnlyList? Operators = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList? OperatorDetails = null); /// /// 信任根的 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(); + 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 ProposeAsync(IReadOnlyList chain, OperatorSet? current, PasskeyContext context, CancellationToken ct) diff --git a/backend/src/MoShiro.Backend.Application/BackendOptions.cs b/backend/src/MoShiro.Backend.Application/BackendOptions.cs index 99d564c..bbcb546 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -26,6 +26,12 @@ public sealed class BackendOptions /// public string BinaryPolicy { get; set; } = "enforce"; + /// + /// 每个 Agent 和 Probe 都必须有属主(passkey):创建加入码时必须已经建立信任根,并且至少选一把操作员集合里的 passkey 作为属主。 + /// 属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地,面板改不了。默认开;没有信任根的老部署才需要关掉。 + /// + public bool RequireOwner { get; set; } = true; + /// 面板的对外地址(用于生成安装命令)。 public string? PublicUrl { get; set; } @@ -102,6 +108,12 @@ public sealed class BackendOptions /// public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz"; + /// + /// 首次认领的认领码(可选):面板里还没有任何用户时,第一个管理员由部署的人自己在页面上填,需要这个码才能创建。 + /// 不设置就在首次启动时随机生成并写进后端的日志。创建完第一个管理员后作废。 + /// + public string? ClaimCode { get; set; } + /// 初始管理员(只在没有任何用户时创建)。 public string? InitialAdminUser { get; set; } diff --git a/backend/src/MoShiro.Backend.Application/StartupTasks.cs b/backend/src/MoShiro.Backend.Application/StartupTasks.cs index 55000a7..473b3d0 100644 --- a/backend/src/MoShiro.Backend.Application/StartupTasks.cs +++ b/backend/src/MoShiro.Backend.Application/StartupTasks.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Hosting; namespace MoShiro.Backend.Application; /// -/// 启动时最先执行:截断没有索引记录的段文件尾部、创建初始管理员、创建默认的线路任务模板。 +/// 启动时最先执行:截断没有索引记录的段文件尾部、(用配置里的凭据创建管理员,没有的话把首次认领码写进日志)创建默认的线路任务模板。 /// 宿主按注册顺序依次等待各个 IHostedService 启动,所以它需要最先注册。 /// 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); } diff --git a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs index c36ce48..6b33085 100644 --- a/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs +++ b/backend/tests/MoShiro.Backend.Tests/BackendFixture.cs @@ -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"; + /// 首次认领码:和线上一样,第一个管理员通过 /api/admin/auth/setup 创建,不再用配置里的默认账户。 + public const string ClaimCode = "TEST-CLAIM-CODE-0001"; + private readonly List _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 origin = BaseAddress.GetLeftPart(UriPartial.Authority); diff --git a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs index 118d1f7..a0647a1 100644 --- a/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/CapabilityTests.cs @@ -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()!.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()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray()); } [Fact] diff --git a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs index a4c2c12..be64709 100644 --- a/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/OpenApiConformanceTests.cs @@ -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"); diff --git a/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs new file mode 100644 index 0000000..ce0c71f --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/OwnerTests.cs @@ -0,0 +1,126 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Fleet.Identity; +using Microsoft.Extensions.DependencyInjection; +using MoShiro.Backend.Application; + +namespace MoShiro.Backend.Tests; + +/// +/// 每个 Agent 和 Probe 都必须有属主(passkey):属主是节点敏感内容(extension 会话、加密数据)绑定的人,装上时固定在节点本地。 +/// 没有信任根、没选属主、选了操作员集合之外的凭证,都不能创建加入码;安装脚本和容器入口缺了属主也不装。 +/// +public sealed class OwnerPolicyTests +{ + private static OperatorSet Set(params string[] ids) => + new(1, OperatorTrust.Genesis, "example.test", ["https://example.test"], [.. ids.Select(id => new OperatorKey(id, [1, 2, 3]))]); + + [Fact] + public void WithoutATrustRoot_AnOwnerCannotBeChosen_WhenOwnersAreRequired() + { + Assert.False(OwnerPolicy.Resolve(required: true, null, null).IsSuccess); + Assert.False(OwnerPolicy.Resolve(required: true, null, ["a"]).IsSuccess); + Assert.Empty(OwnerPolicy.Resolve(required: false, null, null).Value); // 没有信任根的老部署:不固定属主 + } + + [Fact] + public void ByDefaultEveryOperatorIsAnOwner_AndAnExplicitChoiceMustBeNonEmptyAndKnown() + { + OperatorSet set = Set("a", "b"); + + Assert.Equal(["a", "b"], OwnerPolicy.Resolve(true, set, null).Value); + Assert.Equal(["b"], OwnerPolicy.Resolve(true, set, ["b", "b"]).Value); + Assert.False(OwnerPolicy.Resolve(true, set, []).IsSuccess); // 一把都不选:不行 + Assert.False(OwnerPolicy.Resolve(true, set, ["a", "stranger"]).IsSuccess); // 不是操作员集合里的凭证 + Assert.False(OwnerPolicy.Resolve(false, set, ["stranger"]).IsSuccess); + } +} + +[Collection("probe-e2e")] +public sealed class OwnerEndToEndTests : IDisposable +{ + private readonly BackendFixture _backend; + private readonly BackendOptions _options; + private readonly bool _before; + + public OwnerEndToEndTests(BackendFixture backend) + { + _backend = backend; + _options = backend.App.Services.GetRequiredService(); + _before = _options.RequireOwner; + _options.RequireOwner = true; + } + + public void Dispose() => _options.RequireOwner = _before; + + private static string Base64UrlOf(string statement) => Fleet.Core.Base64Url.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(statement))); + + /// 建立信任根:一个有 passkey 的操作员给操作员集合签名。 + private async Task<(HttpClient Session, SoftwarePasskey Passkey)> EstablishTrustAsync() + { + (HttpClient session, SoftwarePasskey passkey, _) = await _backend.CreateStrongSessionAsync("owner"); + JsonNode state = JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!; + if (!state["initialized"]!.GetValue()) + { + string statement = state["proposal"]!["statement"]!.GetValue(); + JsonObject assertion = passkey.Assert(Base64UrlOf(statement)); + var body = new JsonObject + { + ["statement"] = statement, + ["credential_id"] = assertion["credential_id"]!.DeepClone(), + ["authenticator_data"] = assertion["authenticator_data"]!.DeepClone(), + ["client_data_json"] = assertion["client_data_json"]!.DeepClone(), + ["signature"] = assertion["signature"]!.DeepClone(), + }; + using HttpResponseMessage committed = await session.PostAsync("/api/admin/trust", BackendFixture.Json(body)); + Assert.Equal(HttpStatusCode.Created, committed.StatusCode); + } + + return (session, passkey); + } + + private async Task OperatorsAsync(HttpClient session) => + JsonNode.Parse(await session.GetStringAsync("/api/admin/trust"))!["operators"]!; + + [Fact] + public async Task ANodeCannotBeAddedWithoutAnOwner() + { + (HttpClient session, SoftwarePasskey passkey) = await EstablishTrustAsync(); + using HttpClient sessionScope = session; + using SoftwarePasskey passkeyScope = passkey; + string known = (await OperatorsAsync(session))[0]!.GetValue(); + + using HttpResponseMessage none = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["controllers"] = new JsonArray() })); + Assert.Equal(HttpStatusCode.BadRequest, none.StatusCode); + Assert.Contains("owner", await none.Content.ReadAsStringAsync(), StringComparison.Ordinal); + + using HttpResponseMessage stranger = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe", ["controllers"] = new JsonArray("not-an-operator") })); + Assert.Equal(HttpStatusCode.BadRequest, stranger.StatusCode); + + // 明确选了一把,或者不指定(默认是全部操作员):可以,命令里带着创世指纹和属主 + using HttpResponseMessage chosen = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "agent", ["controllers"] = new JsonArray(known) })); + Assert.Equal(HttpStatusCode.Created, chosen.StatusCode); + string linux = JsonNode.Parse(await chosen.Content.ReadAsStringAsync())!["install_commands"]!["linux"]!.GetValue(); + Assert.Contains("MOSHIRO_GENESIS=", linux, StringComparison.Ordinal); + Assert.Contains("MOSHIRO_CONTROLLERS=", linux, StringComparison.Ordinal); + Assert.Contains(known, linux, StringComparison.Ordinal); + + using HttpResponseMessage defaults = await session.PostAsync("/api/admin/join-tokens", BackendFixture.Json(new JsonObject { ["role"] = "probe" })); + Assert.Equal(HttpStatusCode.Created, defaults.StatusCode); + } + + [Fact] + public async Task TheInstallScripts_RefuseANodeWithoutAnOwner() + { + // 脚本是静态的,所以可以直接检查:缺了信任根指纹或属主就不装,只有明确设置 MOSHIRO_ALLOW_NO_OWNER=1(没有信任根的老面板)才放行 + using HttpClient anonymous = _backend.NewClient(); + foreach (string path in new[] { "/install", "/install.ps1" }) + { + string script = await anonymous.GetStringAsync(path); + Assert.Contains("MOSHIRO_ALLOW_NO_OWNER", script, StringComparison.Ordinal); + Assert.Contains("this node has no owner", script, StringComparison.Ordinal); + } + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/SetupTests.cs b/backend/tests/MoShiro.Backend.Tests/SetupTests.cs new file mode 100644 index 0000000..3c13771 --- /dev/null +++ b/backend/tests/MoShiro.Backend.Tests/SetupTests.cs @@ -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; + +/// +/// 首次认领:面板里没有任何用户时,部署的人自己填管理员的用户名和密码——没有默认账户、没有默认密码。 +/// 谁先打开页面谁就能当管理员,所以要有只有部署的人才拿得到的认领码(写在后端日志里,或者部署时指定);创建完第一个管理员,入口就关了。 +/// +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().Features.Get()!.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 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()); + + 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()); // 还是没人 + } + + [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()); + Assert.False(string.IsNullOrEmpty(body["session_token"]?.GetValue())); // 马上登录,接着引导 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()); + + // 管理员能用自己填的账号密码正常登录(密码登录的会话有能力,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().Features.Get()!.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()); + } + finally + { + await generated.StopAsync(); + await generated.DisposeAsync(); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs index 4f7d1dc..1d56db5 100644 --- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -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() == op.Passkey.Id)!; + Assert.False(string.IsNullOrEmpty(detail["passkey"]?.GetValue())); + Assert.False(string.IsNullOrEmpty(detail["account"]?.GetValue())); + 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() == 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()); + Assert.False(string.IsNullOrEmpty(owner["passkey"]?.GetValue())); + Assert.False(string.IsNullOrEmpty(owner["account"]?.GetValue())); + } + [Fact] public async Task TheProposal_ListsEveryNewPasskey_SoTheSignerSeesWhatTheyAreAuthorizing() { diff --git a/compose.yaml b/compose.yaml index 00eb022..36d6ac2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/docs/access.md b/docs/access.md index d87f67b..8b0769b 100644 --- a/docs/access.md +++ b/docs/access.md @@ -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 diff --git a/docs/deployment.md b/docs/deployment.md index 94d996c..731074c 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 文档在普通开发构建中生成。 diff --git a/packaging/container/deploy-node.sh b/packaging/container/deploy-node.sh index 3b59676..40acc45 100644 --- a/packaging/container/deploy-node.sh +++ b/packaging/container/deploy-node.sh @@ -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 [--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" diff --git a/packaging/container/entrypoint.sh b/packaging/container/entrypoint.sh index 6fbc417..a583150 100644 --- a/packaging/container/entrypoint.sh +++ b/packaging/container/entrypoint.sh @@ -13,6 +13,13 @@ if [ ! -f "$identity/node.json" ]; then echo "first start: set MOSHIRO_SERVER, MOSHIRO_JOIN and MOSHIRO_NAME (the panel shows the complete command)" >&2 exit 1 fi + # 每个节点都必须有属主(passkey):信任根指纹和属主缺一个就不注册 + if [ -z "${MOSHIRO_GENESIS:-}" ] || [ -z "${MOSHIRO_CONTROLLERS:-}" ]; then + if [ "${MOSHIRO_ALLOW_NO_OWNER:-}" != 1 ]; then + echo "this node has no owner: set MOSHIRO_GENESIS and MOSHIRO_CONTROLLERS (the panel's command has them; set up the trust root first)" >&2 + exit 1 + fi + fi if [ -n "${MOSHIRO_GENESIS:-}" ]; then # 命令里带着信任根指纹和属主(公开值):先验证面板的操作员集合,并把它们固定在本地 /usr/local/bin/fleet-enroll --server "$MOSHIRO_SERVER" --join "$MOSHIRO_JOIN" --name "$MOSHIRO_NAME" --identity-dir "$identity" \ diff --git a/packaging/install/install.ps1 b/packaging/install/install.ps1 index ba945c2..05b2803 100644 --- a/packaging/install/install.ps1 +++ b/packaging/install/install.ps1 @@ -113,6 +113,11 @@ YOrtO3Vso/QNdSMmepQlx1Q3v+KkN7ro3AY3I6DT2Q2txJu5pS7qzutSQA== # Docker Desktop 没有宿主网络:Agent 把端口发布出来(面板里的地址需要指向这台机器的公网地址) if ($role -eq 'agent') { $arguments += @('-p', '7443:7443/tcp', '-p', '7443:7443/udp', '-p', '7444:7444/tcp') } $arguments += @('-e', "MOSHIRO_SERVER=$server", '-e', "MOSHIRO_JOIN=$code", '-e', "MOSHIRO_NAME=$name") + # 每个节点都必须有属主(passkey):命令里的信任根指纹和属主缺一个就不装(面板的命令总是带着;只有没有信任根的老面板才需要 MOSHIRO_ALLOW_NO_OWNER=1) + if ((-not $env:MOSHIRO_GENESIS -or -not $env:MOSHIRO_CONTROLLERS) -and $env:MOSHIRO_ALLOW_NO_OWNER -ne '1') { + Write-Error 'this node has no owner: MOSHIRO_GENESIS and MOSHIRO_CONTROLLERS are required (copy the complete command from the panel; set up the trust root first)' + return + } # 命令里带着信任根指纹和属主(公开值):节点在注册时验证面板的操作员集合并把它们固定在本地 if ($env:MOSHIRO_GENESIS) { if (($env:MOSHIRO_GENESIS + $env:MOSHIRO_CONTROLLERS) -notmatch '^[A-Za-z0-9_,-]+$') { Write-Error 'MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters'; return } diff --git a/packaging/install/install.sh b/packaging/install/install.sh index 2a5659b..26584fb 100644 --- a/packaging/install/install.sh +++ b/packaging/install/install.sh @@ -109,6 +109,10 @@ elif [ "$ROLE" = agent ]; then set -- "$@" -p 7443:7443/tcp -p 7443:7443/udp -p 7444:7444/tcp fi set -- "$@" -e "MOSHIRO_SERVER=$SERVER" -e "MOSHIRO_JOIN=$CODE" -e "MOSHIRO_NAME=$name" +# 每个节点都必须有属主(passkey):命令里的信任根指纹和属主缺一个就不装(面板的命令总是带着;只有没有信任根的老面板才需要 MOSHIRO_ALLOW_NO_OWNER=1) +if [ -z "${MOSHIRO_GENESIS:-}" ] || [ -z "${MOSHIRO_CONTROLLERS:-}" ]; then + [ "${MOSHIRO_ALLOW_NO_OWNER:-}" = 1 ] || die "this node has no owner: MOSHIRO_GENESIS and MOSHIRO_CONTROLLERS are required (copy the complete command from the panel; set up the trust root first)" +fi if [ -n "${MOSHIRO_GENESIS:-}" ]; then case "$MOSHIRO_GENESIS${MOSHIRO_CONTROLLERS:-}" in *[!A-Za-z0-9_,-]*) die "MOSHIRO_GENESIS or MOSHIRO_CONTROLLERS has unexpected characters" ;; esac set -- "$@" -e "MOSHIRO_GENESIS=$MOSHIRO_GENESIS" -e "MOSHIRO_CONTROLLERS=${MOSHIRO_CONTROLLERS:-}" diff --git a/web/admin/src/App.tsx b/web/admin/src/App.tsx index a068711..5689a6f 100644 --- a/web/admin/src/App.tsx +++ b/web/admin/src/App.tsx @@ -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 (
@@ -240,6 +241,7 @@ export function App() {
) } + if (setupActive) return if (!me) return // 密码登录的会话没有任何能力:只给它添加 Passkey 的页面 diff --git a/web/admin/src/PasskeyNotice.tsx b/web/admin/src/PasskeyNotice.tsx index b17c18f..e7dc0e8 100644 --- a/web/admin/src/PasskeyNotice.tsx +++ b/web/admin/src/PasskeyNotice.tsx @@ -54,7 +54,7 @@ export function PasskeyNotice() {
- 建议添加 Passkey:用指纹、人脸或安全密钥登录,不用输密码;以后加节点这类敏感操作也需要它。 + 还没有 Passkey:不注册 Passkey、不建立信任根就无法添加 Agent 和 Probe,现在只能做基础管理(架构如此:节点只信任由 Passkey 签名的信任根)。Passkey 也可以用来登录,不用输密码。 +
+ + ) : null} + + {step === 2 ? ( +
+ setTrusted(true)} /> +
+ +
+
+ ) : null} + + + ) +} + +function Account({ complete, onDone }: { complete: (code: string, username: string, password: string) => Promise; 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 ( +
+ + setCode(e.target.value)} placeholder="XXXX-XXXX-XXXX-XXXX" autoComplete="off" autoFocus required /> + + + setUsername(e.target.value)} autoComplete="username" required /> + + + setPassword(e.target.value)} autoComplete="new-password" required /> + + + setConfirm(e.target.value)} autoComplete="new-password" required /> + + {error ?
{error}
: null} +
+ +
+
+ ) +} diff --git a/web/admin/src/auth.tsx b/web/admin/src/auth.tsx index ba53ec9..a5b7015 100644 --- a/web/admin/src/auth.tsx +++ b/web/admin/src/auth.tsx @@ -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 logout: () => Promise + /** 面板里还没有任何用户:显示认领向导(创建管理员 → 注册 passkey → 建立信任根)。向导走完(或离开)之前一直为真。 */ + setupActive: boolean + /** 向导第一步:用后端日志里的认领码创建第一个管理员,并马上以他登录。 */ + completeSetup: (claimCode: string, username: string, password: string) => Promise + /** 离开向导,进入面板。 */ + finishSetup: () => void } const AuthContext = createContext(null) @@ -19,6 +25,7 @@ const AuthContext = createContext(null) export function AuthProvider({ children }: { children: ReactNode }) { const [me, setMe] = useState(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('/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('/api/admin/auth/me')) }, []) + const completeSetup = useCallback(async (claimCode: string, username: string, password: string) => { + const r = await api.post('/api/admin/auth/setup', { claim_code: claimCode, username, password }) + setCsrf(r.csrf_token) + setMe(await api.get('/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 {children} + return {children} } export function useAuth(): Auth { diff --git a/web/admin/src/pages/Agents.tsx b/web/admin/src/pages/Agents.tsx index 001e880..c35e465 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -50,7 +50,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated