mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: implement DirectoryClaimStore for managing directory claims and enhance ServerDirectoryService with registration error handling
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("directory")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ while (true)
|
||||
builder.Services.AddSingleton<ImageToAsciiService>();
|
||||
builder.Services.AddSingleton<FileStorageService>();
|
||||
builder.Services.AddSingleton<LinkEmbedService>();
|
||||
builder.Services.AddSingleton<DirectoryClaimStore>();
|
||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||
builder.Services.AddHostedService<FileCleanupService>();
|
||||
builder.Services.AddHostedService<MuteExpirationService>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Persists and exposes the EchoHubSpace directory claim — the opaque token issued on first
|
||||
/// registration and the row's stable <c>ServerId</c>. 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.
|
||||
/// </summary>
|
||||
public sealed class DirectoryClaimStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly string _filePath;
|
||||
private readonly ILogger<DirectoryClaimStore> _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<DirectoryClaimStore> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<PersistedClaim>(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);
|
||||
@@ -13,6 +13,7 @@ public sealed class ServerDirectoryService : BackgroundService
|
||||
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly PresenceTracker _presenceTracker;
|
||||
private readonly DirectoryClaimStore _claimStore;
|
||||
private readonly ILogger<ServerDirectoryService> _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<ServerDirectoryService> 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<RegisterServerResult>("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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user