mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b45bb5661 | ||
|
|
6235565480 | ||
|
|
ecb20c4c52 | ||
|
|
a85d16fff8 | ||
|
|
6db93ecfea | ||
|
|
67587dafc2 | ||
|
|
1bbe099835 |
+6
-1
@@ -6,7 +6,12 @@
|
|||||||
Server__Name=My EchoHub Server
|
Server__Name=My EchoHub Server
|
||||||
Server__Description=A self-hosted EchoHub chat server
|
Server__Description=A self-hosted EchoHub chat server
|
||||||
Server__PublicServer=false
|
Server__PublicServer=false
|
||||||
# Server__PublicHost=echohub.example.com
|
# Hostnames advertised to the EchoHubSpace directory. Index per entry.
|
||||||
|
# Server__PublicHosts__0=echohub.example.com
|
||||||
|
# Server__PublicHosts__1=alias.example.com
|
||||||
|
# Topic tags surfaced in the EchoHubSpace browser. Index per entry.
|
||||||
|
# Server__Tags__0=community
|
||||||
|
# Server__Tags__1=gaming
|
||||||
# Server__Admins__0=adminUsername
|
# Server__Admins__0=adminUsername
|
||||||
|
|
||||||
# ── JWT ──────────────────────────────────────────────────────────────
|
# ── JWT ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
name: Release Checklist
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-checklist:
|
||||||
|
name: Release Checklist
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Extract version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "::error file=src/Directory.Build.props::Could not read version from Directory.Build.props"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Version: $VERSION"
|
||||||
|
|
||||||
|
- name: Check version was bumped from master
|
||||||
|
run: |
|
||||||
|
BRANCH_VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
git fetch origin master --depth=1
|
||||||
|
MASTER_VERSION=$(git show origin/master:src/Directory.Build.props | grep -oP '(?<=<Version>)[^<]+')
|
||||||
|
echo "Branch: $BRANCH_VERSION | Master: $MASTER_VERSION"
|
||||||
|
if [ "$BRANCH_VERSION" = "$MASTER_VERSION" ]; then
|
||||||
|
echo "::error file=src/Directory.Build.props::Version $BRANCH_VERSION was not bumped from master. Update <Version> in src/Directory.Build.props."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Check changelog file exists
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
FILE="docs/changelog/v${VERSION}.md"
|
||||||
|
if [ ! -f "$FILE" ]; then
|
||||||
|
echo "::error::Missing changelog file: $FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Found: $FILE"
|
||||||
|
|
||||||
|
- name: Check changelog TOC
|
||||||
|
run: |
|
||||||
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
|
if ! grep -q "v${VERSION}.md" docs/changelog/toc.yml; then
|
||||||
|
echo "::error file=docs/changelog/toc.yml::v${VERSION} not found in changelog TOC. Add it to docs/changelog/toc.yml."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q "v${VERSION}" docs/changelog/index.md; then
|
||||||
|
echo "::error file=docs/changelog/index.md::v${VERSION} not found in changelog index. Add it to docs/changelog/index.md."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "toc.yml and index.md: OK"
|
||||||
@@ -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:
|
To install a specific version or to a custom directory:
|
||||||
|
|
||||||
```bash
|
```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
|
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## 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.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.9](v0.2.9.md) - Install Script & Chocolatey Fixes
|
||||||
- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix
|
- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.11
|
||||||
|
href: v0.2.11.md
|
||||||
- name: v0.2.10
|
- name: v0.2.10
|
||||||
href: v0.2.10.md
|
href: v0.2.10.md
|
||||||
- name: v0.2.9
|
- name: v0.2.9
|
||||||
|
|||||||
@@ -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<int>` (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<T>` 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
|
||||||
+1
-1
@@ -30,7 +30,7 @@ while [ $# -gt 0 ]; do
|
|||||||
sed -n '2,8p' "$0" 2>/dev/null || true
|
sed -n '2,8p' "$0" 2>/dev/null || true
|
||||||
echo ""
|
echo ""
|
||||||
echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh"
|
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"
|
echo " curl ... | sh -s -- --install-dir /opt/echohub"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.10</Version>
|
<Version>0.2.11</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
@@ -13,11 +16,13 @@ public class ServerController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly EchoHubDbContext _db;
|
private readonly EchoHubDbContext _db;
|
||||||
private readonly IConfiguration _config;
|
private readonly IConfiguration _config;
|
||||||
|
private readonly DirectoryClaimStore _claimStore;
|
||||||
|
|
||||||
public ServerController(EchoHubDbContext db, IConfiguration config)
|
public ServerController(EchoHubDbContext db, IConfiguration config, DirectoryClaimStore claimStore)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_config = config;
|
_config = config;
|
||||||
|
_claimStore = claimStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("info")]
|
[HttpGet("info")]
|
||||||
@@ -47,4 +52,46 @@ public class ServerController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new EncryptionKeyResponse(key));
|
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<ImageToAsciiService>();
|
||||||
builder.Services.AddSingleton<FileStorageService>();
|
builder.Services.AddSingleton<FileStorageService>();
|
||||||
builder.Services.AddSingleton<LinkEmbedService>();
|
builder.Services.AddSingleton<LinkEmbedService>();
|
||||||
|
builder.Services.AddSingleton<DirectoryClaimStore>();
|
||||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||||
builder.Services.AddHostedService<FileCleanupService>();
|
builder.Services.AddHostedService<FileCleanupService>();
|
||||||
builder.Services.AddHostedService<MuteExpirationService>();
|
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);
|
||||||
@@ -10,10 +10,18 @@ public class PresenceTracker
|
|||||||
|
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raised when the distinct online user count changes (multi-connection users only fire once).
|
||||||
|
/// </summary>
|
||||||
|
public event Action<int>? UserCountChanged;
|
||||||
|
|
||||||
public void UserConnected(string connectionId, Guid userId, string username)
|
public void UserConnected(string connectionId, Guid userId, string username)
|
||||||
{
|
{
|
||||||
_connections[connectionId] = (userId, username);
|
_connections[connectionId] = (userId, username);
|
||||||
|
|
||||||
|
bool userIsNew;
|
||||||
|
int newCount;
|
||||||
|
|
||||||
// Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside.
|
// 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.
|
// It also makes the TryGetValue → add sequence atomic to prevent race conditions.
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -22,10 +30,19 @@ public class PresenceTracker
|
|||||||
{
|
{
|
||||||
connections = new HashSet<string>();
|
connections = new HashSet<string>();
|
||||||
_userConnections[username] = connections;
|
_userConnections[username] = connections;
|
||||||
|
userIsNew = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
userIsNew = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
connections.Add(connectionId);
|
connections.Add(connectionId);
|
||||||
|
newCount = _userConnections.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userIsNew)
|
||||||
|
UserCountChanged?.Invoke(newCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? UserDisconnected(string connectionId)
|
public string? UserDisconnected(string connectionId)
|
||||||
@@ -34,6 +51,8 @@ public class PresenceTracker
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
var username = userInfo.username;
|
var username = userInfo.username;
|
||||||
|
bool userRemoved = false;
|
||||||
|
int newCount;
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
@@ -45,10 +64,16 @@ public class PresenceTracker
|
|||||||
{
|
{
|
||||||
_userConnections.TryRemove(username, out _);
|
_userConnections.TryRemove(username, out _);
|
||||||
_userChannels.TryRemove(username, out _);
|
_userChannels.TryRemove(username, out _);
|
||||||
|
userRemoved = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newCount = _userConnections.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userRemoved)
|
||||||
|
UserCountChanged?.Invoke(newCount);
|
||||||
|
|
||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +185,29 @@ public class PresenceTracker
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public (List<string> ConnectionIds, List<string> Channels) ForceRemoveUser(string username)
|
public (List<string> ConnectionIds, List<string> Channels) ForceRemoveUser(string username)
|
||||||
{
|
{
|
||||||
|
bool userRemoved;
|
||||||
|
int newCount;
|
||||||
|
List<string> channels;
|
||||||
|
List<string> connectionIds;
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var channels = _userChannels.TryRemove(username, out var ch)
|
channels = _userChannels.TryRemove(username, out var ch)
|
||||||
? ch.ToList()
|
? ch.ToList()
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
var connectionIds = _userConnections.TryRemove(username, out var conns)
|
userRemoved = _userConnections.TryRemove(username, out var conns);
|
||||||
? conns.ToList()
|
connectionIds = userRemoved ? conns!.ToList() : [];
|
||||||
: [];
|
|
||||||
|
|
||||||
foreach (var connId in connectionIds)
|
foreach (var connId in connectionIds)
|
||||||
_connections.TryRemove(connId, out _);
|
_connections.TryRemove(connId, out _);
|
||||||
|
|
||||||
return (connectionIds, channels);
|
newCount = _userConnections.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userRemoved)
|
||||||
|
UserCountChanged?.Invoke(newCount);
|
||||||
|
|
||||||
|
return (connectionIds, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Channels;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
|
|
||||||
namespace EchoHub.Server.Services;
|
namespace EchoHub.Server.Services;
|
||||||
@@ -5,24 +8,36 @@ namespace EchoHub.Server.Services;
|
|||||||
public sealed class ServerDirectoryService : BackgroundService
|
public sealed class ServerDirectoryService : BackgroundService
|
||||||
{
|
{
|
||||||
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
|
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
|
||||||
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
|
|
||||||
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2);
|
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2);
|
||||||
private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30);
|
private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30);
|
||||||
|
private static readonly TimeSpan UserCountMinInterval = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly DirectoryClaimStore _claimStore;
|
||||||
private readonly ILogger<ServerDirectoryService> _logger;
|
private readonly ILogger<ServerDirectoryService> _logger;
|
||||||
|
|
||||||
|
// Single-slot, latest-wins channel coalesces bursts of presence changes into one update.
|
||||||
|
private readonly Channel<int> _userCountUpdates = Channel.CreateBounded<int>(
|
||||||
|
new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
|
||||||
|
|
||||||
private HubConnection? _connection;
|
private HubConnection? _connection;
|
||||||
private int _lastReportedUserCount = -1;
|
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(
|
public ServerDirectoryService(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
|
DirectoryClaimStore claimStore,
|
||||||
ILogger<ServerDirectoryService> logger)
|
ILogger<ServerDirectoryService> logger)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
|
_claimStore = claimStore;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,19 +53,44 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var host = _configuration["Server:PublicHost"];
|
var hosts = _configuration.GetSection("Server:PublicHosts").Get<string[]>()
|
||||||
|
?.Where(h => !string.IsNullOrWhiteSpace(h))
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var serverName = _configuration["Server:Name"] ?? "EchoHub Server";
|
var serverName = _configuration["Server:Name"] ?? "EchoHub Server";
|
||||||
var description = _configuration["Server:Description"];
|
var description = _configuration["Server:Description"];
|
||||||
|
var tags = _configuration.GetSection("Server:Tags").Get<string[]>()
|
||||||
|
?.Where(t => !string.IsNullOrWhiteSpace(t))
|
||||||
|
.ToArray() ?? Array.Empty<string>();
|
||||||
|
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
|
// Outer loop: rebuilds the connection if automatic reconnect permanently fails
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -76,9 +116,15 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
|
|
||||||
connection.Reconnected += async _ =>
|
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");
|
_logger.LogInformation("Reconnected to directory — re-registering server");
|
||||||
_lastReportedUserCount = -1;
|
_lastReportedUserCount = -1;
|
||||||
await RegisterAsync(serverName, description, host);
|
await RegisterAsync(serverName, description, hosts, version, tags);
|
||||||
};
|
};
|
||||||
|
|
||||||
connection.Closed += ex =>
|
connection.Closed += ex =>
|
||||||
@@ -97,10 +143,10 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
_logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
|
_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
|
// Push user-count updates as PresenceTracker raises events, until the connection closes or cancellation
|
||||||
await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
await ProcessUserCountUpdatesAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
||||||
|
|
||||||
if (stoppingToken.IsCancellationRequested)
|
if (stoppingToken.IsCancellationRequested)
|
||||||
return;
|
return;
|
||||||
@@ -151,33 +197,62 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
return false;
|
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)
|
while (!ct.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var delayTask = Task.Delay(UpdateInterval, ct);
|
var waitTask = _userCountUpdates.Reader.WaitToReadAsync(ct).AsTask();
|
||||||
var completed = await Task.WhenAny(delayTask, connectionClosed);
|
var completed = await Task.WhenAny(waitTask, connectionClosed);
|
||||||
|
|
||||||
if (completed == connectionClosed)
|
if (completed == connectionClosed)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Observe the delay task (may throw if cancelled)
|
bool hasUpdate;
|
||||||
try { await delayTask; }
|
try { hasUpdate = await waitTask; }
|
||||||
catch (OperationCanceledException) { return; }
|
catch (OperationCanceledException) { return; }
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
if (connection.State != HubConnectionState.Connected)
|
if (connection.State != HubConnectionState.Connected)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var currentCount = _presenceTracker.GetOnlineUserCount();
|
// No point pushing presence to a row we don't own (or never claimed)
|
||||||
|
if (_registrationPermanentlyFailed || !_claimStore.Status.IsRegistered)
|
||||||
if (currentCount == _lastReportedUserCount)
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
|
await connection.InvokeAsync("UpdateUserCount", count, ct);
|
||||||
_lastReportedUserCount = currentCount;
|
_lastReportedUserCount = count;
|
||||||
_logger.LogDebug("Updated directory user count to {Count}", currentCount);
|
lastSentAt = DateTimeOffset.UtcNow;
|
||||||
|
_logger.LogDebug("Updated directory user count to {Count}", count);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -192,18 +267,22 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay;
|
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)
|
if (_connection?.State != HubConnectionState.Connected)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (_registrationPermanentlyFailed)
|
||||||
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userCount = _presenceTracker.GetOnlineUserCount();
|
var userCount = _presenceTracker.GetOnlineUserCount();
|
||||||
var dto = new RegisterServerDto(name, description, host, userCount);
|
// ClaimToken is null on first-ever registration; otherwise the token persisted on first claim.
|
||||||
await _connection.InvokeAsync("RegisterServer", dto);
|
var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags, _claimStore.ClaimToken);
|
||||||
_lastReportedUserCount = userCount;
|
|
||||||
_logger.LogInformation("Registered with directory as {Name} at {Host}", name, host);
|
var envelope = await _connection.InvokeAsync<Response<RegisterServerResult>>("RegisterServer", dto);
|
||||||
|
await HandleRegistrationResponseAsync(envelope, userCount, name, hosts);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -211,6 +290,153 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task HandleRegistrationResponseAsync(Response<RegisterServerResult>? 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pulls <c>ConflictingHosts</c> out of an error's loosely-typed <c>Data</c> payload.
|
||||||
|
/// Tolerates both PascalCase and camelCase keys since SignalR's wire casing depends on
|
||||||
|
/// the hub's serializer config and the field is typed <c>object?</c>.
|
||||||
|
/// </summary>
|
||||||
|
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<string> 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<AssemblyInformationalVersionAttribute>()?.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)
|
private static async Task DisposeConnectionAsync(HubConnection connection)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -243,4 +469,44 @@ public sealed class ServerDirectoryService : BackgroundService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Envelope wrapping every directory hub response. Mirrors the EchoHubSpace contract.
|
||||||
|
/// </summary>
|
||||||
|
internal record Response<T>(bool IsSuccess, T? Data, ErrorDetail[]? Errors, string? Version);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Error entry inside a <see cref="Response{T}"/>. <c>Data</c> is loosely-typed because the
|
||||||
|
/// payload shape varies by error code (e.g. <c>{ ConflictingHosts: string[] }</c> for host errors).
|
||||||
|
/// </summary>
|
||||||
|
internal record ErrorDetail(string Code, string? Message, JsonElement? Data);
|
||||||
|
|
||||||
|
internal static class DirectoryProtocol
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pinned envelope protocol version. Bumps are coordinated across both repos.
|
||||||
|
/// </summary>
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
"Name": "My EchoHub Server",
|
"Name": "My EchoHub Server",
|
||||||
"Description": "A self-hosted EchoHub chat server",
|
"Description": "A self-hosted EchoHub chat server",
|
||||||
"PublicServer": false,
|
"PublicServer": false,
|
||||||
"PublicHost": "",
|
"PublicHosts": [],
|
||||||
|
"Tags": [],
|
||||||
"Admins": []
|
"Admins": []
|
||||||
},
|
},
|
||||||
"Storage": {
|
"Storage": {
|
||||||
|
|||||||
Reference in New Issue
Block a user