diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md index 805823b..feee4ee 100644 --- a/docs/articles/getting-started.md +++ b/docs/articles/getting-started.md @@ -17,7 +17,7 @@ curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/inst To install a specific version or to a custom directory: ```bash -curl -sSfL .../install.sh | sh -s -- --version 0.2.10 +curl -sSfL .../install.sh | sh -s -- --version 0.2.11 curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub ``` diff --git a/docs/changelog/index.md b/docs/changelog/index.md index d29fb82..11a614c 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -4,6 +4,7 @@ Release history for EchoHub. ## Releases +- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata - [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes - [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes - [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml index b9b6073..85d2776 100644 --- a/docs/changelog/toc.yml +++ b/docs/changelog/toc.yml @@ -1,5 +1,7 @@ - name: Overview href: index.md +- name: v0.2.11 + href: v0.2.11.md - name: v0.2.10 href: v0.2.10.md - name: v0.2.9 diff --git a/docs/changelog/v0.2.11.md b/docs/changelog/v0.2.11.md new file mode 100644 index 0000000..80f23d4 --- /dev/null +++ b/docs/changelog/v0.2.11.md @@ -0,0 +1,23 @@ +# v0.2.11 + +EchoHubSpace directory protocol overhaul: authenticated server registration with persistent claim tokens, near-real-time user-count updates, and richer server metadata (tags, multi-host, version). Coordinated cutover with the EchoHubSpace directory deploy. + +## New Features + +- EchoHubSpace claim-token authentication — the directory issues a per-server claim token on first registration, persisted atomically alongside the SQLite database (chmod 0600 on Unix). Subsequent reconnects authenticate with the token instead of relying on raw hostname-squatting protection. Token survives both client and directory restarts; lost tokens require an admin-side `DELETE /api/servers/{id}` on the directory to recover +- Server tags — public servers can advertise topic tags via the new `Server:Tags` config array, surfacing as filter facets in the EchoHubSpace browser +- Multi-host advertisement — a single server can register multiple hostnames (e.g. apex domain, IPv6, alias domains) by listing them in `Server:PublicHosts`. All hosts route to the same directory row +- Server version sent to directory — the EchoHubSpace browser shows what version each public server is running, pulled from the server's assembly informational version +- Operator-facing `GET /api/server/directory` endpoint (Admin role required) — returns `ServerId`, `IsRegistered`, `LastRegisteredAt`, `LastError`, and any `ConflictingHosts` for support tickets. Never exposes the claim token itself, only a `HasClaimToken` boolean + +## Refactoring + +- Replace 30s polling with event-driven directory updates — `PresenceTracker` now raises `UserCountChanged` only when the distinct user count actually changes (multi-tab/multi-connection users no longer trigger). `ServerDirectoryService` consumes via a single-slot `Channel` (latest-wins coalesces bursts) with a 1-second min-interval throttle. Directory reflects user-count changes within ~1s instead of up to 30s stale +- Wrap directory hub responses in a `Response` envelope with `IsSuccess`/`Data`/`Errors`/`Version` shape — protocol version is pinned client-side (currently `1.0`); mismatches trigger a permanent-failure stop with operator-facing log +- Stop attempting re-registration after permanent failures (`HostAlreadyClaimed`, `InvalidToken`, `HostConflict`, `InvalidInput`) — the directory no longer terminates the connection on these errors, so the client suppresses re-register on `Reconnected` to avoid tight retry loops. Operator must restart the server after fixing config + +## Configuration + +- **Breaking**: `Server:PublicHost` (string) renamed to `Server:PublicHosts` (string array). Public servers must update `appsettings.json` — single-host deployments use a one-element array +- New `Server:Tags` (string array) — defaults to empty +- New optional `Server:DirectoryClaimPath` — overrides the path of the persisted claim file. Defaults to a `directory-claim.json` next to the SQLite database. Treat the file as a secret; back it up alongside the database diff --git a/scripts/install.sh b/scripts/install.sh index 7a56afd..3692fa1 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -30,7 +30,7 @@ while [ $# -gt 0 ]; do sed -n '2,8p' "$0" 2>/dev/null || true echo "" echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh" - echo " curl ... | sh -s -- --version 0.2.10" + echo " curl ... | sh -s -- --version 0.2.11" echo " curl ... | sh -s -- --install-dir /opt/echohub" exit 0 ;; diff --git a/src/Directory.Build.props b/src/Directory.Build.props index e74631e..0b9a26a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,6 +1,6 @@ - 0.2.10 + 0.2.11 true $(NoWarn);CS1591 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 IActionResult GetEncryptionKey() 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 @@ 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/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs index f97278d..b00e38a 100644 --- a/src/EchoHub.Server/Services/PresenceTracker.cs +++ b/src/EchoHub.Server/Services/PresenceTracker.cs @@ -10,10 +10,18 @@ public class PresenceTracker private readonly object _lock = new(); + /// + /// Raised when the distinct online user count changes (multi-connection users only fire once). + /// + public event Action? UserCountChanged; + public void UserConnected(string connectionId, Guid userId, string username) { _connections[connectionId] = (userId, username); + bool userIsNew; + int newCount; + // Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside. // It also makes the TryGetValue → add sequence atomic to prevent race conditions. lock (_lock) @@ -22,10 +30,19 @@ public void UserConnected(string connectionId, Guid userId, string username) { connections = new HashSet(); _userConnections[username] = connections; + userIsNew = true; + } + else + { + userIsNew = false; } connections.Add(connectionId); + newCount = _userConnections.Count; } + + if (userIsNew) + UserCountChanged?.Invoke(newCount); } public string? UserDisconnected(string connectionId) @@ -34,6 +51,8 @@ public void UserConnected(string connectionId, Guid userId, string username) return null; var username = userInfo.username; + bool userRemoved = false; + int newCount; lock (_lock) { @@ -45,10 +64,16 @@ public void UserConnected(string connectionId, Guid userId, string username) { _userConnections.TryRemove(username, out _); _userChannels.TryRemove(username, out _); + userRemoved = true; } } + + newCount = _userConnections.Count; } + if (userRemoved) + UserCountChanged?.Invoke(newCount); + return username; } @@ -160,20 +185,29 @@ public int GetOnlineUserCount() /// public (List ConnectionIds, List Channels) ForceRemoveUser(string username) { + bool userRemoved; + int newCount; + List channels; + List connectionIds; + lock (_lock) { - var channels = _userChannels.TryRemove(username, out var ch) + channels = _userChannels.TryRemove(username, out var ch) ? ch.ToList() : []; - var connectionIds = _userConnections.TryRemove(username, out var conns) - ? conns.ToList() - : []; + userRemoved = _userConnections.TryRemove(username, out var conns); + connectionIds = userRemoved ? conns!.ToList() : []; foreach (var connId in connectionIds) _connections.TryRemove(connId, out _); - return (connectionIds, channels); + newCount = _userConnections.Count; } + + if (userRemoved) + UserCountChanged?.Invoke(newCount); + + return (connectionIds, channels); } } diff --git a/src/EchoHub.Server/Services/ServerDirectoryService.cs b/src/EchoHub.Server/Services/ServerDirectoryService.cs index 521f619..d5a30da 100644 --- a/src/EchoHub.Server/Services/ServerDirectoryService.cs +++ b/src/EchoHub.Server/Services/ServerDirectoryService.cs @@ -1,28 +1,43 @@ +using System.Reflection; +using System.Text.Json; +using System.Threading.Channels; using Microsoft.AspNetCore.SignalR.Client; namespace EchoHub.Server.Services; public sealed class ServerDirectoryService : BackgroundService { - private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers"; - private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30); + private const string DirectoryHubUrl = "https://localhost:5001/hubs/servers"; private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2); private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30); + private static readonly TimeSpan UserCountMinInterval = TimeSpan.FromSeconds(1); 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. + private readonly Channel _userCountUpdates = Channel.CreateBounded( + new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest }); + 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; } @@ -38,19 +53,44 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) return; } - var host = _configuration["Server:PublicHost"]; + var hosts = _configuration.GetSection("Server:PublicHosts").Get() + ?.Where(h => !string.IsNullOrWhiteSpace(h)) + .ToArray() ?? Array.Empty(); - if (string.IsNullOrWhiteSpace(host)) + if (hosts.Length == 0) { - _logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration"); + _logger.LogWarning("PublicServer is enabled but Server:PublicHosts is empty — skipping directory registration"); return; } var serverName = _configuration["Server:Name"] ?? "EchoHub Server"; var description = _configuration["Server:Description"]; + var tags = _configuration.GetSection("Server:Tags").Get() + ?.Where(t => !string.IsNullOrWhiteSpace(t)) + .ToArray() ?? Array.Empty(); + var version = ResolveVersion(); - _logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host); + _logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Hosts})", serverName, string.Join(", ", hosts)); + _presenceTracker.UserCountChanged += OnUserCountChanged; + try + { + await RunConnectionLoopAsync(serverName, description, hosts, version, tags, stoppingToken); + } + finally + { + _presenceTracker.UserCountChanged -= OnUserCountChanged; + } + } + + private async Task RunConnectionLoopAsync( + string serverName, + string? description, + string[] hosts, + string version, + string[] tags, + CancellationToken stoppingToken) + { // Outer loop: rebuilds the connection if automatic reconnect permanently fails while (!stoppingToken.IsCancellationRequested) { @@ -76,9 +116,15 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) 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, host); + await RegisterAsync(serverName, description, hosts, version, tags); }; connection.Closed += ex => @@ -97,10 +143,10 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) return; _logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl); - await RegisterAsync(serverName, description, host); + await RegisterAsync(serverName, description, hosts, version, tags); - // Poll user count until the connection is permanently closed or cancellation - await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken); + // Push user-count updates as PresenceTracker raises events, until the connection closes or cancellation + await ProcessUserCountUpdatesAsync(connection, connectionPermanentlyClosed.Task, stoppingToken); if (stoppingToken.IsCancellationRequested) return; @@ -151,33 +197,62 @@ private async Task ConnectWithRetryAsync(HubConnection connection, Cancell return false; } - private async Task PollUserCountAsync(HubConnection connection, Task connectionClosed, CancellationToken ct) + private void OnUserCountChanged(int newCount) + { + // Single-slot channel: latest write wins, so a burst of presence changes coalesces. + _userCountUpdates.Writer.TryWrite(newCount); + } + + private async Task ProcessUserCountUpdatesAsync(HubConnection connection, Task connectionClosed, CancellationToken ct) { + var lastSentAt = DateTimeOffset.MinValue; + while (!ct.IsCancellationRequested) { - var delayTask = Task.Delay(UpdateInterval, ct); - var completed = await Task.WhenAny(delayTask, connectionClosed); + var waitTask = _userCountUpdates.Reader.WaitToReadAsync(ct).AsTask(); + var completed = await Task.WhenAny(waitTask, connectionClosed); if (completed == connectionClosed) return; - // Observe the delay task (may throw if cancelled) - try { await delayTask; } + bool hasUpdate; + try { hasUpdate = await waitTask; } catch (OperationCanceledException) { return; } - if (connection.State != HubConnectionState.Connected) + if (!hasUpdate) + return; + + if (!_userCountUpdates.Reader.TryRead(out var count)) + continue; + + // Throttle: enforce a minimum interval between sends. While we wait, drain newer + // values so the eventual send carries the latest count, not a stale snapshot. + var elapsed = DateTimeOffset.UtcNow - lastSentAt; + if (elapsed < UserCountMinInterval) + { + try { await Task.Delay(UserCountMinInterval - elapsed, ct); } + catch (OperationCanceledException) { return; } + + while (_userCountUpdates.Reader.TryRead(out var newer)) + count = newer; + } + + if (count == _lastReportedUserCount) continue; - var currentCount = _presenceTracker.GetOnlineUserCount(); + if (connection.State != HubConnectionState.Connected) + continue; - if (currentCount == _lastReportedUserCount) + // 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", currentCount, ct); - _lastReportedUserCount = currentCount; - _logger.LogDebug("Updated directory user count to {Count}", currentCount); + await connection.InvokeAsync("UpdateUserCount", count, ct); + _lastReportedUserCount = count; + lastSentAt = DateTimeOffset.UtcNow; + _logger.LogDebug("Updated directory user count to {Count}", count); } catch (Exception ex) { @@ -192,18 +267,22 @@ private static TimeSpan GetBackoffDelay(int attempt) return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay; } - private async Task RegisterAsync(string name, string? description, string host) + private async Task RegisterAsync(string name, string? description, string[] hosts, string version, string[] tags) { if (_connection?.State != HubConnectionState.Connected) return; + if (_registrationPermanentlyFailed) + return; + try { var userCount = _presenceTracker.GetOnlineUserCount(); - var dto = new RegisterServerDto(name, description, host, userCount); - await _connection.InvokeAsync("RegisterServer", dto); - _lastReportedUserCount = userCount; - _logger.LogInformation("Registered with directory as {Name} at {Host}", name, host); + // 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 envelope = await _connection.InvokeAsync>("RegisterServer", dto); + await HandleRegistrationResponseAsync(envelope, userCount, name, hosts); } catch (Exception ex) { @@ -211,6 +290,153 @@ private async Task RegisterAsync(string name, string? description, string host) } } + private async Task HandleRegistrationResponseAsync(Response? envelope, int userCount, string name, string[] hosts) + { + if (envelope is null) + { + _registrationPermanentlyFailed = true; + _logger.LogError("Directory returned a null envelope for RegisterServer — treating as malformed. Server will not retry until restarted."); + _claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null); + return; + } + + // Pin protocol version. Spec: fail hard on mismatch — bumps are coordinated. + if (!string.Equals(envelope.Version, DirectoryProtocol.Version, StringComparison.Ordinal)) + { + _registrationPermanentlyFailed = true; + _logger.LogError( + "Directory protocol version mismatch: client expects {Expected}, hub returned {Actual}. " + + "Refusing to operate. Coordinate a deploy that aligns both sides.", + DirectoryProtocol.Version, envelope.Version ?? "(null)"); + _claimStore.SetFailure(DirectoryRegistrationErrors.ProtocolVersionMismatch, null); + return; + } + + if (!envelope.IsSuccess) + { + await HandleRegistrationErrorAsync(envelope.Errors); + return; + } + + if (envelope.Data is null) + { + _registrationPermanentlyFailed = true; + _logger.LogError("Directory returned IsSuccess=true but Data was null — treating as malformed. Server will not retry until restarted."); + _claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null); + return; + } + + var data = envelope.Data; + var serverId = data.ServerId; + + // Persist a freshly-issued claim token *before* anything else acks success — durability guarantee for first claim. + if (!string.IsNullOrEmpty(data.ClaimToken)) + { + await _claimStore.SaveClaimAsync(data.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 Task HandleRegistrationErrorAsync(ErrorDetail[]? errors) + { + _registrationPermanentlyFailed = true; + + var firstError = errors is { Length: > 0 } ? errors[0] : null; + var code = firstError?.Code ?? "UnknownError"; + var conflictingHosts = ExtractConflictingHosts(firstError); + var conflicts = conflictingHosts is { Length: > 0 } + ? string.Join(", ", conflictingHosts) + : "(none reported)"; + + switch (code) + { + 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; + + case DirectoryRegistrationErrors.InvalidInput: + _logger.LogError( + "Directory rejected registration as InvalidInput ({Message}). Likely a client/hub contract drift — check Server config. Server will not retry until restarted.", + firstError?.Message ?? "(no message)"); + break; + + default: + _logger.LogError( + "Directory rejected registration with unknown error code: {Error} ({Message}). Server will not retry until restarted.", + code, firstError?.Message ?? "(no message)"); + break; + } + + _claimStore.SetFailure(code, conflictingHosts); + return Task.CompletedTask; + } + + /// + /// Pulls ConflictingHosts out of an error's loosely-typed Data payload. + /// Tolerates both PascalCase and camelCase keys since SignalR's wire casing depends on + /// the hub's serializer config and the field is typed object?. + /// + private static string[]? ExtractConflictingHosts(ErrorDetail? error) + { + if (error?.Data is not JsonElement element || element.ValueKind != JsonValueKind.Object) + return null; + + if (!element.TryGetProperty("ConflictingHosts", out var hostsProp) + && !element.TryGetProperty("conflictingHosts", out hostsProp)) + return null; + + if (hostsProp.ValueKind != JsonValueKind.Array) + return null; + + List hosts = []; + foreach (var item in hostsProp.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s) + hosts.Add(s); + } + + return hosts.Count == 0 ? null : hosts.ToArray(); + } + + private static string ResolveVersion() + { + var assembly = typeof(ServerDirectoryService).Assembly; + var informational = assembly.GetCustomAttribute()?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(informational)) + { + // Strip git SHA suffix that SourceLink appends (e.g. "0.2.10+abc123") + var plus = informational.IndexOf('+'); + return plus >= 0 ? informational[..plus] : informational; + } + + return assembly.GetName().Version?.ToString() ?? "0.0.0"; + } + private static async Task DisposeConnectionAsync(HubConnection connection) { try @@ -243,4 +469,44 @@ private sealed class InfiniteRetryPolicy : IRetryPolicy } } -internal record RegisterServerDto(string Name, string? Description, string Host, int UserCount); +internal record RegisterServerDto( + string Name, + string? Description, + string[] Hosts, + int UserCount, + string Version, + string[] Tags, + string? ClaimToken); + +internal record RegisterServerResult(Guid ServerId, string? ClaimToken); + +/// +/// Envelope wrapping every directory hub response. Mirrors the EchoHubSpace contract. +/// +internal record Response(bool IsSuccess, T? Data, ErrorDetail[]? Errors, string? Version); + +/// +/// Error entry inside a . Data is loosely-typed because the +/// payload shape varies by error code (e.g. { ConflictingHosts: string[] } for host errors). +/// +internal record ErrorDetail(string Code, string? Message, JsonElement? Data); + +internal static class DirectoryProtocol +{ + /// + /// Pinned envelope protocol version. Bumps are coordinated across both repos. + /// + public const string Version = "1.0"; +} + +internal static class DirectoryRegistrationErrors +{ + public const string InvalidInput = "InvalidInput"; + public const string InvalidToken = "InvalidToken"; + public const string HostAlreadyClaimed = "HostAlreadyClaimed"; + public const string HostConflict = "HostConflict"; + + // Client-side synthetic codes (never returned by hub, generated locally for status reporting) + public const string ProtocolVersionMismatch = "ProtocolVersionMismatch"; + public const string MalformedResponse = "MalformedResponse"; +} diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json index 5d21c45..2db3c11 100644 --- a/src/EchoHub.Server/appsettings.example.json +++ b/src/EchoHub.Server/appsettings.example.json @@ -12,7 +12,8 @@ "Name": "My EchoHub Server", "Description": "A self-hosted EchoHub chat server", "PublicServer": false, - "PublicHost": "", + "PublicHosts": [], + "Tags": [], "Admins": [] }, "Storage": {