From 67587dafc2796ce6eacd95cb410bdf3118e6fdbb Mon Sep 17 00:00:00 2001 From: HueByte Date: Wed, 22 Apr 2026 12:04:11 +0200 Subject: [PATCH] feat: implement DirectoryClaimStore for managing directory claims and enhance ServerDirectoryService with registration error handling --- .../Controllers/ServerController.cs | 49 ++++- src/EchoHub.Server/Program.cs | 1 + .../Services/DirectoryClaimStore.cs | 204 ++++++++++++++++++ .../Services/ServerDirectoryService.cs | 117 +++++++++- 4 files changed, 365 insertions(+), 6 deletions(-) create mode 100644 src/EchoHub.Server/Services/DirectoryClaimStore.cs diff --git a/src/EchoHub.Server/Controllers/ServerController.cs b/src/EchoHub.Server/Controllers/ServerController.cs index 23491ca..d875949 100644 --- a/src/EchoHub.Server/Controllers/ServerController.cs +++ b/src/EchoHub.Server/Controllers/ServerController.cs @@ -1,5 +1,8 @@ +using System.Security.Claims; using EchoHub.Core.DTOs; +using EchoHub.Core.Models; using EchoHub.Server.Data; +using EchoHub.Server.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; @@ -13,11 +16,13 @@ public class ServerController : ControllerBase { private readonly EchoHubDbContext _db; private readonly IConfiguration _config; + private readonly DirectoryClaimStore _claimStore; - public ServerController(EchoHubDbContext db, IConfiguration config) + public ServerController(EchoHubDbContext db, IConfiguration config, DirectoryClaimStore claimStore) { _db = db; _config = config; + _claimStore = claimStore; } [HttpGet("info")] @@ -47,4 +52,46 @@ public class ServerController : ControllerBase return Ok(new EncryptionKeyResponse(key)); } + + /// + /// Operator-facing view of the EchoHubSpace directory registration: ServerId for admin + /// support tickets, current registration state, and the last error/conflict if any. + /// Never exposes the claim token itself. + /// + [HttpGet("directory")] + [Authorize] + public async Task GetDirectoryStatus() + { + var (_, error) = await GetCallerAsync(ServerRole.Admin); + if (error is not null) return error; + + var status = _claimStore.Status; + var response = new + { + ServerId = _claimStore.ServerId, + HasClaimToken = _claimStore.ClaimToken is not null, + status.IsRegistered, + status.LastRegisteredAt, + status.LastError, + status.ConflictingHosts, + }; + + return Ok(response); + } + + private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole) + { + var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userIdClaim is null) + return (null, Unauthorized(new ErrorResponse("Authentication required."))); + + var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim)); + if (caller is null) + return (null, Unauthorized(new ErrorResponse("User not found."))); + + if (caller.Role < minimumRole) + return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher."))); + + return (caller, null); + } } diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index b6fde78..7693bd9 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -107,6 +107,7 @@ while (true) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/EchoHub.Server/Services/DirectoryClaimStore.cs b/src/EchoHub.Server/Services/DirectoryClaimStore.cs new file mode 100644 index 0000000..415ddb3 --- /dev/null +++ b/src/EchoHub.Server/Services/DirectoryClaimStore.cs @@ -0,0 +1,204 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Data.Sqlite; + +namespace EchoHub.Server.Services; + +/// +/// Persists and exposes the EchoHubSpace directory claim — the opaque token issued on first +/// registration and the row's stable ServerId. Also surfaces ephemeral registration +/// status (success/failure code, conflicting hosts) for operator-facing endpoints. +/// +/// Persistence uses atomic write (tmp + rename). Treat the file contents as a secret. +/// +public sealed class DirectoryClaimStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly string _filePath; + private readonly ILogger _logger; + private readonly SemaphoreSlim _writeLock = new(1, 1); + + private PersistedClaim _persisted = new(null, null); + private RegistrationStatus _status = new(false, null, null, null, null); + + public DirectoryClaimStore(IConfiguration configuration, ILogger logger) + { + _logger = logger; + _filePath = ResolveFilePath(configuration); + Load(); + } + + public string FilePath => _filePath; + + public string? ClaimToken => Volatile.Read(ref _persisted).ClaimToken; + public Guid? ServerId => Volatile.Read(ref _persisted).ServerId; + + public RegistrationStatus Status => Volatile.Read(ref _status); + + /// + /// Persist a freshly-issued claim token alongside the server's stable ServerId. + /// Called exactly once per row's lifetime — on first claim. Atomic on-disk swap. + /// + public async Task SaveClaimAsync(string claimToken, Guid serverId, CancellationToken ct = default) + { + await _writeLock.WaitAsync(ct); + try + { + var next = new PersistedClaim(claimToken, serverId); + await WriteAtomicAsync(next, ct); + Volatile.Write(ref _persisted, next); + _logger.LogInformation("Persisted directory claim token for ServerId {ServerId} at {Path}", serverId, _filePath); + } + finally + { + _writeLock.Release(); + } + } + + /// + /// Update only the ServerId — used when re-registering with an existing token (Success path, + /// hub returns ServerId again but no fresh token). No-op if the value is unchanged. + /// + public async Task UpdateServerIdAsync(Guid serverId, CancellationToken ct = default) + { + var current = Volatile.Read(ref _persisted); + if (current.ServerId == serverId) + return; + + await _writeLock.WaitAsync(ct); + try + { + var next = current with { ServerId = serverId }; + await WriteAtomicAsync(next, ct); + Volatile.Write(ref _persisted, next); + } + finally + { + _writeLock.Release(); + } + } + + public void SetSuccess(Guid serverId) + { + Volatile.Write(ref _status, new RegistrationStatus( + IsRegistered: true, + ServerId: serverId, + LastRegisteredAt: DateTimeOffset.UtcNow, + LastError: null, + ConflictingHosts: null)); + } + + public void SetFailure(string errorCode, string[]? conflictingHosts) + { + var current = Volatile.Read(ref _status); + Volatile.Write(ref _status, current with + { + IsRegistered = false, + LastError = errorCode, + ConflictingHosts = conflictingHosts, + }); + } + + private void Load() + { + if (!File.Exists(_filePath)) + return; + + try + { + using var stream = File.OpenRead(_filePath); + var loaded = JsonSerializer.Deserialize(stream, JsonOptions); + if (loaded is not null) + { + _persisted = loaded; + _logger.LogInformation("Loaded directory claim from {Path} (ServerId {ServerId})", _filePath, loaded.ServerId); + } + } + catch (Exception ex) + { + // Don't crash startup over a corrupt state file — log and proceed as if no claim exists. + // Operator will see HostAlreadyClaimed on next register and can intervene. + _logger.LogError(ex, "Failed to read directory claim file at {Path} — treating as unclaimed", _filePath); + } + } + + private async Task WriteAtomicAsync(PersistedClaim claim, CancellationToken ct) + { + var dir = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + var tmpPath = _filePath + ".tmp"; + + await using (var stream = new FileStream( + tmpPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + useAsync: true)) + { + await JsonSerializer.SerializeAsync(stream, claim, JsonOptions, ct); + await stream.FlushAsync(ct); + } + + // 0600 on Unix — the file holds a secret. No-op on Windows. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + try + { + File.SetUnixFileMode(tmpPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to set restrictive permissions on {Path}", tmpPath); + } + } + + File.Move(tmpPath, _filePath, overwrite: true); + } + + private static string ResolveFilePath(IConfiguration configuration) + { + var configured = configuration["Server:DirectoryClaimPath"]; + if (!string.IsNullOrWhiteSpace(configured)) + return configured; + + // Co-locate with the SQLite database so a single data-directory backup captures both. + var connectionString = configuration.GetConnectionString("DefaultConnection"); + if (!string.IsNullOrWhiteSpace(connectionString)) + { + try + { + var builder = new SqliteConnectionStringBuilder(connectionString); + if (!string.IsNullOrWhiteSpace(builder.DataSource)) + { + var dir = Path.GetDirectoryName(Path.GetFullPath(builder.DataSource)); + if (!string.IsNullOrWhiteSpace(dir)) + return Path.Combine(dir, "directory-claim.json"); + } + } + catch + { + // Fall through to default + } + } + + return Path.Combine(AppContext.BaseDirectory, "directory-claim.json"); + } + + private sealed record PersistedClaim(string? ClaimToken, Guid? ServerId); +} + +public sealed record RegistrationStatus( + bool IsRegistered, + Guid? ServerId, + DateTimeOffset? LastRegisteredAt, + string? LastError, + string[]? ConflictingHosts); diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 3753bde..5cb5dc4 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -13,6 +13,7 @@ public sealed class ServerDirectoryService : BackgroundService private readonly IConfiguration _configuration; private readonly PresenceTracker _presenceTracker; + private readonly DirectoryClaimStore _claimStore; private readonly ILogger _logger; // Single-slot, latest-wins channel coalesces bursts of presence changes into one update. @@ -22,13 +23,20 @@ public sealed class ServerDirectoryService : BackgroundService private HubConnection? _connection; private int _lastReportedUserCount = -1; + // Set true when a registration error code arrives (HostAlreadyClaimed/InvalidToken/HostConflict). + // Once set, we stop attempting register on this connection AND on any reconnects, since the + // hub won't kick us off and we'd otherwise tight-loop. Operator must restart after fixing config. + private bool _registrationPermanentlyFailed; + public ServerDirectoryService( IConfiguration configuration, PresenceTracker presenceTracker, + DirectoryClaimStore claimStore, ILogger logger) { _configuration = configuration; _presenceTracker = presenceTracker; + _claimStore = claimStore; _logger = logger; } @@ -107,6 +115,12 @@ public sealed class ServerDirectoryService : BackgroundService connection.Reconnected += async _ => { + if (_registrationPermanentlyFailed) + { + _logger.LogWarning("Reconnected to directory but previous registration permanently failed — not re-registering. Restart the server after fixing configuration."); + return; + } + _logger.LogInformation("Reconnected to directory — re-registering server"); _lastReportedUserCount = -1; await RegisterAsync(serverName, description, hosts, version, tags); @@ -228,6 +242,10 @@ public sealed class ServerDirectoryService : BackgroundService if (connection.State != HubConnectionState.Connected) continue; + // No point pushing presence to a row we don't own (or never claimed) + if (_registrationPermanentlyFailed || !_claimStore.Status.IsRegistered) + continue; + try { await connection.InvokeAsync("UpdateUserCount", count, ct); @@ -253,13 +271,17 @@ public sealed class ServerDirectoryService : BackgroundService if (_connection?.State != HubConnectionState.Connected) return; + if (_registrationPermanentlyFailed) + return; + try { var userCount = _presenceTracker.GetOnlineUserCount(); - var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags); - await _connection.InvokeAsync("RegisterServer", dto); - _lastReportedUserCount = userCount; - _logger.LogInformation("Registered with directory as {Name} at {Hosts}", name, string.Join(", ", hosts)); + // ClaimToken is null on first-ever registration; otherwise the token persisted on first claim. + var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags, _claimStore.ClaimToken); + + var result = await _connection.InvokeAsync("RegisterServer", dto); + await HandleRegistrationResultAsync(result, userCount, name, hosts); } catch (Exception ex) { @@ -267,6 +289,76 @@ public sealed class ServerDirectoryService : BackgroundService } } + private async Task HandleRegistrationResultAsync(RegisterServerResult result, int userCount, string name, string[] hosts) + { + if (!result.Success) + { + _registrationPermanentlyFailed = true; + + var error = result.Error ?? "UnknownError"; + var conflicts = result.ConflictingHosts is { Length: > 0 } + ? string.Join(", ", result.ConflictingHosts) + : "(none reported)"; + + switch (error) + { + case DirectoryRegistrationErrors.HostAlreadyClaimed: + _logger.LogError( + "Directory rejected registration: host(s) already claimed by another server: {ConflictingHosts}. " + + "Change Server:PublicHosts or contact the directory admin to release the claim. Server will not retry until restarted.", + conflicts); + break; + + case DirectoryRegistrationErrors.InvalidToken: + _logger.LogError( + "Directory rejected registration: persisted claim token is invalid (likely deleted by admin or stale). " + + "Delete the claim file ({ClaimFile}) to claim fresh, or contact the directory admin. Server will not retry until restarted.", + _claimStore.FilePath); + break; + + case DirectoryRegistrationErrors.HostConflict: + _logger.LogError( + "Directory rejected registration: token is valid but newly-advertised host(s) conflict with another server's row: {ConflictingHosts}. " + + "Remove the conflicting entries from Server:PublicHosts. Server will not retry until restarted.", + conflicts); + break; + + default: + _logger.LogError("Directory rejected registration with unknown error code: {Error}. Server will not retry until restarted.", error); + break; + } + + _claimStore.SetFailure(error, result.ConflictingHosts); + return; + } + + // Success path + if (!result.ServerId.HasValue) + { + _logger.LogWarning("Directory registration succeeded but ServerId was missing — treating as failure to be safe."); + _registrationPermanentlyFailed = true; + _claimStore.SetFailure("MissingServerId", null); + return; + } + + var serverId = result.ServerId.Value; + + // Persist a freshly-issued claim token *before* anything else acks success — this is our durability guarantee. + if (!string.IsNullOrEmpty(result.ClaimToken)) + { + await _claimStore.SaveClaimAsync(result.ClaimToken, serverId); + } + else + { + // No fresh token (re-register): just keep the persisted ServerId in sync defensively. + await _claimStore.UpdateServerIdAsync(serverId); + } + + _claimStore.SetSuccess(serverId); + _lastReportedUserCount = userCount; + _logger.LogInformation("Registered with directory as {Name} at {Hosts} (ServerId {ServerId})", name, string.Join(", ", hosts), serverId); + } + private static string ResolveVersion() { var assembly = typeof(ServerDirectoryService).Assembly; @@ -319,4 +411,19 @@ internal record RegisterServerDto( string[] Hosts, int UserCount, string Version, - string[] Tags); + string[] Tags, + string? ClaimToken); + +internal record RegisterServerResult( + bool Success, + Guid? ServerId, + string? ClaimToken, + string? Error, + string[]? ConflictingHosts); + +internal static class DirectoryRegistrationErrors +{ + public const string HostAlreadyClaimed = "HostAlreadyClaimed"; + public const string InvalidToken = "InvalidToken"; + public const string HostConflict = "HostConflict"; +}