Tells apart a slave that is down from one that does not know us

The integrity check mapped four outcomes onto a single null: no response,
a 404, a rejected key, and a genuine answer. Only "rejected key" is
recoverable, and it was being reported as "unreachable" and never repaired —
so an instance registered against the wrong URL stayed broken until someone
edited the database by hand. That is exactly what happened locally.

GetRegisteredMasterUrlAsync now returns an outcome alongside the URL.
Unauthorized triggers registration; 404 is reported as "this host does not
serve the master/slave protocol", which names the actual mistake instead of
hiding it behind a generic contact failure; unreachable and server errors
behave as before.

Registering on a rejected key cannot hijack a slave that belongs to another
master: the slave accepts a registration only when it has none, and refuses
any key that does not match an existing one. So it succeeds exactly in the
case worth recovering and fails harmlessly otherwise. That guarantee lives on
the slave, so the test asserting the refusal now says out loud that the
master depends on it.

Found while diagnosing a status push that failed against a frontend URL.
Small and contained, so fixed here rather than filed as tech debt.

372 tests pass, up from 366.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
2026-07-28 12:31:00 +02:00
co-authored by Claude Opus 5
parent 980dc80701
commit 6957ec7c60
7 changed files with 251 additions and 20 deletions
@@ -157,20 +157,56 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
try
{
var plainKey = deps.ApiKeyProtector.Unprotect(instance.ApiKey);
var registeredUrl = await deps.SlaveClient.GetRegisteredMasterUrlAsync(instance.Url, plainKey);
var contact = await deps.SlaveClient.GetRegisteredMasterUrlAsync(instance.Url, plainKey);
if (registeredUrl is null)
if (contact.Outcome is SlaveContactOutcome.Unreachable or SlaveContactOutcome.NotAProtocolEndpoint)
{
deps.Logger.LogWarning("Slave {SlaveUrl} unreachable during integrity check.", instance.Url);
if (contact.Outcome == SlaveContactOutcome.NotAProtocolEndpoint)
{
// Distinct from "down": something is listening but does not serve
// /api/v1/master/*. Almost always a URL pointing at a frontend or an
// unrelated site rather than a CMS instance's API — which is invisible if
// this is reported as a generic contact failure.
deps.Logger.LogWarning(
"Slave {SlaveUrl} responded but does not serve the master/slave protocol (404). Is this the instance's API URL?",
instance.Url);
}
else
{
deps.Logger.LogWarning("Slave {SlaveUrl} unreachable during integrity check.", instance.Url);
}
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow;
deps.Repository.Update(instance);
await deps.Repository.SaveChangesAsync();
continue;
}
if (!string.Equals(registeredUrl, masterUrl, StringComparison.OrdinalIgnoreCase))
// Two states need the same repair — the slave does not recognise us, or it
// recognises us under a stale master URL.
//
// Registering on a rejected key cannot hijack a slave that belongs to someone
// else: the slave accepts a registration only when it has none yet, and refuses
// any key that does not match an existing one (MasterAvailabilityService.
// RegisterAsync). So this succeeds exactly in the case worth recovering — a slave
// that was never registered, typically because the original registration call went
// to the wrong URL — and fails harmlessly otherwise. That guarantee lives on the
// slave, and MasterAvailabilityServiceTests locks it down.
var needsRegistration = contact.Outcome == SlaveContactOutcome.Unauthorized
|| !string.Equals(contact.MasterUrl, masterUrl, StringComparison.OrdinalIgnoreCase);
if (needsRegistration)
{
deps.Logger.LogWarning("Slave {SlaveUrl} has wrong master URL '{RegisteredUrl}'; re-registering.", instance.Url, registeredUrl);
if (contact.Outcome == SlaveContactOutcome.Unauthorized)
{
deps.Logger.LogWarning(
"Slave {SlaveUrl} does not recognise this master; attempting registration.", instance.Url);
}
else
{
deps.Logger.LogWarning("Slave {SlaveUrl} has wrong master URL '{RegisteredUrl}'; re-registering.", instance.Url, contact.MasterUrl);
}
var reRegistered = await deps.SlaveClient.RegisterMasterAsync(instance.Url, plainKey, masterUrl);
if (reRegistered)
{
@@ -179,6 +215,8 @@ public class CmsInstanceService(MasterServiceDependencies deps) : ICmsInstanceSe
}
else
{
// A slave registered to a different master lands here, and stays here.
deps.Logger.LogWarning("Registration with slave {SlaveUrl} was refused.", instance.Url);
instance.LastIntegrityCheckFailedAt = DateTimeOffset.UtcNow;
}
}
@@ -4,5 +4,13 @@ public interface ISlaveApiClient
{
Task<bool> RegisterMasterAsync(string slaveUrl, string plainApiKey, string masterUrl);
Task<bool> PushStatusAsync(string slaveUrl, string plainApiKey, bool isAvailable, string? disableMessage);
Task<string?> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey);
/// <summary>
/// Asks the slave which master it is registered to.
/// </summary>
/// <returns>
/// A result carrying <b>why</b> the call ended as it did, not just the URL. The integrity
/// check needs the distinction: an unreachable slave can only be retried, whereas one that
/// rejects our key may simply have no registration yet and can be recovered.
/// </returns>
Task<RegisteredMasterUrlResult> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey);
}
@@ -1,3 +1,4 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
@@ -41,7 +42,7 @@ public class SlaveApiClient(HttpClient httpClient) : ISlaveApiClient
}
}
public async Task<string?> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey)
public async Task<RegisteredMasterUrlResult> GetRegisteredMasterUrlAsync(string slaveUrl, string plainApiKey)
{
try
{
@@ -49,15 +50,33 @@ public class SlaveApiClient(HttpClient httpClient) : ISlaveApiClient
request.Headers.Add("X-Master-Api-Key", plainApiKey);
var response = await httpClient.SendAsync(request);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
// The slave answered and refused the key. Recoverable when it simply has no
// registration yet, so the caller gets to decide rather than seeing "unreachable".
return RegisteredMasterUrlResult.Unauthorized;
}
if (response.StatusCode == HttpStatusCode.NotFound)
{
// Something is listening, but it does not serve /api/v1/master/*. Nearly always a
// URL pointing at a frontend or an unrelated site instead of a CMS instance's API.
return RegisteredMasterUrlResult.NotAProtocolEndpoint;
}
if (!response.IsSuccessStatusCode)
return null;
{
return RegisteredMasterUrlResult.Unreachable;
}
var result = await response.Content.ReadFromJsonAsync<RegisteredMasterUrlResponse>(JsonOptions);
return result?.MasterUrl;
return RegisteredMasterUrlResult.Ok(result?.MasterUrl);
}
catch
{
return null;
// No HTTP response at all: host down, DNS, TLS or timeout.
return RegisteredMasterUrlResult.Unreachable;
}
}
@@ -0,0 +1,45 @@
namespace SlpModularCms.Modules.Master.Services;
/// <summary>
/// How a call to a slave ended.
/// </summary>
/// <remarks>
/// These four cases used to collapse into a single <c>null</c>, which meant the integrity check
/// could not tell "the slave is down" from "the slave does not recognise this master" — and only
/// the second is recoverable. It also meant a host that does not speak this protocol at all (a
/// frontend dev server, say) reported exactly the same as an unreachable one.
/// </remarks>
public enum SlaveContactOutcome
{
/// <summary>No HTTP response at all: host down, wrong host, DNS or TLS failure, timeout.</summary>
Unreachable,
/// <summary>
/// The host answered, but not with this protocol — a 404 on <c>/api/v1/master/*</c>. Almost
/// always a URL pointing at something other than a CMS instance's API.
/// </summary>
NotAProtocolEndpoint,
/// <summary>
/// The slave answered and rejected our API key. Either it has no registration yet, or it
/// belongs to a different master.
/// </summary>
Unauthorized,
/// <summary>The slave answered and accepted our key.</summary>
Ok
}
/// <summary>Result of asking a slave which master it is registered to.</summary>
/// <param name="Outcome">How the call ended.</param>
/// <param name="MasterUrl">The registered master URL; only meaningful when <see cref="SlaveContactOutcome.Ok"/>.</param>
public readonly record struct RegisteredMasterUrlResult(SlaveContactOutcome Outcome, string? MasterUrl)
{
public static RegisteredMasterUrlResult Unreachable { get; } = new(SlaveContactOutcome.Unreachable, null);
public static RegisteredMasterUrlResult NotAProtocolEndpoint { get; } = new(SlaveContactOutcome.NotAProtocolEndpoint, null);
public static RegisteredMasterUrlResult Unauthorized { get; } = new(SlaveContactOutcome.Unauthorized, null);
public static RegisteredMasterUrlResult Ok(string? masterUrl) => new(SlaveContactOutcome.Ok, masterUrl);
}