feat: first-run claim wizard (no default admin, claim code from the backend log); owner list shows account and passkey; container install first [CI]
ci/woodpecker/push/main-amd64-build Pipeline was successful
ci/woodpecker/push/main-amd64-build Pipeline was successful
This commit is contained in:
1 parent
2ae63653c4
commit
f22fdf8b68
31 files changed
+799
-49
No files matched your search
+1
-1
@@ -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)。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -19,6 +19,14 @@ public static class AuthEndpoints
|
||||
.AllowsAnonymous()
|
||||
.Api("auth_login", "Sign in with a username and password", Tag)
|
||||
.Returns<LoginResponse>().ProducesProblems(400, 401, 429);
|
||||
open.MapGet("/auth/setup", SetupStatusAsync)
|
||||
.AllowsAnonymous()
|
||||
.Api("setup_status", "Whether the panel has no users yet and still has to be claimed", Tag)
|
||||
.Returns<SetupStatusDto>();
|
||||
open.MapPost("/auth/setup", SetupAsync)
|
||||
.AllowsAnonymous()
|
||||
.Api("setup_complete", "Create the first administrator (needs the claim code from the backend's log); the same response as the password sign-in. Closed once the panel is claimed (a user exists)", Tag)
|
||||
.Returns<LoginResponse>(StatusCodes.Status201Created).ProducesProblems(400, 401, 409, 429);
|
||||
open.MapPost("/auth/passkey/options", PasskeyLoginOptions)
|
||||
.AllowsAnonymous()
|
||||
.Api("auth_passkey_options", "A challenge for signing in with a passkey (discoverable credential, no username needed)", Tag)
|
||||
@@ -77,6 +85,32 @@ public static class AuthEndpoints
|
||||
return IssueSession(http, login, request.ReturnSession == true, options);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetupStatusAsync(SetupService setup, CancellationToken ct)
|
||||
{
|
||||
return TypedResults.Json(new SetupStatusDto(await setup.NeedsSetupAsync(ct)), ApiJsonContext.Default.SetupStatusDto);
|
||||
}
|
||||
|
||||
private static async Task<IResult> SetupAsync(HttpContext http, SetupRequest request, SetupService setup, AuditService audit, LoginRateLimiter limiter, BackendOptions options)
|
||||
{
|
||||
IResult limit = Limited(http, limiter, out bool ok, "setup:");
|
||||
if (!ok)
|
||||
{
|
||||
return limit;
|
||||
}
|
||||
|
||||
string remote = http.ClientAddress();
|
||||
Result<LoginResult> result = await setup.CompleteAsync(request.ClaimCode, request.Username ?? "", request.Password ?? "", http.RequestAborted);
|
||||
if (!result.TryGet(out LoginResult? login, out FleetError? error))
|
||||
{
|
||||
await audit.RecordAsync(null, request.Username ?? "", AuthMethods.Password, "setup.complete", null, null, remote, false, http.RequestAborted);
|
||||
return Problems.From(http, error);
|
||||
}
|
||||
|
||||
await audit.RecordAsync(login.UserId, login.Username, AuthMethods.Password, "setup.complete", null, null, remote, true, http.RequestAborted);
|
||||
Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> session = IssueSession(http, login, request.ReturnSession == true, options);
|
||||
return TypedResults.Json(session.Value!, ApiJsonContext.Default.LoginResponse, statusCode: StatusCodes.Status201Created);
|
||||
}
|
||||
|
||||
/// <summary>发放会话:Cookie(同源前端)或在响应里返回会话令牌(跨域前端的 return_session)。密码登录和 passkey 登录共用。</summary>
|
||||
private static Microsoft.AspNetCore.Http.HttpResults.JsonHttpResult<LoginResponse> IssueSession(HttpContext http, LoginResult login, bool returnSession, BackendOptions options)
|
||||
{
|
||||
|
||||
@@ -156,7 +156,7 @@ public static class Mappers
|
||||
public static JoinTokenDto ToDto(JoinTokenInfo t) =>
|
||||
new(t.Id, t.Note, t.Labels, t.AutoApprove, t.MaxUses, t.Uses, Time(t.ExpiresAtMs), Time(t.RevokedAtMs), Time(t.CreatedAtMs), JoinTokenService.RoleOf(t.Kind), t.CreatedBy);
|
||||
|
||||
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed);
|
||||
public static TrustStateDto ToDto(TrustState s, string installUrl = "", string installSh = "", string installPs1 = "", bool? matches = null) => new(s.Initialized, s.Genesis, s.Version, s.InSync, s.Proposal is null ? null : new TrustProposalDto(s.Proposal.Statement, s.Proposal.Version, s.Proposal.Challenge, [.. s.Proposal.Added.Select(a => new ProposedCredentialDto(a.Id, a.Name, Time(a.CreatedAtMs)))], s.Proposal.Removed, s.Proposal.ServerKeyChanged), installUrl, s.Operators ?? [], installSh, installPs1, matches, s.ServerKey, s.ServerKeyListed, [.. (s.OperatorDetails ?? []).Select(o => new NodeOwnerDto(o.CredentialId, o.PasskeyName, o.Username))]);
|
||||
|
||||
|
||||
public static AuditDto ToDto(AuditRecord a) => new(a.Id, Time(a.AtMs), a.Actor, a.Method, a.Action, a.Target, a.Detail, a.Remote, a.Success);
|
||||
|
||||
@@ -75,6 +75,12 @@ public sealed record JoinTokenDto(string Id, string? Note, IReadOnlyList<string>
|
||||
public sealed record CreateJoinTokenRequest(string? Note, IReadOnlyList<string>? Labels, bool? AutoApprove, int? MaxUses, int? ExpiresInHours, string? Role = null, IReadOnlyList<string>? Controllers = null);
|
||||
|
||||
/// <summary>令牌明文只在创建的响应里出现一次。</summary>
|
||||
/// <summary>面板是不是还没有任何用户(需要首次初始化)。</summary>
|
||||
public sealed record SetupStatusDto(bool NeedsSetup);
|
||||
|
||||
/// <param name="ClaimCode">首次启动时写在后端日志里的认领码(或者部署时指定的 <c>MoShiro__ClaimCode</c>)。</param>
|
||||
public sealed record SetupRequest(string? ClaimCode, string? Username, string? Password, bool? ReturnSession);
|
||||
|
||||
public sealed record InstallCommandsDto(string Linux, string Macos, string Windows);
|
||||
|
||||
/// <summary>下一版里新增的 passkey。</summary>
|
||||
@@ -89,9 +95,10 @@ public sealed record TrustProposalDto(string Statement, int Version, string Chal
|
||||
/// <param name="InSync">当前生效的集合和已注册的 passkey、域名一致;不一致(比如新加了 passkey)就要签一个新版本。</param>
|
||||
/// <param name="Proposal">下一个要签的版本(<c>challenge</c> 就是 WebAuthn 的挑战)。</param>
|
||||
/// <param name="Operators">当前操作员集合里的 passkey 凭证 id:加入码的 <c>controllers</c> 只能从这里选。</param>
|
||||
/// <param name="OperatorDetails">每个操作员是哪个账户的哪把 passkey(对不上已注册的 passkey 时名称和账户为空)。</param>
|
||||
/// <param name="InstallShSha256">面板提供的安装脚本的 SHA-256(命令里核对的就是它)。</param>
|
||||
/// <param name="InstallMatchesRelease">和 CI 签名的发布清单里记的哈希是否一致;没有清单或清单里没记时为空。不一致说明这个面板提供的脚本不是 CI 构建出来的那份。</param>
|
||||
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false);
|
||||
public sealed record TrustStateDto(bool Initialized, string? Genesis, int Version, bool InSync, TrustProposalDto? Proposal, string InstallUrl = "", IReadOnlyList<string>? Operators = null, string InstallShSha256 = "", string InstallPs1Sha256 = "", bool? InstallMatchesRelease = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwnerDto>? OperatorDetails = null);
|
||||
|
||||
public sealed record CreatedJoinTokenDto(JoinTokenDto Token, string Plaintext, InstallCommandsDto InstallCommands);
|
||||
|
||||
|
||||
@@ -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<PasskeyDto>))]
|
||||
|
||||
@@ -38,6 +38,7 @@ public static class ApplicationServiceCollectionExtensions
|
||||
services.AddSingleton<PasskeyService>();
|
||||
services.AddSingleton<AuditService>();
|
||||
services.AddSingleton<ServerIdentityService>();
|
||||
services.AddSingleton<SetupService>();
|
||||
services.AddSingleton<BinaryBindingService>();
|
||||
services.AddSingleton<NodeOwnerService>();
|
||||
services.AddSingleton<Fleet.Server.IBinaryAttestor>(sp => sp.GetRequiredService<BinaryBindingService>());
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Security.Cryptography;
|
||||
using Fleet.Core;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 首次认领:面板里还没有任何用户时,由部署的人自己填管理员的用户名和密码——没有默认账户,也没有默认密码。
|
||||
/// <para>
|
||||
/// 谁先打开页面谁就能当管理员,所以要有一个只有部署的人才拿得到的<b>认领码</b>:首次启动时随机生成并写进后端的日志
|
||||
/// (<c>journalctl -u moshiro-backend</c> / <c>podman logs</c>),或者用 <c>MoShiro__ClaimCode</c> 自己指定。
|
||||
/// 创建完第一个管理员,认领码立刻作废,这个入口也就关闭了。
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed partial class SetupService : IDisposable
|
||||
{
|
||||
private const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
private const int CodeLength = 16;
|
||||
|
||||
private readonly AuthService _auth;
|
||||
private readonly IAuthStore _store;
|
||||
private readonly BackendOptions _options;
|
||||
private readonly ILogger<SetupService> _log;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private string? _code;
|
||||
|
||||
public SetupService(AuthService auth, IAuthStore store, BackendOptions options, ILogger<SetupService> log)
|
||||
{
|
||||
_auth = auth;
|
||||
_store = store;
|
||||
_options = options;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public void Dispose() => _gate.Dispose();
|
||||
|
||||
/// <summary>面板里还没有任何用户。</summary>
|
||||
public async ValueTask<bool> NeedsSetupAsync(CancellationToken ct) => await _store.CountUsersAsync(ct) == 0;
|
||||
|
||||
/// <summary>启动时调用:需要初始化就准备好认领码,并写进日志(只有能看到日志的人才拿得到)。</summary>
|
||||
public async ValueTask AnnounceAsync(CancellationToken ct)
|
||||
{
|
||||
if (await NeedsSetupAsync(ct))
|
||||
{
|
||||
LogClaimCode(_log, _options.PublicUrl ?? "the panel's address", Code());
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Result<LoginResult>> CompleteAsync(string? claimCode, string username, string password, CancellationToken ct)
|
||||
{
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (!await NeedsSetupAsync(ct))
|
||||
{
|
||||
return Result<LoginResult>.Fail(MoShiroErrors.NameConflict, "the panel has already been claimed");
|
||||
}
|
||||
|
||||
if (claimCode is null || !AuthService.FixedTimeEquals(Normalize(claimCode), Normalize(Code())))
|
||||
{
|
||||
return Result<LoginResult>.Fail(MoShiroErrors.Unauthorized, "the claim code is not valid (see the backend's log)");
|
||||
}
|
||||
|
||||
Result<UserRecord> created = await _auth.CreateUserAsync(username, password, ct);
|
||||
if (!created.TryGet(out _, out FleetError? error))
|
||||
{
|
||||
return Result<LoginResult>.Fail(error);
|
||||
}
|
||||
|
||||
_code = null; // 一次有效:第一个管理员创建完,这个入口就关了
|
||||
return await _auth.LoginAsync(username, password, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private string Code() => _code ??= string.IsNullOrWhiteSpace(_options.ClaimCode) ? Generate() : _options.ClaimCode.Trim();
|
||||
|
||||
private static string Generate()
|
||||
{
|
||||
char[] chars = new char[CodeLength];
|
||||
for (int i = 0; i < chars.Length; i++)
|
||||
{
|
||||
chars[i] = Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)];
|
||||
}
|
||||
|
||||
string raw = new(chars);
|
||||
return string.Join('-', Enumerable.Range(0, CodeLength / 4).Select(g => raw.Substring(g * 4, 4)));
|
||||
}
|
||||
|
||||
/// <summary>比较时不分大小写、忽略分隔符和空格。</summary>
|
||||
private static string Normalize(string code) => new(code.Where(char.IsLetterOrDigit).Select(char.ToUpperInvariant).ToArray());
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "First start: no administrator exists yet. Open {Address} and finish the setup there with this claim code: {Code}")]
|
||||
private static partial void LogClaimCode(ILogger logger, string address, string code);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public sealed record ProposedCredential(string Id, string Name, long CreatedAtMs
|
||||
|
||||
/// <summary><c>InSync</c>:当前生效的集合和现有的 passkey、域名是否一致;不一致就需要签一个新版本。</summary>
|
||||
/// <param name="ServerKey">这个面板的服务器身份密钥的指纹;<c>ServerKeyListed</c>:当前生效的操作员集合里已经登记了它(节点据此认证服务器、加密链路)。</param>
|
||||
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false);
|
||||
public sealed record TrustState(bool Initialized, string? Genesis, int Version, bool InSync, IReadOnlyList<SignedStatementDto> Chain, TrustProposal? Proposal, IReadOnlyList<string>? Operators = null, string ServerKey = "", bool ServerKeyListed = false, IReadOnlyList<NodeOwner>? OperatorDetails = null);
|
||||
|
||||
/// <summary>
|
||||
/// 信任根的 Backend 一侧:保存操作员集合的链、给操作员出下一版的语句、验证他签回来的版本。
|
||||
@@ -56,7 +56,16 @@ public sealed class TrustService : IOperatorTrustSource, IDisposable
|
||||
TrustProposal? proposal = await ProposeAsync(chain, current, context, ct);
|
||||
OperatorSet? proposed = proposal is null ? null : OperatorTrust.ParseSet(proposal.Statement);
|
||||
bool inSync = current is not null && proposed is not null && Same(current, proposed);
|
||||
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true);
|
||||
// 每个操作员是哪个账户的哪把 passkey:添加节点时选属主、信任页都要显示
|
||||
var details = new List<NodeOwner>();
|
||||
foreach (OperatorKey key in current?.Keys ?? [])
|
||||
{
|
||||
PasskeyRecord? passkey = await _auth.FindPasskeyAsync(key.CredentialId, ct);
|
||||
string? account = passkey is null ? null : (await _auth.GetUserAsync(passkey.UserId, ct))?.Username;
|
||||
details.Add(new NodeOwner(key.CredentialId, passkey?.Name, account));
|
||||
}
|
||||
|
||||
return new TrustState(current is not null, chain.Count == 0 ? null : OperatorTrust.Fingerprint(chain[0].Statement), current?.Version ?? 0, inSync, chain, inSync ? null : proposal, current is null ? [] : [.. current.Keys.Select(k => k.CredentialId)], _server.Fingerprint, current?.Servers?.Any(k => k.AsSpan().SequenceEqual(_server.PublicKeySpki)) == true, details);
|
||||
}
|
||||
|
||||
private async ValueTask<TrustProposal?> ProposeAsync(IReadOnlyList<SignedStatementDto> chain, OperatorSet? current, PasskeyContext context, CancellationToken ct)
|
||||
|
||||
@@ -108,6 +108,12 @@ public sealed class BackendOptions
|
||||
/// </summary>
|
||||
public string? GeoLookupUrl { get; set; } = "https://ip.007995.xyz";
|
||||
|
||||
/// <summary>
|
||||
/// 首次认领的认领码(可选):面板里还没有任何用户时,第一个管理员由部署的人自己在页面上填,需要这个码才能创建。
|
||||
/// 不设置就在首次启动时随机生成并写进后端的日志。创建完第一个管理员后作废。
|
||||
/// </summary>
|
||||
public string? ClaimCode { get; set; }
|
||||
|
||||
/// <summary>初始管理员(只在没有任何用户时创建)。</summary>
|
||||
public string? InitialAdminUser { get; set; }
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.Extensions.Hosting;
|
||||
namespace MoShiro.Backend.Application;
|
||||
|
||||
/// <summary>
|
||||
/// 启动时最先执行:截断没有索引记录的段文件尾部、创建初始管理员、创建默认的线路任务模板。
|
||||
/// 启动时最先执行:截断没有索引记录的段文件尾部、(用配置里的凭据创建管理员,没有的话把首次认领码写进日志)创建默认的线路任务模板。
|
||||
/// 宿主按注册顺序依次等待各个 IHostedService 启动,所以它需要最先注册。
|
||||
/// </summary>
|
||||
public sealed class StartupTasks : IHostedService
|
||||
@@ -12,9 +12,11 @@ public sealed class StartupTasks : IHostedService
|
||||
private readonly AuthService _auth;
|
||||
private readonly TaskService _tasks;
|
||||
private readonly ServerIdentityService _identity;
|
||||
private readonly SetupService _setup;
|
||||
|
||||
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity)
|
||||
public StartupTasks(IRawStore raw, AuthService auth, TaskService tasks, ServerIdentityService identity, SetupService setup)
|
||||
{
|
||||
_setup = setup;
|
||||
_identity = identity;
|
||||
_raw = raw;
|
||||
_auth = auth;
|
||||
@@ -25,6 +27,7 @@ public sealed class StartupTasks : IHostedService
|
||||
{
|
||||
await _raw.RecoverAsync(cancellationToken);
|
||||
await _auth.EnsureInitialAdminAsync(cancellationToken);
|
||||
await _setup.AnnounceAsync(cancellationToken);
|
||||
await _tasks.SeedDefaultsAsync(cancellationToken);
|
||||
await _identity.EnsureAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -25,6 +26,9 @@ public class BackendFixture : IAsyncLifetime
|
||||
public const string AdminUser = "admin";
|
||||
public const string AdminPassword = "correct horse battery";
|
||||
|
||||
/// <summary>首次认领码:和线上一样,第一个管理员通过 <c>/api/admin/auth/setup</c> 创建,不再用配置里的默认账户。</summary>
|
||||
public const string ClaimCode = "TEST-CLAIM-CODE-0001";
|
||||
|
||||
private readonly List<AgentSimulator> _agents = [];
|
||||
private string _adminToken = "";
|
||||
private int _counter;
|
||||
@@ -256,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>());
|
||||
|
||||
string origin = BaseAddress.GetLeftPart(UriPartial.Authority);
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class CapabilityTests
|
||||
}
|
||||
|
||||
// 只有登录(密码和 passkey)是匿名的
|
||||
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
|
||||
Assert.Equal(["/api/admin/auth/login", "/api/admin/auth/passkey/login", "/api/admin/auth/passkey/options", "/api/admin/auth/setup"], endpoints.Where(e => e.Metadata.GetMetadata<CapabilityRequirement>()!.Capability is null).Select(e => e.RoutePattern.RawText!).Distinct().Order(StringComparer.Ordinal).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -122,7 +122,7 @@ public sealed partial class OpenApiConformanceTests
|
||||
public async Task EveryAdminOperation_DocumentsItsAuthenticationFailures()
|
||||
{
|
||||
JsonObject doc = await DocumentAsync();
|
||||
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options")))
|
||||
foreach ((string path, string method, JsonObject operation) in Operations(doc).Where(o => o.Path.StartsWith("/api/admin/", StringComparison.Ordinal) && o.Path is not ("/api/admin/auth/login" or "/api/admin/auth/passkey/login" or "/api/admin/auth/passkey/options" or "/api/admin/auth/setup")))
|
||||
{
|
||||
JsonObject responses = operation["responses"]!.AsObject();
|
||||
Assert.True(responses.ContainsKey("401"), $"{method} {path} must document 401");
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Net;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MoShiro.Backend.Application;
|
||||
|
||||
namespace MoShiro.Backend.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 首次认领:面板里没有任何用户时,部署的人自己填管理员的用户名和密码——没有默认账户、没有默认密码。
|
||||
/// 谁先打开页面谁就能当管理员,所以要有只有部署的人才拿得到的认领码(写在后端日志里,或者部署时指定);创建完第一个管理员,入口就关了。
|
||||
/// </summary>
|
||||
public sealed class SetupTests : IAsyncLifetime
|
||||
{
|
||||
private const string Code = "ABCD-EFGH-JKLM-NPQR";
|
||||
|
||||
private WebApplication _app = null!;
|
||||
private HttpClient _http = null!;
|
||||
private string _directory = "";
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
_directory = Path.Combine(Path.GetTempPath(), "moshiro-setup-" + Guid.NewGuid().ToString("N"));
|
||||
_app = BackendHost.Build(
|
||||
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
|
||||
options =>
|
||||
{
|
||||
options.DataDirectory = _directory;
|
||||
options.Pbkdf2Iterations = 1000;
|
||||
options.SecureCookies = false;
|
||||
options.ClaimCode = Code;
|
||||
options.LoginAttemptsPerMinute = 1000;
|
||||
options.GeoLookupUrl = string.Empty;
|
||||
});
|
||||
await _app.StartAsync();
|
||||
_http = new HttpClient { BaseAddress = new Uri(_app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_http.Dispose();
|
||||
await _app.StopAsync();
|
||||
await _app.DisposeAsync();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); // 连接池还拿着数据库文件
|
||||
Directory.Delete(_directory, recursive: true);
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> SetupAsync(string? code, string username, string password, bool returnSession = false) =>
|
||||
_http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = code, ["username"] = username, ["password"] = password, ["return_session"] = returnSession }));
|
||||
|
||||
[Fact]
|
||||
public async Task ABrandNewPanel_HasNoUsers_AndNoDefaultAccountToSignInWith()
|
||||
{
|
||||
JsonNode status = JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!;
|
||||
Assert.True(status["needs_setup"]!.GetValue<bool>());
|
||||
|
||||
foreach (string username in new[] { "admin", "administrator", "root" })
|
||||
{
|
||||
using HttpResponseMessage login = await _http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = username, ["password"] = "admin" }));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheClaimCode_IsRequired_SoTheFirstVisitorCannotTakeThePanel()
|
||||
{
|
||||
using HttpResponseMessage none = await SetupAsync(null, "owner", "correct horse battery");
|
||||
using HttpResponseMessage wrong = await SetupAsync("AAAA-BBBB-CCCC-DDDD", "owner", "correct horse battery");
|
||||
using HttpResponseMessage empty = await SetupAsync("", "owner", "correct horse battery");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, none.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, wrong.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, empty.StatusCode);
|
||||
Assert.True(JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>()); // 还是没人
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheCredentialsAreValidated_AndAMistakeDoesNotBurnTheCode()
|
||||
{
|
||||
using HttpResponseMessage shortPassword = await SetupAsync(Code, "owner", "short");
|
||||
using HttpResponseMessage badName = await SetupAsync(Code, "x", "correct horse battery");
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, shortPassword.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, badName.StatusCode);
|
||||
|
||||
// 填错了还能再来
|
||||
using HttpResponseMessage ok = await SetupAsync(Code, "owner", "correct horse battery");
|
||||
Assert.Equal(HttpStatusCode.Created, ok.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheFirstAdministrator_IsCreatedByTheDeployer_SignedInRightAway_AndTheDoorCloses()
|
||||
{
|
||||
// 认领码不分大小写、不看分隔符
|
||||
using HttpResponseMessage created = await SetupAsync("abcd efgh-jklm npqr", "owner", "correct horse battery", returnSession: true);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
||||
JsonNode body = JsonNode.Parse(await created.Content.ReadAsStringAsync())!;
|
||||
Assert.Equal("owner", body["username"]!.GetValue<string>());
|
||||
Assert.False(string.IsNullOrEmpty(body["session_token"]?.GetValue<string>())); // 马上登录,接着引导 passkey 和信任根
|
||||
|
||||
// 入口关了:再用同一个码、别的用户名也不行,状态也变了
|
||||
using HttpResponseMessage again = await SetupAsync(Code, "intruder", "correct horse battery");
|
||||
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
||||
Assert.False(JsonNode.Parse(await _http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>());
|
||||
|
||||
// 管理员能用自己填的账号密码正常登录(密码登录的会话有能力,passkey 之后再加)
|
||||
using HttpResponseMessage login = await _http.PostAsync("/api/admin/auth/login", BackendFixture.Json(new JsonObject { ["username"] = "owner", ["password"] = "correct horse battery" }));
|
||||
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WithoutAConfiguredCode_TheBackendGeneratesOne_AndNothingElseUnlocksTheSetup()
|
||||
{
|
||||
// 另起一个没有指定认领码的后端:随机生成并写进日志,猜不到;任何别的码都进不去
|
||||
string directory = Path.Combine(Path.GetTempPath(), "moshiro-setup-" + Guid.NewGuid().ToString("N"));
|
||||
await using WebApplication generated = BackendHost.Build(
|
||||
["--urls", "http://127.0.0.1:0", "--Logging:LogLevel:Default=Warning"],
|
||||
options =>
|
||||
{
|
||||
options.DataDirectory = directory;
|
||||
options.Pbkdf2Iterations = 1000;
|
||||
options.SecureCookies = false;
|
||||
options.LoginAttemptsPerMinute = 1000;
|
||||
options.GeoLookupUrl = string.Empty;
|
||||
});
|
||||
await generated.StartAsync();
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient { BaseAddress = new Uri(generated.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.First()) };
|
||||
foreach (string guess in new[] { Code, "0000-0000-0000-0000", "admin", "TEST-CLAIM-CODE-0001" })
|
||||
{
|
||||
using HttpResponseMessage response = await http.PostAsync("/api/admin/auth/setup", BackendFixture.Json(new JsonObject { ["claim_code"] = guess, ["username"] = "owner", ["password"] = "correct horse battery" }));
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
Assert.True(JsonNode.Parse(await http.GetStringAsync("/api/admin/auth/setup"))!["needs_setup"]!.GetValue<bool>());
|
||||
}
|
||||
finally
|
||||
{
|
||||
await generated.StopAsync();
|
||||
await generated.DisposeAsync();
|
||||
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -250,6 +250,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<string>() == op.Passkey.Id)!;
|
||||
Assert.False(string.IsNullOrEmpty(detail["passkey"]?.GetValue<string>()));
|
||||
Assert.False(string.IsNullOrEmpty(detail["account"]?.GetValue<string>()));
|
||||
Assert.Equal(state["operators"]!.AsArray().Count, state["operator_details"]!.AsArray().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APinnedAgent_ReportsItsOwner_AndThePanelFindsTheAccountAndPasskey()
|
||||
{
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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,节点不用重装。
|
||||
|
||||
|
||||
+7
-1
@@ -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 文档在普通开发构建中生成。
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
#
|
||||
# 首次部署 Agent:sudo sh deploy-node.sh agent --image-repo REPO --server https://panel --join jt_xxx --name hk-1
|
||||
# 首次部署 Probe:sudo sh deploy-node.sh probe --image-repo REPO --server https://panel --join jt_xxx --name home-1
|
||||
# 首次部署 Backend:sudo sh deploy-node.sh backend --channel dev --admin-password '…' [--public-url https://panel] [--port 29181] [--env KEY=VALUE]...
|
||||
# 首次部署 Backend:sudo sh deploy-node.sh backend --channel dev [--public-url https://panel] [--port 29181] [--env KEY=VALUE]...
|
||||
# 没有默认管理员:部署完打开面板,按页面引导自己创建管理员、注册 passkey、建立信任根;页面要的认领码在后端日志里(journalctl -u moshiro-backend)。
|
||||
# 更新(配置迁移、单元和更新器、镜像):sudo sh deploy-node.sh agent (其余参数沿用上次的更新器配置)
|
||||
# 旧版 Probe(probe.json 里没有 fleet 段):加上 --server --join --name 重新运行即可,旧文件备份为 probe.json.old
|
||||
#
|
||||
@@ -16,7 +17,7 @@ set -eu
|
||||
usage() {
|
||||
echo "usage: deploy-node.sh <agent|probe|backend> [--engine podman|docker] [--image-repo R] [--channel dev|release] [--tag T] [--image REF]" >&2
|
||||
echo " [--server URL --join TOKEN --name NAME] [--policy auto|manual] [--authfile F]" >&2
|
||||
echo " backend: [--channel dev|release] [--port N] [--admin-user U] [--admin-password P] [--public-url URL] [--env KEY=VALUE]..." >&2
|
||||
echo " backend: [--channel dev|release] [--port N] [--admin-user U --admin-password P:只给自动化用,正常不要] [--public-url URL] [--env KEY=VALUE]..." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
@@ -143,18 +144,17 @@ if [ "$ROLE" = backend ]; then
|
||||
# Backend 的环境文件放在宿主机上(单元用 --env-file 读取);已有的不会被覆盖
|
||||
install -d -m 755 /etc/moshiro
|
||||
if [ ! -f /etc/moshiro/backend.env ]; then
|
||||
if [ -z "$ADMIN_PASSWORD" ]; then
|
||||
ADMIN_PASSWORD="$(head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 20)"
|
||||
echo "generated admin password (shown once): $ADMIN_PASSWORD"
|
||||
fi
|
||||
(
|
||||
umask 077
|
||||
{
|
||||
echo "MoShiro__DataDirectory=/data"
|
||||
echo "MoShiro__UpdateChannel=$CHANNEL"
|
||||
echo "MoShiro__ImageNamespace=$NAMESPACE"
|
||||
echo "MoShiro__InitialAdminUser=${ADMIN_USER:-admin}"
|
||||
echo "MoShiro__InitialAdminPassword=$ADMIN_PASSWORD"
|
||||
# 没有默认管理员:第一个管理员由部署的人在首次打开的页面上自己填,认领码在后端日志里(--admin-password 只给自动化用)
|
||||
if [ -n "$ADMIN_PASSWORD" ]; then
|
||||
echo "MoShiro__InitialAdminUser=${ADMIN_USER:-admin}"
|
||||
echo "MoShiro__InitialAdminPassword=$ADMIN_PASSWORD"
|
||||
fi
|
||||
[ -z "$PUBLIC_URL" ] || echo "MoShiro__PublicUrl=$PUBLIC_URL"
|
||||
# 其他设置原样写入,例如 --env MoShiro__CorsOrigins=http://localhost:5173 --env MoShiro__SecureCookies=false
|
||||
[ -z "$EXTRA_ENV" ] || printf '%s' "$EXTRA_ENV"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describeError, Field, Segmented, SourceButton, Spinner, ThemeToggle } f
|
||||
import { useAuth } from './auth'
|
||||
import { BackendUpdateNotice } from './BackendUpdateNotice'
|
||||
import { PasskeyNotice } from './PasskeyNotice'
|
||||
import { Setup } from './Setup'
|
||||
import { AgentDetail } from './pages/AgentDetail'
|
||||
import { Agents } from './pages/Agents'
|
||||
import { Probes } from './pages/Probes'
|
||||
@@ -232,7 +233,7 @@ const NAV = [
|
||||
]
|
||||
|
||||
export function App() {
|
||||
const { me, ready, logout } = useAuth()
|
||||
const { me, ready, logout, setupActive } = useAuth()
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="login">
|
||||
@@ -240,6 +241,7 @@ export function App() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (setupActive) return <Setup />
|
||||
if (!me) return <Login />
|
||||
// 密码登录的会话没有任何能力:只给它添加 Passkey 的页面
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export function PasskeyNotice() {
|
||||
<div className="row wrap">
|
||||
<KeyRound size={16} />
|
||||
<span>
|
||||
<b>建议添加 Passkey</b>:用指纹、人脸或安全密钥登录,不用输密码;以后加节点这类敏感操作也需要它。
|
||||
<b>还没有 Passkey</b>:不注册 Passkey、不建立信任根就无法添加 Agent 和 Probe,现在只能做基础管理(架构如此:节点只信任由 Passkey 签名的信任根)。Passkey 也可以用来登录,不用输密码。
|
||||
</span>
|
||||
<span className="right row">
|
||||
<button type="button" className="btn small text" onClick={later}>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Check, KeyRound, ShieldCheck, UserPlus } from 'lucide-react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { ApiError } from '@shared/api/client'
|
||||
import { passkeySupported } from '@shared/api/webauthn'
|
||||
import { describeError, Field } from '@shared/ui'
|
||||
import { useAuth } from './auth'
|
||||
import { Trust } from './pages/Trust'
|
||||
import { PasskeyAddForm } from './pages/PasskeyAdd'
|
||||
|
||||
const STEPS = [
|
||||
{ icon: UserPlus, label: '认领面板' },
|
||||
{ icon: KeyRound, label: '注册 Passkey' },
|
||||
{ icon: ShieldCheck, label: '建立信任根' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 认领向导:面板里还没有任何用户时出现。没有默认账户,管理员的用户名和密码由部署的人自己填;
|
||||
* 谁先打开页面谁就能当管理员,所以第一步要后端日志里的认领码(只有部署的人拿得到)。
|
||||
* 后两步是引导,不是门槛:之后随时可以在「用户 → 我的 Passkey」和「系统 → 信任」里补,没有 passkey 也能用密码登录管理。
|
||||
*/
|
||||
export function Setup() {
|
||||
const { me, completeSetup, finishSetup } = useAuth()
|
||||
const [step, setStep] = useState(me ? 1 : 0)
|
||||
const [trusted, setTrusted] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="card stack" style={{ maxWidth: 640, width: '100%' }}>
|
||||
<div>
|
||||
<h1>MoShiro</h1>
|
||||
<p className="muted">第一次打开:先认领这个面板——建好管理员、Passkey 和信任根。</p>
|
||||
</div>
|
||||
<ol className="row" style={{ gap: 16, listStyle: 'none', padding: 0, margin: 0, flexWrap: 'wrap' }}>
|
||||
{STEPS.map((s, i) => (
|
||||
<li key={s.label} className="row" style={{ gap: 6, opacity: i === step ? 1 : 0.55, fontWeight: i === step ? 600 : 400 }}>
|
||||
{i < step ? <Check size={16} /> : <s.icon size={16} />}
|
||||
{i + 1}. {s.label}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{step === 0 ? <Account onDone={() => setStep(1)} complete={completeSetup} /> : null}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="stack">
|
||||
<p className="muted">
|
||||
Passkey 是整个系统的根:它给「操作员集合」签名,节点只认从它出发的信任根,以后 web ssh 等敏感操作也只认它。
|
||||
<b>不注册 Passkey、不建立信任根就无法使用这些功能:添加不了 Agent 和 Probe,只能进入面板做基础管理</b>,这是架构如此,面板自己没法替你代签。
|
||||
{passkeySupported() ? '' : ' 这个浏览器不支持 Passkey(需要 HTTPS 或 localhost)。'}
|
||||
</p>
|
||||
<PasskeyAddForm compact onAdded={() => setStep(2)} />
|
||||
<div>
|
||||
<button type="button" className="btn text" onClick={finishSetup}>
|
||||
跳过,只进入面板做基础管理
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="stack">
|
||||
<Trust onInitialized={() => setTrusted(true)} />
|
||||
<div>
|
||||
<button type="button" className="btn primary" onClick={finishSetup}>
|
||||
{trusted ? '完成,进入面板' : '跳过,只进入面板做基础管理(之后到「系统 → 信任」建立)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Account({ complete, onDone }: { complete: (code: string, username: string, password: string) => Promise<void>; onDone: () => void }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (password !== confirm) {
|
||||
setError('两次输入的密码不一样')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await complete(code, username, password)
|
||||
onDone()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) setError('认领码不对:它写在后端的日志里(journalctl -u moshiro-backend,或 docker logs)')
|
||||
else if (err instanceof ApiError && err.status === 409) setError('这个面板已经被认领过了,请直接登录(刷新页面)')
|
||||
else setError(describeError(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="stack" onSubmit={submit}>
|
||||
<Field label="认领码" hint="用来证明你是部署这个面板的人(防止别人抢先认领它)。首次启动时写在后端日志里(journalctl -u moshiro-backend 或 docker logs),或者部署时用 MoShiro__ClaimCode 指定。只有部署的人拿得到。">
|
||||
<input className="input mono" value={code} onChange={(e) => setCode(e.target.value)} placeholder="XXXX-XXXX-XXXX-XXXX" autoComplete="off" autoFocus required />
|
||||
</Field>
|
||||
<Field label="管理员用户名" hint="3–32 个字符:字母、数字、. _ -">
|
||||
<input className="input" value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required />
|
||||
</Field>
|
||||
<Field label="密码" hint="至少 12 个字符">
|
||||
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" required />
|
||||
</Field>
|
||||
<Field label="再输一遍密码">
|
||||
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" required />
|
||||
</Field>
|
||||
{error ? <div className="notice bad">{error}</div> : null}
|
||||
<div>
|
||||
<button className="btn primary" type="submit" disabled={busy}>
|
||||
认领面板
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
+31
-4
@@ -1,7 +1,7 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||
import { api, ApiError, loginTo, setCsrf } from '@shared/api/client'
|
||||
import { getBase, normalizeBase, setSource, setToken } from '@shared/api/source'
|
||||
import type { LoginResponse, MeDto } from '@shared/api/types'
|
||||
import type { LoginResponse, MeDto, SetupStatusDto } from '@shared/api/types'
|
||||
import { signInWithPasskey } from '@shared/api/webauthn'
|
||||
|
||||
interface Auth {
|
||||
@@ -12,6 +12,12 @@ interface Auth {
|
||||
/** passkey 登录(只对页面所在的后端;passkey 绑定在后端的域名上)。 */
|
||||
loginWithPasskey: (opts?: { conditional?: boolean; signal?: AbortSignal }) => Promise<void>
|
||||
logout: () => Promise<void>
|
||||
/** 面板里还没有任何用户:显示认领向导(创建管理员 → 注册 passkey → 建立信任根)。向导走完(或离开)之前一直为真。 */
|
||||
setupActive: boolean
|
||||
/** 向导第一步:用后端日志里的认领码创建第一个管理员,并马上以他登录。 */
|
||||
completeSetup: (claimCode: string, username: string, password: string) => Promise<void>
|
||||
/** 离开向导,进入面板。 */
|
||||
finishSetup: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<Auth | null>(null)
|
||||
@@ -19,6 +25,7 @@ const AuthContext = createContext<Auth | null>(null)
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [me, setMe] = useState<MeDto | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [setupActive, setSetupActive] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
@@ -27,8 +34,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setCsrf(m.csrf_token ?? null)
|
||||
setMe(m)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!(e instanceof ApiError) || (e.status !== 401 && e.status !== 403)) console.error(e)
|
||||
.catch(async (e: unknown) => {
|
||||
if (!(e instanceof ApiError) || (e.status !== 401 && e.status !== 403)) {
|
||||
console.error(e)
|
||||
return
|
||||
}
|
||||
// 没登录:如果面板里连一个用户都没有,就是第一次打开,进认领向导(只对页面所在的这个后端)
|
||||
if (!getBase()) {
|
||||
try {
|
||||
const status = await api.get<SetupStatusDto>('/api/admin/auth/setup')
|
||||
if (status.needs_setup) setSetupActive(true)
|
||||
} catch {
|
||||
// 取不到就当作普通登录页
|
||||
}
|
||||
}
|
||||
})
|
||||
.finally(() => setReady(true))
|
||||
}, [])
|
||||
@@ -61,6 +80,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setMe(await api.get<MeDto>('/api/admin/auth/me'))
|
||||
}, [])
|
||||
|
||||
const completeSetup = useCallback(async (claimCode: string, username: string, password: string) => {
|
||||
const r = await api.post<LoginResponse>('/api/admin/auth/setup', { claim_code: claimCode, username, password })
|
||||
setCsrf(r.csrf_token)
|
||||
setMe(await api.get<MeDto>('/api/admin/auth/me'))
|
||||
}, [])
|
||||
|
||||
const finishSetup = useCallback(() => setSetupActive(false), [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.post('/api/admin/auth/logout')
|
||||
@@ -73,7 +100,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <AuthContext.Provider value={{ me, ready, login, loginWithPasskey, logout }}>{children}</AuthContext.Provider>
|
||||
return <AuthContext.Provider value={{ me, ready, login, loginWithPasskey, logout, setupActive, completeSetup, finishSetup }}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth(): Auth {
|
||||
|
||||
@@ -92,7 +92,7 @@ function AddAgentDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
||||
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
|
||||
</Field>
|
||||
<div className="wide">
|
||||
<Field label="属主(passkey)" hint="必选(至少一把):这个节点的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
|
||||
<Field label="属主(passkey)" hint="必选(至少一把):这个节点绑定到哪几个账户的哪几把 Passkey 上,它的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
|
||||
<OwnerPicker value={controllers} onChange={setControllers} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -21,21 +21,21 @@ export function JoinTokenCreated({ created, kind, onClose }: { created: CreatedJ
|
||||
<div className="stack">
|
||||
<div className="notice warn">命令里的代码只显示这一次,请现在就复制。命令里只有公开的值(面板地址、一次性码、信任根指纹和属主),没有任何私钥。</div>
|
||||
<Field
|
||||
label={`在要加入的机器上运行(${kind})`}
|
||||
label={`容器安装(推荐):在要加入的机器上运行(${kind})`}
|
||||
hint="需要 Docker 或 Podman。命令会先下载安装脚本并核对它的 SHA-256,不一致就不执行;节点名字默认取主机名;身份和配置存在命名卷里,重启、换新镜像重建容器都不用再给参数。名字想自己定,或者提示名字被占用,在命令里加上 MOSHIRO_NAME=名字(Linux 和 macOS 写在 bash 或 zsh 前面,Windows 先执行 $env:MOSHIRO_NAME='名字')。"
|
||||
>
|
||||
<CommandTabs
|
||||
storageKey="moshiro.install_tab"
|
||||
initial={guessPlatform()}
|
||||
tabs={[
|
||||
{ id: 'linux', label: 'Linux', command: commands.linux },
|
||||
{ id: 'macos', label: 'macOS', command: commands.macos },
|
||||
{ id: 'windows', label: 'Windows', command: commands.windows },
|
||||
{ id: 'linux', label: 'Linux · 容器', command: commands.linux },
|
||||
{ id: 'macos', label: 'macOS · 容器', command: commands.macos },
|
||||
{ id: 'windows', label: 'Windows · 容器', command: commands.windows },
|
||||
]}
|
||||
/>
|
||||
</Field>
|
||||
<p className="muted">
|
||||
{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
|
||||
容器方式是首选:镜像按 CI 签名的清单固定到 digest,宿主机更新器能自己更新它,属主和信任根在注册时固定。{kind === 'Agent' ? (created.token.auto_approve ? '使用它注册的 Agent 会自动启用。' : '使用它注册的 Agent 需要你在列表里审批之后才会启用。') : ''}
|
||||
可用 {created.token.max_uses} 次,{fmtTime(created.token.expires_at)} 过期。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { Page, PasskeyDto, TrustStateDto } from '@shared/api/types'
|
||||
import type { TrustStateDto } from '@shared/api/types'
|
||||
|
||||
/**
|
||||
* 选这个节点的属主(必选,至少一把):允许授权它 extension 会话(web ssh 等,以后)、解密它加密内容的 passkey。只是选,不需要触碰 Passkey:
|
||||
* 选这个节点的属主(必选,至少一把):节点绑定在哪几个账户的哪几把 passkey 上,它的 extension 会话(web ssh 等,以后)和加密内容只认它们。只是选,不需要触碰 Passkey:
|
||||
* 选定的凭证 id 会写进安装命令,节点装上时固定在本地,之后面板改不了。默认是操作员集合里的全部。
|
||||
* 还没有建立信任根时不显示选项,只提示去建立。
|
||||
*/
|
||||
export function OwnerPicker({ value, onChange }: { value: string[] | null; onChange: (next: string[]) => void }) {
|
||||
const [trust, setTrust] = useState<TrustStateDto>()
|
||||
const [own, setOwn] = useState<PasskeyDto[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
@@ -21,10 +20,6 @@ export function OwnerPicker({ value, onChange }: { value: string[] | null; onCha
|
||||
if (value === null) onChange(t.operators ?? [])
|
||||
})
|
||||
.catch(() => undefined)
|
||||
api
|
||||
.get<Page<PasskeyDto>>('/api/admin/auth/passkeys')
|
||||
.then((list) => alive && setOwn(list.items))
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
@@ -33,11 +28,20 @@ export function OwnerPicker({ value, onChange }: { value: string[] | null; onCha
|
||||
|
||||
if (!trust) return null
|
||||
if (!trust.initialized) {
|
||||
return <div className="notice warn">还没有建立信任根:每个节点都必须有属主(passkey),先到「系统 → 信任」用 Passkey 建立信任根,才能添加节点。</div>
|
||||
return (
|
||||
<div className="notice warn">
|
||||
还没有建立信任根,所以现在无法添加 Agent 和 Probe:每个节点都要绑定到属主的 Passkey 上,节点只信任由 Passkey 签名的信任根。没有信任根时只能进入面板做基础管理,这是架构如此。请先到「系统 → 信任」注册 Passkey 并建立信任根。
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chosen = new Set(value ?? [])
|
||||
const name = (id: string) => own.find((p) => p.id === id)?.name ?? `其他账号的 Passkey(${id.slice(0, 8)}…)`
|
||||
const details = new Map((trust.operator_details ?? []).map((d) => [d.credential_id, d] as const))
|
||||
// 绑定到哪个账户的哪把 Passkey:「Passkey 名称 · 账户」;对不上任何已注册的 Passkey 时标明
|
||||
const name = (id: string) => {
|
||||
const d = details.get(id)
|
||||
return d?.passkey ? `${d.passkey}${d.account ? ` · ${d.account}` : ''}` : `未知 Passkey(${id.slice(0, 8)}…)`
|
||||
}
|
||||
return (
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
{(trust.operators ?? []).map((id) => (
|
||||
|
||||
@@ -147,7 +147,7 @@ function AddProbeDialog({ onClose, onCreated }: { onClose: () => void; onCreated
|
||||
<input className="input" type="number" min={1} max={720} value={hours} onChange={(e) => setHours(Number(e.target.value))} />
|
||||
</Field>
|
||||
<div className="wide">
|
||||
<Field label="属主(passkey)" hint="必选(至少一把):这个节点的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
|
||||
<Field label="属主(passkey)" hint="必选(至少一把):这个节点绑定到哪几个账户的哪几把 Passkey 上,它的敏感操作(web ssh 等)和加密内容只认它们。只是选,不用触碰;装上后固定在节点本地,面板改不了。">
|
||||
<OwnerPicker value={controllers} onChange={setControllers} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { api } from '@shared/api/client'
|
||||
import type { TrustStateDto } from '@shared/api/types'
|
||||
import { signStatement } from '@shared/api/webauthn'
|
||||
@@ -49,11 +50,15 @@ function Changes({ proposal }: { proposal: Proposal }) {
|
||||
* 信任根:你的 Passkey 是整个系统的根。节点安装时把下面这个指纹和属主(passkey)固定在本地,之后只认从它出发、由你的 Passkey 签过名的
|
||||
* 操作员集合,面板自己改不了,也签不出新的版本。
|
||||
*/
|
||||
export function Trust() {
|
||||
export function Trust({ onInitialized }: { onInitialized?: () => void } = {}) {
|
||||
const trust = useQuery(() => api.get<TrustStateDto>('/api/admin/trust'), [])
|
||||
const { busy, run } = useAction()
|
||||
const toast = useToast()
|
||||
const state = trust.data
|
||||
useEffect(() => {
|
||||
if (state?.initialized) onInitialized?.()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state?.initialized])
|
||||
|
||||
const sign = async () => {
|
||||
if (!state?.proposal) return
|
||||
@@ -80,7 +85,7 @@ export function Trust() {
|
||||
<b>还没有建立信任根</b>
|
||||
</div>
|
||||
<p className="muted">
|
||||
建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。
|
||||
<b>不建立信任根就无法添加 Agent 和 Probe,只能进入面板做基础管理——这是架构如此:节点只信任由你的 Passkey 签名的信任根,面板自己没法代签。</b>建立之后,新装的节点会把你的信任根指纹和属主固定在本地:以后 web ssh 等敏感操作只认你的 Passkey,Backend 被攻破也改不了。添加节点本身不需要触碰 Passkey。
|
||||
这一步会把下面列出的 Passkey 登记为操作员,需要你触碰一次 Passkey 给集合签名。
|
||||
</p>
|
||||
{state.proposal ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -56,6 +56,7 @@ export type GapDto = Narrow<S['GapDto']>
|
||||
export type RecomputeDto = Narrow<S['RecomputeDto']>
|
||||
export type RawChunkMetaDto = Narrow<S['RawChunkMetaDto']>
|
||||
export type LoginResponse = Narrow<S['LoginResponse']>
|
||||
export type SetupStatusDto = Narrow<S['SetupStatusDto']>
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[]
|
||||
|
||||
Reference in new issue
Block a user