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";
+}