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); }