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/openapi/v1.json b/backend/openapi/v1.json index 185acd9..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": [ @@ -8773,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": { @@ -9260,6 +9396,15 @@ "server_key_listed": { "type": "boolean", "default": false + }, + "operator_details": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/NodeOwnerDto" + } } } }, 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/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 e9279c9..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); diff --git a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs index da9e61b..278049a 100644 --- a/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs +++ b/backend/src/MoShiro.Backend.Api/Contracts/ApiJsonContext.cs @@ -83,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/ApplicationServiceCollectionExtensions.cs b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs index b31ac09..d680d0e 100644 --- a/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs +++ b/backend/src/MoShiro.Backend.Application/ApplicationServiceCollectionExtensions.cs @@ -38,6 +38,7 @@ public static class ApplicationServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); 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 12937ba..bbcb546 100644 --- a/backend/src/MoShiro.Backend.Application/BackendOptions.cs +++ b/backend/src/MoShiro.Backend.Application/BackendOptions.cs @@ -108,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 b7f5cd9..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,8 +260,7 @@ 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; // 测试里不访问外部服务 @@ -282,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/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 6a5e845..1d56db5 100644 --- a/backend/tests/MoShiro.Backend.Tests/TrustTests.cs +++ b/backend/tests/MoShiro.Backend.Tests/TrustTests.cs @@ -250,6 +250,20 @@ 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() { 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 f170aba..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,节点不用重装。 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/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 3942c23..c35e465 100644 --- a/web/admin/src/pages/Agents.tsx +++ b/web/admin/src/pages/Agents.tsx @@ -92,7 +92,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated setHours(Number(e.target.value))} />
- +
diff --git a/web/admin/src/pages/JoinTokenCreated.tsx b/web/admin/src/pages/JoinTokenCreated.tsx index 48a05ae..0c18a61 100644 --- a/web/admin/src/pages/JoinTokenCreated.tsx +++ b/web/admin/src/pages/JoinTokenCreated.tsx @@ -21,21 +21,21 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
命令里的代码只显示这一次,请现在就复制。命令里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。

- {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)} 过期。

diff --git a/web/admin/src/pages/OwnerPicker.tsx b/web/admin/src/pages/OwnerPicker.tsx index fec5f94..737f5fd 100644 --- a/web/admin/src/pages/OwnerPicker.tsx +++ b/web/admin/src/pages/OwnerPicker.tsx @@ -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() - const [own, setOwn] = useState([]) 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>('/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
还没有建立信任根:每个节点都必须有属主(passkey),先到「系统 → 信任」用 Passkey 建立信任根,才能添加节点。
+ return ( +
+ 还没有建立信任根,所以现在无法添加 Agent 和 Probe:每个节点都要绑定到属主的 Passkey 上,节点只信任由 Passkey 签名的信任根。没有信任根时只能进入面板做基础管理,这是架构如此。请先到「系统 → 信任」注册 Passkey 并建立信任根。 +
+ ) } 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 (
{(trust.operators ?? []).map((id) => ( diff --git a/web/admin/src/pages/Probes.tsx b/web/admin/src/pages/Probes.tsx index 813e871..c64bcc6 100644 --- a/web/admin/src/pages/Probes.tsx +++ b/web/admin/src/pages/Probes.tsx @@ -147,7 +147,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated setHours(Number(e.target.value))} />
- +
diff --git a/web/admin/src/pages/Trust.tsx b/web/admin/src/pages/Trust.tsx index 687fa07..9582d66 100644 --- a/web/admin/src/pages/Trust.tsx +++ b/web/admin/src/pages/Trust.tsx @@ -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('/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() { 还没有建立信任根

- 建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。 + 不建立信任根就无法添加 Agent 和 Probe,只能进入面板做基础管理——这是架构如此:节点只信任由你的 Passkey 签名的信任根,面板自己没法代签。建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。 这一步会把下面列出的 Passkey 登记为操作员,需要你触碰一次 Passkey 给集合签名。

{state.proposal ? ( diff --git a/web/shared/api/schema.ts b/web/shared/api/schema.ts index 98e9fac..4102822 100644 --- a/web/shared/api/schema.ts +++ b/web/shared/api/schema.ts @@ -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; @@ -2073,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; @@ -2194,6 +2221,7 @@ export interface components { server_key: string; /** @default false */ server_key_listed: boolean; + operator_details?: null | components["schemas"]["NodeOwnerDto"][]; }; UserDto: { id: string; @@ -3076,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; diff --git a/web/shared/api/types.ts b/web/shared/api/types.ts index 721db0f..618df03 100644 --- a/web/shared/api/types.ts +++ b/web/shared/api/types.ts @@ -56,6 +56,7 @@ export type GapDto = Narrow export type RecomputeDto = Narrow export type RawChunkMetaDto = Narrow export type LoginResponse = Narrow +export type SetupStatusDto = Narrow export interface Page { items: T[]