Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/articles/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
1 change: 1 addition & 0 deletions docs/changelog/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/changelog/toc.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
23 changes: 23 additions & 0 deletions docs/changelog/v0.2.11.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
;;
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.2.10</Version>
<Version>0.2.11</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
Expand Down
49 changes: 48 additions & 1 deletion src/EchoHub.Server/Controllers/ServerController.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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")]
Expand Down Expand Up @@ -47,4 +52,46 @@ public IActionResult GetEncryptionKey()

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);
}
}
1 change: 1 addition & 0 deletions src/EchoHub.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
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>();
Expand Down
204 changes: 204 additions & 0 deletions src/EchoHub.Server/Services/DirectoryClaimStore.cs
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading