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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user