1b3928a0a2
- ManagementAuthenticator accepts Authorization: Bearer <jwt> alongside Basic on /management, the audit REST endpoints and the debug-stream hub. Tokens are validated locally (HS256 only, alg=none rejected, issuer/audience, lifetime with 30 s skew); roles and site scope inside a valid token are authoritative for its lifetime, so a bearer call costs no LDAP bind and no role re-map. - /auth/token returns expires_in + management_bearer:true, carries username, display name, roles and permitted sites, and answers 404 under DisableLogin. - CLI exchanges credentials once per process (in-memory CliTokenStore), sends Bearer, re-exchanges once on 401 then falls back to Basic; older/login-disabled servers are Basic-only; bad credentials are not retried with Basic. Debug stream uses the token via AccessTokenProvider. SCADABRIDGE_AUTH_MODE=basic opts out. - StartupValidator fails a Central boot on a JwtSigningKey under 32 UTF-8 bytes; fixed the false 'fail-fast at construction' comment. Removed the dead RefreshToken/RecordActivity/ShouldRefresh/IsIdleTimedOut members and the inert JwtRefreshThresholdMinutes; JwtExpiryMinutes now documented as the bearer lifetime (validated > 0). - RoleMapper reads through a process-wide RoleMappingCache (RoleMappingCacheSeconds, default 30 s): one refresh loads the table plus every Deployer mapping's scope rules, Invalidate() drops it (generation-guarded against in-flight refreshes). - Component-Security.md / Component-CLI.md / CLI README describe the real model (cookie claims ticket, bearer for CLI) instead of a cookie-embedded JWT.
382 lines
25 KiB
C#
382 lines
25 KiB
C#
using ZB.MOM.WW.Configuration;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Host;
|
|
|
|
/// <summary>
|
|
/// Validates required configuration before Akka.NET actor system creation.
|
|
/// Runs early in startup to fail fast with clear error messages.
|
|
/// </summary>
|
|
public static class StartupValidator
|
|
{
|
|
/// <summary>Validates required configuration values and throws <see cref="InvalidOperationException"/> listing all errors if any are found.</summary>
|
|
/// <param name="configuration">The application configuration to validate.</param>
|
|
public static void Validate(IConfiguration configuration)
|
|
{
|
|
// Resolve the same locals the original imperative validator used, so the
|
|
// cross-field predicates below can close over them. ConfigPreflight.Require
|
|
// passes config[key] to each predicate, but the cross-field rules ignore that
|
|
// argument and read these resolved values instead — preserving the exact
|
|
// conditions (and therefore the byte-identical failure messages and ordering)
|
|
// of the original StartupValidator.
|
|
var nodeSection = configuration.GetSection("ScadaBridge:Node");
|
|
var role = nodeSection["Role"];
|
|
|
|
var portStr = nodeSection["RemotingPort"];
|
|
bool portValid = int.TryParse(portStr, out var port) && port >= 1 && port <= 65535;
|
|
|
|
var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>();
|
|
|
|
// Secrets replication (the gRPC hub, and the SQL-Server ReplicatingSecretStore mode that
|
|
// preceded it) is retired in ScadaBridge — sites receive secrets exclusively via
|
|
// Deploy Artifacts, and a replicated site store would publish DeployedSecretsMirror's local
|
|
// deletes fleet-wide. Checked unconditionally (both roles): a Site used to be the gRPC
|
|
// hub's follower half, so the flag was never Central-only.
|
|
var secretsReplicationEnabled = IsSecretsReplicationEnabled(configuration);
|
|
|
|
// GrpcPort: default 8083 when absent; only fails the range rule when the key is
|
|
// present AND invalid. The out-param assignment mirrors the original so the
|
|
// resolved grpcPort feeds the cross-field rules even on a parse failure.
|
|
var grpcPortStr = nodeSection["GrpcPort"];
|
|
int grpcPort = 8083; // NodeOptions default when the key is absent
|
|
bool grpcValid = !(grpcPortStr != null && (!int.TryParse(grpcPortStr, out grpcPort) || grpcPort < 1 || grpcPort > 65535));
|
|
|
|
// MetricsPort: default 8084 when absent; same parse-or-default contract as GrpcPort.
|
|
var metricsPortStr = nodeSection["MetricsPort"];
|
|
int metricsPort = 8084; // NodeOptions default when the key is absent
|
|
bool metricsValid = !(metricsPortStr != null && (!int.TryParse(metricsPortStr, out metricsPort) || metricsPort < 1 || metricsPort > 65535));
|
|
|
|
ConfigPreflight.For(configuration)
|
|
// Role / NodeHostname / RemotingPort (unconditional)
|
|
.Require("ScadaBridge:Node:Role",
|
|
_ => !(string.IsNullOrEmpty(role) || (role != "Central" && role != "Site")),
|
|
"must be 'Central' or 'Site'")
|
|
.Require("ScadaBridge:Node:NodeHostname",
|
|
_ => !string.IsNullOrEmpty(nodeSection["NodeHostname"]),
|
|
"is required")
|
|
.Require("ScadaBridge:Node:RemotingPort",
|
|
_ => portValid,
|
|
"must be 1-65535")
|
|
// Retired for BOTH roles — a Site could set this too, as the gRPC hub's
|
|
// follower half. Deliberately NOT folded into a role-scoped block below.
|
|
.Require("Secrets:Replication:Enabled",
|
|
_ => !secretsReplicationEnabled,
|
|
"Secrets replication (the gRPC hub) is retired in ScadaBridge — sites receive "
|
|
+ "secrets with Deploy Artifacts; central uses Secrets:Store:Provider=SqlServer")
|
|
// SiteId (Site only) — note: OUTSIDE the big Site block in the original,
|
|
// so it must run before the unconditional SeedNodes-count rule.
|
|
.When(role == "Site", p => p
|
|
.Require("ScadaBridge:Node:SiteId",
|
|
_ => !string.IsNullOrEmpty(nodeSection["SiteId"]),
|
|
"is required for Site nodes"))
|
|
// Central-only database/security rules.
|
|
.When(role == "Central", p => p
|
|
.Require("ScadaBridge:Database:ConfigurationDb",
|
|
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Database")["ConfigurationDb"]),
|
|
"connection string required for Central")
|
|
// The LDAP server key moved into the nested Security:Ldap
|
|
// sub-section (bound to the shared LdapOptions). Validate the nested key so
|
|
// the pre-host preflight still fails fast on a missing LDAP server for
|
|
// Central. The full LDAP option set (SearchBase / ServiceAccountDn /
|
|
// transport) is additionally validated post-host by the shared
|
|
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth).
|
|
.Require("ScadaBridge:Security:Ldap:Server",
|
|
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security:Ldap")["Server"]),
|
|
"required for Central")
|
|
.Require("ScadaBridge:Security:JwtSigningKey",
|
|
_ => !string.IsNullOrEmpty(configuration.GetSection("ScadaBridge:Security")["JwtSigningKey"]),
|
|
"required for Central")
|
|
// The key signs the CLI bearer JWT (/auth/token → ManagementAuthenticator).
|
|
// JwtTokenService is resolved lazily (first token request), so its own
|
|
// constructor check cannot fail the boot — a 1-byte key used to boot clean
|
|
// and mint trivially forgeable tokens (review 2026-10-21 05/U1). HMAC-SHA256
|
|
// needs >= 256 bits; measured in UTF-8 bytes, exactly as the service keys the
|
|
// HMAC. A missing key is reported by the rule above, not twice.
|
|
.Require("ScadaBridge:Security:JwtSigningKey",
|
|
value => string.IsNullOrEmpty(value)
|
|
|| System.Text.Encoding.UTF8.GetByteCount(value) >= ZB.MOM.WW.ScadaBridge.Security.SecurityOptions.MinJwtSigningKeyBytes,
|
|
$"must be at least {ZB.MOM.WW.ScadaBridge.Security.SecurityOptions.MinJwtSigningKeyBytes} bytes "
|
|
+ "(256 bits) for HMAC-SHA256 — it signs the CLI bearer tokens, and a short key makes them forgeable")
|
|
// The inbound API-key pepper
|
|
// backs the peppered-HMAC secret compare in the shared
|
|
// ZB.MOM.WW.Auth.ApiKeys verifier (wired by AddZbApiKeyAuth at the
|
|
// Central composition root). A missing or too-short pepper does not
|
|
// fault at boot — the verifier just fails every secret compare, so the
|
|
// inbound API silently serves 401s to otherwise-valid keys. Validate it
|
|
// here (Central-only, pre-host) so a misconfigured pepper fails fast at
|
|
// startup with a clear message instead of as a runtime auth blackout.
|
|
// The Require predicate receives config[key] directly; the >=16-char
|
|
// floor matches the test pepper's minimum and the secret-strength
|
|
// baseline used elsewhere.
|
|
.Require("ScadaBridge:InboundApi:ApiKeyPepper",
|
|
value => !string.IsNullOrEmpty(value) && value.Length >= 16,
|
|
"is required and must be at least 16 characters for Central (backs the inbound API-key peppered-HMAC verifier)")
|
|
// The inbound API key store provider (design 2026-09-02). Not required — an
|
|
// unset value defaults to Sqlite (InboundApiKeyStoreOptions.ReadProvider) — but
|
|
// when it IS set, it must be one of the two legal values so a typo fails fast at
|
|
// boot instead of throwing later out of ReadProvider once the host is already up.
|
|
.Require("ScadaBridge:InboundApi:ApiKeyStore:Provider",
|
|
value => string.IsNullOrWhiteSpace(value)
|
|
|| (Enum.TryParse<InboundApiKeyStoreProvider>(value, ignoreCase: true, out var provider)
|
|
&& Enum.IsDefined(provider)),
|
|
"must be 'Sqlite' or 'SqlServer' when set")
|
|
// The central shared secret store provider (Secrets:Store:Provider — a fleet that
|
|
// ships secrets to sites on every artifact deploy needs a clustered central pair to
|
|
// see the same secret set). Not required — an unset value defaults to Sqlite,
|
|
// per-node, mirroring the inbound API-key store default above — but when it IS set,
|
|
// it must be one of the two legal values so a typo fails fast at boot instead of
|
|
// silently keeping the per-node SQLite store it looks like it opted out of.
|
|
.Require("Secrets:Store:Provider",
|
|
value => string.IsNullOrWhiteSpace(value)
|
|
|| string.Equals(value, "Sqlite", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(value, "SqlServer", StringComparison.OrdinalIgnoreCase),
|
|
"must be 'Sqlite' or 'SqlServer' when set"))
|
|
// SeedNodes count (unconditional, after SiteId).
|
|
.Require("ScadaBridge:Cluster:SeedNodes",
|
|
_ => seedNodes != null && seedNodes.Count >= 2,
|
|
"must have at least 2 entries")
|
|
// Self-first seed ordering (decision 2026-07-22). Akka runs FirstSeedNodeProcess —
|
|
// the ONLY bootstrap path that can form a new cluster when no peer answers InitJoin —
|
|
// exclusively when seed-nodes[0] is this node's own address. Every other node runs
|
|
// JoinSeedNodeProcess and retries InitJoin forever, so a node listing its partner
|
|
// first cannot cold-start alone: that is the "registered outage gap"
|
|
// (docker/README.md), and it is a silent failure at boot rather than a loud one.
|
|
// Enforced here rather than in ClusterOptionsValidator because only this validator
|
|
// sees both the node identity and the seed list.
|
|
.Require("ScadaBridge:Cluster:SeedNodes",
|
|
_ => seedNodes is not { Count: >= 1 }
|
|
|| SeedNodeIsSelf(seedNodes[0], nodeSection["NodeHostname"], port),
|
|
"must list this node itself first: seed-nodes[0] has to be this node's own "
|
|
+ $"'akka.tcp://scadabridge@{nodeSection["NodeHostname"]}:{port}'. Akka only lets "
|
|
+ "seed-nodes[0] form a new cluster, so with the partner listed first this node "
|
|
+ "can never start while its peer is down (Component-ClusterInfrastructure.md → "
|
|
+ "Seed Node Ordering)")
|
|
// The big Site-only block: GrpcPort/MetricsPort validity + cross-field
|
|
// collisions + seed-node-port loop, in the original order.
|
|
.When(role == "Site", p =>
|
|
{
|
|
// GrpcPort range, then GrpcPort vs RemotingPort.
|
|
p.Require("ScadaBridge:Node:GrpcPort", _ => grpcValid, "must be 1-65535");
|
|
// Identical GrpcPort/RemotingPort make Kestrel and Akka.Remote contend
|
|
// for the same TCP port. Uses the resolved GrpcPort, including 8083.
|
|
p.Require("ScadaBridge:Node:GrpcPort", _ => port != grpcPort, "must differ from RemotingPort");
|
|
|
|
// MetricsPort range, then MetricsPort vs both ports.
|
|
p.Require("ScadaBridge:Node:MetricsPort", _ => metricsValid, "must be 1-65535");
|
|
// The Kestrel metrics (HTTP/1.1) listener port must differ from BOTH the
|
|
// Akka remoting port and the gRPC port. Uses the resolved MetricsPort (8084 default).
|
|
p.Require("ScadaBridge:Node:MetricsPort", _ => metricsPort != port, "must differ from RemotingPort");
|
|
p.Require("ScadaBridge:Node:MetricsPort", _ => metricsPort != grpcPort, "must differ from GrpcPort");
|
|
|
|
// The gRPC control-plane preshared key. Same argument as the inbound
|
|
// API-key pepper above: without it the node boots and looks healthy, but
|
|
// ControlPlaneAuthInterceptor is fail-closed, so every SiteStream call —
|
|
// live subscriptions, audit pulls, cached-telemetry ingest — is refused
|
|
// with PermissionDenied. A silent, total loss of the site's central-facing
|
|
// surface is far worse than a loud boot failure, so require it here.
|
|
// Central holds the matching value per site in its secret store as
|
|
// SB-GRPC-PSK-{SiteId}; production supplies this one as
|
|
// ${secret:SB-GRPC-PSK-<siteId>}, expanded before the host is built.
|
|
p.Require("ScadaBridge:Communication:GrpcPsk",
|
|
value => !string.IsNullOrWhiteSpace(value),
|
|
"is required for Site nodes: it is the preshared key the gRPC control "
|
|
+ "plane authenticates with, and the interceptor is fail-closed, so an "
|
|
+ "unset key refuses every SiteStream call. Set the same value here (in "
|
|
+ "production as ${secret:SB-GRPC-PSK-<siteId>}) and under the secret "
|
|
+ "name SB-GRPC-PSK-<siteId> in central's secret store");
|
|
|
|
// gRPC (CentralControlService) is the only site→central transport after the
|
|
// ClusterClient→gRPC migration's Phase 4 — the Akka ClusterClient path and its
|
|
// CentralContactPoints option are gone. A site with no central gRPC endpoint has
|
|
// nothing to dial: heartbeats, health reports, notification forwards and audit
|
|
// ingest all silently fail. The shared CommunicationOptionsValidator only rejects
|
|
// BLANK entries (it is role-agnostic, and central nodes legitimately leave the list
|
|
// empty), so the "a Site must have at least one" rule lives here, where the role is
|
|
// known. The predicate reads index :0 directly, so its non-empty presence proves the
|
|
// list has a usable first endpoint.
|
|
p.Require("ScadaBridge:Communication:CentralGrpcEndpoints:0",
|
|
value => !string.IsNullOrWhiteSpace(value),
|
|
"is required for Site nodes: gRPC (CentralControlService) is the only "
|
|
+ "site→central transport, so each site must list at least one central gRPC "
|
|
+ "endpoint under ScadaBridge:Communication:CentralGrpcEndpoints "
|
|
+ "(e.g. http://scadabridge-central-a:8083). Central nodes leave it empty.");
|
|
|
|
// The site hot-path audit writer's SQLite file (arch-review remediation).
|
|
// SqliteAuditWriterOptions.DatabasePath has no default (mirrors
|
|
// LocalDb:Path) — an unset value would previously fall back to a bare
|
|
// "auditlog.db" resolved relative to the process CWD, which on the docker
|
|
// rig is the container's ephemeral overlayfs, not the mounted /app/data
|
|
// volume: the file, and every pending (not-yet-forwarded) audit row in it,
|
|
// was silently discarded on every container recreate. AddAuditLog binds
|
|
// this options type on BOTH roles (SqliteAuditWriterOptionsValidator
|
|
// deliberately does not check DatabasePath there, so Central's boot is
|
|
// unaffected — see that validator's remarks), so the Site-only requirement
|
|
// lives here, the same way GrpcPsk is gated just above. NOTE: unlike every
|
|
// other key in this method, AuditLog:SiteWriter is a TOP-LEVEL config
|
|
// section (AddAuditLog binds "AuditLog:SiteWriter" off the configuration
|
|
// root, not "ScadaBridge:AuditLog:SiteWriter") — no ScadaBridge: prefix.
|
|
p.Require("AuditLog:SiteWriter:DatabasePath",
|
|
value => !string.IsNullOrWhiteSpace(value),
|
|
"is required for Site nodes: the SQLite hot-path audit writer has no "
|
|
+ "default path (mirrors LocalDb:Path) — an unset value would silently "
|
|
+ "resolve to a CWD-relative file on the container's ephemeral overlayfs "
|
|
+ "and lose the pending audit backlog on every redeploy. Point it at the "
|
|
+ "mounted data volume, e.g. /app/data/auditlog.db.");
|
|
|
|
// ScadaBridge:Database:SiteDbPath was required here until LocalDb
|
|
// Phase 2. The site's tables now live in the consolidated LocalDb
|
|
// database (LocalDb:Path, which SiteServiceRegistration requires),
|
|
// and SiteDbPath survives only as the legacy migration source — so
|
|
// its absence means "nothing to migrate", not a misconfiguration.
|
|
// DatabaseOptionsValidator still rejects a present-but-blank value.
|
|
|
|
// A seed node must reference an Akka.Remote endpoint, never the
|
|
// Kestrel HTTP/2 gRPC port. A seed entry whose port equals this node's
|
|
// GrpcPort would make a joining node attempt an Akka.Remote TCP
|
|
// association against the gRPC listener and fail.
|
|
foreach (var seed in seedNodes ?? Enumerable.Empty<string>())
|
|
{
|
|
p.Require("ScadaBridge:Cluster:SeedNodes",
|
|
_ => SeedNodePort(seed) != grpcPort,
|
|
$"entry '{seed}' must not target the gRPC port " +
|
|
$"({grpcPort}); seed nodes must reference Akka remoting ports");
|
|
|
|
// Same failure mode as the gRPC guard: the Kestrel HTTP/1.1
|
|
// metrics listener is not an Akka.Remote endpoint, so a seed on
|
|
// it dials a doomed association forever.
|
|
p.Require("ScadaBridge:Cluster:SeedNodes",
|
|
_ => SeedNodePort(seed) != metricsPort,
|
|
$"entry '{seed}' must not target the metrics port " +
|
|
$"({metricsPort}); seed nodes must reference Akka remoting ports");
|
|
}
|
|
})
|
|
.ThrowIfInvalid();
|
|
}
|
|
|
|
/// <summary>Non-fatal configuration findings. Collected separately from <see cref="Validate"/> because this runs before the logger exists; Program.cs logs each entry at Warning once Serilog is configured.</summary>
|
|
/// <param name="configuration">The application configuration to inspect.</param>
|
|
/// <returns>The warning messages to log; empty when there are none.</returns>
|
|
public static IReadOnlyList<string> CollectWarnings(IConfiguration configuration)
|
|
{
|
|
var role = configuration.GetSection("ScadaBridge:Node")["Role"];
|
|
var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>();
|
|
var provider = InboundApiKeyStoreOptions.ReadProvider(configuration);
|
|
var secretsStoreProvider = configuration["Secrets:Store:Provider"];
|
|
var secretsReplicationEnabled = IsSecretsReplicationEnabled(configuration);
|
|
var isClusteredCentral = role == "Central" && seedNodes is { Count: >= 2 };
|
|
|
|
var warnings = new List<string>();
|
|
|
|
if (isClusteredCentral && provider == InboundApiKeyStoreProvider.Sqlite)
|
|
{
|
|
warnings.Add(
|
|
$"Inbound API keys use the per-node SQLite store on a {seedNodes!.Count}-node central "
|
|
+ "cluster; a failover will reject every inbound client until keys are re-issued. Set "
|
|
+ "ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer (design 2026-09-02).");
|
|
}
|
|
|
|
// Secrets:Store:Provider defaults to Sqlite (per-node) when unset, mirroring the inbound
|
|
// API-key store default above — so this fires both when the key is absent and when it is
|
|
// explicitly "Sqlite". A fleet that ships secrets to sites on every artifact deploy needs
|
|
// both central nodes to deploy the SAME secret set; a per-node store means each node has
|
|
// silently drifted the moment anyone edits a secret on only one of them.
|
|
//
|
|
// No Secrets:Replication:Enabled gate here any more: that flag is now a hard
|
|
// Validate() failure on either role (the gRPC hub and SQL-Server secrets replication are
|
|
// both retired), so CollectWarnings never runs with it true on a config that also passed
|
|
// Validate — the old gate that suppressed this warning when replication was on is dead code
|
|
// and would otherwise mask the exact per-node-drift risk it exists to catch on any caller
|
|
// that invokes CollectWarnings directly.
|
|
if (isClusteredCentral
|
|
&& !string.Equals(secretsStoreProvider, "SqlServer", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
warnings.Add(
|
|
"Secrets use a per-node SQLite store on a clustered central — each node would "
|
|
+ "deploy its own secret set to sites; set Secrets:Store:Provider=SqlServer");
|
|
}
|
|
|
|
// A config that still carries Secrets:Replication or
|
|
// Secrets:GrpcHub child keys — left over from before the hub was retired — is not
|
|
// itself an error (Validate already rejects Enabled=true outright), but every key under
|
|
// either section is now silently ignored. That is worth a Warning so an operator cleans
|
|
// QA/prod appsettings rather than assuming a still-present Mode/BearerToken/Endpoint value
|
|
// does anything. Excludes the bare "Enabled" key itself: "Enabled: false" alone is the
|
|
// normal, still-valid shape of every existing deployment's config and must not warn.
|
|
var hasStaleReplicationKeys = configuration.GetSection("Secrets:Replication").GetChildren()
|
|
.Any(child => !string.Equals(child.Key, "Enabled", StringComparison.OrdinalIgnoreCase));
|
|
var hasStaleGrpcHubKeys = configuration.GetSection("Secrets:GrpcHub").GetChildren().Any();
|
|
|
|
if (!secretsReplicationEnabled && (hasStaleReplicationKeys || hasStaleGrpcHubKeys))
|
|
{
|
|
warnings.Add(
|
|
"Secrets:Replication / Secrets:GrpcHub settings are ignored — the secrets hub is "
|
|
+ "retired; remove them");
|
|
}
|
|
|
|
return warnings;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether <c>Secrets:Replication:Enabled</c> is "set" — read as a raw string rather than
|
|
/// <c>IConfiguration.GetValue<bool></c> which throws a raw
|
|
/// binder <see cref="InvalidOperationException"/> on a non-boolean value like <c>"yes"</c> or
|
|
/// <c>"1"</c>, so a malformed leftover key would surface as an unrelated parse crash instead of
|
|
/// the designed retirement message in <see cref="Validate"/>. Any value other than absent,
|
|
/// empty, or (case-insensitively) <c>"false"</c> counts as "set" — deliberately stricter than
|
|
/// bool parsing, so a typo'd leftover (<c>Enabled: 1</c>, <c>Enabled: yes</c>) still gets the
|
|
/// explanatory message rather than silently passing as falsy. Shared by <see cref="Validate"/>
|
|
/// (which rejects "set" outright) and <see cref="CollectWarnings"/> (which, for the "not set"
|
|
/// case, separately warns about any OTHER leftover key under the same retired sections).
|
|
/// </summary>
|
|
private static bool IsSecretsReplicationEnabled(IConfiguration configuration)
|
|
{
|
|
var raw = configuration["Secrets:Replication:Enabled"];
|
|
return !string.IsNullOrEmpty(raw)
|
|
&& !string.Equals(raw, "false", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the TCP port from an Akka seed-node address of the form
|
|
/// <c>akka.tcp://system@host:port</c>. Returns <c>-1</c> when no port can be parsed.
|
|
/// </summary>
|
|
private static int SeedNodePort(string seedNode)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(seedNode))
|
|
return -1;
|
|
|
|
var lastColon = seedNode.LastIndexOf(':');
|
|
if (lastColon < 0 || lastColon == seedNode.Length - 1)
|
|
return -1;
|
|
|
|
return int.TryParse(seedNode[(lastColon + 1)..], out var port) ? port : -1;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts the host from an Akka seed-node address of the form
|
|
/// <c>akka.tcp://system@host:port</c>. Returns an empty string when no host can be parsed.
|
|
/// </summary>
|
|
private static string SeedNodeHost(string seedNode)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(seedNode))
|
|
return string.Empty;
|
|
|
|
var at = seedNode.LastIndexOf('@');
|
|
var lastColon = seedNode.LastIndexOf(':');
|
|
if (at < 0 || lastColon <= at)
|
|
return string.Empty;
|
|
|
|
return seedNode[(at + 1)..lastColon];
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when <paramref name="seedNode"/> addresses this node itself (host AND port).
|
|
/// Host comparison is case-insensitive because DNS names are; it is otherwise exact —
|
|
/// Akka does no DNS canonicalisation either, so <c>node-a</c> and
|
|
/// <c>node-a.example.com</c> are genuinely different seed identities to the cluster.
|
|
/// </summary>
|
|
private static bool SeedNodeIsSelf(string seedNode, string? nodeHostname, int remotingPort)
|
|
{
|
|
return SeedNodePort(seedNode) == remotingPort
|
|
&& string.Equals(SeedNodeHost(seedNode), nodeHostname, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
}
|