Fix E2E test gaps and add comprehensive E2E + parity test suites
- Fix pull consumer fetch: send original stream subject in HMSG (not inbox) so NATS client distinguishes data messages from control messages - Fix MaxAge expiry: add background timer in StreamManager for periodic pruning - Fix JetStream wire format: Go-compatible anonymous objects with string enums, proper offset-based pagination for stream/consumer list APIs - Add 42 E2E black-box tests (core messaging, auth, TLS, accounts, JetStream) - Add ~1000 parity tests across all subsystems (gaps closure) - Update gap inventory docs to reflect implementation status
This commit is contained in:
@@ -8,6 +8,7 @@ public sealed class Account : IDisposable
|
||||
{
|
||||
public const string GlobalAccountName = "$G";
|
||||
public const string SystemAccountName = "$SYS";
|
||||
public const string ClientInfoHdr = "Nats-Request-Info";
|
||||
|
||||
public string Name { get; }
|
||||
public SubList SubList { get; } = new();
|
||||
@@ -789,6 +790,100 @@ public sealed class Account : IDisposable
|
||||
return matchResult.PlainSubs.Length > 0 || matchResult.QueueSubs.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this account has at least one matching subscription for the given subject.
|
||||
/// Go reference: accounts.go SubscriptionInterest.
|
||||
/// </summary>
|
||||
public bool SubscriptionInterest(string subject) => Interest(subject) > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total number of matching subscriptions (plain + queue) for the given subject.
|
||||
/// Go reference: accounts.go Interest.
|
||||
/// </summary>
|
||||
public int Interest(string subject)
|
||||
{
|
||||
var (plainCount, queueCount) = SubList.NumInterest(subject);
|
||||
return plainCount + queueCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the total number of outstanding response mappings for service exports.
|
||||
/// Go reference: accounts.go NumPendingAllResponses.
|
||||
/// </summary>
|
||||
public int NumPendingAllResponses() => NumPendingResponses(string.Empty);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of outstanding response mappings for service exports.
|
||||
/// When <paramref name="filter"/> is empty, counts all mappings.
|
||||
/// Go reference: accounts.go NumPendingResponses.
|
||||
/// </summary>
|
||||
public int NumPendingResponses(string filter)
|
||||
{
|
||||
if (string.IsNullOrEmpty(filter))
|
||||
return Exports.Responses.Count;
|
||||
|
||||
var se = GetServiceExportEntry(filter);
|
||||
if (se == null)
|
||||
return 0;
|
||||
|
||||
var count = 0;
|
||||
foreach (var (_, si) in Exports.Responses)
|
||||
{
|
||||
if (ReferenceEquals(si.Export, se))
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of configured service import subjects.
|
||||
/// Go reference: accounts.go NumServiceImports.
|
||||
/// </summary>
|
||||
public int NumServiceImports() => Imports.Services.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Removes a response service import mapping.
|
||||
/// Go reference: accounts.go removeRespServiceImport.
|
||||
/// </summary>
|
||||
public void RemoveRespServiceImport(ServiceImport? serviceImport, ResponseServiceImportRemovalReason reason = ResponseServiceImportRemovalReason.Ok)
|
||||
{
|
||||
if (serviceImport == null)
|
||||
return;
|
||||
|
||||
string? replyPrefix = null;
|
||||
foreach (var (prefix, si) in Exports.Responses)
|
||||
{
|
||||
if (ReferenceEquals(si, serviceImport))
|
||||
{
|
||||
replyPrefix = prefix;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (replyPrefix == null)
|
||||
return;
|
||||
|
||||
// Current parity scope removes the response mapping. Reason-specific
|
||||
// metrics/latency side effects are tracked separately.
|
||||
ResponseRouter.CleanupResponse(this, replyPrefix, serviceImport);
|
||||
_ = reason;
|
||||
}
|
||||
|
||||
private ServiceExport? GetServiceExportEntry(string subject)
|
||||
{
|
||||
if (Exports.Services.TryGetValue(subject, out var exact))
|
||||
return exact;
|
||||
|
||||
foreach (var (pattern, export) in Exports.Services)
|
||||
{
|
||||
if (SubjectMatch.MatchLiteral(subject, pattern))
|
||||
return export;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all service import subjects registered on this account that are currently
|
||||
/// shadowed by a local subscription in the SubList.
|
||||
@@ -945,6 +1040,17 @@ public sealed record ActivationCheckResult(
|
||||
DateTime? ExpiresAt,
|
||||
TimeSpan? TimeToExpiry);
|
||||
|
||||
/// <summary>
|
||||
/// Reason for removing a response service import.
|
||||
/// Go reference: accounts.go rsiReason enum.
|
||||
/// </summary>
|
||||
public enum ResponseServiceImportRemovalReason
|
||||
{
|
||||
Ok = 0,
|
||||
NoDelivery = 1,
|
||||
Timeout = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of account JWT claim fields used for hot-reload diff detection.
|
||||
/// Go reference: server/accounts.go — AccountClaims / jwt.AccountClaims fields applied in updateAccountClaimsWithRefresh (~line 3374).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Auth;
|
||||
|
||||
@@ -33,11 +34,13 @@ public sealed class AuthService
|
||||
var authRequired = false;
|
||||
var nonceRequired = false;
|
||||
Dictionary<string, User>? usersMap = null;
|
||||
var users = NormalizeUsers(options.Users);
|
||||
var nkeys = NormalizeNKeys(options.NKeys);
|
||||
|
||||
// TLS certificate mapping (highest priority when enabled)
|
||||
if (options.TlsMap && options.TlsVerify && options.Users is { Count: > 0 })
|
||||
if (options.TlsMap && options.TlsVerify && users is { Count: > 0 })
|
||||
{
|
||||
authenticators.Add(new TlsMapAuthenticator(options.Users));
|
||||
authenticators.Add(new TlsMapAuthenticator(users));
|
||||
authRequired = true;
|
||||
}
|
||||
|
||||
@@ -63,19 +66,19 @@ public sealed class AuthService
|
||||
|
||||
// Priority order (matching Go): NKeys > Users > Token > SimpleUserPassword
|
||||
|
||||
if (options.NKeys is { Count: > 0 })
|
||||
if (nkeys is { Count: > 0 })
|
||||
{
|
||||
authenticators.Add(new NKeyAuthenticator(options.NKeys));
|
||||
authenticators.Add(new NKeyAuthenticator(nkeys));
|
||||
authRequired = true;
|
||||
nonceRequired = true;
|
||||
}
|
||||
|
||||
if (options.Users is { Count: > 0 })
|
||||
if (users is { Count: > 0 })
|
||||
{
|
||||
authenticators.Add(new UserPasswordAuthenticator(options.Users));
|
||||
authenticators.Add(new UserPasswordAuthenticator(users));
|
||||
authRequired = true;
|
||||
usersMap = new Dictionary<string, User>(StringComparer.Ordinal);
|
||||
foreach (var u in options.Users)
|
||||
foreach (var u in users)
|
||||
usersMap[u.Username] = u;
|
||||
}
|
||||
|
||||
@@ -169,4 +172,77 @@ public sealed class AuthService
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
private static IReadOnlyList<User>? NormalizeUsers(IReadOnlyList<User>? users)
|
||||
{
|
||||
if (users is null)
|
||||
return null;
|
||||
|
||||
var normalized = new List<User>(users.Count);
|
||||
foreach (var user in users)
|
||||
{
|
||||
normalized.Add(new User
|
||||
{
|
||||
Username = user.Username,
|
||||
Password = user.Password,
|
||||
Account = string.IsNullOrWhiteSpace(user.Account) ? Account.GlobalAccountName : user.Account,
|
||||
Permissions = NormalizePermissions(user.Permissions),
|
||||
ConnectionDeadline = user.ConnectionDeadline,
|
||||
AllowedConnectionTypes = user.AllowedConnectionTypes,
|
||||
ProxyRequired = user.ProxyRequired,
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NKeyUser>? NormalizeNKeys(IReadOnlyList<NKeyUser>? nkeys)
|
||||
{
|
||||
if (nkeys is null)
|
||||
return null;
|
||||
|
||||
var normalized = new List<NKeyUser>(nkeys.Count);
|
||||
foreach (var nkey in nkeys)
|
||||
{
|
||||
normalized.Add(new NKeyUser
|
||||
{
|
||||
Nkey = nkey.Nkey,
|
||||
Account = string.IsNullOrWhiteSpace(nkey.Account) ? Account.GlobalAccountName : nkey.Account,
|
||||
Permissions = NormalizePermissions(nkey.Permissions),
|
||||
SigningKey = nkey.SigningKey,
|
||||
Issued = nkey.Issued,
|
||||
AllowedConnectionTypes = nkey.AllowedConnectionTypes,
|
||||
ProxyRequired = nkey.ProxyRequired,
|
||||
});
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static Permissions? NormalizePermissions(Permissions? permissions)
|
||||
{
|
||||
if (permissions?.Response is null)
|
||||
return permissions;
|
||||
|
||||
var publish = permissions.Publish;
|
||||
if (publish?.Allow is null)
|
||||
{
|
||||
publish = new SubjectPermission
|
||||
{
|
||||
Allow = [],
|
||||
Deny = publish?.Deny,
|
||||
};
|
||||
}
|
||||
|
||||
var response = permissions.Response;
|
||||
var maxMsgs = response.MaxMsgs == 0 ? NatsProtocol.DefaultAllowResponseMaxMsgs : response.MaxMsgs;
|
||||
var expires = response.Expires == TimeSpan.Zero ? NatsProtocol.DefaultAllowResponseExpiration : response.Expires;
|
||||
|
||||
return new Permissions
|
||||
{
|
||||
Publish = publish,
|
||||
Subscribe = permissions.Subscribe,
|
||||
Response = new ResponsePermission { MaxMsgs = maxMsgs, Expires = expires },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ namespace NATS.Server.Auth;
|
||||
|
||||
public sealed class ExternalAuthCalloutAuthenticator : IAuthenticator
|
||||
{
|
||||
public const string AuthCalloutSubject = "$SYS.REQ.USER.AUTH";
|
||||
public const string AuthRequestSubject = "nats-authorization-request";
|
||||
public const string AuthRequestXKeyHeader = "Nats-Server-Xkey";
|
||||
|
||||
private readonly IExternalAuthClient _client;
|
||||
private readonly TimeSpan _timeout;
|
||||
|
||||
|
||||
@@ -6,4 +6,7 @@ public sealed class NKeyUser
|
||||
public Permissions? Permissions { get; init; }
|
||||
public string? Account { get; init; }
|
||||
public string? SigningKey { get; init; }
|
||||
public DateTimeOffset? Issued { get; init; }
|
||||
public IReadOnlySet<string>? AllowedConnectionTypes { get; init; }
|
||||
public bool ProxyRequired { get; init; }
|
||||
}
|
||||
|
||||
@@ -40,9 +40,88 @@ public sealed class TlsMapAuthenticator : IAuthenticator
|
||||
if (cn != null && _usersByCn.TryGetValue(cn, out user))
|
||||
return BuildResult(user);
|
||||
|
||||
// Try SAN-based values
|
||||
var email = cert.GetNameInfo(X509NameType.EmailName, forIssuer: false);
|
||||
if (!string.IsNullOrWhiteSpace(email) && _usersByCn.TryGetValue(email, out user))
|
||||
return BuildResult(user);
|
||||
|
||||
var dns = cert.GetNameInfo(X509NameType.DnsName, forIssuer: false);
|
||||
if (!string.IsNullOrWhiteSpace(dns) && _usersByCn.TryGetValue(dns, out user))
|
||||
return BuildResult(user);
|
||||
|
||||
var uri = cert.GetNameInfo(X509NameType.UrlName, forIssuer: false);
|
||||
if (!string.IsNullOrWhiteSpace(uri) && _usersByCn.TryGetValue(uri, out user))
|
||||
return BuildResult(user);
|
||||
|
||||
// Match using full RDN + DC components if present.
|
||||
var dcs = GetTlsAuthDcs(dn);
|
||||
if (!string.IsNullOrEmpty(dcs))
|
||||
{
|
||||
var rdnWithDcs = string.IsNullOrEmpty(dnString) ? dcs : $"{dnString},{dcs}";
|
||||
if (_usersByDn.TryGetValue(rdnWithDcs, out user))
|
||||
return BuildResult(user);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string GetTlsAuthDcs(X500DistinguishedName dn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dn.Name))
|
||||
return string.Empty;
|
||||
|
||||
var dcs = new List<string>();
|
||||
foreach (var rdn in dn.Name.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (!rdn.StartsWith("DC=", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
dcs.Add("DC=" + rdn[3..].Trim());
|
||||
}
|
||||
|
||||
return string.Join(",", dcs);
|
||||
}
|
||||
|
||||
internal static string[] DnsAltNameLabels(string dnsAltName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dnsAltName))
|
||||
return [];
|
||||
|
||||
return dnsAltName.ToLowerInvariant().Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
}
|
||||
|
||||
internal static bool DnsAltNameMatches(string[] dnsAltNameLabels, IReadOnlyList<Uri?> urls)
|
||||
{
|
||||
foreach (var url in urls)
|
||||
{
|
||||
if (url == null)
|
||||
continue;
|
||||
|
||||
var hostLabels = url.DnsSafeHost.ToLowerInvariant().Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (hostLabels.Length != dnsAltNameLabels.Length)
|
||||
continue;
|
||||
|
||||
var i = 0;
|
||||
if (dnsAltNameLabels.Length > 0 && dnsAltNameLabels[0] == "*")
|
||||
i = 1;
|
||||
|
||||
var matched = true;
|
||||
for (; i < dnsAltNameLabels.Length; i++)
|
||||
{
|
||||
if (!string.Equals(dnsAltNameLabels[i], hostLabels[i], StringComparison.Ordinal))
|
||||
{
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? ExtractCn(X500DistinguishedName dn)
|
||||
{
|
||||
var dnString = dn.Name;
|
||||
|
||||
@@ -7,4 +7,6 @@ public sealed class User
|
||||
public Permissions? Permissions { get; init; }
|
||||
public string? Account { get; init; }
|
||||
public DateTimeOffset? ConnectionDeadline { get; init; }
|
||||
public IReadOnlySet<string>? AllowedConnectionTypes { get; init; }
|
||||
public bool ProxyRequired { get; init; }
|
||||
}
|
||||
|
||||
17
src/NATS.Server/ClientConnectionType.cs
Normal file
17
src/NATS.Server/ClientConnectionType.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace NATS.Server;
|
||||
|
||||
// Go reference: server/client.go NON_CLIENT/NATS/MQTT/WS constants.
|
||||
public enum ClientConnectionType
|
||||
{
|
||||
NonClient = 0,
|
||||
Nats = 1,
|
||||
Mqtt = 2,
|
||||
WebSocket = 3,
|
||||
}
|
||||
|
||||
// Go reference: server/client.go ClientProtoZero/ClientProtoInfo constants.
|
||||
public static class ClientProtocolVersion
|
||||
{
|
||||
public const int ClientProtoZero = 0;
|
||||
public const int ClientProtoInfo = 1;
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.JetStream;
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
@@ -43,12 +45,13 @@ public static class ConfigProcessor
|
||||
public static void ApplyConfig(Dictionary<string, object?> config, NatsOptions opts)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
foreach (var (key, value) in config)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessKey(key, value, opts, errors);
|
||||
ProcessKey(key, value, opts, errors, warnings);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -58,11 +61,16 @@ public static class ConfigProcessor
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
throw new ConfigProcessorException("Configuration errors", errors);
|
||||
throw new ConfigProcessorException("Configuration errors", errors, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ProcessKey(string key, object? value, NatsOptions opts, List<string> errors)
|
||||
private static void ProcessKey(
|
||||
string key,
|
||||
object? value,
|
||||
NatsOptions opts,
|
||||
List<string> errors,
|
||||
List<string> warnings)
|
||||
{
|
||||
// Keys are already case-insensitive from the parser (OrdinalIgnoreCase dictionaries),
|
||||
// but we normalize here for the switch statement.
|
||||
@@ -277,8 +285,15 @@ public static class ConfigProcessor
|
||||
ParseWebSocket(wsDict, opts, errors);
|
||||
break;
|
||||
|
||||
// Unknown keys silently ignored (accounts, resolver, operator, etc.)
|
||||
// Accounts block — each key is an account name containing users/limits
|
||||
case "accounts":
|
||||
if (value is Dictionary<string, object?> accountsDict)
|
||||
ParseAccounts(accountsDict, opts, errors);
|
||||
break;
|
||||
|
||||
// Unknown keys silently ignored (resolver, operator, etc.)
|
||||
default:
|
||||
warnings.Add(new UnknownConfigFieldWarning(key).Message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -745,6 +760,9 @@ public static class ConfigProcessor
|
||||
case "store_dir":
|
||||
options.StoreDir = ToString(value);
|
||||
break;
|
||||
case "domain":
|
||||
options.Domain = ToString(value);
|
||||
break;
|
||||
case "max_mem_store":
|
||||
try
|
||||
{
|
||||
@@ -766,6 +784,68 @@ public static class ConfigProcessor
|
||||
errors.Add($"Invalid jetstream.max_file_store: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "sync_interval":
|
||||
try
|
||||
{
|
||||
options.SyncInterval = ParseDuration(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.sync_interval: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "sync_always":
|
||||
options.SyncAlways = ToBool(value);
|
||||
break;
|
||||
case "compress_ok":
|
||||
options.CompressOk = ToBool(value);
|
||||
break;
|
||||
case "unique_tag":
|
||||
options.UniqueTag = ToString(value);
|
||||
break;
|
||||
case "strict":
|
||||
options.Strict = ToBool(value);
|
||||
break;
|
||||
case "max_ack_pending":
|
||||
options.MaxAckPending = ToInt(value);
|
||||
break;
|
||||
case "memory_max_stream_bytes":
|
||||
try
|
||||
{
|
||||
options.MemoryMaxStreamBytes = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.memory_max_stream_bytes: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "store_max_stream_bytes":
|
||||
try
|
||||
{
|
||||
options.StoreMaxStreamBytes = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.store_max_stream_bytes: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "max_bytes_required":
|
||||
options.MaxBytesRequired = ToBool(value);
|
||||
break;
|
||||
case "tiers":
|
||||
if (value is Dictionary<string, object?> tiers)
|
||||
{
|
||||
foreach (var (tierName, rawTier) in tiers)
|
||||
{
|
||||
if (rawTier is Dictionary<string, object?> tierDict)
|
||||
options.Tiers[tierName] = ParseJetStreamTier(tierName, tierDict, errors);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -773,6 +853,47 @@ public static class ConfigProcessor
|
||||
return options;
|
||||
}
|
||||
|
||||
private static JetStreamTier ParseJetStreamTier(string tierName, Dictionary<string, object?> dict, List<string> errors)
|
||||
{
|
||||
var tier = new JetStreamTier { Name = tierName };
|
||||
foreach (var (key, value) in dict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "memory" or "max_memory":
|
||||
try
|
||||
{
|
||||
tier.Memory = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.tiers.{tierName}.memory: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "store" or "max_store":
|
||||
try
|
||||
{
|
||||
tier.Store = ParseByteSize(value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"Invalid jetstream.tiers.{tierName}.store: {ex.Message}");
|
||||
}
|
||||
|
||||
break;
|
||||
case "streams" or "max_streams":
|
||||
tier.Streams = ToInt(value);
|
||||
break;
|
||||
case "consumers" or "max_consumers":
|
||||
tier.Consumers = ToInt(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return tier;
|
||||
}
|
||||
|
||||
// ─── Authorization parsing ─────────────────────────────────────
|
||||
|
||||
private static void ParseAuthorization(Dictionary<string, object?> dict, NatsOptions opts, List<string> errors)
|
||||
@@ -831,12 +952,80 @@ public static class ConfigProcessor
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Accounts parsing ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parses the top-level "accounts" block. Each key is an account name, and each
|
||||
/// value is a dictionary that may contain "users" (array) and account-level limits.
|
||||
/// Users are stamped with the account name and appended to opts.Users / opts.NKeys.
|
||||
/// Go reference: opts.go — configureAccounts / parseAccounts.
|
||||
/// </summary>
|
||||
private static void ParseAccounts(Dictionary<string, object?> accountsDict, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
opts.Accounts ??= new Dictionary<string, AccountConfig>();
|
||||
|
||||
foreach (var (accountName, accountValue) in accountsDict)
|
||||
{
|
||||
if (accountValue is not Dictionary<string, object?> acctDict)
|
||||
{
|
||||
errors.Add($"Expected account '{accountName}' value to be a map");
|
||||
continue;
|
||||
}
|
||||
|
||||
int maxConnections = 0;
|
||||
int maxSubscriptions = 0;
|
||||
List<object?>? userList = null;
|
||||
|
||||
foreach (var (key, value) in acctDict)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "users":
|
||||
if (value is List<object?> ul)
|
||||
userList = ul;
|
||||
break;
|
||||
case "max_connections" or "max_conns":
|
||||
maxConnections = ToInt(value);
|
||||
break;
|
||||
case "max_subscriptions" or "max_subs":
|
||||
maxSubscriptions = ToInt(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
opts.Accounts[accountName] = new AccountConfig
|
||||
{
|
||||
MaxConnections = maxConnections,
|
||||
MaxSubscriptions = maxSubscriptions,
|
||||
};
|
||||
|
||||
if (userList is not null)
|
||||
{
|
||||
var (plainUsers, nkeyUsers) = ParseUsersAndNkeys(userList, errors, defaultAccount: accountName);
|
||||
|
||||
if (plainUsers.Count > 0)
|
||||
{
|
||||
var existing = opts.Users?.ToList() ?? [];
|
||||
existing.AddRange(plainUsers);
|
||||
opts.Users = existing;
|
||||
}
|
||||
|
||||
if (nkeyUsers.Count > 0)
|
||||
{
|
||||
var existing = opts.NKeys?.ToList() ?? [];
|
||||
existing.AddRange(nkeyUsers);
|
||||
opts.NKeys = existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a users array into plain users and NKey users.
|
||||
/// An entry with an "nkey" field is an NKey user; entries with "user" are plain users.
|
||||
/// Go reference: opts.go — parseUsers (lines ~2500-2700).
|
||||
/// </summary>
|
||||
private static (List<User> PlainUsers, List<Auth.NKeyUser> NkeyUsers) ParseUsersAndNkeys(List<object?> list, List<string> errors)
|
||||
private static (List<User> PlainUsers, List<Auth.NKeyUser> NkeyUsers) ParseUsersAndNkeys(List<object?> list, List<string> errors, string? defaultAccount = null)
|
||||
{
|
||||
var plainUsers = new List<User>();
|
||||
var nkeyUsers = new List<Auth.NKeyUser>();
|
||||
@@ -888,7 +1077,7 @@ public static class ConfigProcessor
|
||||
{
|
||||
Nkey = nkey,
|
||||
Permissions = permissions,
|
||||
Account = account,
|
||||
Account = account ?? defaultAccount,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -903,7 +1092,7 @@ public static class ConfigProcessor
|
||||
{
|
||||
Username = username,
|
||||
Password = password ?? string.Empty,
|
||||
Account = account,
|
||||
Account = account ?? defaultAccount,
|
||||
Permissions = permissions,
|
||||
});
|
||||
}
|
||||
@@ -1087,6 +1276,9 @@ public static class ConfigProcessor
|
||||
case "handshake_first_fallback":
|
||||
opts.TlsHandshakeFirstFallback = ParseDuration(value);
|
||||
break;
|
||||
case "ocsp_peer":
|
||||
ParseOcspPeer(value, opts, errors);
|
||||
break;
|
||||
default:
|
||||
// Unknown TLS keys silently ignored
|
||||
break;
|
||||
@@ -1094,6 +1286,31 @@ public static class ConfigProcessor
|
||||
}
|
||||
}
|
||||
|
||||
private static void ParseOcspPeer(object? value, NatsOptions opts, List<string> errors)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case bool verify:
|
||||
opts.OcspPeerVerify = verify;
|
||||
return;
|
||||
case Dictionary<string, object?> dict:
|
||||
try
|
||||
{
|
||||
var cfg = OCSPPeerConfig.Parse(dict);
|
||||
opts.OcspPeerVerify = cfg.Verify;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
errors.Add(ex.Message);
|
||||
}
|
||||
|
||||
return;
|
||||
default:
|
||||
errors.Add($"expected map to define OCSP peer options, got [{value?.GetType().Name ?? "null"}]");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tags parsing ──────────────────────────────────────────────
|
||||
|
||||
private static void ParseTags(Dictionary<string, object?> dict, NatsOptions opts)
|
||||
@@ -1431,8 +1648,28 @@ public static class ConfigProcessor
|
||||
/// Thrown when one or more configuration validation errors are detected.
|
||||
/// All errors are collected rather than failing on the first one.
|
||||
/// </summary>
|
||||
public sealed class ConfigProcessorException(string message, List<string> errors)
|
||||
public sealed class ConfigProcessorException(string message, List<string> errors, List<string>? warnings = null)
|
||||
: Exception(message)
|
||||
{
|
||||
public IReadOnlyList<string> Errors => errors;
|
||||
public IReadOnlyList<string> Warnings => warnings ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a non-fatal configuration warning.
|
||||
/// Go reference: configWarningErr.
|
||||
/// </summary>
|
||||
public class ConfigWarningException(string message, string? source = null) : Exception(message)
|
||||
{
|
||||
public string? SourceLocation { get; } = source;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warning used when an unknown config field is encountered.
|
||||
/// Go reference: unknownConfigFieldErr.
|
||||
/// </summary>
|
||||
public sealed class UnknownConfigFieldWarning(string field, string? source = null)
|
||||
: ConfigWarningException($"unknown field {field}", source)
|
||||
{
|
||||
public string Field { get; } = field;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,115 @@ public sealed class GatewayOptions
|
||||
/// </summary>
|
||||
public sealed class RemoteGatewayOptions
|
||||
{
|
||||
private int _connAttempts;
|
||||
|
||||
public string? Name { get; set; }
|
||||
public List<string> Urls { get; set; } = [];
|
||||
public bool Implicit { get; set; }
|
||||
public byte[]? Hash { get; set; }
|
||||
public byte[]? OldHash { get; set; }
|
||||
public string? TlsName { get; private set; }
|
||||
public bool VarzUpdateUrls { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Deep clone helper for remote gateway options.
|
||||
/// Go reference: RemoteGatewayOpts.clone.
|
||||
/// </summary>
|
||||
public RemoteGatewayOptions Clone()
|
||||
{
|
||||
return new RemoteGatewayOptions
|
||||
{
|
||||
Name = Name,
|
||||
Urls = [.. Urls],
|
||||
Implicit = Implicit,
|
||||
Hash = Hash == null ? null : [.. Hash],
|
||||
OldHash = OldHash == null ? null : [.. OldHash],
|
||||
TlsName = TlsName,
|
||||
VarzUpdateUrls = VarzUpdateUrls,
|
||||
};
|
||||
}
|
||||
|
||||
public int BumpConnAttempts() => Interlocked.Increment(ref _connAttempts);
|
||||
|
||||
public int GetConnAttempts() => Volatile.Read(ref _connAttempts);
|
||||
|
||||
public void ResetConnAttempts() => Interlocked.Exchange(ref _connAttempts, 0);
|
||||
|
||||
public bool IsImplicit() => Implicit;
|
||||
|
||||
public List<Uri> GetUrls(Random? random = null)
|
||||
{
|
||||
var urls = new List<Uri>();
|
||||
foreach (var url in Urls)
|
||||
{
|
||||
if (TryNormalizeRemoteUrl(url, out var uri))
|
||||
urls.Add(uri);
|
||||
}
|
||||
|
||||
random ??= Random.Shared;
|
||||
for (var i = urls.Count - 1; i > 0; i--)
|
||||
{
|
||||
var j = random.Next(i + 1);
|
||||
(urls[i], urls[j]) = (urls[j], urls[i]);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
public List<string> GetUrlsAsStrings()
|
||||
{
|
||||
var result = new List<string>();
|
||||
foreach (var uri in GetUrls())
|
||||
result.Add($"{uri.Scheme}://{uri.Authority}");
|
||||
return result;
|
||||
}
|
||||
|
||||
public void UpdateUrls(IEnumerable<string> configuredUrls, IEnumerable<string> discoveredUrls)
|
||||
{
|
||||
var merged = new List<string>();
|
||||
AddUrlsInternal(merged, configuredUrls);
|
||||
AddUrlsInternal(merged, discoveredUrls);
|
||||
Urls = merged;
|
||||
}
|
||||
|
||||
public void SaveTlsHostname(string url)
|
||||
{
|
||||
if (TryNormalizeRemoteUrl(url, out var uri))
|
||||
TlsName = uri.Host;
|
||||
}
|
||||
|
||||
public void AddUrls(IEnumerable<string> discoveredUrls)
|
||||
{
|
||||
AddUrlsInternal(Urls, discoveredUrls);
|
||||
}
|
||||
|
||||
private static void AddUrlsInternal(List<string> target, IEnumerable<string> urls)
|
||||
{
|
||||
var seen = new HashSet<string>(target, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var url in urls)
|
||||
{
|
||||
if (!TryNormalizeRemoteUrl(url, out var uri))
|
||||
continue;
|
||||
|
||||
var normalized = $"{uri.Scheme}://{uri.Authority}";
|
||||
if (seen.Add(normalized))
|
||||
target.Add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryNormalizeRemoteUrl(string? raw, out Uri uri)
|
||||
{
|
||||
uri = default!;
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return false;
|
||||
|
||||
var normalized = raw.Contains("://", StringComparison.Ordinal) ? raw : $"nats://{raw}";
|
||||
if (Uri.TryCreate(normalized, UriKind.Absolute, out var parsed) && parsed is not null)
|
||||
{
|
||||
uri = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ namespace NATS.Server.Configuration;
|
||||
// Controls the lifecycle parameters for the JetStream subsystem.
|
||||
public sealed class JetStreamOptions
|
||||
{
|
||||
// Go: server/jetstream.go constants (dynJetStreamConfig defaults)
|
||||
public const string JetStreamStoreDir = "jetstream";
|
||||
public const long JetStreamMaxStoreDefault = 1L << 40; // 1 TiB
|
||||
public const long JetStreamMaxMemDefault = 256L * 1024 * 1024; // 256 MiB
|
||||
|
||||
/// <summary>
|
||||
/// Directory where JetStream persists stream data.
|
||||
/// Maps to Go's JetStreamConfig.StoreDir (jetstream.go:enableJetStream:430).
|
||||
@@ -41,4 +46,64 @@ public sealed class JetStreamOptions
|
||||
/// Maps to Go's Options.JetStreamDomain (opts.go).
|
||||
/// </summary>
|
||||
public string? Domain { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// File-store sync interval.
|
||||
/// Go reference: server/jetstream.go JetStreamConfig.SyncInterval.
|
||||
/// </summary>
|
||||
public TimeSpan SyncInterval { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Forces sync on each write when true.
|
||||
/// Go reference: server/jetstream.go JetStreamConfig.SyncAlways.
|
||||
/// </summary>
|
||||
public bool SyncAlways { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether compression is allowed for JetStream replication/storage paths.
|
||||
/// Go reference: server/jetstream.go JetStreamConfig.CompressOK.
|
||||
/// </summary>
|
||||
public bool CompressOk { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Unique placement tag used in clustered deployments.
|
||||
/// Go reference: server/jetstream.go JetStreamConfig.UniqueTag.
|
||||
/// </summary>
|
||||
public string? UniqueTag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enables strict validation mode for JetStream.
|
||||
/// Go reference: server/jetstream.go JetStreamConfig.Strict.
|
||||
/// </summary>
|
||||
public bool Strict { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Account-level maximum pending acknowledgements.
|
||||
/// Go reference: server/jetstream.go JetStreamAccountLimits.MaxAckPending.
|
||||
/// </summary>
|
||||
public int MaxAckPending { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bytes allowed per memory-backed stream.
|
||||
/// Go reference: server/jetstream.go JetStreamAccountLimits.MemoryMaxStreamBytes.
|
||||
/// </summary>
|
||||
public long MemoryMaxStreamBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum bytes allowed per file-backed stream.
|
||||
/// Go reference: server/jetstream.go JetStreamAccountLimits.StoreMaxStreamBytes.
|
||||
/// </summary>
|
||||
public long StoreMaxStreamBytes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, stream configs must specify explicit MaxBytes.
|
||||
/// Go reference: server/jetstream.go JetStreamAccountLimits.MaxBytesRequired.
|
||||
/// </summary>
|
||||
public bool MaxBytesRequired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-tier JetStream limits keyed by tier name.
|
||||
/// Go reference: server/jetstream.go JetStreamAccountLimits tiers.
|
||||
/// </summary>
|
||||
public Dictionary<string, NATS.Server.JetStream.JetStreamTier> Tiers { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,109 @@ public sealed class RemoteLeafOptions
|
||||
|
||||
/// <summary>Whether to not randomize URL order.</summary>
|
||||
public bool DontRandomize { get; init; }
|
||||
|
||||
private int _urlIndex = -1;
|
||||
private TimeSpan _connectDelay;
|
||||
private Timer? _migrateTimer;
|
||||
|
||||
/// <summary>Last URL selected by <see cref="PickNextUrl"/>.</summary>
|
||||
public string? CurrentUrl { get; private set; }
|
||||
|
||||
/// <summary>Saved TLS hostname for SNI usage on solicited connections.</summary>
|
||||
public string? TlsName { get; private set; }
|
||||
|
||||
/// <summary>Username parsed from URL user-info fallback.</summary>
|
||||
public string? Username { get; private set; }
|
||||
|
||||
/// <summary>Password parsed from URL user-info fallback.</summary>
|
||||
public string? Password { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the next URL using round-robin order and updates <see cref="CurrentUrl"/>.
|
||||
/// Go reference: leafnode.go leafNodeCfg.pickNextURL.
|
||||
/// </summary>
|
||||
public string PickNextUrl()
|
||||
{
|
||||
if (Urls.Count == 0)
|
||||
throw new InvalidOperationException("No remote leaf URLs configured.");
|
||||
|
||||
var idx = Interlocked.Increment(ref _urlIndex);
|
||||
var next = Urls[idx % Urls.Count];
|
||||
CurrentUrl = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current selected URL, or null if no URL has been selected yet.
|
||||
/// Go reference: leafnode.go leafNodeCfg.getCurrentURL.
|
||||
/// </summary>
|
||||
public string? GetCurrentUrl() => CurrentUrl;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the currently configured reconnect/connect delay for this remote.
|
||||
/// Go reference: leafnode.go leafNodeCfg.getConnectDelay.
|
||||
/// </summary>
|
||||
public TimeSpan GetConnectDelay() => _connectDelay;
|
||||
|
||||
/// <summary>
|
||||
/// Sets reconnect/connect delay for this remote.
|
||||
/// Go reference: leafnode.go leafNodeCfg.setConnectDelay.
|
||||
/// </summary>
|
||||
public void SetConnectDelay(TimeSpan delay) => _connectDelay = delay;
|
||||
|
||||
/// <summary>
|
||||
/// Starts or replaces the JetStream migration timer callback for this remote leaf.
|
||||
/// Go reference: leafnode.go leafNodeCfg.migrateTimer.
|
||||
/// </summary>
|
||||
public void StartMigrateTimer(TimerCallback callback, TimeSpan delay)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(callback);
|
||||
var timer = new Timer(callback, null, delay, Timeout.InfiniteTimeSpan);
|
||||
var previous = Interlocked.Exchange(ref _migrateTimer, timer);
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels the JetStream migration timer if active.
|
||||
/// Go reference: leafnode.go leafNodeCfg.cancelMigrateTimer.
|
||||
/// </summary>
|
||||
public void CancelMigrateTimer()
|
||||
{
|
||||
var timer = Interlocked.Exchange(ref _migrateTimer, null);
|
||||
timer?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves TLS hostname from URL for future SNI usage.
|
||||
/// Go reference: leafnode.go leafNodeCfg.saveTLSHostname.
|
||||
/// </summary>
|
||||
public void SaveTlsHostname(string url)
|
||||
{
|
||||
if (TryParseUrl(url, out var uri))
|
||||
TlsName = uri.Host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves username/password from URL user info for fallback auth.
|
||||
/// Go reference: leafnode.go leafNodeCfg.saveUserPassword.
|
||||
/// </summary>
|
||||
public void SaveUserPassword(string url)
|
||||
{
|
||||
if (!TryParseUrl(url, out var uri) || string.IsNullOrEmpty(uri.UserInfo))
|
||||
return;
|
||||
|
||||
var parts = uri.UserInfo.Split(':', 2, StringSplitOptions.None);
|
||||
Username = Uri.UnescapeDataString(parts[0]);
|
||||
Password = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
|
||||
}
|
||||
|
||||
private static bool TryParseUrl(string url, out Uri uri)
|
||||
{
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out uri!))
|
||||
return true;
|
||||
|
||||
return Uri.TryCreate($"nats://{url}", UriKind.Absolute, out uri!);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class LeafNodeOptions
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Port of Go conf/lex.go — state-machine tokenizer for NATS config files.
|
||||
// Reference: golang/nats-server/conf/lex.go
|
||||
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
public sealed class NatsConfLexer
|
||||
@@ -145,16 +149,23 @@ public sealed class NatsConfLexer
|
||||
return Eof;
|
||||
}
|
||||
|
||||
if (_input[_pos] == '\n')
|
||||
var span = _input.AsSpan(_pos);
|
||||
var status = Rune.DecodeFromUtf16(span, out var rune, out var consumed);
|
||||
if (status != OperationStatus.Done || consumed <= 0)
|
||||
{
|
||||
consumed = 1;
|
||||
rune = new Rune(_input[_pos]);
|
||||
}
|
||||
|
||||
if (rune.Value == '\n')
|
||||
{
|
||||
_line++;
|
||||
_lstart = _pos;
|
||||
}
|
||||
|
||||
var c = _input[_pos];
|
||||
_width = 1;
|
||||
_pos += _width;
|
||||
return c;
|
||||
_width = consumed;
|
||||
_pos += consumed;
|
||||
return rune.IsBmp ? (char)rune.Value : '\uFFFD';
|
||||
}
|
||||
|
||||
private void Ignore()
|
||||
@@ -186,6 +197,20 @@ public sealed class NatsConfLexer
|
||||
return null;
|
||||
}
|
||||
|
||||
private LexState? Errorf(string format, params object?[] args)
|
||||
{
|
||||
if (args.Length == 0)
|
||||
return Errorf(format);
|
||||
|
||||
var escapedArgs = new object?[args.Length];
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
escapedArgs[i] = args[i] is char c ? EscapeSpecial(c) : args[i];
|
||||
}
|
||||
|
||||
return Errorf(string.Format(CultureInfo.InvariantCulture, format, escapedArgs));
|
||||
}
|
||||
|
||||
// --- Helper methods ---
|
||||
|
||||
private static bool IsWhitespace(char c) => c is '\t' or ' ';
|
||||
@@ -1476,9 +1501,8 @@ public sealed class NatsConfLexer
|
||||
var r = lx.Peek();
|
||||
if (IsNL(r) || r == Eof)
|
||||
{
|
||||
// Consume the comment text but don't emit it as a user-visible token.
|
||||
// Just ignore it and pop back.
|
||||
lx.Ignore();
|
||||
// Match Go behavior: emit comment body as a text token.
|
||||
lx.Emit(TokenType.Text);
|
||||
return lx.Pop();
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
@@ -15,6 +16,7 @@ namespace NATS.Server.Configuration;
|
||||
/// </summary>
|
||||
public static class NatsConfParser
|
||||
{
|
||||
private const string BcryptPrefix = "2a$";
|
||||
// Bcrypt hashes start with $2a$ or $2b$. The lexer consumes the leading '$'
|
||||
// and emits a Variable token whose value begins with "2a$" or "2b$".
|
||||
private const string BcryptPrefix2A = "2a$";
|
||||
@@ -34,12 +36,24 @@ public static class NatsConfParser
|
||||
return state.Mapping;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pedantic compatibility API (Go: ParseWithChecks).
|
||||
/// Uses the same parser behavior as <see cref="Parse(string)"/>.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> ParseWithChecks(string data) => Parse(data);
|
||||
|
||||
/// <summary>
|
||||
/// Parses a NATS configuration file into a dictionary.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> ParseFile(string filePath) =>
|
||||
ParseFile(filePath, includeDepth: 0);
|
||||
|
||||
/// <summary>
|
||||
/// Pedantic compatibility API (Go: ParseFileWithChecks).
|
||||
/// Uses the same parser behavior as <see cref="ParseFile(string)"/>.
|
||||
/// </summary>
|
||||
public static Dictionary<string, object?> ParseFileWithChecks(string filePath) => ParseFile(filePath);
|
||||
|
||||
private static Dictionary<string, object?> ParseFile(string filePath, int includeDepth)
|
||||
{
|
||||
var data = File.ReadAllText(filePath);
|
||||
@@ -68,6 +82,94 @@ public static class NatsConfParser
|
||||
return (state.Mapping, digest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pedantic compatibility API (Go: ParseFileWithChecksDigest).
|
||||
/// </summary>
|
||||
public static (Dictionary<string, object?> Config, string Digest) ParseFileWithChecksDigest(string filePath)
|
||||
{
|
||||
var data = File.ReadAllText(filePath);
|
||||
var tokens = NatsConfLexer.Tokenize(data);
|
||||
var baseDir = Path.GetDirectoryName(Path.GetFullPath(filePath)) ?? string.Empty;
|
||||
var state = new ParserState(tokens, baseDir, [], includeDepth: 0);
|
||||
state.Run();
|
||||
CleanupUsedEnvVars(state.Mapping);
|
||||
|
||||
var digest = ComputeConfigDigest(state.Mapping);
|
||||
return (state.Mapping, digest);
|
||||
}
|
||||
|
||||
// Go pedantic mode removes env-var wrapper nodes from parsed maps before digesting.
|
||||
// The current parser does not persist env-wrapper nodes, so this remains a no-op hook.
|
||||
private static void CleanupUsedEnvVars(Dictionary<string, object?> _) { }
|
||||
|
||||
private static string ComputeConfigDigest(Dictionary<string, object?> config)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var writer = new Utf8JsonWriter(ms))
|
||||
{
|
||||
WriteCanonicalJsonValue(writer, config);
|
||||
writer.Flush();
|
||||
}
|
||||
|
||||
var hashBytes = SHA256.HashData(ms.ToArray());
|
||||
return "sha256:" + Convert.ToHexStringLower(hashBytes);
|
||||
}
|
||||
|
||||
private static void WriteCanonicalJsonValue(Utf8JsonWriter writer, object? value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case null:
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
case Dictionary<string, object?> map:
|
||||
writer.WriteStartObject();
|
||||
foreach (var key in map.Keys.OrderBy(static k => k, StringComparer.Ordinal))
|
||||
{
|
||||
writer.WritePropertyName(key);
|
||||
WriteCanonicalJsonValue(writer, map[key]);
|
||||
}
|
||||
writer.WriteEndObject();
|
||||
return;
|
||||
case List<object?> list:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in list)
|
||||
WriteCanonicalJsonValue(writer, item);
|
||||
writer.WriteEndArray();
|
||||
return;
|
||||
case IReadOnlyList<string> stringList:
|
||||
writer.WriteStartArray();
|
||||
foreach (var item in stringList)
|
||||
writer.WriteStringValue(item);
|
||||
writer.WriteEndArray();
|
||||
return;
|
||||
case bool b:
|
||||
writer.WriteBooleanValue(b);
|
||||
return;
|
||||
case int i:
|
||||
writer.WriteNumberValue(i);
|
||||
return;
|
||||
case long l:
|
||||
writer.WriteNumberValue(l);
|
||||
return;
|
||||
case double d:
|
||||
writer.WriteNumberValue(d);
|
||||
return;
|
||||
case float f:
|
||||
writer.WriteNumberValue(f);
|
||||
return;
|
||||
case decimal dec:
|
||||
writer.WriteNumberValue(dec);
|
||||
return;
|
||||
case string s:
|
||||
writer.WriteStringValue(s);
|
||||
return;
|
||||
default:
|
||||
JsonSerializer.Serialize(writer, value, value.GetType());
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal: parse an environment variable value by wrapping it in a synthetic
|
||||
/// key-value assignment and parsing it. Shares the parent's env var cycle tracker.
|
||||
@@ -99,6 +201,8 @@ public static class NatsConfParser
|
||||
|
||||
// Key stack for map assignments.
|
||||
private readonly List<string> _keys = new(4);
|
||||
// Pedantic-mode key token stack (Go parser field: ikeys).
|
||||
private readonly List<Token> _itemKeys = new(4);
|
||||
|
||||
public Dictionary<string, object?> Mapping { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -182,6 +286,18 @@ public static class NatsConfParser
|
||||
return last;
|
||||
}
|
||||
|
||||
private void PushItemKey(Token token) => _itemKeys.Add(token);
|
||||
|
||||
private Token PopItemKey()
|
||||
{
|
||||
if (_itemKeys.Count == 0)
|
||||
return default;
|
||||
|
||||
var last = _itemKeys[^1];
|
||||
_itemKeys.RemoveAt(_itemKeys.Count - 1);
|
||||
return last;
|
||||
}
|
||||
|
||||
private void SetValue(object? val)
|
||||
{
|
||||
// Array context: append the value.
|
||||
@@ -195,6 +311,7 @@ public static class NatsConfParser
|
||||
if (_ctx is Dictionary<string, object?> map)
|
||||
{
|
||||
var key = PopKey();
|
||||
_ = PopItemKey();
|
||||
map[key] = val;
|
||||
return;
|
||||
}
|
||||
@@ -211,6 +328,7 @@ public static class NatsConfParser
|
||||
|
||||
case TokenType.Key:
|
||||
PushKey(token.Value);
|
||||
PushItemKey(token);
|
||||
break;
|
||||
|
||||
case TokenType.String:
|
||||
@@ -262,6 +380,7 @@ public static class NatsConfParser
|
||||
break;
|
||||
|
||||
case TokenType.Comment:
|
||||
case TokenType.Text:
|
||||
// Skip comments entirely.
|
||||
break;
|
||||
|
||||
@@ -347,7 +466,8 @@ public static class NatsConfParser
|
||||
|
||||
// Special case: raw bcrypt strings ($2a$... or $2b$...).
|
||||
// The lexer consumed the leading '$', so the variable value starts with "2a$" or "2b$".
|
||||
if (varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) ||
|
||||
if (varName.StartsWith(BcryptPrefix, StringComparison.Ordinal) ||
|
||||
varName.StartsWith(BcryptPrefix2A, StringComparison.Ordinal) ||
|
||||
varName.StartsWith(BcryptPrefix2B, StringComparison.Ordinal))
|
||||
{
|
||||
SetValue("$" + varName);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Port of Go conf/lex.go token types.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NATS.Server.Configuration;
|
||||
|
||||
public enum TokenType
|
||||
@@ -7,6 +9,7 @@ public enum TokenType
|
||||
Error,
|
||||
Eof,
|
||||
Key,
|
||||
Text,
|
||||
String,
|
||||
Bool,
|
||||
Integer,
|
||||
@@ -22,3 +25,34 @@ public enum TokenType
|
||||
}
|
||||
|
||||
public readonly record struct Token(TokenType Type, string Value, int Line, int Position);
|
||||
|
||||
/// <summary>
|
||||
/// Pedantic token wrapper matching Go conf/parse.go token accessors.
|
||||
/// </summary>
|
||||
public sealed class PedanticToken
|
||||
{
|
||||
private readonly Token _item;
|
||||
private readonly object? _value;
|
||||
private readonly bool _usedVariable;
|
||||
private readonly string _sourceFile;
|
||||
|
||||
public PedanticToken(Token item, object? value = null, bool usedVariable = false, string sourceFile = "")
|
||||
{
|
||||
_item = item;
|
||||
_value = value;
|
||||
_usedVariable = usedVariable;
|
||||
_sourceFile = sourceFile ?? string.Empty;
|
||||
}
|
||||
|
||||
public string MarshalJson() => JsonSerializer.Serialize(Value());
|
||||
|
||||
public object? Value() => _value ?? _item.Value;
|
||||
|
||||
public int Line() => _item.Line;
|
||||
|
||||
public bool IsUsedVariable() => _usedVariable;
|
||||
|
||||
public string SourceFile() => _sourceFile;
|
||||
|
||||
public int Position() => _item.Position;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
// Go reference: server/events.go:2081-2090 — compressionType, snappyCompression,
|
||||
// and events.go:578-598 — internalSendLoop compression via s2.WriterSnappyCompat().
|
||||
|
||||
using System.IO.Compression;
|
||||
using IronSnappy;
|
||||
|
||||
namespace NATS.Server.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Compression encodings supported for event API responses.
|
||||
/// Go reference: events.go compressionType constants.
|
||||
/// </summary>
|
||||
public enum EventCompressionType : sbyte
|
||||
{
|
||||
None = 0,
|
||||
Gzip = 1,
|
||||
Snappy = 2,
|
||||
Unsupported = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides S2 (Snappy-compatible) compression for system event payloads.
|
||||
/// Maps to Go's compressionType / snappyCompression handling in events.go:2082-2098
|
||||
@@ -12,6 +25,9 @@ namespace NATS.Server.Events;
|
||||
/// </summary>
|
||||
public static class EventCompressor
|
||||
{
|
||||
public const string AcceptEncodingHeader = "Accept-Encoding";
|
||||
public const string ContentEncodingHeader = "Content-Encoding";
|
||||
|
||||
// Default threshold: only compress payloads larger than this many bytes.
|
||||
// Compressing tiny payloads wastes CPU and may produce larger output.
|
||||
private const int DefaultThresholdBytes = 256;
|
||||
@@ -56,11 +72,23 @@ public static class EventCompressor
|
||||
/// <param name="payload">Raw bytes to compress.</param>
|
||||
/// <returns>Compressed bytes. Returns an empty array for empty input.</returns>
|
||||
public static byte[] Compress(ReadOnlySpan<byte> payload)
|
||||
=> Compress(payload, EventCompressionType.Snappy);
|
||||
|
||||
/// <summary>
|
||||
/// Compresses <paramref name="payload"/> using the requested <paramref name="compression"/>.
|
||||
/// </summary>
|
||||
public static byte[] Compress(ReadOnlySpan<byte> payload, EventCompressionType compression)
|
||||
{
|
||||
if (payload.IsEmpty)
|
||||
return [];
|
||||
|
||||
return Snappy.Encode(payload);
|
||||
return compression switch
|
||||
{
|
||||
EventCompressionType.None => payload.ToArray(),
|
||||
EventCompressionType.Gzip => CompressGzip(payload),
|
||||
EventCompressionType.Snappy => Snappy.Encode(payload),
|
||||
_ => throw new InvalidOperationException($"Unsupported compression type: {compression}."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -71,11 +99,23 @@ public static class EventCompressor
|
||||
/// <returns>Decompressed bytes. Returns an empty array for empty input.</returns>
|
||||
/// <exception cref="Exception">Propagated from IronSnappy if data is corrupt.</exception>
|
||||
public static byte[] Decompress(ReadOnlySpan<byte> compressed)
|
||||
=> Decompress(compressed, EventCompressionType.Snappy);
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses <paramref name="compressed"/> using the selected <paramref name="compression"/>.
|
||||
/// </summary>
|
||||
public static byte[] Decompress(ReadOnlySpan<byte> compressed, EventCompressionType compression)
|
||||
{
|
||||
if (compressed.IsEmpty)
|
||||
return [];
|
||||
|
||||
return Snappy.Decode(compressed);
|
||||
return compression switch
|
||||
{
|
||||
EventCompressionType.None => compressed.ToArray(),
|
||||
EventCompressionType.Gzip => DecompressGzip(compressed),
|
||||
EventCompressionType.Snappy => Snappy.Decode(compressed),
|
||||
_ => throw new InvalidOperationException($"Unsupported compression type: {compression}."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -105,6 +145,15 @@ public static class EventCompressor
|
||||
public static (byte[] Data, bool Compressed) CompressIfBeneficial(
|
||||
ReadOnlySpan<byte> payload,
|
||||
int thresholdBytes = DefaultThresholdBytes)
|
||||
=> CompressIfBeneficial(payload, EventCompressionType.Snappy, thresholdBytes);
|
||||
|
||||
/// <summary>
|
||||
/// Compresses using <paramref name="compression"/> when payload size exceeds threshold.
|
||||
/// </summary>
|
||||
public static (byte[] Data, bool Compressed) CompressIfBeneficial(
|
||||
ReadOnlySpan<byte> payload,
|
||||
EventCompressionType compression,
|
||||
int thresholdBytes = DefaultThresholdBytes)
|
||||
{
|
||||
if (!ShouldCompress(payload.Length, thresholdBytes))
|
||||
{
|
||||
@@ -112,7 +161,7 @@ public static class EventCompressor
|
||||
return (payload.ToArray(), false);
|
||||
}
|
||||
|
||||
var compressed = Compress(payload);
|
||||
var compressed = Compress(payload, compression);
|
||||
Interlocked.Increment(ref _totalCompressed);
|
||||
var saved = payload.Length - compressed.Length;
|
||||
if (saved > 0)
|
||||
@@ -135,4 +184,45 @@ public static class EventCompressor
|
||||
|
||||
return (double)compressedSize / originalSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an HTTP Accept-Encoding value into a supported compression type.
|
||||
/// Go reference: events.go getAcceptEncoding().
|
||||
/// </summary>
|
||||
public static EventCompressionType GetAcceptEncoding(string? acceptEncoding)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(acceptEncoding))
|
||||
return EventCompressionType.None;
|
||||
|
||||
var value = acceptEncoding.ToLowerInvariant();
|
||||
if (value.Contains("snappy", StringComparison.Ordinal)
|
||||
|| value.Contains("s2", StringComparison.Ordinal))
|
||||
{
|
||||
return EventCompressionType.Snappy;
|
||||
}
|
||||
|
||||
if (value.Contains("gzip", StringComparison.Ordinal))
|
||||
return EventCompressionType.Gzip;
|
||||
|
||||
return EventCompressionType.Unsupported;
|
||||
}
|
||||
|
||||
private static byte[] CompressGzip(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
using var output = new MemoryStream();
|
||||
using (var gzip = new GZipStream(output, CompressionLevel.Fastest, leaveOpen: true))
|
||||
{
|
||||
gzip.Write(payload);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] DecompressGzip(ReadOnlySpan<byte> compressed)
|
||||
{
|
||||
using var input = new MemoryStream(compressed.ToArray());
|
||||
using var gzip = new GZipStream(input, CompressionMode.Decompress);
|
||||
using var output = new MemoryStream();
|
||||
gzip.CopyTo(output);
|
||||
return output.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,9 @@ public static class EventSubjects
|
||||
// Remote server and leaf node events
|
||||
public const string RemoteServerShutdown = "$SYS.SERVER.{0}.REMOTE.SHUTDOWN";
|
||||
public const string RemoteServerUpdate = "$SYS.SERVER.{0}.REMOTE.UPDATE";
|
||||
public const string LeafNodeConnected = "$SYS.SERVER.{0}.LEAFNODE.CONNECT";
|
||||
public const string LeafNodeConnected = "$SYS.ACCOUNT.{0}.LEAFNODE.CONNECT";
|
||||
public const string LeafNodeDisconnected = "$SYS.SERVER.{0}.LEAFNODE.DISCONNECT";
|
||||
public const string RemoteLatency = "$SYS.SERVER.{0}.ACC.{1}.LATENCY.M2";
|
||||
|
||||
// Request-reply subjects (server-specific)
|
||||
public const string ServerReq = "$SYS.REQ.SERVER.{0}.{1}";
|
||||
@@ -36,13 +37,22 @@ public static class EventSubjects
|
||||
|
||||
// Account-scoped request subjects
|
||||
public const string AccountReq = "$SYS.REQ.ACCOUNT.{0}.{1}";
|
||||
public const string UserDirectInfo = "$SYS.REQ.USER.INFO";
|
||||
public const string UserDirectReq = "$SYS.REQ.USER.{0}.INFO";
|
||||
public const string AccountNumSubsReq = "$SYS.REQ.ACCOUNT.NSUBS";
|
||||
public const string AccountSubs = "$SYS._INBOX_.{0}.NSUBS";
|
||||
public const string ClientKickReq = "$SYS.REQ.SERVER.{0}.KICK";
|
||||
public const string ClientLdmReq = "$SYS.REQ.SERVER.{0}.LDM";
|
||||
public const string ServerStatsPingReq = "$SYS.REQ.SERVER.PING.STATSZ";
|
||||
public const string ServerReloadReq = "$SYS.REQ.SERVER.{0}.RELOAD";
|
||||
|
||||
// Inbox for responses
|
||||
public const string InboxResponse = "$SYS._INBOX_.{0}";
|
||||
|
||||
// OCSP advisory events
|
||||
// Go reference: ocsp.go — OCSP peer reject and chain validation subjects.
|
||||
public const string OcspPeerReject = "$SYS.SERVER.{0}.OCSP.PEER.REJECT";
|
||||
public const string OcspPeerReject = "$SYS.SERVER.{0}.OCSP.PEER.CONN.REJECT";
|
||||
public const string OcspPeerChainlinkInvalid = "$SYS.SERVER.{0}.OCSP.PEER.LINK.INVALID";
|
||||
public const string OcspChainValidation = "$SYS.SERVER.{0}.OCSP.CHAIN.VALIDATION";
|
||||
|
||||
// JetStream advisory events
|
||||
|
||||
@@ -2,6 +2,35 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Server capability flags.
|
||||
/// Go reference: events.go ServerCapability constants.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum ServerCapability : ulong
|
||||
{
|
||||
None = 0,
|
||||
JetStreamEnabled = 1UL << 0,
|
||||
BinaryStreamSnapshot = 1UL << 1,
|
||||
AccountNRG = 1UL << 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server identity response model used by IDZ requests.
|
||||
/// Go reference: events.go ServerID.
|
||||
/// </summary>
|
||||
public sealed class ServerID
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server identity block embedded in all system events.
|
||||
/// Go reference: events.go:249-265 ServerInfo struct.
|
||||
@@ -53,6 +82,27 @@ public sealed class EventServerInfo
|
||||
[JsonPropertyName("time")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public DateTime Time { get; set; }
|
||||
|
||||
public void SetJetStreamEnabled()
|
||||
{
|
||||
JetStream = true;
|
||||
Flags |= (ulong)ServerCapability.JetStreamEnabled;
|
||||
}
|
||||
|
||||
public bool JetStreamEnabled() =>
|
||||
(Flags & (ulong)ServerCapability.JetStreamEnabled) != 0;
|
||||
|
||||
public void SetBinaryStreamSnapshot() =>
|
||||
Flags |= (ulong)ServerCapability.BinaryStreamSnapshot;
|
||||
|
||||
public bool BinaryStreamSnapshot() =>
|
||||
(Flags & (ulong)ServerCapability.BinaryStreamSnapshot) != 0;
|
||||
|
||||
public void SetAccountNRG() =>
|
||||
Flags |= (ulong)ServerCapability.AccountNRG;
|
||||
|
||||
public bool AccountNRG() =>
|
||||
(Flags & (ulong)ServerCapability.AccountNRG) != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -536,10 +586,66 @@ public sealed class OcspPeerRejectEventMsg
|
||||
[JsonPropertyName("server")]
|
||||
public EventServerInfo Server { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("peer")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public EventCertInfo? Peer { get; set; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Certificate identity block used by OCSP peer advisories.
|
||||
/// Go reference: certidp.CertInfo payload embedded in events.go OCSP messages.
|
||||
/// </summary>
|
||||
public sealed class EventCertInfo
|
||||
{
|
||||
[JsonPropertyName("subject")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Issuer { get; set; }
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Fingerprint { get; set; }
|
||||
|
||||
[JsonPropertyName("raw")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Raw { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCSP chain-link invalid advisory.
|
||||
/// Go reference: events.go OCSPPeerChainlinkInvalidEventMsg.
|
||||
/// </summary>
|
||||
public sealed class OcspPeerChainlinkInvalidEventMsg
|
||||
{
|
||||
public const string EventType = "io.nats.server.advisory.v1.ocsp_peer_link_invalid";
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = EventType;
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("timestamp")]
|
||||
public DateTime Time { get; set; }
|
||||
|
||||
[JsonPropertyName("server")]
|
||||
public EventServerInfo Server { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("link")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public EventCertInfo? Link { get; set; }
|
||||
|
||||
[JsonPropertyName("peer")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public EventCertInfo? Peer { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OCSP chain validation advisory, published when a certificate's OCSP status
|
||||
/// is checked during TLS handshake.
|
||||
@@ -803,6 +909,149 @@ public sealed class AccNumConnsReq
|
||||
public string Account { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Account subscription-count request.
|
||||
/// Go reference: events.go accNumSubsReq.
|
||||
/// </summary>
|
||||
public sealed class AccNumSubsReq
|
||||
{
|
||||
[JsonPropertyName("server")]
|
||||
public EventServerInfo Server { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("acc")]
|
||||
public string Account { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared request filter options for system request subjects.
|
||||
/// Go reference: events.go EventFilterOptions.
|
||||
/// </summary>
|
||||
public class EventFilterOptions
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Name { get; set; }
|
||||
|
||||
[JsonPropertyName("cluster")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Cluster { get; set; }
|
||||
|
||||
[JsonPropertyName("host")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Host { get; set; }
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string[]? Tags { get; set; }
|
||||
|
||||
[JsonPropertyName("domain")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Domain { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StatszEventOptions : EventFilterOptions;
|
||||
public sealed class AccInfoEventOptions : EventFilterOptions;
|
||||
public sealed class ConnzEventOptions : EventFilterOptions;
|
||||
public sealed class RoutezEventOptions : EventFilterOptions;
|
||||
public sealed class SubszEventOptions : EventFilterOptions;
|
||||
public sealed class VarzEventOptions : EventFilterOptions;
|
||||
public sealed class GatewayzEventOptions : EventFilterOptions;
|
||||
public sealed class LeafzEventOptions : EventFilterOptions;
|
||||
public sealed class AccountzEventOptions : EventFilterOptions;
|
||||
public sealed class AccountStatzEventOptions : EventFilterOptions;
|
||||
public sealed class JszEventOptions : EventFilterOptions;
|
||||
public sealed class HealthzEventOptions : EventFilterOptions;
|
||||
public sealed class ProfilezEventOptions : EventFilterOptions;
|
||||
public sealed class ExpvarzEventOptions : EventFilterOptions;
|
||||
public sealed class IpqueueszEventOptions : EventFilterOptions;
|
||||
public sealed class RaftzEventOptions : EventFilterOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Generic server API error payload.
|
||||
/// Go reference: events.go server API response errors.
|
||||
/// </summary>
|
||||
public sealed class ServerAPIError
|
||||
{
|
||||
[JsonPropertyName("code")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public int Code { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generic server request/response envelope for $SYS.REQ services.
|
||||
/// Go reference: events.go ServerAPIResponse.
|
||||
/// </summary>
|
||||
public class ServerAPIResponse
|
||||
{
|
||||
[JsonPropertyName("server")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public EventServerInfo? Server { get; set; }
|
||||
|
||||
[JsonPropertyName("data")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public object? Data { get; set; }
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public ServerAPIError? Error { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ServerAPIConnzResponse : ServerAPIResponse;
|
||||
public sealed class ServerAPIRoutezResponse : ServerAPIResponse;
|
||||
public sealed class ServerAPIGatewayzResponse : ServerAPIResponse;
|
||||
public sealed class ServerAPIJszResponse : ServerAPIResponse;
|
||||
public sealed class ServerAPIHealthzResponse : ServerAPIResponse;
|
||||
public sealed class ServerAPIVarzResponse : ServerAPIResponse;
|
||||
public sealed class ServerAPISubszResponse : ServerAPIResponse;
|
||||
public sealed class ServerAPILeafzResponse : ServerAPIResponse;
|
||||
public sealed class ServerAPIAccountzResponse : ServerAPIResponse;
|
||||
public sealed class ServerAPIExpvarzResponse : ServerAPIResponse;
|
||||
public sealed class ServerAPIpqueueszResponse : ServerAPIResponse;
|
||||
public sealed class ServerAPIRaftzResponse : ServerAPIResponse;
|
||||
|
||||
/// <summary>
|
||||
/// Kick client request payload.
|
||||
/// Go reference: events.go KickClientReq.
|
||||
/// </summary>
|
||||
public sealed class KickClientReq
|
||||
{
|
||||
[JsonPropertyName("cid")]
|
||||
public ulong ClientId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lame duck mode client request payload.
|
||||
/// Go reference: events.go LDMClientReq.
|
||||
/// </summary>
|
||||
public sealed class LDMClientReq
|
||||
{
|
||||
[JsonPropertyName("cid")]
|
||||
public ulong ClientId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User info payload for direct user info requests.
|
||||
/// Go reference: events.go UserInfo.
|
||||
/// </summary>
|
||||
public sealed class UserInfo
|
||||
{
|
||||
[JsonPropertyName("user")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? User { get; set; }
|
||||
|
||||
[JsonPropertyName("acc")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Account { get; set; }
|
||||
|
||||
[JsonPropertyName("permissions")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Permissions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Factory helpers that construct fully-populated system event messages,
|
||||
/// mirroring Go's inline struct initialization patterns in events.go.
|
||||
|
||||
@@ -61,7 +61,14 @@ public sealed record ConnectEventDetail(
|
||||
string RemoteAddress,
|
||||
string? AccountName,
|
||||
string? UserName,
|
||||
DateTime ConnectedAt);
|
||||
DateTime ConnectedAt,
|
||||
string? Jwt = null,
|
||||
string? IssuerKey = null,
|
||||
string[]? Tags = null,
|
||||
string? NameTag = null,
|
||||
string? Kind = null,
|
||||
string? ClientType = null,
|
||||
string? MqttClientId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Detail payload for a client disconnect advisory.
|
||||
@@ -73,7 +80,15 @@ public sealed record DisconnectEventDetail(
|
||||
string RemoteAddress,
|
||||
string? AccountName,
|
||||
string Reason,
|
||||
DateTime DisconnectedAt);
|
||||
DateTime DisconnectedAt,
|
||||
long RttNanos = 0,
|
||||
string? Jwt = null,
|
||||
string? IssuerKey = null,
|
||||
string[]? Tags = null,
|
||||
string? NameTag = null,
|
||||
string? Kind = null,
|
||||
string? ClientType = null,
|
||||
string? MqttClientId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Manages the server's internal event system with Channel-based send/receive loops.
|
||||
@@ -114,14 +129,29 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
SystemAccount = systemAccount;
|
||||
SystemClient = systemClient;
|
||||
|
||||
// Hash server name for inbox routing (matches Go's shash)
|
||||
ServerHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(serverName)))[..8].ToLowerInvariant();
|
||||
// Hash server name for inbox routing (matches Go's shash).
|
||||
ServerHash = GetHash(serverName, GetHashSize());
|
||||
|
||||
_sendQueue = Channel.CreateUnbounded<PublishMessage>(new UnboundedChannelOptions { SingleReader = true });
|
||||
_receiveQueue = Channel.CreateUnbounded<InternalSystemMessage>(new UnboundedChannelOptions { SingleReader = true });
|
||||
_receiveQueuePings = Channel.CreateUnbounded<InternalSystemMessage>(new UnboundedChannelOptions { SingleReader = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equivalent to Go getHashSize(): default short hash width used in eventing subjects.
|
||||
/// </summary>
|
||||
public static int GetHashSize() => 8;
|
||||
|
||||
/// <summary>
|
||||
/// Equivalent to Go getHash() / getHashSize() helpers for server hash identifiers.
|
||||
/// </summary>
|
||||
public static string GetHash(string value, int size)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(size, 1);
|
||||
var full = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
return size >= full.Length ? full : full[..size];
|
||||
}
|
||||
|
||||
public void Start(NatsServer server)
|
||||
{
|
||||
_server = server;
|
||||
@@ -316,6 +346,13 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
Account = detail.AccountName,
|
||||
User = detail.UserName,
|
||||
Start = detail.ConnectedAt,
|
||||
Jwt = detail.Jwt,
|
||||
IssuerKey = detail.IssuerKey,
|
||||
Tags = detail.Tags,
|
||||
NameTag = detail.NameTag,
|
||||
Kind = detail.Kind,
|
||||
ClientType = detail.ClientType,
|
||||
MqttClient = detail.MqttClientId,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -341,6 +378,14 @@ public sealed class InternalEventSystem : IAsyncDisposable
|
||||
Host = detail.RemoteAddress,
|
||||
Account = detail.AccountName,
|
||||
Stop = detail.DisconnectedAt,
|
||||
RttNanos = detail.RttNanos,
|
||||
Jwt = detail.Jwt,
|
||||
IssuerKey = detail.IssuerKey,
|
||||
Tags = detail.Tags,
|
||||
NameTag = detail.NameTag,
|
||||
Kind = detail.Kind,
|
||||
ClientType = detail.ClientType,
|
||||
MqttClient = detail.MqttClientId,
|
||||
},
|
||||
Reason = detail.Reason,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class GatewayConnection(Socket socket) : IAsyncDisposable
|
||||
private Task? _loopTask;
|
||||
|
||||
public string? RemoteId { get; private set; }
|
||||
public bool IsOutbound { get; internal set; }
|
||||
public string RemoteEndpoint => socket.RemoteEndPoint?.ToString() ?? Guid.NewGuid().ToString("N");
|
||||
public Func<RemoteSubscription, Task>? RemoteSubscriptionReceived { get; set; }
|
||||
public Func<GatewayMessage, Task>? MessageReceived { get; set; }
|
||||
|
||||
@@ -65,6 +65,9 @@ public sealed class GatewayReconnectPolicy
|
||||
|
||||
public sealed class GatewayManager : IAsyncDisposable
|
||||
{
|
||||
public const string GatewayTlsInsecureWarning =
|
||||
"Gateway TLS insecure configuration is enabled; verify certificates and hostname validation for production.";
|
||||
|
||||
private readonly GatewayOptions _options;
|
||||
private readonly ServerStats _stats;
|
||||
private readonly string _serverId;
|
||||
@@ -103,6 +106,43 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates gateway options for required fields and basic endpoint correctness.
|
||||
/// Go reference: validateGatewayOptions.
|
||||
/// </summary>
|
||||
public static bool ValidateGatewayOptions(GatewayOptions? options, out string? error)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
error = "Gateway options are required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Name))
|
||||
{
|
||||
error = "Gateway name is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (options.Port is < 0 or > 65535)
|
||||
{
|
||||
error = "Gateway port must be in range 0-65535.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var remote in options.Remotes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(remote))
|
||||
{
|
||||
error = "Gateway remote entries cannot be empty.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gateway clusters auto-discovered via INFO gossip.
|
||||
/// Go reference: server/gateway.go processImplicitGateway.
|
||||
@@ -284,6 +324,48 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
public int GetConnectedGatewayCount()
|
||||
=> _registrations.Values.Count(r => r.State == GatewayConnectionState.Connected);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of active outbound gateway connections.
|
||||
/// Go reference: server/gateway.go NumOutboundGateways / numOutboundGateways.
|
||||
/// </summary>
|
||||
public int NumOutboundGateways()
|
||||
=> _connections.Values.Count(c => c.IsOutbound);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the number of active inbound gateway connections.
|
||||
/// Go reference: server/gateway.go numInboundGateways.
|
||||
/// </summary>
|
||||
public int NumInboundGateways()
|
||||
=> _connections.Values.Count(c => !c.IsOutbound);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if an inbound gateway connection exists for the given remote server id.
|
||||
/// Go reference: server/gateway.go srvGateway.hasInbound.
|
||||
/// </summary>
|
||||
public bool HasInbound(string remoteServerId)
|
||||
=> _connections.Values.Any(c => !c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Returns the first outbound gateway connection for the given remote server id, or null.
|
||||
/// Go reference: server/gateway.go getOutboundGatewayConnection.
|
||||
/// </summary>
|
||||
public GatewayConnection? GetOutboundGatewayConnection(string remoteServerId)
|
||||
=> _connections.Values.FirstOrDefault(c => c.IsOutbound && string.Equals(c.RemoteId, remoteServerId, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>
|
||||
/// Returns all outbound gateway connections.
|
||||
/// Go reference: server/gateway.go getOutboundGatewayConnections.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GatewayConnection> GetOutboundGatewayConnections()
|
||||
=> _connections.Values.Where(c => c.IsOutbound).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Returns all inbound gateway connections.
|
||||
/// Go reference: server/gateway.go getInboundGatewayConnections.
|
||||
/// </summary>
|
||||
public IReadOnlyList<GatewayConnection> GetInboundGatewayConnections()
|
||||
=> _connections.Values.Where(c => !c.IsOutbound).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Atomically increments the messages-sent counter for the named gateway.
|
||||
/// Go reference: server/gateway.go outboundGateway.msgs.
|
||||
@@ -364,7 +446,7 @@ public sealed class GatewayManager : IAsyncDisposable
|
||||
var endPoint = ParseEndpoint(remote);
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await socket.ConnectAsync(endPoint.Address, endPoint.Port, ct);
|
||||
var connection = new GatewayConnection(socket);
|
||||
var connection = new GatewayConnection(socket) { IsOutbound = true };
|
||||
await connection.PerformOutboundHandshakeAsync(_serverId, ct);
|
||||
Register(connection);
|
||||
return;
|
||||
|
||||
@@ -9,14 +9,42 @@ namespace NATS.Server.Gateways;
|
||||
/// </summary>
|
||||
public static class ReplyMapper
|
||||
{
|
||||
private const string GatewayReplyPrefix = "_GR_.";
|
||||
public const string GatewayReplyPrefix = "_GR_.";
|
||||
public const string OldGatewayReplyPrefix = "$GR.";
|
||||
public const int GatewayReplyPrefixLen = 5;
|
||||
public const int OldGatewayReplyPrefixLen = 4;
|
||||
public const int GatewayHashLen = 6;
|
||||
public const int OldGatewayHashLen = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the subject starts with the gateway reply prefix <c>_GR_.</c>.
|
||||
/// Checks whether the subject starts with either gateway reply prefix:
|
||||
/// <c>_GR_.</c> (current) or <c>$GR.</c> (legacy).
|
||||
/// </summary>
|
||||
public static bool HasGatewayReplyPrefix(string? subject)
|
||||
=> !string.IsNullOrWhiteSpace(subject)
|
||||
&& subject.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal);
|
||||
=> IsGatewayRoutedSubject(subject, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the subject is gateway-routed and indicates if the legacy
|
||||
/// old prefix (<c>$GR.</c>) was used.
|
||||
/// Go reference: isGWRoutedSubjectAndIsOldPrefix.
|
||||
/// </summary>
|
||||
public static bool IsGatewayRoutedSubject(string? subject, out bool isOldPrefix)
|
||||
{
|
||||
isOldPrefix = false;
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
return false;
|
||||
|
||||
if (subject.StartsWith(GatewayReplyPrefix, StringComparison.Ordinal))
|
||||
return true;
|
||||
|
||||
if (subject.StartsWith(OldGatewayReplyPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
isOldPrefix = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes a deterministic FNV-1a hash of the reply subject.
|
||||
@@ -40,6 +68,26 @@ public static class ReplyMapper
|
||||
return (long)(hash & 0x7FFFFFFFFFFFFFFF);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the short (6-char) gateway hash used in modern gateway reply routing.
|
||||
/// Go reference: getGWHash.
|
||||
/// </summary>
|
||||
public static string ComputeGatewayHash(string gatewayName)
|
||||
{
|
||||
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(gatewayName));
|
||||
return Convert.ToHexString(digest.AsSpan(0, 3)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the short (4-char) legacy gateway hash used with old prefixes.
|
||||
/// Go reference: getOldHash.
|
||||
/// </summary>
|
||||
public static string ComputeOldGatewayHash(string gatewayName)
|
||||
{
|
||||
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(gatewayName));
|
||||
return Convert.ToHexString(digest.AsSpan(0, 2)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a reply subject to gateway form with an explicit hash segment.
|
||||
/// Format: <c>_GR_.{clusterId}.{hash}.{originalReply}</c>.
|
||||
@@ -75,14 +123,14 @@ public static class ReplyMapper
|
||||
{
|
||||
restoredReply = string.Empty;
|
||||
|
||||
if (!HasGatewayReplyPrefix(gatewayReply))
|
||||
if (!IsGatewayRoutedSubject(gatewayReply, out _))
|
||||
return false;
|
||||
|
||||
var current = gatewayReply!;
|
||||
while (HasGatewayReplyPrefix(current))
|
||||
while (IsGatewayRoutedSubject(current, out var isOldPrefix))
|
||||
{
|
||||
// Skip the "_GR_." prefix
|
||||
var afterPrefix = current[GatewayReplyPrefix.Length..];
|
||||
var prefixLen = isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen;
|
||||
var afterPrefix = current[prefixLen..];
|
||||
|
||||
// Find the first dot (end of clusterId)
|
||||
var firstDot = afterPrefix.IndexOf('.');
|
||||
@@ -117,10 +165,10 @@ public static class ReplyMapper
|
||||
{
|
||||
clusterId = string.Empty;
|
||||
|
||||
if (!HasGatewayReplyPrefix(gatewayReply))
|
||||
if (!IsGatewayRoutedSubject(gatewayReply, out var isOldPrefix))
|
||||
return false;
|
||||
|
||||
var afterPrefix = gatewayReply![GatewayReplyPrefix.Length..];
|
||||
var afterPrefix = gatewayReply![(isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen)..];
|
||||
var dot = afterPrefix.IndexOf('.');
|
||||
if (dot <= 0)
|
||||
return false;
|
||||
@@ -137,10 +185,10 @@ public static class ReplyMapper
|
||||
{
|
||||
hash = 0;
|
||||
|
||||
if (!HasGatewayReplyPrefix(gatewayReply))
|
||||
if (!IsGatewayRoutedSubject(gatewayReply, out var isOldPrefix))
|
||||
return false;
|
||||
|
||||
var afterPrefix = gatewayReply![GatewayReplyPrefix.Length..];
|
||||
var afterPrefix = gatewayReply![(isOldPrefix ? OldGatewayReplyPrefixLen : GatewayReplyPrefixLen)..];
|
||||
|
||||
// Skip clusterId
|
||||
var firstDot = afterPrefix.IndexOf('.');
|
||||
|
||||
@@ -254,31 +254,43 @@ public class SequenceSet
|
||||
/// <summary>Encodes the set to a compact binary format.</summary>
|
||||
public byte[] Encode()
|
||||
{
|
||||
var encLen = EncodeLength();
|
||||
var buf = new byte[encLen];
|
||||
var buf = new byte[EncodeLength()];
|
||||
var written = Encode(buf);
|
||||
return buf.AsSpan(0, written).ToArray();
|
||||
}
|
||||
|
||||
buf[0] = Magic;
|
||||
buf[1] = Version;
|
||||
/// <summary>
|
||||
/// Encodes the set into a caller-provided buffer.
|
||||
/// Returns the number of bytes written.
|
||||
/// </summary>
|
||||
public int Encode(byte[] destination)
|
||||
{
|
||||
var encLen = EncodeLength();
|
||||
if (destination.Length < encLen)
|
||||
throw new ArgumentException("Destination buffer too small for encoded SequenceSet.", nameof(destination));
|
||||
|
||||
destination[0] = Magic;
|
||||
destination[1] = Version;
|
||||
var i = HdrLen;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(i), (uint)_nodes);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(i + 4), (uint)_size);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination.AsSpan(i), (uint)_nodes);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(destination.AsSpan(i + 4), (uint)_size);
|
||||
i += 8;
|
||||
|
||||
Node.NodeIter(Root, n =>
|
||||
{
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(i), n.Base);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(destination.AsSpan(i), n.Base);
|
||||
i += 8;
|
||||
for (var bi = 0; bi < NumBuckets; bi++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(i), n.Bits[bi]);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(destination.AsSpan(i), n.Bits[bi]);
|
||||
i += 8;
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(i), (ushort)n.Height);
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(destination.AsSpan(i), (ushort)n.Height);
|
||||
i += 2;
|
||||
});
|
||||
|
||||
return buf.AsSpan(0, i).ToArray();
|
||||
return i;
|
||||
}
|
||||
|
||||
/// <summary>Decodes a SequenceSet from a binary buffer. Returns the set and number of bytes read.</summary>
|
||||
|
||||
@@ -643,8 +643,14 @@ public class GenericSubjectList<T> where T : IEquatable<T>
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SimpleSubjectList is an alias for GenericSubjectList that uses int values,
|
||||
/// Empty marker used by <see cref="SimpleSubjectList"/> to mirror Go's `struct{}` payload.
|
||||
/// Go reference: server/gsl/gsl.go SimpleSublist
|
||||
/// </summary>
|
||||
public readonly record struct SimpleSublistValue;
|
||||
|
||||
/// <summary>
|
||||
/// SimpleSubjectList is an alias for GenericSubjectList that uses an empty marker payload,
|
||||
/// useful for tracking interest only.
|
||||
/// Go reference: server/gsl/gsl.go SimpleSublist
|
||||
/// </summary>
|
||||
public class SimpleSubjectList : GenericSubjectList<int>;
|
||||
public class SimpleSubjectList : GenericSubjectList<SimpleSublistValue>;
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using NATS.Server.Internal.Gsl;
|
||||
|
||||
// Go reference: server/stree/stree.go
|
||||
namespace NATS.Server.Internal.SubjectTree;
|
||||
|
||||
@@ -161,8 +165,57 @@ public class SubjectTree<T>
|
||||
IterInternal(Root, [], ordered: false, cb);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dumps a human-readable representation of the tree.
|
||||
/// Go reference: server/stree/dump.go
|
||||
/// </summary>
|
||||
public void Dump(TextWriter writer)
|
||||
{
|
||||
Dump(writer, Root, 0);
|
||||
writer.WriteLine();
|
||||
}
|
||||
|
||||
#region Internal Methods
|
||||
|
||||
private void Dump(TextWriter writer, INode? node, int depth)
|
||||
{
|
||||
if (node == null)
|
||||
{
|
||||
writer.WriteLine("EMPTY");
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.IsLeaf)
|
||||
{
|
||||
var leaf = (Leaf<T>)node;
|
||||
writer.WriteLine($"{DumpPre(depth)} LEAF: Suffix: {QuoteBytes(leaf.Suffix)} Value: {leaf.Value}");
|
||||
return;
|
||||
}
|
||||
|
||||
var bn = node.Base!;
|
||||
writer.WriteLine($"{DumpPre(depth)} {node.Kind} Prefix: {QuoteBytes(bn.Prefix)}");
|
||||
depth++;
|
||||
node.Iter(n =>
|
||||
{
|
||||
Dump(writer, n, depth);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private static string DumpPre(int depth)
|
||||
{
|
||||
if (depth == 0)
|
||||
return "-- ";
|
||||
|
||||
var sb = new StringBuilder(depth * 2 + 4);
|
||||
for (var i = 0; i < depth; i++)
|
||||
sb.Append(" ");
|
||||
sb.Append("|__ ");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string QuoteBytes(byte[] data) => $"\"{Encoding.UTF8.GetString(data)}\"";
|
||||
|
||||
/// <summary>
|
||||
/// Internal recursive insert.
|
||||
/// Go reference: server/stree/stree.go:insert
|
||||
@@ -613,4 +666,85 @@ public static class SubjectTreeHelper
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches all items in the subject tree that have interest in the given sublist.
|
||||
/// The callback is invoked at most once per matching subject.
|
||||
/// Go reference: server/stree/stree.go IntersectGSL
|
||||
/// </summary>
|
||||
public static void IntersectGSL<T, SL>(
|
||||
SubjectTree<T>? tree,
|
||||
GenericSubjectList<SL>? sublist,
|
||||
Action<byte[], T> cb) where SL : IEquatable<SL>
|
||||
{
|
||||
if (tree?.Root == null || sublist == null)
|
||||
return;
|
||||
|
||||
IntersectGslInternal(tree.Root, [], sublist, cb);
|
||||
}
|
||||
|
||||
private static void IntersectGslInternal<T, SL>(
|
||||
INode node,
|
||||
byte[] pre,
|
||||
GenericSubjectList<SL> sublist,
|
||||
Action<byte[], T> cb) where SL : IEquatable<SL>
|
||||
{
|
||||
if (node.IsLeaf)
|
||||
{
|
||||
var leaf = (Leaf<T>)node;
|
||||
var subject = Concat(pre, leaf.Suffix);
|
||||
if (sublist.HasInterest(BytesToString(subject)))
|
||||
cb(subject, leaf.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
var bn = node.Base!;
|
||||
pre = Concat(pre, bn.Prefix);
|
||||
|
||||
foreach (var child in node.Children())
|
||||
{
|
||||
if (child == null)
|
||||
continue;
|
||||
|
||||
var subject = Concat(pre, child.Path());
|
||||
if (!HasInterestForTokens(sublist, subject, pre.Length))
|
||||
continue;
|
||||
|
||||
IntersectGslInternal(child, pre, sublist, cb);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasInterestForTokens<SL>(
|
||||
GenericSubjectList<SL> sublist,
|
||||
byte[] subject,
|
||||
int since) where SL : IEquatable<SL>
|
||||
{
|
||||
for (var i = since; i < subject.Length; i++)
|
||||
{
|
||||
if (subject[i] != Parts.Tsep)
|
||||
continue;
|
||||
|
||||
if (!sublist.HasInterestStartingIn(BytesToString(subject.AsSpan(0, i))))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string BytesToString(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.Length == 0)
|
||||
return string.Empty;
|
||||
return Encoding.UTF8.GetString(data);
|
||||
}
|
||||
|
||||
private static byte[] Concat(byte[] a, byte[] b)
|
||||
{
|
||||
if (a.Length == 0) return b;
|
||||
if (b.Length == 0) return a;
|
||||
var result = new byte[a.Length + b.Length];
|
||||
a.CopyTo(result, 0);
|
||||
b.CopyTo(result, a.Length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
21
src/NATS.Server/Internal/SysMem/SystemMemory.cs
Normal file
21
src/NATS.Server/Internal/SysMem/SystemMemory.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace NATS.Server.Internal.SysMem;
|
||||
|
||||
/// <summary>
|
||||
/// Cross-platform system memory query helpers.
|
||||
/// Go reference: server/sysmem/mem_*.go Memory
|
||||
/// </summary>
|
||||
public static class SystemMemory
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns total memory available to the runtime for the current OS.
|
||||
/// </summary>
|
||||
public static long Memory()
|
||||
{
|
||||
var total = GC.GetGCMemoryInfo().TotalAvailableMemoryBytes;
|
||||
if (total > 0)
|
||||
return total;
|
||||
|
||||
// Conservative fallback when runtime does not report a limit.
|
||||
return Environment.Is64BitProcess ? long.MaxValue : int.MaxValue;
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,12 @@ public class HashWheel
|
||||
/// </summary>
|
||||
internal Slot?[] Wheel => _wheel;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a hash wheel entry (sequence + expiration timestamp).
|
||||
/// Go reference: server/thw/thw.go HashWheelEntry
|
||||
/// </summary>
|
||||
internal readonly record struct HashWheelEntry(ulong Sequence, long Expires);
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single slot in the wheel containing entries that hash to the same position.
|
||||
/// </summary>
|
||||
|
||||
@@ -53,30 +53,57 @@ public static class ConsumerApiHandlers
|
||||
: JetStreamApiResponse.NotFound(subject);
|
||||
}
|
||||
|
||||
public static JetStreamApiResponse HandleNames(string subject, ConsumerManager consumerManager)
|
||||
public static JetStreamApiResponse HandleNames(string subject, ReadOnlySpan<byte> payload, ConsumerManager consumerManager)
|
||||
{
|
||||
var stream = ParseStreamSubject(subject, NamesPrefix);
|
||||
if (stream == null)
|
||||
return JetStreamApiResponse.NotFound(subject);
|
||||
|
||||
var offset = ParseOffset(payload);
|
||||
var all = consumerManager.ListNames(stream);
|
||||
var page = offset >= all.Count ? [] : all.Skip(offset).ToList();
|
||||
return new JetStreamApiResponse
|
||||
{
|
||||
ConsumerNames = consumerManager.ListNames(stream),
|
||||
ConsumerNames = page,
|
||||
PaginationTotal = all.Count,
|
||||
PaginationOffset = offset,
|
||||
};
|
||||
}
|
||||
|
||||
public static JetStreamApiResponse HandleList(string subject, ConsumerManager consumerManager)
|
||||
public static JetStreamApiResponse HandleList(string subject, ReadOnlySpan<byte> payload, ConsumerManager consumerManager)
|
||||
{
|
||||
var stream = ParseStreamSubject(subject, ListPrefix);
|
||||
if (stream == null)
|
||||
return JetStreamApiResponse.NotFound(subject);
|
||||
|
||||
var offset = ParseOffset(payload);
|
||||
var all = consumerManager.ListConsumerInfos(stream);
|
||||
var page = offset >= all.Count ? [] : all.Skip(offset).ToList();
|
||||
return new JetStreamApiResponse
|
||||
{
|
||||
ConsumerNames = consumerManager.ListNames(stream),
|
||||
ConsumerInfoList = page,
|
||||
PaginationTotal = all.Count,
|
||||
PaginationOffset = offset,
|
||||
};
|
||||
}
|
||||
|
||||
private static int ParseOffset(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.IsEmpty) return 0;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(payload.ToArray());
|
||||
if (doc.RootElement.TryGetProperty("offset", out var el) && el.TryGetInt32(out var v))
|
||||
return Math.Max(v, 0);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Malformed offset payload: {ex.Message}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static JetStreamApiResponse HandlePause(string subject, ReadOnlySpan<byte> payload, ConsumerManager consumerManager)
|
||||
{
|
||||
var parsed = ParseSubject(subject, PausePrefix);
|
||||
@@ -254,15 +281,21 @@ public static class ConsumerApiHandlers
|
||||
{
|
||||
using var doc = JsonDocument.Parse(payload.ToArray());
|
||||
var root = doc.RootElement;
|
||||
|
||||
// The client wraps config in a "config" property (CreateConsumerRequest).
|
||||
// Go reference: consumer.go — CreateConsumerRequest { Config ConsumerConfig `json:"config"` }
|
||||
var configEl = root.TryGetProperty("config", out var nested) ? nested : root;
|
||||
var config = new ConsumerConfig();
|
||||
|
||||
if (root.TryGetProperty("durable_name", out var durableEl))
|
||||
if (configEl.TryGetProperty("durable_name", out var durableEl))
|
||||
config.DurableName = durableEl.GetString() ?? string.Empty;
|
||||
else if (configEl.TryGetProperty("name", out var nameEl))
|
||||
config.DurableName = nameEl.GetString() ?? string.Empty;
|
||||
|
||||
if (root.TryGetProperty("filter_subject", out var filterEl))
|
||||
if (configEl.TryGetProperty("filter_subject", out var filterEl))
|
||||
config.FilterSubject = filterEl.GetString();
|
||||
|
||||
if (root.TryGetProperty("filter_subjects", out var filterSubjectsEl) && filterSubjectsEl.ValueKind == JsonValueKind.Array)
|
||||
if (configEl.TryGetProperty("filter_subjects", out var filterSubjectsEl) && filterSubjectsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in filterSubjectsEl.EnumerateArray())
|
||||
{
|
||||
@@ -272,41 +305,41 @@ public static class ConsumerApiHandlers
|
||||
}
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("ephemeral", out var ephemeralEl) && ephemeralEl.ValueKind == JsonValueKind.True)
|
||||
if (configEl.TryGetProperty("ephemeral", out var ephemeralEl) && ephemeralEl.ValueKind == JsonValueKind.True)
|
||||
config.Ephemeral = true;
|
||||
|
||||
if (root.TryGetProperty("push", out var pushEl) && pushEl.ValueKind == JsonValueKind.True)
|
||||
if (configEl.TryGetProperty("push", out var pushEl) && pushEl.ValueKind == JsonValueKind.True)
|
||||
config.Push = true;
|
||||
|
||||
if (root.TryGetProperty("heartbeat_ms", out var hbEl) && hbEl.TryGetInt32(out var hbMs))
|
||||
if (configEl.TryGetProperty("heartbeat_ms", out var hbEl) && hbEl.TryGetInt32(out var hbMs))
|
||||
config.HeartbeatMs = hbMs;
|
||||
|
||||
if (root.TryGetProperty("ack_wait_ms", out var ackWaitEl) && ackWaitEl.TryGetInt32(out var ackWait))
|
||||
if (configEl.TryGetProperty("ack_wait_ms", out var ackWaitEl) && ackWaitEl.TryGetInt32(out var ackWait))
|
||||
config.AckWaitMs = ackWait;
|
||||
|
||||
if (root.TryGetProperty("max_deliver", out var maxDeliverEl) && maxDeliverEl.TryGetInt32(out var maxDeliver))
|
||||
if (configEl.TryGetProperty("max_deliver", out var maxDeliverEl) && maxDeliverEl.TryGetInt32(out var maxDeliver))
|
||||
config.MaxDeliver = Math.Max(maxDeliver, 0);
|
||||
|
||||
if (root.TryGetProperty("max_ack_pending", out var maxAckPendingEl) && maxAckPendingEl.TryGetInt32(out var maxAckPending))
|
||||
if (configEl.TryGetProperty("max_ack_pending", out var maxAckPendingEl) && maxAckPendingEl.TryGetInt32(out var maxAckPending))
|
||||
config.MaxAckPending = Math.Max(maxAckPending, 0);
|
||||
|
||||
if (root.TryGetProperty("flow_control", out var flowControlEl) && flowControlEl.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
if (configEl.TryGetProperty("flow_control", out var flowControlEl) && flowControlEl.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
config.FlowControl = flowControlEl.GetBoolean();
|
||||
|
||||
if (root.TryGetProperty("rate_limit_bps", out var rateLimitEl) && rateLimitEl.TryGetInt64(out var rateLimit))
|
||||
if (configEl.TryGetProperty("rate_limit_bps", out var rateLimitEl) && rateLimitEl.TryGetInt64(out var rateLimit))
|
||||
config.RateLimitBps = Math.Max(rateLimit, 0);
|
||||
|
||||
if (root.TryGetProperty("opt_start_seq", out var optStartSeqEl) && optStartSeqEl.TryGetUInt64(out var optStartSeq))
|
||||
if (configEl.TryGetProperty("opt_start_seq", out var optStartSeqEl) && optStartSeqEl.TryGetUInt64(out var optStartSeq))
|
||||
config.OptStartSeq = optStartSeq;
|
||||
|
||||
if (root.TryGetProperty("opt_start_time_utc", out var optStartTimeEl)
|
||||
if (configEl.TryGetProperty("opt_start_time_utc", out var optStartTimeEl)
|
||||
&& optStartTimeEl.ValueKind == JsonValueKind.String
|
||||
&& DateTime.TryParse(optStartTimeEl.GetString(), out var optStartTime))
|
||||
{
|
||||
config.OptStartTimeUtc = optStartTime.ToUniversalTime();
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("backoff_ms", out var backoffEl) && backoffEl.ValueKind == JsonValueKind.Array)
|
||||
if (configEl.TryGetProperty("backoff_ms", out var backoffEl) && backoffEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in backoffEl.EnumerateArray())
|
||||
{
|
||||
@@ -315,7 +348,7 @@ public static class ConsumerApiHandlers
|
||||
}
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("ack_policy", out var ackPolicyEl))
|
||||
if (configEl.TryGetProperty("ack_policy", out var ackPolicyEl))
|
||||
{
|
||||
var ackPolicy = ackPolicyEl.GetString();
|
||||
if (string.Equals(ackPolicy, "explicit", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -324,7 +357,7 @@ public static class ConsumerApiHandlers
|
||||
config.AckPolicy = AckPolicy.All;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("deliver_policy", out var deliverPolicyEl))
|
||||
if (configEl.TryGetProperty("deliver_policy", out var deliverPolicyEl))
|
||||
{
|
||||
var deliver = deliverPolicyEl.GetString();
|
||||
if (string.Equals(deliver, "last", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -339,7 +372,7 @@ public static class ConsumerApiHandlers
|
||||
config.DeliverPolicy = DeliverPolicy.LastPerSubject;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("replay_policy", out var replayPolicyEl))
|
||||
if (configEl.TryGetProperty("replay_policy", out var replayPolicyEl))
|
||||
{
|
||||
var replay = replayPolicyEl.GetString();
|
||||
if (string.Equals(replay, "original", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -102,17 +102,47 @@ public static class StreamApiHandlers
|
||||
return JetStreamApiResponse.PurgeResponse((ulong)purged);
|
||||
}
|
||||
|
||||
public static JetStreamApiResponse HandleNames(StreamManager streamManager)
|
||||
public static JetStreamApiResponse HandleNames(ReadOnlySpan<byte> payload, StreamManager streamManager)
|
||||
{
|
||||
var offset = ParseOffset(payload);
|
||||
var all = streamManager.ListNames();
|
||||
var page = offset >= all.Count ? [] : all.Skip(offset).ToList();
|
||||
return new JetStreamApiResponse
|
||||
{
|
||||
StreamNames = streamManager.ListNames(),
|
||||
StreamNames = page,
|
||||
PaginationTotal = all.Count,
|
||||
PaginationOffset = offset,
|
||||
};
|
||||
}
|
||||
|
||||
public static JetStreamApiResponse HandleList(StreamManager streamManager)
|
||||
public static JetStreamApiResponse HandleList(ReadOnlySpan<byte> payload, StreamManager streamManager)
|
||||
{
|
||||
return HandleNames(streamManager);
|
||||
var offset = ParseOffset(payload);
|
||||
var all = streamManager.ListStreamInfos();
|
||||
var page = offset >= all.Count ? [] : all.Skip(offset).ToList();
|
||||
return new JetStreamApiResponse
|
||||
{
|
||||
StreamInfoList = page,
|
||||
PaginationTotal = all.Count,
|
||||
PaginationOffset = offset,
|
||||
};
|
||||
}
|
||||
|
||||
private static int ParseOffset(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.IsEmpty) return 0;
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(payload.ToArray());
|
||||
if (doc.RootElement.TryGetProperty("offset", out var el) && el.TryGetInt32(out var v))
|
||||
return Math.Max(v, 0);
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Malformed offset payload: {ex.Message}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static JetStreamApiResponse HandleMessageGet(string subject, ReadOnlySpan<byte> payload, StreamManager streamManager)
|
||||
@@ -445,7 +475,9 @@ public static class StreamApiHandlers
|
||||
if (root.TryGetProperty("max_msgs_per", out var maxMsgsPerEl) && maxMsgsPerEl.TryGetInt32(out var maxMsgsPer))
|
||||
config.MaxMsgsPer = maxMsgsPer;
|
||||
|
||||
if (root.TryGetProperty("max_age_ms", out var maxAgeMsEl) && maxAgeMsEl.TryGetInt32(out var maxAgeMs))
|
||||
if (root.TryGetProperty("max_age", out var maxAgeNsEl) && maxAgeNsEl.TryGetInt64(out var maxAgeNs))
|
||||
config.MaxAge = maxAgeNs;
|
||||
else if (root.TryGetProperty("max_age_ms", out var maxAgeMsEl) && maxAgeMsEl.TryGetInt32(out var maxAgeMs))
|
||||
config.MaxAgeMs = maxAgeMs;
|
||||
|
||||
if (root.TryGetProperty("max_msg_size", out var maxMsgSizeEl) && maxMsgSizeEl.TryGetInt32(out var maxMsgSize))
|
||||
|
||||
14
src/NATS.Server/JetStream/Api/JetStreamApiLimits.cs
Normal file
14
src/NATS.Server/JetStream/Api/JetStreamApiLimits.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace NATS.Server.JetStream.Api;
|
||||
|
||||
/// <summary>
|
||||
/// JetStream API size and queue limits aligned with Go server constants.
|
||||
/// Go reference: server/jetstream_api.go (JSMaxDescriptionLen, JSMaxMetadataLen,
|
||||
/// JSMaxNameLen, JSDefaultRequestQueueLimit).
|
||||
/// </summary>
|
||||
public static class JetStreamApiLimits
|
||||
{
|
||||
public const int JSMaxDescriptionLen = 4_096;
|
||||
public const int JSMaxMetadataLen = 128 * 1024;
|
||||
public const int JSMaxNameLen = 255;
|
||||
public const int JSDefaultRequestQueueLimit = 10_000;
|
||||
}
|
||||
@@ -9,7 +9,9 @@ public sealed class JetStreamApiResponse
|
||||
public JetStreamConsumerInfo? ConsumerInfo { get; init; }
|
||||
public JetStreamAccountInfo? AccountInfo { get; init; }
|
||||
public IReadOnlyList<string>? StreamNames { get; init; }
|
||||
public IReadOnlyList<JetStreamStreamInfo>? StreamInfoList { get; init; }
|
||||
public IReadOnlyList<string>? ConsumerNames { get; init; }
|
||||
public IReadOnlyList<JetStreamConsumerInfo>? ConsumerInfoList { get; init; }
|
||||
public JetStreamStreamMessage? StreamMessage { get; init; }
|
||||
public JetStreamDirectMessage? DirectMessage { get; init; }
|
||||
public JetStreamSnapshot? Snapshot { get; init; }
|
||||
@@ -17,6 +19,17 @@ public sealed class JetStreamApiResponse
|
||||
public bool Success { get; init; }
|
||||
public ulong Purged { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total count of all items (before pagination). Used by list responses for offset-based pagination.
|
||||
/// Go reference: jetstream_api.go — ApiPaged struct includes Total, Offset, Limit fields.
|
||||
/// </summary>
|
||||
public int PaginationTotal { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Requested offset for pagination. Echoed back to client so it can calculate the next page.
|
||||
/// </summary>
|
||||
public int PaginationOffset { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the consumer is currently paused. Populated by pause/resume API responses.
|
||||
/// Go reference: server/consumer.go jsConsumerPauseResponse.paused field.
|
||||
@@ -29,6 +42,123 @@ public sealed class JetStreamApiResponse
|
||||
/// </summary>
|
||||
public DateTime? PauseUntil { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns a wire-format object for JSON serialization matching the Go server's
|
||||
/// flat response structure (e.g., config/state at root level for stream responses,
|
||||
/// not nested under a wrapper property).
|
||||
/// </summary>
|
||||
public object ToWireFormat()
|
||||
{
|
||||
if (StreamInfo != null)
|
||||
{
|
||||
if (Error != null)
|
||||
return new { type = "io.nats.jetstream.api.v1.stream_create_response", error = Error };
|
||||
return new
|
||||
{
|
||||
type = "io.nats.jetstream.api.v1.stream_create_response",
|
||||
config = ToWireConfig(StreamInfo.Config),
|
||||
state = ToWireState(StreamInfo.State),
|
||||
};
|
||||
}
|
||||
|
||||
if (ConsumerInfo != null)
|
||||
{
|
||||
if (Error != null)
|
||||
return new { type = "io.nats.jetstream.api.v1.consumer_create_response", error = Error };
|
||||
return new
|
||||
{
|
||||
type = "io.nats.jetstream.api.v1.consumer_create_response",
|
||||
stream_name = ConsumerInfo.StreamName,
|
||||
name = ConsumerInfo.Name,
|
||||
config = ToWireConsumerConfig(ConsumerInfo.Config),
|
||||
};
|
||||
}
|
||||
|
||||
if (Error != null)
|
||||
return new { error = Error };
|
||||
|
||||
if (StreamInfoList != null)
|
||||
{
|
||||
var wireStreams = StreamInfoList.Select(s => new
|
||||
{
|
||||
config = ToWireConfig(s.Config),
|
||||
state = ToWireState(s.State),
|
||||
}).ToList();
|
||||
return new { total = PaginationTotal, offset = PaginationOffset, limit = wireStreams.Count, streams = wireStreams };
|
||||
}
|
||||
|
||||
if (StreamNames != null)
|
||||
return new { total = PaginationTotal, offset = PaginationOffset, limit = StreamNames.Count, streams = StreamNames };
|
||||
|
||||
if (ConsumerInfoList != null)
|
||||
{
|
||||
var wireConsumers = ConsumerInfoList.Select(c => new
|
||||
{
|
||||
stream_name = c.StreamName,
|
||||
name = c.Name,
|
||||
config = ToWireConsumerConfig(c.Config),
|
||||
}).ToList();
|
||||
return new { total = PaginationTotal, offset = PaginationOffset, limit = wireConsumers.Count, consumers = wireConsumers };
|
||||
}
|
||||
|
||||
if (ConsumerNames != null)
|
||||
return new { total = PaginationTotal, offset = PaginationOffset, limit = ConsumerNames.Count, consumers = ConsumerNames };
|
||||
|
||||
if (Purged > 0 || Success)
|
||||
return new { success = Success, purged = Purged };
|
||||
|
||||
return new { success = Success };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a Go-compatible wire format for StreamConfig.
|
||||
/// Only includes fields the Go server sends, with enums as lowercase strings.
|
||||
/// Go reference: server/stream.go StreamConfig JSON marshaling.
|
||||
/// </summary>
|
||||
private static object ToWireConfig(StreamConfig c) => new
|
||||
{
|
||||
name = c.Name,
|
||||
subjects = c.Subjects,
|
||||
retention = c.Retention.ToString().ToLowerInvariant(),
|
||||
max_consumers = c.MaxConsumers,
|
||||
max_msgs = c.MaxMsgs,
|
||||
max_bytes = c.MaxBytes,
|
||||
max_age = c.MaxAge,
|
||||
max_msgs_per_subject = c.MaxMsgsPer,
|
||||
max_msg_size = c.MaxMsgSize,
|
||||
storage = c.Storage.ToString().ToLowerInvariant(),
|
||||
discard = c.Discard.ToString().ToLowerInvariant(),
|
||||
num_replicas = c.Replicas,
|
||||
duplicate_window = (long)c.DuplicateWindowMs * 1_000_000L,
|
||||
sealed_field = c.Sealed,
|
||||
deny_delete = c.DenyDelete,
|
||||
deny_purge = c.DenyPurge,
|
||||
allow_direct = c.AllowDirect,
|
||||
first_seq = c.FirstSeq,
|
||||
};
|
||||
|
||||
private static object ToWireState(ApiStreamState s) => new
|
||||
{
|
||||
messages = s.Messages,
|
||||
bytes = s.Bytes,
|
||||
first_seq = s.FirstSeq,
|
||||
last_seq = s.LastSeq,
|
||||
consumer_count = 0,
|
||||
};
|
||||
|
||||
private static object ToWireConsumerConfig(ConsumerConfig c) => new
|
||||
{
|
||||
durable_name = string.IsNullOrEmpty(c.DurableName) ? null : c.DurableName,
|
||||
name = string.IsNullOrEmpty(c.DurableName) ? null : c.DurableName,
|
||||
deliver_policy = c.DeliverPolicy.ToString().ToLowerInvariant(),
|
||||
ack_policy = c.AckPolicy.ToString().ToLowerInvariant(),
|
||||
replay_policy = c.ReplayPolicy.ToString().ToLowerInvariant(),
|
||||
ack_wait = (long)c.AckWaitMs * 1_000_000L,
|
||||
max_deliver = c.MaxDeliver,
|
||||
max_ack_pending = c.MaxAckPending,
|
||||
filter_subject = c.FilterSubject,
|
||||
};
|
||||
|
||||
public static JetStreamApiResponse NotFound(string subject) => new()
|
||||
{
|
||||
Error = new JetStreamApiError
|
||||
@@ -99,6 +229,8 @@ public sealed class JetStreamStreamInfo
|
||||
|
||||
public sealed class JetStreamConsumerInfo
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? StreamName { get; init; }
|
||||
public required ConsumerConfig Config { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -249,10 +249,10 @@ public sealed class JetStreamApiRouter
|
||||
return StreamApiHandlers.HandleInfo(subject, _streamManager);
|
||||
|
||||
if (subject.Equals(JetStreamApiSubjects.StreamNames, StringComparison.Ordinal))
|
||||
return StreamApiHandlers.HandleNames(_streamManager);
|
||||
return StreamApiHandlers.HandleNames(payload, _streamManager);
|
||||
|
||||
if (subject.Equals(JetStreamApiSubjects.StreamList, StringComparison.Ordinal))
|
||||
return StreamApiHandlers.HandleList(_streamManager);
|
||||
return StreamApiHandlers.HandleList(payload, _streamManager);
|
||||
|
||||
if (subject.StartsWith(JetStreamApiSubjects.StreamUpdate, StringComparison.Ordinal))
|
||||
return StreamApiHandlers.HandleUpdate(subject, payload, _streamManager);
|
||||
@@ -288,10 +288,10 @@ public sealed class JetStreamApiRouter
|
||||
return ConsumerApiHandlers.HandleInfo(subject, _consumerManager);
|
||||
|
||||
if (subject.StartsWith(JetStreamApiSubjects.ConsumerNames, StringComparison.Ordinal))
|
||||
return ConsumerApiHandlers.HandleNames(subject, _consumerManager);
|
||||
return ConsumerApiHandlers.HandleNames(subject, payload, _consumerManager);
|
||||
|
||||
if (subject.StartsWith(JetStreamApiSubjects.ConsumerList, StringComparison.Ordinal))
|
||||
return ConsumerApiHandlers.HandleList(subject, _consumerManager);
|
||||
return ConsumerApiHandlers.HandleList(subject, payload, _consumerManager);
|
||||
|
||||
if (subject.StartsWith(JetStreamApiSubjects.ConsumerDelete, StringComparison.Ordinal))
|
||||
return ConsumerApiHandlers.HandleDelete(subject, _consumerManager);
|
||||
|
||||
@@ -4,6 +4,7 @@ using NATS.Server.JetStream.Cluster;
|
||||
using NATS.Server.JetStream.Consumers;
|
||||
using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Storage;
|
||||
using NATS.Server.JetStream.Validation;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.JetStream;
|
||||
@@ -40,6 +41,12 @@ public sealed class ConsumerManager : IDisposable
|
||||
return JetStreamApiResponse.ErrorResponse(400, "durable name required");
|
||||
}
|
||||
|
||||
if (!JetStreamConfigValidator.IsValidName(config.DurableName))
|
||||
return JetStreamApiResponse.ErrorResponse(400, "invalid durable name");
|
||||
|
||||
if (!JetStreamConfigValidator.IsMetadataWithinLimit(config.Metadata))
|
||||
return JetStreamApiResponse.ErrorResponse(400, "consumer metadata exceeds maximum size");
|
||||
|
||||
if (config.FilterSubjects.Count == 0 && !string.IsNullOrWhiteSpace(config.FilterSubject))
|
||||
config.FilterSubjects.Add(config.FilterSubject);
|
||||
|
||||
@@ -58,6 +65,8 @@ public sealed class ConsumerManager : IDisposable
|
||||
{
|
||||
ConsumerInfo = new JetStreamConsumerInfo
|
||||
{
|
||||
Name = handle.Config.DurableName,
|
||||
StreamName = stream,
|
||||
Config = handle.Config,
|
||||
},
|
||||
};
|
||||
@@ -71,6 +80,8 @@ public sealed class ConsumerManager : IDisposable
|
||||
{
|
||||
ConsumerInfo = new JetStreamConsumerInfo
|
||||
{
|
||||
Name = handle.Config.DurableName,
|
||||
StreamName = stream,
|
||||
Config = handle.Config,
|
||||
},
|
||||
};
|
||||
@@ -95,6 +106,13 @@ public sealed class ConsumerManager : IDisposable
|
||||
.OrderBy(x => x, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
public IReadOnlyList<JetStreamConsumerInfo> ListConsumerInfos(string stream)
|
||||
=> _consumers
|
||||
.Where(kv => string.Equals(kv.Key.Stream, stream, StringComparison.Ordinal))
|
||||
.OrderBy(kv => kv.Key.Name, StringComparer.Ordinal)
|
||||
.Select(kv => new JetStreamConsumerInfo { Name = kv.Value.Config.DurableName, StreamName = stream, Config = kv.Value.Config })
|
||||
.ToList();
|
||||
|
||||
public bool Pause(string stream, string durableName, bool paused)
|
||||
{
|
||||
if (!_consumers.TryGetValue((stream, durableName), out var handle))
|
||||
|
||||
58
src/NATS.Server/JetStream/JetStreamParityModels.cs
Normal file
58
src/NATS.Server/JetStream/JetStreamParityModels.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace NATS.Server.JetStream;
|
||||
|
||||
/// <summary>
|
||||
/// API usage counters for JetStream.
|
||||
/// Go reference: server/jetstream.go JetStreamAPIStats.
|
||||
/// </summary>
|
||||
public sealed class JetStreamApiStats
|
||||
{
|
||||
public int Level { get; set; }
|
||||
public ulong Total { get; set; }
|
||||
public ulong Errors { get; set; }
|
||||
public int Inflight { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-tier JetStream resource view.
|
||||
/// Go reference: server/jetstream.go JetStreamTier.
|
||||
/// </summary>
|
||||
public sealed class JetStreamTier
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public long Memory { get; set; }
|
||||
public long Store { get; set; }
|
||||
public int Streams { get; set; }
|
||||
public int Consumers { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-account JetStream limits.
|
||||
/// Go reference: server/jetstream.go JetStreamAccountLimits.
|
||||
/// </summary>
|
||||
public sealed class JetStreamAccountLimits
|
||||
{
|
||||
public long MaxMemory { get; set; }
|
||||
public long MaxStore { get; set; }
|
||||
public int MaxStreams { get; set; }
|
||||
public int MaxConsumers { get; set; }
|
||||
public int MaxAckPending { get; set; }
|
||||
public long MemoryMaxStreamBytes { get; set; }
|
||||
public long StoreMaxStreamBytes { get; set; }
|
||||
public bool MaxBytesRequired { get; set; }
|
||||
public Dictionary<string, JetStreamTier> Tiers { get; set; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-level JetStream usage stats.
|
||||
/// Go reference: server/jetstream.go JetStreamStats.
|
||||
/// </summary>
|
||||
public sealed class JetStreamStats
|
||||
{
|
||||
public long Memory { get; set; }
|
||||
public long Store { get; set; }
|
||||
public long ReservedMemory { get; set; }
|
||||
public long ReservedStore { get; set; }
|
||||
public int Accounts { get; set; }
|
||||
public int HaAssets { get; set; }
|
||||
public JetStreamApiStats Api { get; set; } = new();
|
||||
}
|
||||
@@ -8,7 +8,18 @@ public sealed class StreamConfig
|
||||
public int MaxMsgs { get; set; }
|
||||
public long MaxBytes { get; set; }
|
||||
public int MaxMsgsPer { get; set; }
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public int MaxAgeMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// MaxAge in nanoseconds for JSON wire compatibility with Go server.
|
||||
/// Go reference: StreamConfig.MaxAge is a time.Duration (nanoseconds in JSON).
|
||||
/// </summary>
|
||||
public long MaxAge
|
||||
{
|
||||
get => (long)MaxAgeMs * 1_000_000L;
|
||||
set => MaxAgeMs = (int)(value / 1_000_000);
|
||||
}
|
||||
public int MaxMsgSize { get; set; }
|
||||
public int MaxConsumers { get; set; }
|
||||
public int DuplicateWindowMs { get; set; }
|
||||
|
||||
@@ -774,11 +774,7 @@ public sealed class FileStore : IStreamStore, IAsyncDisposable, IDisposable
|
||||
{
|
||||
if (string.IsNullOrEmpty(filter))
|
||||
return true;
|
||||
|
||||
if (NATS.Server.Subscriptions.SubjectMatch.IsLiteral(filter))
|
||||
return string.Equals(subject, filter, StringComparison.Ordinal);
|
||||
|
||||
return NATS.Server.Subscriptions.SubjectMatch.MatchLiteral(subject, filter);
|
||||
return NATS.Server.Subscriptions.SubjectMatch.SubjectMatchesFilter(subject, filter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1176,9 +1176,7 @@ public sealed class MemStore : IStreamStore
|
||||
{
|
||||
if (string.IsNullOrEmpty(filter) || filter == ">")
|
||||
return true;
|
||||
if (SubjectMatch.IsLiteral(filter))
|
||||
return string.Equals(subject, filter, StringComparison.Ordinal);
|
||||
return SubjectMatch.MatchLiteral(subject, filter);
|
||||
return SubjectMatch.SubjectMatchesFilter(subject, filter);
|
||||
}
|
||||
|
||||
// Fill a StoreMsg from an internal Msg
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.JetStream.Api;
|
||||
using NATS.Server.JetStream.Cluster;
|
||||
@@ -7,11 +8,12 @@ using NATS.Server.JetStream.Models;
|
||||
using NATS.Server.JetStream.Publish;
|
||||
using NATS.Server.JetStream.Snapshots;
|
||||
using NATS.Server.JetStream.Storage;
|
||||
using NATS.Server.JetStream.Validation;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.JetStream;
|
||||
|
||||
public sealed class StreamManager
|
||||
public sealed class StreamManager : IDisposable
|
||||
{
|
||||
private readonly Account? _account;
|
||||
private readonly ConsumerManager? _consumerManager;
|
||||
@@ -25,12 +27,52 @@ public sealed class StreamManager
|
||||
private readonly ConcurrentDictionary<string, List<SourceCoordinator>> _sourcesByOrigin =
|
||||
new(StringComparer.Ordinal);
|
||||
private readonly StreamSnapshotService _snapshotService = new();
|
||||
private readonly CancellationTokenSource _expiryTimerCts = new();
|
||||
private Task? _expiryTimerTask;
|
||||
|
||||
public StreamManager(JetStreamMetaGroup? metaGroup = null, Account? account = null, ConsumerManager? consumerManager = null)
|
||||
{
|
||||
_metaGroup = metaGroup;
|
||||
_account = account;
|
||||
_consumerManager = consumerManager;
|
||||
_expiryTimerTask = RunExpiryTimerAsync(_expiryTimerCts.Token);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_expiryTimerCts.Cancel();
|
||||
_expiryTimerCts.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodically prunes expired messages from streams with MaxAge configured.
|
||||
/// Go reference: stream.go — expireMsgs runs on a timer (checkMaxAge interval).
|
||||
/// </summary>
|
||||
private async Task RunExpiryTimerAsync(CancellationToken ct)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var ticked = false;
|
||||
try
|
||||
{
|
||||
ticked = await timer.WaitForNextTickAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return; // Shutdown requested via Dispose — exit the timer loop
|
||||
}
|
||||
|
||||
if (!ticked)
|
||||
return;
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
foreach (var stream in _streams.Values)
|
||||
{
|
||||
if (stream.Config.MaxAgeMs > 0)
|
||||
PruneExpiredMessages(stream, nowUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyCollection<string> StreamNames => _streams.Keys.ToArray();
|
||||
@@ -39,10 +81,31 @@ public sealed class StreamManager
|
||||
public IReadOnlyList<string> ListNames()
|
||||
=> [.. _streams.Keys.OrderBy(x => x, StringComparer.Ordinal)];
|
||||
|
||||
public IReadOnlyList<JetStreamStreamInfo> ListStreamInfos()
|
||||
{
|
||||
return _streams.OrderBy(kv => kv.Key, StringComparer.Ordinal)
|
||||
.Select(kv =>
|
||||
{
|
||||
var state = kv.Value.Store.GetStateAsync(default).GetAwaiter().GetResult();
|
||||
return new JetStreamStreamInfo
|
||||
{
|
||||
Config = kv.Value.Config,
|
||||
State = state,
|
||||
};
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public JetStreamApiResponse CreateOrUpdate(StreamConfig config)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.Name))
|
||||
return JetStreamApiResponse.ErrorResponse(400, "stream name required");
|
||||
if (!JetStreamConfigValidator.IsValidName(config.Name))
|
||||
return JetStreamApiResponse.ErrorResponse(400, "invalid stream name");
|
||||
|
||||
if (Encoding.UTF8.GetByteCount(config.Description) > JetStreamApiLimits.JSMaxDescriptionLen)
|
||||
return JetStreamApiResponse.ErrorResponse(400, "stream description is too long");
|
||||
|
||||
if (!JetStreamConfigValidator.IsMetadataWithinLimit(config.Metadata))
|
||||
return JetStreamApiResponse.ErrorResponse(400, "stream metadata exceeds maximum size");
|
||||
|
||||
var normalized = NormalizeConfig(config);
|
||||
|
||||
@@ -302,6 +365,8 @@ public sealed class StreamManager
|
||||
if (stream == null)
|
||||
return null;
|
||||
|
||||
|
||||
|
||||
if (stream.Config.MaxMsgSize > 0 && payload.Length > stream.Config.MaxMsgSize)
|
||||
{
|
||||
return new PubAck
|
||||
|
||||
@@ -1,10 +1,48 @@
|
||||
using System.Text;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.JetStream.Api;
|
||||
using NATS.Server.JetStream.Models;
|
||||
|
||||
namespace NATS.Server.JetStream.Validation;
|
||||
|
||||
public static class JetStreamConfigValidator
|
||||
{
|
||||
public static bool IsValidName(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return false;
|
||||
|
||||
// Go len(name) checks byte length, not character length.
|
||||
if (Encoding.UTF8.GetByteCount(name) > JetStreamApiLimits.JSMaxNameLen)
|
||||
return false;
|
||||
|
||||
foreach (var ch in name)
|
||||
{
|
||||
if (char.IsWhiteSpace(ch) || ch is '*' or '>')
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsMetadataWithinLimit(Dictionary<string, string>? metadata)
|
||||
=> MetadataByteSize(metadata) <= JetStreamApiLimits.JSMaxMetadataLen;
|
||||
|
||||
public static int MetadataByteSize(Dictionary<string, string>? metadata)
|
||||
{
|
||||
if (metadata is null || metadata.Count == 0)
|
||||
return 0;
|
||||
|
||||
var size = 0;
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
size += Encoding.UTF8.GetByteCount(key);
|
||||
size += Encoding.UTF8.GetByteCount(value);
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
public static ValidationResult Validate(StreamConfig config)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.Name) || config.Subjects.Count == 0)
|
||||
|
||||
40
src/NATS.Server/LeafNodes/LeafConnectInfo.cs
Normal file
40
src/NATS.Server/LeafNodes/LeafConnectInfo.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.LeafNodes;
|
||||
|
||||
/// <summary>
|
||||
/// CONNECT payload sent on solicited leaf connections.
|
||||
/// Go reference: leafnode.go leafConnectInfo.
|
||||
/// </summary>
|
||||
public sealed class LeafConnectInfo
|
||||
{
|
||||
[JsonPropertyName("jwt")]
|
||||
public string? Jwt { get; init; }
|
||||
|
||||
[JsonPropertyName("nkey")]
|
||||
public string? Nkey { get; init; }
|
||||
|
||||
[JsonPropertyName("sig")]
|
||||
public string? Sig { get; init; }
|
||||
|
||||
[JsonPropertyName("hub")]
|
||||
public bool Hub { get; init; }
|
||||
|
||||
[JsonPropertyName("cluster")]
|
||||
public string? Cluster { get; init; }
|
||||
|
||||
[JsonPropertyName("headers")]
|
||||
public bool Headers { get; init; }
|
||||
|
||||
[JsonPropertyName("jetstream")]
|
||||
public bool JetStream { get; init; }
|
||||
|
||||
[JsonPropertyName("compression")]
|
||||
public string? Compression { get; init; }
|
||||
|
||||
[JsonPropertyName("remote_account")]
|
||||
public string? RemoteAccount { get; init; }
|
||||
|
||||
[JsonPropertyName("proto")]
|
||||
public int Proto { get; init; }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.LeafNodes;
|
||||
@@ -15,6 +16,8 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
||||
private readonly NetworkStream _stream = new(socket, ownsSocket: true);
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
private readonly CancellationTokenSource _closedCts = new();
|
||||
private TimeSpan _connectDelay;
|
||||
private string? _remoteCluster;
|
||||
private Task? _loopTask;
|
||||
|
||||
public string? RemoteId { get; internal set; }
|
||||
@@ -22,6 +25,24 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
||||
public Func<RemoteSubscription, Task>? RemoteSubscriptionReceived { get; set; }
|
||||
public Func<LeafMessage, Task>? MessageReceived { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this connection was solicited (outbound dial) rather than accepted inbound.
|
||||
/// Go reference: isSolicitedLeafNode.
|
||||
/// </summary>
|
||||
public bool IsSolicited { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this connection is a spoke-side leaf connection.
|
||||
/// Go reference: isSpokeLeafNode / isHubLeafNode.
|
||||
/// </summary>
|
||||
public bool IsSpoke { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this leaf connection is isolated from hub propagation.
|
||||
/// Go reference: isIsolatedLeafNode.
|
||||
/// </summary>
|
||||
public bool Isolated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// JetStream domain for this leaf connection. When set, the domain is propagated
|
||||
/// in the LEAF handshake and included in LMSG frames for domain-aware routing.
|
||||
@@ -58,6 +79,12 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
||||
/// </summary>
|
||||
public bool PermsSynced { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns the currently configured reconnect delay for this connection.
|
||||
/// Go reference: leafnode.go setLeafConnectDelayIfSoliciting.
|
||||
/// </summary>
|
||||
public TimeSpan GetConnectDelay() => _connectDelay;
|
||||
|
||||
/// <summary>
|
||||
/// Sets the allowed publish and subscribe subjects for this connection and marks
|
||||
/// permissions as synced. Passing null for either list clears that list.
|
||||
@@ -106,11 +133,35 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
||||
=> _loopTask?.WaitAsync(ct) ?? Task.CompletedTask;
|
||||
|
||||
public Task SendLsPlusAsync(string account, string subject, string? queue, CancellationToken ct)
|
||||
=> WriteLineAsync(queue is { Length: > 0 } ? $"LS+ {account} {subject} {queue}" : $"LS+ {account} {subject}", ct);
|
||||
=> SendLsPlusAsync(account, subject, queue, queueWeight: 0, ct);
|
||||
|
||||
public Task SendLsPlusAsync(string account, string subject, string? queue, int queueWeight, CancellationToken ct)
|
||||
{
|
||||
string frame;
|
||||
if (queue is { Length: > 0 } && queueWeight > 0)
|
||||
frame = $"LS+ {account} {subject} {queue} {queueWeight}";
|
||||
else if (queue is { Length: > 0 })
|
||||
frame = $"LS+ {account} {subject} {queue}";
|
||||
else
|
||||
frame = $"LS+ {account} {subject}";
|
||||
|
||||
return WriteLineAsync(frame, ct);
|
||||
}
|
||||
|
||||
public Task SendLsMinusAsync(string account, string subject, string? queue, CancellationToken ct)
|
||||
=> WriteLineAsync(queue is { Length: > 0 } ? $"LS- {account} {subject} {queue}" : $"LS- {account} {subject}", ct);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a CONNECT protocol line with JSON payload for solicited leaf links.
|
||||
/// Go reference: leafnode.go sendLeafConnect.
|
||||
/// </summary>
|
||||
public Task SendLeafConnectAsync(LeafConnectInfo connectInfo, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(connectInfo);
|
||||
var json = JsonSerializer.Serialize(connectInfo);
|
||||
return WriteLineAsync($"CONNECT {json}", ct);
|
||||
}
|
||||
|
||||
public async Task SendMessageAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
var reply = string.IsNullOrEmpty(replyTo) ? "-" : replyTo;
|
||||
@@ -148,6 +199,63 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
||||
return $"LEAF {serverId}";
|
||||
}
|
||||
|
||||
public bool IsSolicitedLeafNode() => IsSolicited;
|
||||
public bool IsSpokeLeafNode() => IsSpoke;
|
||||
public bool IsHubLeafNode() => !IsSpoke;
|
||||
public bool IsIsolatedLeafNode() => Isolated;
|
||||
public string? RemoteCluster() => _remoteCluster;
|
||||
|
||||
/// <summary>
|
||||
/// Applies connect delay only when this is a solicited leaf connection.
|
||||
/// Go reference: leafnode.go setLeafConnectDelayIfSoliciting.
|
||||
/// </summary>
|
||||
public void SetLeafConnectDelayIfSoliciting(TimeSpan delay)
|
||||
{
|
||||
if (IsSolicited)
|
||||
_connectDelay = delay;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles remote ERR protocol for leaf links and applies reconnect delay hints.
|
||||
/// Go reference: leafnode.go leafProcessErr.
|
||||
/// </summary>
|
||||
public void LeafProcessErr(string errStr)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(errStr))
|
||||
return;
|
||||
|
||||
if (errStr.Contains("permission", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetLeafConnectDelayIfSoliciting(LeafNodeManager.LeafNodeReconnectAfterPermViolation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errStr.Contains("loop", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetLeafConnectDelayIfSoliciting(LeafNodeManager.LeafNodeReconnectDelayAfterLoopDetected);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errStr.Contains("cluster name", StringComparison.OrdinalIgnoreCase)
|
||||
&& errStr.Contains("same", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
SetLeafConnectDelayIfSoliciting(LeafNodeManager.LeafNodeReconnectDelayAfterClusterNameSame);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles subscription permission violations.
|
||||
/// Go reference: leafnode.go leafSubPermViolation.
|
||||
/// </summary>
|
||||
public void LeafSubPermViolation(string subj) => LeafPermViolation(pub: false, subj);
|
||||
|
||||
/// <summary>
|
||||
/// Handles publish/subscribe permission violations.
|
||||
/// Go reference: leafnode.go leafPermViolation.
|
||||
/// </summary>
|
||||
public void LeafPermViolation(bool pub, string subj)
|
||||
=> SetLeafConnectDelayIfSoliciting(LeafNodeManager.LeafNodeReconnectAfterPermViolation);
|
||||
|
||||
private void ParseHandshakeResponse(string line)
|
||||
{
|
||||
if (!line.StartsWith("LEAF ", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -163,9 +271,19 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
||||
{
|
||||
RemoteId = rest[..spaceIdx];
|
||||
var attrs = rest[(spaceIdx + 1)..];
|
||||
const string domainPrefix = "domain=";
|
||||
if (attrs.StartsWith(domainPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
RemoteJetStreamDomain = attrs[domainPrefix.Length..].Trim();
|
||||
foreach (var token in attrs.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
const string domainPrefix = "domain=";
|
||||
if (token.StartsWith(domainPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
RemoteJetStreamDomain = token[domainPrefix.Length..].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
const string clusterPrefix = "cluster=";
|
||||
if (token.StartsWith(clusterPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
_remoteCluster = token[clusterPrefix.Length..].Trim();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -190,9 +308,10 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
||||
if (line.StartsWith("LS+ ", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue))
|
||||
if (RemoteSubscriptionReceived != null &&
|
||||
TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue, out var queueWeight))
|
||||
{
|
||||
await RemoteSubscriptionReceived(new RemoteSubscription(parsedSubject, queue, RemoteId ?? string.Empty, parsedAccount));
|
||||
await RemoteSubscriptionReceived(new RemoteSubscription(parsedSubject, queue, RemoteId ?? string.Empty, parsedAccount, QueueWeight: queueWeight));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -200,7 +319,8 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
||||
if (line.StartsWith("LS- ", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue))
|
||||
if (RemoteSubscriptionReceived != null &&
|
||||
TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue, out _))
|
||||
{
|
||||
await RemoteSubscriptionReceived(RemoteSubscription.Removal(parsedSubject, queue, RemoteId ?? string.Empty, parsedAccount));
|
||||
}
|
||||
@@ -294,11 +414,12 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
||||
return Encoding.ASCII.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
private static bool TryParseAccountScopedInterest(string[] parts, out string account, out string subject, out string? queue)
|
||||
private static bool TryParseAccountScopedInterest(string[] parts, out string account, out string subject, out string? queue, out int queueWeight)
|
||||
{
|
||||
account = "$G";
|
||||
subject = string.Empty;
|
||||
queue = null;
|
||||
queueWeight = 1;
|
||||
|
||||
if (parts.Length < 2)
|
||||
return false;
|
||||
@@ -310,11 +431,15 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
||||
account = parts[1];
|
||||
subject = parts[2];
|
||||
queue = parts.Length >= 4 ? parts[3] : null;
|
||||
if (queue is { Length: > 0 } && parts.Length >= 5)
|
||||
queueWeight = ParseQueueWeight(parts[4]);
|
||||
return true;
|
||||
}
|
||||
|
||||
subject = parts[1];
|
||||
queue = parts.Length >= 3 ? parts[2] : null;
|
||||
if (queue is { Length: > 0 } && parts.Length >= 4)
|
||||
queueWeight = ParseQueueWeight(parts[3]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -322,6 +447,9 @@ public sealed class LeafConnection(Socket socket) : IAsyncDisposable
|
||||
=> token.Contains('.', StringComparison.Ordinal)
|
||||
|| token.Contains('*', StringComparison.Ordinal)
|
||||
|| token.Contains('>', StringComparison.Ordinal);
|
||||
|
||||
private static int ParseQueueWeight(string token)
|
||||
=> int.TryParse(token, out var parsed) && parsed > 0 ? parsed : 1;
|
||||
}
|
||||
|
||||
public sealed record LeafMessage(string Subject, string? ReplyTo, ReadOnlyMemory<byte> Payload, string Account = "$G");
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Gateways;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.LeafNodes;
|
||||
@@ -16,6 +17,11 @@ namespace NATS.Server.LeafNodes;
|
||||
/// </summary>
|
||||
public sealed class LeafNodeManager : IAsyncDisposable
|
||||
{
|
||||
public static readonly TimeSpan LeafNodeReconnectDelayAfterLoopDetected = TimeSpan.FromSeconds(30);
|
||||
public static readonly TimeSpan LeafNodeReconnectAfterPermViolation = TimeSpan.FromSeconds(30);
|
||||
public static readonly TimeSpan LeafNodeReconnectDelayAfterClusterNameSame = TimeSpan.FromSeconds(30);
|
||||
public static readonly TimeSpan LeafNodeWaitBeforeClose = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly LeafNodeOptions _options;
|
||||
private readonly ServerStats _stats;
|
||||
private readonly string _serverId;
|
||||
@@ -90,6 +96,27 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
public bool IsLeafConnectDisabled(string remoteUrl)
|
||||
=> IsGloballyDisabled || _disabledRemotes.ContainsKey(remoteUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the remote URL is still configured and not disabled.
|
||||
/// Go reference: leafnode.go remoteLeafNodeStillValid.
|
||||
/// </summary>
|
||||
internal bool RemoteLeafNodeStillValid(string remoteUrl)
|
||||
{
|
||||
if (IsLeafConnectDisabled(remoteUrl))
|
||||
return false;
|
||||
|
||||
if (_options.Remotes.Any(r => string.Equals(r, remoteUrl, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
|
||||
foreach (var remote in _options.RemoteLeaves)
|
||||
{
|
||||
if (remote.Urls.Any(u => string.Equals(u, remoteUrl, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables outbound leaf connections to the specified remote URL.
|
||||
/// Has no effect if the remote is already disabled.
|
||||
@@ -232,6 +259,8 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
var connection = new LeafConnection(socket)
|
||||
{
|
||||
JetStreamDomain = _options.JetStreamDomain,
|
||||
IsSolicited = true,
|
||||
IsSpoke = true,
|
||||
};
|
||||
await connection.PerformOutboundHandshakeAsync(_serverId, ct);
|
||||
Register(connection);
|
||||
@@ -263,6 +292,9 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
}
|
||||
|
||||
public void PropagateLocalSubscription(string account, string subject, string? queue)
|
||||
=> PropagateLocalSubscription(account, subject, queue, queueWeight: 0);
|
||||
|
||||
public void PropagateLocalSubscription(string account, string subject, string? queue, int queueWeight)
|
||||
{
|
||||
// Subscription propagation is also subject to export filtering:
|
||||
// we don't propagate subscriptions for subjects that are denied.
|
||||
@@ -273,7 +305,18 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
}
|
||||
|
||||
foreach (var connection in _connections.Values)
|
||||
_ = connection.SendLsPlusAsync(account, subject, queue, _cts?.Token ?? CancellationToken.None);
|
||||
{
|
||||
if (!CanSpokeSendSubscription(connection, subject))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Leaf subscription propagation denied for spoke connection {RemoteId} and subject {Subject} (subscribe permissions)",
|
||||
connection.RemoteId ?? "<unknown>",
|
||||
subject);
|
||||
continue;
|
||||
}
|
||||
|
||||
_ = connection.SendLsPlusAsync(account, subject, queue, queueWeight, _cts?.Token ?? CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
public void PropagateLocalUnsubscription(string account, string subject, string? queue)
|
||||
@@ -585,6 +628,9 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
var attempt = 0;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
if (!RemoteLeafNodeStillValid(remote))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var endPoint = ParseEndpoint(remote);
|
||||
@@ -595,6 +641,8 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
var connection = new LeafConnection(socket)
|
||||
{
|
||||
JetStreamDomain = jetStreamDomain,
|
||||
IsSolicited = true,
|
||||
IsSpoke = true,
|
||||
};
|
||||
await connection.PerformOutboundHandshakeAsync(_serverId, ct);
|
||||
Register(connection);
|
||||
@@ -736,6 +784,39 @@ public sealed class LeafNodeManager : IAsyncDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool CanSpokeSendSubscription(LeafConnection connection, string subject)
|
||||
{
|
||||
if (!connection.IsSpokeLeafNode())
|
||||
return true;
|
||||
|
||||
if (ShouldBypassSpokeSubscribePermission(subject))
|
||||
return true;
|
||||
|
||||
if (!connection.PermsSynced || connection.AllowedSubscribeSubjects.Count == 0)
|
||||
return true;
|
||||
|
||||
for (var i = 0; i < connection.AllowedSubscribeSubjects.Count; i++)
|
||||
{
|
||||
if (SubjectMatch.MatchLiteral(subject, connection.AllowedSubscribeSubjects[i]))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ShouldBypassSpokeSubscribePermission(string subject)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
return false;
|
||||
|
||||
if (subject[0] != '$' && subject[0] != '_')
|
||||
return false;
|
||||
|
||||
return subject.StartsWith("$LDS.", StringComparison.Ordinal)
|
||||
|| subject.StartsWith(ReplyMapper.GatewayReplyPrefix, StringComparison.Ordinal)
|
||||
|| subject.StartsWith(ReplyMapper.OldGatewayReplyPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static IPEndPoint ParseEndpoint(string endpoint)
|
||||
{
|
||||
var parts = endpoint.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
46
src/NATS.Server/LeafNodes/LeafSubKey.cs
Normal file
46
src/NATS.Server/LeafNodes/LeafSubKey.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.LeafNodes;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for building leaf-node subscription map keys.
|
||||
/// Go reference: server/leafnode.go keyFromSub / keyFromSubWithOrigin.
|
||||
/// </summary>
|
||||
public static class LeafSubKey
|
||||
{
|
||||
public const string KeyRoutedSub = "R";
|
||||
public const byte KeyRoutedSubByte = (byte)'R';
|
||||
public const string KeyRoutedLeafSub = "L";
|
||||
public const byte KeyRoutedLeafSubByte = (byte)'L';
|
||||
|
||||
public static readonly TimeSpan SharedSysAccDelay = TimeSpan.FromMilliseconds(250);
|
||||
public static readonly TimeSpan ConnectProcessTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
public static string KeyFromSub(Subscription sub)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sub);
|
||||
return sub.Queue is { Length: > 0 }
|
||||
? $"{sub.Subject} {sub.Queue}"
|
||||
: sub.Subject;
|
||||
}
|
||||
|
||||
public static string KeyFromSubWithOrigin(Subscription sub, string? origin = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sub);
|
||||
var hasOrigin = !string.IsNullOrEmpty(origin);
|
||||
var prefix = hasOrigin ? KeyRoutedLeafSub : KeyRoutedSub;
|
||||
|
||||
if (sub.Queue is { Length: > 0 })
|
||||
{
|
||||
if (hasOrigin)
|
||||
return $"{prefix} {sub.Subject} {sub.Queue} {origin}";
|
||||
|
||||
return $"{prefix} {sub.Subject} {sub.Queue}";
|
||||
}
|
||||
|
||||
if (hasOrigin)
|
||||
return $"{prefix} {sub.Subject} {origin}";
|
||||
|
||||
return $"{prefix} {sub.Subject}";
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,13 @@ public sealed record ClosedClient
|
||||
public string TlsVersion { get; init; } = "";
|
||||
public string TlsCipherSuite { get; init; } = "";
|
||||
public string TlsPeerCertSubject { get; init; } = "";
|
||||
public string TlsPeerCertSubjectPkSha256 { get; init; } = "";
|
||||
public string TlsPeerCertSha256 { get; init; } = "";
|
||||
public string MqttClient { get; init; } = "";
|
||||
public string JwtIssuerKey { get; init; } = "";
|
||||
public string JwtTags { get; init; } = "";
|
||||
public string Proxy { get; init; } = "";
|
||||
public long Stalls { get; init; }
|
||||
public string Jwt { get; init; } = "";
|
||||
public string IssuerKey { get; init; } = "";
|
||||
public string NameTag { get; init; } = "";
|
||||
public string[] Tags { get; init; } = [];
|
||||
public string ProxyKey { get; init; } = "";
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ public sealed class ConnInfo
|
||||
[JsonPropertyName("out_bytes")]
|
||||
public long OutBytes { get; set; }
|
||||
|
||||
[JsonPropertyName("stalls")]
|
||||
public long Stalls { get; set; }
|
||||
|
||||
[JsonPropertyName("subscriptions")]
|
||||
public uint NumSubs { get; set; }
|
||||
|
||||
@@ -119,20 +122,54 @@ public sealed class ConnInfo
|
||||
[JsonPropertyName("tls_peer_cert_subject")]
|
||||
public string TlsPeerCertSubject { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("tls_peer_certs")]
|
||||
public TLSPeerCert[] TlsPeerCerts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("tls_first")]
|
||||
public bool TlsFirst { get; set; }
|
||||
|
||||
[JsonPropertyName("mqtt_client")]
|
||||
public string MqttClient { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("jwt_issuer_key")]
|
||||
public string JwtIssuerKey { get; set; } = "";
|
||||
[JsonPropertyName("jwt")]
|
||||
public string Jwt { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("jwt_tags")]
|
||||
public string JwtTags { get; set; } = "";
|
||||
[JsonPropertyName("issuer_key")]
|
||||
public string IssuerKey { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("name_tag")]
|
||||
public string NameTag { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("tags")]
|
||||
public string[] Tags { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("proxy")]
|
||||
public string Proxy { get; set; } = "";
|
||||
public ProxyInfo? Proxy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proxy metadata for proxied client connections.
|
||||
/// Corresponds to Go server/monitor.go ProxyInfo.
|
||||
/// </summary>
|
||||
public sealed class ProxyInfo
|
||||
{
|
||||
[JsonPropertyName("key")]
|
||||
public string Key { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TLS peer certificate detail for /connz parity with Go monitor.go.
|
||||
/// </summary>
|
||||
public sealed class TLSPeerCert
|
||||
{
|
||||
[JsonPropertyName("subject")]
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("subject_pk_sha256")]
|
||||
public string SubjectPKISha256 { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("cert_sha256")]
|
||||
public string CertSha256 { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -610,6 +647,28 @@ public enum SortOpt
|
||||
ByReason,
|
||||
}
|
||||
|
||||
public static class SortOptExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Go parity for SortOpt.IsValid().
|
||||
/// </summary>
|
||||
public static bool IsValid(this SortOpt sort) =>
|
||||
sort is SortOpt.ByCid
|
||||
or SortOpt.ByStart
|
||||
or SortOpt.BySubs
|
||||
or SortOpt.ByPending
|
||||
or SortOpt.ByMsgsTo
|
||||
or SortOpt.ByMsgsFrom
|
||||
or SortOpt.ByBytesTo
|
||||
or SortOpt.ByBytesFrom
|
||||
or SortOpt.ByLast
|
||||
or SortOpt.ByIdle
|
||||
or SortOpt.ByUptime
|
||||
or SortOpt.ByRtt
|
||||
or SortOpt.ByStop
|
||||
or SortOpt.ByReason;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connection state filter.
|
||||
/// Corresponds to Go server/monitor.go ConnState type.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using NATS.Server.Auth.Jwt;
|
||||
using NATS.Server.Subscriptions;
|
||||
|
||||
namespace NATS.Server.Monitoring;
|
||||
@@ -143,6 +144,12 @@ public sealed class ConnzHandler(NatsServer server)
|
||||
|
||||
private static ConnInfo BuildConnInfo(NatsClient client, DateTime now, ConnzOptions opts)
|
||||
{
|
||||
var tlsPeerCerts = TlsPeerCertMapper.FromCertificate(client.TlsState?.PeerCert);
|
||||
var (jwt, issuerKey, tags) = opts.Auth
|
||||
? ExtractJwtMetadata(client.ClientOpts?.JWT)
|
||||
: ("", "", Array.Empty<string>());
|
||||
var proxyKey = ExtractProxyKey(client.ClientOpts?.Username);
|
||||
|
||||
var info = new ConnInfo
|
||||
{
|
||||
Cid = client.Id,
|
||||
@@ -158,6 +165,7 @@ public sealed class ConnzHandler(NatsServer server)
|
||||
OutMsgs = Interlocked.Read(ref client.OutMsgs),
|
||||
InBytes = Interlocked.Read(ref client.InBytes),
|
||||
OutBytes = Interlocked.Read(ref client.OutBytes),
|
||||
Stalls = 0,
|
||||
NumSubs = (uint)client.Subscriptions.Count,
|
||||
Name = client.ClientOpts?.Name ?? "",
|
||||
Lang = client.ClientOpts?.Lang ?? "",
|
||||
@@ -168,10 +176,13 @@ public sealed class ConnzHandler(NatsServer server)
|
||||
Reason = client.CloseReason.ToReasonString(),
|
||||
TlsVersion = client.TlsState?.TlsVersion ?? "",
|
||||
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
|
||||
TlsPeerCertSubject = client.TlsState?.PeerCert?.Subject ?? "",
|
||||
JwtIssuerKey = string.IsNullOrEmpty(client.ClientOpts?.JWT) ? "" : "present",
|
||||
JwtTags = "",
|
||||
Proxy = client.ClientOpts?.Username?.StartsWith("proxy:", StringComparison.Ordinal) == true ? "true" : "",
|
||||
TlsPeerCertSubject = tlsPeerCerts.FirstOrDefault()?.Subject ?? "",
|
||||
TlsPeerCerts = tlsPeerCerts,
|
||||
Jwt = jwt,
|
||||
IssuerKey = issuerKey,
|
||||
NameTag = "",
|
||||
Tags = tags,
|
||||
Proxy = string.IsNullOrEmpty(proxyKey) ? null : new ProxyInfo { Key = proxyKey },
|
||||
Rtt = FormatRtt(client.Rtt),
|
||||
};
|
||||
|
||||
@@ -202,6 +213,8 @@ public sealed class ConnzHandler(NatsServer server)
|
||||
|
||||
private static ConnInfo BuildClosedConnInfo(ClosedClient closed, DateTime now, ConnzOptions opts)
|
||||
{
|
||||
var tlsPeerCerts = TlsPeerCertMapper.FromClosedClient(closed);
|
||||
|
||||
return new ConnInfo
|
||||
{
|
||||
Cid = closed.Cid,
|
||||
@@ -229,10 +242,14 @@ public sealed class ConnzHandler(NatsServer server)
|
||||
TlsVersion = closed.TlsVersion,
|
||||
TlsCipherSuite = closed.TlsCipherSuite,
|
||||
TlsPeerCertSubject = closed.TlsPeerCertSubject,
|
||||
TlsPeerCerts = tlsPeerCerts,
|
||||
MqttClient = closed.MqttClient,
|
||||
JwtIssuerKey = closed.JwtIssuerKey,
|
||||
JwtTags = closed.JwtTags,
|
||||
Proxy = closed.Proxy,
|
||||
Stalls = closed.Stalls,
|
||||
Jwt = closed.Jwt,
|
||||
IssuerKey = closed.IssuerKey,
|
||||
NameTag = closed.NameTag,
|
||||
Tags = closed.Tags,
|
||||
Proxy = string.IsNullOrEmpty(closed.ProxyKey) ? null : new ProxyInfo { Key = closed.ProxyKey },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -243,7 +260,7 @@ public sealed class ConnzHandler(NatsServer server)
|
||||
|
||||
if (q.TryGetValue("sort", out var sort))
|
||||
{
|
||||
opts.Sort = sort.ToString().ToLowerInvariant() switch
|
||||
var parsedSort = sort.ToString().ToLowerInvariant() switch
|
||||
{
|
||||
"cid" => SortOpt.ByCid,
|
||||
"start" => SortOpt.ByStart,
|
||||
@@ -261,6 +278,8 @@ public sealed class ConnzHandler(NatsServer server)
|
||||
"reason" => SortOpt.ByReason,
|
||||
_ => SortOpt.ByCid,
|
||||
};
|
||||
|
||||
opts.Sort = parsedSort.IsValid() ? parsedSort : SortOpt.ByCid;
|
||||
}
|
||||
|
||||
if (q.TryGetValue("subs", out var subs))
|
||||
@@ -338,4 +357,32 @@ public sealed class ConnzHandler(NatsServer server)
|
||||
return $"{(int)ts.TotalMinutes}m{ts.Seconds}s";
|
||||
return $"{(int)ts.TotalSeconds}s";
|
||||
}
|
||||
|
||||
private static (string Jwt, string IssuerKey, string[] Tags) ExtractJwtMetadata(string? jwt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jwt))
|
||||
return ("", "", []);
|
||||
|
||||
var issuerKey = "";
|
||||
var tags = Array.Empty<string>();
|
||||
var claims = NatsJwt.DecodeUserClaims(jwt);
|
||||
if (claims != null)
|
||||
{
|
||||
issuerKey = claims.Issuer ?? "";
|
||||
tags = claims.Nats?.Tags ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
return (jwt, issuerKey, tags);
|
||||
}
|
||||
|
||||
private static string ExtractProxyKey(string? username)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return "";
|
||||
|
||||
const string prefix = "proxy:";
|
||||
return username.StartsWith(prefix, StringComparison.Ordinal)
|
||||
? username[prefix.Length..]
|
||||
: "";
|
||||
}
|
||||
}
|
||||
|
||||
50
src/NATS.Server/Monitoring/Healthz.cs
Normal file
50
src/NATS.Server/Monitoring/Healthz.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace NATS.Server.Monitoring;
|
||||
|
||||
/// <summary>
|
||||
/// Structured health response shape for /healthz.
|
||||
/// Go reference: monitor.go HealthStatus.
|
||||
/// </summary>
|
||||
public sealed class HealthStatus
|
||||
{
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; init; } = "ok";
|
||||
|
||||
[JsonPropertyName("status_code")]
|
||||
public int StatusCode { get; init; } = 200;
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string? Error { get; init; }
|
||||
|
||||
[JsonPropertyName("errors")]
|
||||
public HealthzError[] Errors { get; init; } = [];
|
||||
|
||||
public static HealthStatus Ok() => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual health check failure record.
|
||||
/// Go reference: monitor.go HealthzError.
|
||||
/// </summary>
|
||||
public sealed class HealthzError
|
||||
{
|
||||
[JsonPropertyName("type")]
|
||||
public HealthzErrorType Type { get; init; } = HealthzErrorType.Unknown;
|
||||
|
||||
[JsonPropertyName("error")]
|
||||
public string Error { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Health error classification.
|
||||
/// Go reference: monitor.go HealthZErrorType.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public enum HealthzErrorType
|
||||
{
|
||||
Unknown,
|
||||
JetStream,
|
||||
Account,
|
||||
Cluster,
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public sealed class MonitorServer : IAsyncDisposable
|
||||
_app = builder.Build();
|
||||
var basePath = options.MonitorBasePath ?? "";
|
||||
|
||||
_varzHandler = new VarzHandler(server, options);
|
||||
_varzHandler = new VarzHandler(server, options, loggerFactory);
|
||||
_connzHandler = new ConnzHandler(server);
|
||||
_subszHandler = new SubszHandler(server);
|
||||
_jszHandler = new JszHandler(server, options);
|
||||
@@ -59,7 +59,7 @@ public sealed class MonitorServer : IAsyncDisposable
|
||||
_app.MapGet(basePath + "/healthz", () =>
|
||||
{
|
||||
stats.HttpReqStats.AddOrUpdate("/healthz", 1, (_, v) => v + 1);
|
||||
return Results.Ok("ok");
|
||||
return Results.Ok(HealthStatus.Ok());
|
||||
});
|
||||
_app.MapGet(basePath + "/varz", async (HttpContext ctx) =>
|
||||
{
|
||||
|
||||
60
src/NATS.Server/Monitoring/TlsPeerCertMapper.cs
Normal file
60
src/NATS.Server/Monitoring/TlsPeerCertMapper.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace NATS.Server.Monitoring;
|
||||
|
||||
internal static class TlsPeerCertMapper
|
||||
{
|
||||
public static TLSPeerCert[] FromCertificate(X509Certificate2? cert)
|
||||
{
|
||||
if (cert == null)
|
||||
return [];
|
||||
|
||||
return
|
||||
[
|
||||
new TLSPeerCert
|
||||
{
|
||||
Subject = cert.Subject ?? string.Empty,
|
||||
SubjectPKISha256 = ComputeSubjectPkSha256(cert),
|
||||
CertSha256 = ComputeCertSha256(cert),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public static TLSPeerCert[] FromClosedClient(ClosedClient closed)
|
||||
{
|
||||
if (string.IsNullOrEmpty(closed.TlsPeerCertSubject))
|
||||
return [];
|
||||
|
||||
return
|
||||
[
|
||||
new TLSPeerCert
|
||||
{
|
||||
Subject = closed.TlsPeerCertSubject,
|
||||
SubjectPKISha256 = closed.TlsPeerCertSubjectPkSha256,
|
||||
CertSha256 = closed.TlsPeerCertSha256,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public static (string Subject, string SubjectPkSha256, string CertSha256) ToClosedFields(X509Certificate2? cert)
|
||||
{
|
||||
if (cert == null)
|
||||
return (string.Empty, string.Empty, string.Empty);
|
||||
|
||||
return (
|
||||
cert.Subject ?? string.Empty,
|
||||
ComputeSubjectPkSha256(cert),
|
||||
ComputeCertSha256(cert)
|
||||
);
|
||||
}
|
||||
|
||||
private static string ComputeSubjectPkSha256(X509Certificate2 cert)
|
||||
=> ToHexLower(SHA256.HashData(cert.GetPublicKey()));
|
||||
|
||||
private static string ComputeCertSha256(X509Certificate2 cert)
|
||||
=> ToHexLower(SHA256.HashData(cert.RawData));
|
||||
|
||||
private static string ToHexLower(ReadOnlySpan<byte> bytes)
|
||||
=> Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.Server.Protocol;
|
||||
|
||||
namespace NATS.Server.Monitoring;
|
||||
@@ -13,18 +14,23 @@ public sealed class VarzHandler : IDisposable
|
||||
{
|
||||
private readonly NatsServer _server;
|
||||
private readonly NatsOptions _options;
|
||||
private readonly ILogger<VarzHandler> _logger;
|
||||
private readonly SemaphoreSlim _varzMu = new(1, 1);
|
||||
private readonly object _cpuSampleSync = new();
|
||||
private readonly Timer _cpuSampleTimer;
|
||||
private DateTime _lastCpuSampleTime;
|
||||
private TimeSpan _lastCpuUsage;
|
||||
private double _cachedCpuPercent;
|
||||
|
||||
public VarzHandler(NatsServer server, NatsOptions options)
|
||||
public VarzHandler(NatsServer server, NatsOptions options, ILoggerFactory loggerFactory)
|
||||
{
|
||||
_server = server;
|
||||
_options = options;
|
||||
_logger = loggerFactory.CreateLogger<VarzHandler>();
|
||||
using var proc = Process.GetCurrentProcess();
|
||||
_lastCpuSampleTime = DateTime.UtcNow;
|
||||
_lastCpuUsage = proc.TotalProcessorTime;
|
||||
_cpuSampleTimer = new Timer(_ => SampleCpuUsage(), null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
public async Task<Varz> HandleVarzAsync(CancellationToken ct = default)
|
||||
@@ -37,16 +43,7 @@ public sealed class VarzHandler : IDisposable
|
||||
var uptime = now - _server.StartTime;
|
||||
var stats = _server.Stats;
|
||||
|
||||
// CPU sampling with 1-second cache to avoid excessive sampling
|
||||
if ((now - _lastCpuSampleTime).TotalSeconds >= 1.0)
|
||||
{
|
||||
var currentCpu = proc.TotalProcessorTime;
|
||||
var elapsed = now - _lastCpuSampleTime;
|
||||
_cachedCpuPercent = (currentCpu - _lastCpuUsage).TotalMilliseconds
|
||||
/ elapsed.TotalMilliseconds / Environment.ProcessorCount * 100.0;
|
||||
_lastCpuSampleTime = now;
|
||||
_lastCpuUsage = currentCpu;
|
||||
}
|
||||
var cachedCpuPercent = GetCachedCpuPercent();
|
||||
|
||||
// Load the TLS certificate to report its expiry date in /varz.
|
||||
// Corresponds to Go server/monitor.go handleVarz populating TLSCertExpiry.
|
||||
@@ -93,7 +90,7 @@ public sealed class VarzHandler : IDisposable
|
||||
Now = now,
|
||||
Uptime = FormatUptime(uptime),
|
||||
Mem = proc.WorkingSet64,
|
||||
Cpu = Math.Round(_cachedCpuPercent, 2),
|
||||
Cpu = Math.Round(cachedCpuPercent, 2),
|
||||
Cores = Environment.ProcessorCount,
|
||||
MaxProcs = ThreadPool.ThreadCount,
|
||||
Connections = _server.ClientCount,
|
||||
@@ -153,9 +150,43 @@ public sealed class VarzHandler : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cpuSampleTimer.Dispose();
|
||||
_varzMu.Dispose();
|
||||
}
|
||||
|
||||
private void SampleCpuUsage()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var proc = Process.GetCurrentProcess();
|
||||
var now = DateTime.UtcNow;
|
||||
lock (_cpuSampleSync)
|
||||
{
|
||||
var currentCpu = proc.TotalProcessorTime;
|
||||
var elapsed = now - _lastCpuSampleTime;
|
||||
if (elapsed.TotalMilliseconds <= 0)
|
||||
return;
|
||||
|
||||
_cachedCpuPercent = (currentCpu - _lastCpuUsage).TotalMilliseconds
|
||||
/ elapsed.TotalMilliseconds / Environment.ProcessorCount * 100.0;
|
||||
_lastCpuSampleTime = now;
|
||||
_lastCpuUsage = currentCpu;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "CPU usage sampling failed; retaining last cached value");
|
||||
}
|
||||
}
|
||||
|
||||
private double GetCachedCpuPercent()
|
||||
{
|
||||
lock (_cpuSampleSync)
|
||||
{
|
||||
return _cachedCpuPercent;
|
||||
}
|
||||
}
|
||||
|
||||
private MqttOptsVarz BuildMqttVarz()
|
||||
{
|
||||
var mqtt = _options.Mqtt;
|
||||
|
||||
@@ -193,9 +193,15 @@ public static class MqttBinaryDecoder
|
||||
/// Parses the payload bytes of an MQTT SUBSCRIBE packet.
|
||||
/// </summary>
|
||||
/// <param name="payload">The payload bytes from <see cref="MqttControlPacket.Payload"/>.</param>
|
||||
/// <param name="flags">
|
||||
/// Optional fixed-header flags nibble. When provided, must match SUBSCRIBE flags (0x02).
|
||||
/// </param>
|
||||
/// <returns>A populated <see cref="MqttSubscribeInfo"/>.</returns>
|
||||
public static MqttSubscribeInfo ParseSubscribe(ReadOnlySpan<byte> payload)
|
||||
public static MqttSubscribeInfo ParseSubscribe(ReadOnlySpan<byte> payload, byte? flags = null)
|
||||
{
|
||||
if (flags.HasValue && flags.Value != MqttProtocolConstants.SubscribeFlags)
|
||||
throw new FormatException("MQTT SUBSCRIBE packet has invalid fixed-header flags.");
|
||||
|
||||
// Variable header: packet identifier (2 bytes, big-endian)
|
||||
// Payload: one or more topic-filter entries, each:
|
||||
// 2-byte length prefix + UTF-8 filter string + 1-byte requested QoS
|
||||
|
||||
@@ -31,6 +31,8 @@ public static class MqttPacketReader
|
||||
var type = (MqttControlPacketType)(first >> 4);
|
||||
var flags = (byte)(first & 0x0F);
|
||||
var remainingLength = DecodeRemainingLength(buffer[1..], out var consumed);
|
||||
if (remainingLength > MqttProtocolConstants.MaxPayloadSize)
|
||||
throw new FormatException("MQTT packet remaining length exceeds protocol maximum.");
|
||||
var payloadStart = 1 + consumed;
|
||||
var totalLength = payloadStart + remainingLength;
|
||||
if (remainingLength < 0 || totalLength > buffer.Length)
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace NATS.Server.Mqtt;
|
||||
|
||||
public static class MqttPacketWriter
|
||||
{
|
||||
public static byte[] WriteString(string value)
|
||||
=> WriteBytes(Encoding.UTF8.GetBytes(value));
|
||||
|
||||
public static byte[] WriteBytes(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length > ushort.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(bytes), "MQTT length-prefixed field cannot exceed 65535 bytes.");
|
||||
|
||||
var buffer = new byte[2 + bytes.Length];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buffer.AsSpan(0, 2), (ushort)bytes.Length);
|
||||
bytes.CopyTo(buffer.AsSpan(2));
|
||||
return buffer;
|
||||
}
|
||||
|
||||
public static byte[] Write(MqttControlPacketType type, ReadOnlySpan<byte> payload, byte flags = 0)
|
||||
{
|
||||
if (type == MqttControlPacketType.Reserved)
|
||||
@@ -18,8 +35,10 @@ public static class MqttPacketWriter
|
||||
|
||||
internal static byte[] EncodeRemainingLength(int value)
|
||||
{
|
||||
if (value < 0 || value > 268_435_455)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), "MQTT remaining length must be between 0 and 268435455.");
|
||||
if (value < 0 || value > MqttProtocolConstants.MaxPayloadSize)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(value),
|
||||
$"MQTT remaining length must be between 0 and {MqttProtocolConstants.MaxPayloadSize}.");
|
||||
|
||||
Span<byte> scratch = stackalloc byte[4];
|
||||
var index = 0;
|
||||
|
||||
90
src/NATS.Server/Mqtt/MqttParityModels.cs
Normal file
90
src/NATS.Server/Mqtt/MqttParityModels.cs
Normal file
@@ -0,0 +1,90 @@
|
||||
namespace NATS.Server.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// JetStream API helper context for MQTT account/session operations.
|
||||
/// Go reference: mqtt.go mqttJSA.
|
||||
/// </summary>
|
||||
public sealed class MqttJsa
|
||||
{
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public string ReplyPrefix { get; set; } = string.Empty;
|
||||
public string? Domain { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MQTT JetStream publish request shape.
|
||||
/// Go reference: mqtt.go mqttJSPubMsg.
|
||||
/// </summary>
|
||||
public sealed class MqttJsPubMsg
|
||||
{
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public byte[] Payload { get; set; } = [];
|
||||
public string? ReplyTo { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retained-message delete notification payload.
|
||||
/// Go reference: mqtt.go mqttRetMsgDel.
|
||||
/// </summary>
|
||||
public sealed class MqttRetMsgDel
|
||||
{
|
||||
public string Topic { get; set; } = string.Empty;
|
||||
public ulong Sequence { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persisted MQTT session metadata.
|
||||
/// Go reference: mqtt.go mqttPersistedSession.
|
||||
/// </summary>
|
||||
public sealed class MqttPersistedSession
|
||||
{
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
public int LastPacketId { get; set; }
|
||||
public int MaxAckPending { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reference to a retained message in storage.
|
||||
/// Go reference: mqtt.go mqttRetainedMsgRef.
|
||||
/// </summary>
|
||||
public sealed class MqttRetainedMessageRef
|
||||
{
|
||||
public ulong StreamSequence { get; set; }
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MQTT subscription metadata.
|
||||
/// Go reference: mqtt.go mqttSub.
|
||||
/// </summary>
|
||||
public sealed class MqttSub
|
||||
{
|
||||
public string Filter { get; set; } = string.Empty;
|
||||
public byte Qos { get; set; }
|
||||
public string? JsDur { get; set; }
|
||||
public bool Prm { get; set; }
|
||||
public bool Reserved { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed MQTT filter metadata.
|
||||
/// Go reference: mqtt.go mqttFilter.
|
||||
/// </summary>
|
||||
public sealed class MqttFilter
|
||||
{
|
||||
public string Filter { get; set; } = string.Empty;
|
||||
public byte Qos { get; set; }
|
||||
public string? TopicToken { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed NATS headers associated with an MQTT publish flow.
|
||||
/// Go reference: mqtt.go mqttParsedPublishNATSHeader.
|
||||
/// </summary>
|
||||
public sealed class MqttParsedPublishNatsHeader
|
||||
{
|
||||
public string? Subject { get; set; }
|
||||
public string? Mapped { get; set; }
|
||||
public bool IsPublish { get; set; }
|
||||
public bool IsPubRel { get; set; }
|
||||
}
|
||||
91
src/NATS.Server/Mqtt/MqttProtocolConstants.cs
Normal file
91
src/NATS.Server/Mqtt/MqttProtocolConstants.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
namespace NATS.Server.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// MQTT protocol and server integration constants aligned with Go mqtt.go.
|
||||
/// </summary>
|
||||
public static class MqttProtocolConstants
|
||||
{
|
||||
// MQTT fixed-header flags for SUBSCRIBE packets (MQTT 3.1.1 section 3.8.1).
|
||||
public const byte SubscribeFlags = 0x02;
|
||||
|
||||
// MQTT 3.1.1 CONNACK return codes.
|
||||
public const byte ConnAckAccepted = 0x00;
|
||||
public const byte ConnAckUnacceptableProtocolVersion = 0x01;
|
||||
public const byte ConnAckIdentifierRejected = 0x02;
|
||||
public const byte ConnAckServerUnavailable = 0x03;
|
||||
public const byte ConnAckBadUserNameOrPassword = 0x04;
|
||||
public const byte ConnAckNotAuthorized = 0x05;
|
||||
|
||||
// MQTT Remaining Length upper bound (max packet payload envelope).
|
||||
public const int MaxPayloadSize = 268_435_455;
|
||||
|
||||
// Go mqttDefaultAckWait: 30 seconds.
|
||||
public static readonly TimeSpan DefaultAckWait = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Go mqttMaxAckTotalLimit.
|
||||
public const int MaxAckTotalLimit = 0xFFFF;
|
||||
|
||||
// MQTT wildcard helper suffix attached to sid for synthetic "level-up" subscriptions.
|
||||
public const string MultiLevelSidSuffix = " fwc";
|
||||
|
||||
// Internal MQTT subject prefixes.
|
||||
public const string Prefix = "$MQTT.";
|
||||
public const string SubPrefix = Prefix + "sub.";
|
||||
|
||||
// Per-account stream names and subject prefixes.
|
||||
public const string StreamName = "$MQTT_msgs";
|
||||
public const string StreamSubjectPrefix = Prefix + "msgs.";
|
||||
public const string RetainedMsgsStreamName = "$MQTT_rmsgs";
|
||||
public const string RetainedMsgsStreamSubject = Prefix + "rmsgs.";
|
||||
public const string SessStreamName = "$MQTT_sess";
|
||||
public const string SessStreamSubjectPrefix = Prefix + "sess.";
|
||||
public const string SessionsStreamNamePrefix = "$MQTT_sess_";
|
||||
public const string QoS2IncomingMsgsStreamName = "$MQTT_qos2in";
|
||||
public const string QoS2IncomingMsgsStreamSubjectPrefix = Prefix + "qos2.in.";
|
||||
|
||||
// Outbound/PUBREL stream and subject identifiers.
|
||||
public const string OutStreamName = "$MQTT_out";
|
||||
public const string OutSubjectPrefix = Prefix + "out.";
|
||||
public const string PubRelSubjectPrefix = Prefix + "out.pubrel.";
|
||||
public const string PubRelDeliverySubjectPrefix = Prefix + "deliver.pubrel.";
|
||||
public const string PubRelConsumerDurablePrefix = "$MQTT_PUBREL_";
|
||||
|
||||
// JS API reply subject prefix and token positions.
|
||||
public const string JSARepliesPrefix = Prefix + "JSA.";
|
||||
public const int JSAIdTokenPos = 3;
|
||||
public const int JSATokenPos = 4;
|
||||
public const int JSAClientIDPos = 5;
|
||||
|
||||
// JS API token discriminators.
|
||||
public const string JSAStreamCreate = "SC";
|
||||
public const string JSAStreamUpdate = "SU";
|
||||
public const string JSAStreamLookup = "SL";
|
||||
public const string JSAStreamDel = "SD";
|
||||
public const string JSAConsumerCreate = "CC";
|
||||
public const string JSAConsumerLookup = "CL";
|
||||
public const string JSAConsumerDel = "CD";
|
||||
public const string JSAMsgStore = "MS";
|
||||
public const string JSAMsgLoad = "ML";
|
||||
public const string JSAMsgDelete = "MD";
|
||||
public const string JSASessPersist = "SP";
|
||||
public const string JSARetainedMsgDel = "RD";
|
||||
public const string JSAStreamNames = "SN";
|
||||
|
||||
// Sparkplug B constants.
|
||||
public const string SparkbNBirth = "NBIRTH";
|
||||
public const string SparkbDBirth = "DBIRTH";
|
||||
public const string SparkbNDeath = "NDEATH";
|
||||
public const string SparkbDDeath = "DDEATH";
|
||||
public static readonly byte[] SparkbNamespaceTopicPrefix = "spBv1.0/"u8.ToArray();
|
||||
public static readonly byte[] SparkbCertificatesTopicPrefix = "$sparkplug/certificates/"u8.ToArray();
|
||||
|
||||
// NATS headers used when re-encoding MQTT messages.
|
||||
public const string NatsHeaderPublish = "Nmqtt-Pub";
|
||||
public const string NatsRetainedMessageTopic = "Nmqtt-RTopic";
|
||||
public const string NatsRetainedMessageOrigin = "Nmqtt-ROrigin";
|
||||
public const string NatsRetainedMessageFlags = "Nmqtt-RFlags";
|
||||
public const string NatsRetainedMessageSource = "Nmqtt-RSource";
|
||||
public const string NatsPubRelHeader = "Nmqtt-PubRel";
|
||||
public const string NatsHeaderSubject = "Nmqtt-Subject";
|
||||
public const string NatsHeaderMapped = "Nmqtt-Mapped";
|
||||
}
|
||||
@@ -11,7 +11,12 @@ namespace NATS.Server.Mqtt;
|
||||
/// <summary>
|
||||
/// A retained message stored for a topic.
|
||||
/// </summary>
|
||||
public sealed record MqttRetainedMessage(string Topic, ReadOnlyMemory<byte> Payload);
|
||||
public sealed record MqttRetainedMessage(
|
||||
string Topic,
|
||||
ReadOnlyMemory<byte> Payload,
|
||||
string? Origin = null,
|
||||
byte Flags = 0,
|
||||
string? Source = null);
|
||||
|
||||
/// <summary>
|
||||
/// In-memory store for MQTT retained messages.
|
||||
|
||||
@@ -104,6 +104,7 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
private long _rtt;
|
||||
public TimeSpan Rtt => new(Interlocked.Read(ref _rtt));
|
||||
|
||||
public bool IsMqtt { get; set; }
|
||||
public bool IsWebSocket { get; set; }
|
||||
public WsUpgradeResult? WsInfo { get; set; }
|
||||
|
||||
@@ -137,6 +138,27 @@ public sealed class NatsClient : INatsClient, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public byte[]? GetNonce() => _nonce?.ToArray();
|
||||
|
||||
public string GetName() => ClientOpts?.Name ?? string.Empty;
|
||||
|
||||
public ClientConnectionType ClientType()
|
||||
{
|
||||
if (Kind != ClientKind.Client)
|
||||
return ClientConnectionType.NonClient;
|
||||
if (IsMqtt)
|
||||
return ClientConnectionType.Mqtt;
|
||||
if (IsWebSocket)
|
||||
return ClientConnectionType.WebSocket;
|
||||
return ClientConnectionType.Nats;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
var endpoint = RemoteIp is null ? "unknown" : $"{RemoteIp}:{RemotePort}";
|
||||
return $"{Kind} cid={Id} endpoint={endpoint}";
|
||||
}
|
||||
|
||||
public bool QueueOutbound(ReadOnlyMemory<byte> data)
|
||||
{
|
||||
if (_flags.HasFlag(ClientFlags.CloseConnection))
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Authentication;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Protocol;
|
||||
using NATS.Server.Tls;
|
||||
|
||||
namespace NATS.Server;
|
||||
|
||||
public sealed class NatsOptions
|
||||
{
|
||||
public string Host { get; set; } = "0.0.0.0";
|
||||
public int Port { get; set; } = 4222;
|
||||
private static bool _allowUnknownTopLevelFields;
|
||||
private string _configDigest = string.Empty;
|
||||
|
||||
public string Host { get; set; } = NatsProtocol.DefaultHost;
|
||||
public int Port { get; set; } = NatsProtocol.DefaultPort;
|
||||
public string? ServerName { get; set; }
|
||||
public int MaxPayload { get; set; } = 1024 * 1024;
|
||||
public int MaxControlLine { get; set; } = 4096;
|
||||
public int MaxConnections { get; set; } = 65536;
|
||||
public int MaxConnections { get; set; } = NatsProtocol.DefaultMaxConnections;
|
||||
public long MaxPending { get; set; } = 64 * 1024 * 1024; // 64MB, matching Go MAX_PENDING_SIZE
|
||||
public TimeSpan WriteDeadline { get; set; } = TimeSpan.FromSeconds(10);
|
||||
public TimeSpan PingInterval { get; set; } = TimeSpan.FromMinutes(2);
|
||||
public int MaxPingsOut { get; set; } = 2;
|
||||
public TimeSpan WriteDeadline { get; set; } = NatsProtocol.DefaultFlushDeadline;
|
||||
public TimeSpan PingInterval { get; set; } = NatsProtocol.DefaultPingInterval;
|
||||
public int MaxPingsOut { get; set; } = NatsProtocol.DefaultPingMaxOut;
|
||||
|
||||
// Subscription limits
|
||||
public int MaxSubs { get; set; } // 0 = unlimited (per-connection)
|
||||
@@ -45,7 +52,7 @@ public sealed class NatsOptions
|
||||
public Auth.ProxyAuthOptions? ProxyAuth { get; set; }
|
||||
|
||||
// Auth timing
|
||||
public TimeSpan AuthTimeout { get; set; } = TimeSpan.FromSeconds(2);
|
||||
public TimeSpan AuthTimeout { get; set; } = NatsProtocol.AuthTimeout;
|
||||
|
||||
// Monitoring (0 = disabled; standard port is 8222)
|
||||
public int MonitorPort { get; set; }
|
||||
@@ -55,8 +62,8 @@ public sealed class NatsOptions
|
||||
public int MonitorHttpsPort { get; set; }
|
||||
|
||||
// Lifecycle / lame-duck mode
|
||||
public TimeSpan LameDuckDuration { get; set; } = TimeSpan.FromMinutes(2);
|
||||
public TimeSpan LameDuckGracePeriod { get; set; } = TimeSpan.FromSeconds(10);
|
||||
public TimeSpan LameDuckDuration { get; set; } = NatsProtocol.DefaultLameDuckDuration;
|
||||
public TimeSpan LameDuckGracePeriod { get; set; } = NatsProtocol.DefaultLameDuckGracePeriod;
|
||||
|
||||
// File paths
|
||||
public string? PidFile { get; set; }
|
||||
@@ -82,10 +89,10 @@ public sealed class NatsOptions
|
||||
public bool TraceVerbose { get; set; }
|
||||
public int MaxTracedMsgLen { get; set; }
|
||||
public bool DisableSublistCache { get; set; }
|
||||
public int ConnectErrorReports { get; set; } = 3600;
|
||||
public int ReconnectErrorReports { get; set; } = 1;
|
||||
public int ConnectErrorReports { get; set; } = NatsProtocol.DefaultConnectErrorReports;
|
||||
public int ReconnectErrorReports { get; set; } = NatsProtocol.DefaultReconnectErrorReports;
|
||||
public bool NoHeaderSupport { get; set; }
|
||||
public int MaxClosedClients { get; set; } = 10_000;
|
||||
public int MaxClosedClients { get; set; } = NatsProtocol.DefaultMaxClosedClients;
|
||||
public bool NoSystemAccount { get; set; }
|
||||
public string? SystemAccount { get; set; }
|
||||
|
||||
@@ -98,9 +105,9 @@ public sealed class NatsOptions
|
||||
public string? TlsCaCert { get; set; }
|
||||
public bool TlsVerify { get; set; }
|
||||
public bool TlsMap { get; set; }
|
||||
public TimeSpan TlsTimeout { get; set; } = TimeSpan.FromSeconds(2);
|
||||
public TimeSpan TlsTimeout { get; set; } = NatsProtocol.TlsTimeout;
|
||||
public bool TlsHandshakeFirst { get; set; }
|
||||
public TimeSpan TlsHandshakeFirstFallback { get; set; } = TimeSpan.FromMilliseconds(50);
|
||||
public TimeSpan TlsHandshakeFirstFallback { get; set; } = NatsProtocol.DefaultTlsHandshakeFirstFallbackDelay;
|
||||
public bool AllowNonTls { get; set; }
|
||||
public long TlsRateLimit { get; set; }
|
||||
public HashSet<string>? TlsPinnedCerts { get; set; }
|
||||
@@ -133,6 +140,141 @@ public sealed class NatsOptions
|
||||
|
||||
// WebSocket
|
||||
public WebSocketOptions WebSocket { get; set; } = new();
|
||||
|
||||
public static void NoErrOnUnknownFields(bool noError)
|
||||
{
|
||||
_allowUnknownTopLevelFields = noError;
|
||||
}
|
||||
|
||||
internal static bool AllowUnknownTopLevelFields => _allowUnknownTopLevelFields;
|
||||
|
||||
public static List<Uri> RoutesFromStr(string routesStr)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(routesStr))
|
||||
return [];
|
||||
|
||||
var routes = new List<Uri>();
|
||||
foreach (var route in routesStr.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (Uri.TryCreate(route, UriKind.Absolute, out var uri))
|
||||
routes.Add(uri);
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
public NatsOptions Clone()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(this);
|
||||
var clone = JsonSerializer.Deserialize<NatsOptions>(json) ?? new NatsOptions();
|
||||
clone.InCmdLine.Clear();
|
||||
foreach (var flag in InCmdLine)
|
||||
clone.InCmdLine.Add(flag);
|
||||
if (TlsPinnedCerts != null)
|
||||
clone.TlsPinnedCerts = [.. TlsPinnedCerts];
|
||||
return clone;
|
||||
}
|
||||
catch
|
||||
{
|
||||
var clone = new NatsOptions();
|
||||
CopyFrom(clone, this);
|
||||
clone.InCmdLine.Clear();
|
||||
foreach (var flag in InCmdLine)
|
||||
clone.InCmdLine.Add(flag);
|
||||
if (Tags != null)
|
||||
clone.Tags = new Dictionary<string, string>(Tags);
|
||||
if (SubjectMappings != null)
|
||||
clone.SubjectMappings = new Dictionary<string, string>(SubjectMappings);
|
||||
if (TlsPinnedCerts != null)
|
||||
clone.TlsPinnedCerts = [.. TlsPinnedCerts];
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
public void ProcessConfigString(string data)
|
||||
{
|
||||
var parsed = ConfigProcessor.ProcessConfig(data);
|
||||
CopyFrom(this, parsed);
|
||||
_configDigest = ComputeDigest(data);
|
||||
}
|
||||
|
||||
public string ConfigDigest() => _configDigest;
|
||||
|
||||
private static void CopyFrom(NatsOptions destination, NatsOptions source)
|
||||
{
|
||||
foreach (var prop in typeof(NatsOptions).GetProperties())
|
||||
{
|
||||
if (!prop.CanRead || !prop.CanWrite)
|
||||
continue;
|
||||
prop.SetValue(destination, prop.GetValue(source));
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeDigest(string text)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(text));
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class JSLimitOpts
|
||||
{
|
||||
public int MaxRequestBatch { get; set; }
|
||||
public int MaxAckPending { get; set; }
|
||||
public int MaxHAAssets { get; set; }
|
||||
public TimeSpan Duplicates { get; set; }
|
||||
public int MaxBatchInflightPerStream { get; set; }
|
||||
public int MaxBatchInflightTotal { get; set; }
|
||||
public int MaxBatchSize { get; set; }
|
||||
public TimeSpan MaxBatchTimeout { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuthCallout
|
||||
{
|
||||
public string Issuer { get; set; } = string.Empty;
|
||||
public string Account { get; set; } = string.Empty;
|
||||
public List<string> AuthUsers { get; set; } = [];
|
||||
public string XKey { get; set; } = string.Empty;
|
||||
public List<string> AllowedAccounts { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ProxiesConfig
|
||||
{
|
||||
public List<ProxyConfig> Trusted { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ProxyConfig
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class Ports
|
||||
{
|
||||
public List<string> Nats { get; set; } = [];
|
||||
public List<string> Monitoring { get; set; } = [];
|
||||
public List<string> Cluster { get; set; } = [];
|
||||
public List<string> Profile { get; set; } = [];
|
||||
public List<string> WebSocket { get; set; } = [];
|
||||
public List<string> LeafNodes { get; set; } = [];
|
||||
}
|
||||
|
||||
public static class CompressionModes
|
||||
{
|
||||
public const string Off = "off";
|
||||
public const string Accept = "accept";
|
||||
public const string S2Fast = "s2_fast";
|
||||
public const string S2Better = "s2_better";
|
||||
public const string S2Best = "s2_best";
|
||||
public const string S2Uncompressed = "s2_uncompressed";
|
||||
public const string S2Auto = "s2_auto";
|
||||
}
|
||||
|
||||
public sealed class CompressionOpts
|
||||
{
|
||||
public string Mode { get; set; } = CompressionModes.Off;
|
||||
public List<int> RTTThresholds { get; set; } = [10, 50, 100, 250];
|
||||
}
|
||||
|
||||
public sealed class WebSocketOptions
|
||||
@@ -158,4 +300,8 @@ public sealed class WebSocketOptions
|
||||
public TimeSpan HandshakeTimeout { get; set; } = TimeSpan.FromSeconds(2);
|
||||
public TimeSpan? PingInterval { get; set; }
|
||||
public Dictionary<string, string>? Headers { get; set; }
|
||||
|
||||
// Go websocket.go srvWebsocket.authOverride parity bit:
|
||||
// true when websocket auth options override top-level auth config.
|
||||
public bool AuthOverride { get; internal set; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Security;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
@@ -9,6 +10,7 @@ using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NATS.NKeys;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Auth.Jwt;
|
||||
using NATS.Server.Configuration;
|
||||
using NATS.Server.Events;
|
||||
using NATS.Server.Gateways;
|
||||
@@ -61,6 +63,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
/// via InternalsVisibleTo.
|
||||
/// </summary>
|
||||
internal RouteManager? RouteManager => _routeManager;
|
||||
internal GatewayManager? GatewayManager => _gatewayManager;
|
||||
private readonly GatewayManager? _gatewayManager;
|
||||
private readonly LeafNodeManager? _leafNodeManager;
|
||||
private readonly InternalClient? _jetStreamInternalClient;
|
||||
@@ -90,9 +93,15 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
private readonly List<PosixSignalRegistration> _signalRegistrations = [];
|
||||
|
||||
private string? _portsFilePath;
|
||||
private DateTime _configTime = DateTime.UtcNow;
|
||||
|
||||
private static readonly TimeSpan AcceptMinSleep = TimeSpan.FromMilliseconds(10);
|
||||
private static readonly TimeSpan AcceptMaxSleep = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan AcceptMinSleep = NatsProtocol.AcceptMinSleep;
|
||||
private static readonly TimeSpan AcceptMaxSleep = NatsProtocol.AcceptMaxSleep;
|
||||
private static readonly JsonSerializerOptions s_jetStreamJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public SubList SubList => _globalAccount.SubList;
|
||||
public byte[] CachedInfoLine => _cachedInfoLine;
|
||||
@@ -117,6 +126,152 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
public int JetStreamConsumers => _jetStreamConsumerManager?.ConsumerCount ?? 0;
|
||||
public Action? ReOpenLogFile { get; set; }
|
||||
public IEnumerable<NatsClient> GetClients() => _clients.Values;
|
||||
public string? ClusterName() => _options.Cluster?.Name;
|
||||
public IReadOnlyList<string> ActivePeers()
|
||||
=> _routeManager?.BuildTopologySnapshot().ConnectedServerIds ?? [];
|
||||
|
||||
public bool StartProfiler()
|
||||
{
|
||||
if (_options.ProfPort <= 0)
|
||||
return false;
|
||||
|
||||
_logger.LogWarning("Profiling endpoint not yet supported (port: {ProfPort})", _options.ProfPort);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool DisconnectClientByID(ulong clientId)
|
||||
=> CloseClientById(clientId, minimalFlush: true);
|
||||
|
||||
public bool LDMClientByID(ulong clientId)
|
||||
=> CloseClientById(clientId, minimalFlush: false);
|
||||
|
||||
public Ports PortsInfo()
|
||||
{
|
||||
var ports = new Ports();
|
||||
|
||||
AddEndpoint(ports.Nats, _options.Host, _options.Port);
|
||||
AddEndpoint(ports.Monitoring, _options.MonitorHost, _options.MonitorPort);
|
||||
|
||||
if (_routeManager != null)
|
||||
AddEndpoint(ports.Cluster, _routeManager.ListenEndpoint);
|
||||
else if (_options.Cluster != null)
|
||||
AddEndpoint(ports.Cluster, _options.Cluster.Host, _options.Cluster.Port);
|
||||
|
||||
AddEndpoint(ports.Profile, _options.Host, _options.ProfPort);
|
||||
|
||||
if (_options.WebSocket.Port >= 0)
|
||||
AddEndpoint(ports.WebSocket, _options.WebSocket.Host, _options.WebSocket.Port);
|
||||
|
||||
if (_leafNodeManager != null)
|
||||
AddEndpoint(ports.LeafNodes, _leafNodeManager.ListenEndpoint);
|
||||
else if (_options.LeafNode != null)
|
||||
AddEndpoint(ports.LeafNodes, _options.LeafNode.Host, _options.LeafNode.Port);
|
||||
|
||||
return ports;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetConnectURLs()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ClientAdvertise))
|
||||
return [NormalizeAdvertiseUrl(_options.ClientAdvertise!, "nats")];
|
||||
|
||||
var hosts = GetNonLocalIPsIfHostIsIPAny(_options.Host);
|
||||
var result = new List<string>(hosts.Count);
|
||||
foreach (var host in hosts)
|
||||
result.Add($"nats://{host}:{_options.Port}");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public void UpdateServerINFOAndSendINFOToClients()
|
||||
{
|
||||
_serverInfo.ConnectUrls = [.. GetConnectURLs()];
|
||||
BuildCachedInfo();
|
||||
|
||||
foreach (var client in _clients.Values)
|
||||
{
|
||||
if (client.ConnectReceived)
|
||||
client.QueueOutbound(_cachedInfoLine);
|
||||
}
|
||||
}
|
||||
|
||||
public string ClientURL()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.ClientAdvertise))
|
||||
return NormalizeAdvertiseUrl(_options.ClientAdvertise!, "nats");
|
||||
|
||||
var host = IsWildcardHost(_options.Host) ? "127.0.0.1" : _options.Host;
|
||||
return $"nats://{host}:{_options.Port}";
|
||||
}
|
||||
|
||||
public string? WebsocketURL()
|
||||
{
|
||||
if (_options.WebSocket.Port < 0)
|
||||
return null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.WebSocket.Advertise))
|
||||
{
|
||||
var scheme = _options.WebSocket.NoTls ? "ws" : "wss";
|
||||
return NormalizeAdvertiseUrl(_options.WebSocket.Advertise!, scheme);
|
||||
}
|
||||
|
||||
var wsHost = IsWildcardHost(_options.WebSocket.Host) ? "127.0.0.1" : _options.WebSocket.Host;
|
||||
var wsScheme = _options.WebSocket.NoTls ? "ws" : "wss";
|
||||
return $"{wsScheme}://{wsHost}:{_options.WebSocket.Port}";
|
||||
}
|
||||
|
||||
public int NumRoutes() => (int)Interlocked.Read(ref _stats.Routes);
|
||||
|
||||
public int NumRemotes()
|
||||
=> (int)(Interlocked.Read(ref _stats.Routes) + Interlocked.Read(ref _stats.Gateways) + Interlocked.Read(ref _stats.Leafs));
|
||||
|
||||
public int NumLeafNodes() => (int)Interlocked.Read(ref _stats.Leafs);
|
||||
public int NumOutboundGateways() => _gatewayManager?.NumOutboundGateways() ?? 0;
|
||||
public int NumInboundGateways() => _gatewayManager?.NumInboundGateways() ?? 0;
|
||||
|
||||
public int NumSubscriptions() => _accounts.Values.Sum(acc => acc.SubscriptionCount);
|
||||
public bool JetStreamEnabled() => _jetStreamService?.IsRunning ?? false;
|
||||
|
||||
public JetStreamOptions? JetStreamConfig()
|
||||
{
|
||||
if (_options.JetStream is null)
|
||||
return null;
|
||||
|
||||
return new JetStreamOptions
|
||||
{
|
||||
StoreDir = _options.JetStream.StoreDir,
|
||||
MaxMemoryStore = _options.JetStream.MaxMemoryStore,
|
||||
MaxFileStore = _options.JetStream.MaxFileStore,
|
||||
MaxStreams = _options.JetStream.MaxStreams,
|
||||
MaxConsumers = _options.JetStream.MaxConsumers,
|
||||
Domain = _options.JetStream.Domain,
|
||||
};
|
||||
}
|
||||
|
||||
public string StoreDir() => _options.JetStream?.StoreDir ?? string.Empty;
|
||||
|
||||
public DateTime ConfigTime() => _configTime;
|
||||
|
||||
public string Addr() => $"{_options.Host}:{_options.Port}";
|
||||
|
||||
public string? MonitorAddr()
|
||||
=> _options.MonitorPort > 0
|
||||
? $"{_options.MonitorHost}:{_options.MonitorPort}"
|
||||
: null;
|
||||
|
||||
public string? ClusterAddr() => _routeManager?.ListenEndpoint;
|
||||
public string? GatewayAddr() => _gatewayManager?.ListenEndpoint;
|
||||
public string? GetGatewayURL() => _gatewayManager?.ListenEndpoint;
|
||||
public string? GetGatewayName() => _options.Gateway?.Name;
|
||||
|
||||
public string? ProfilerAddr()
|
||||
=> _options.ProfPort > 0
|
||||
? $"{_options.Host}:{_options.ProfPort}"
|
||||
: null;
|
||||
|
||||
public int NumActiveAccounts() => _accounts.Values.Count(acc => acc.ClientCount > 0);
|
||||
|
||||
public int NumLoadedAccounts() => _accounts.Count;
|
||||
|
||||
public IReadOnlyList<ClosedClient> GetClosedClients() => _closedClients.GetAll();
|
||||
|
||||
@@ -402,6 +557,8 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_routeManager = new RouteManager(options.Cluster, _stats, _serverInfo.ServerId, ApplyRemoteSubscription,
|
||||
ProcessRoutedMessage,
|
||||
_loggerFactory.CreateLogger<RouteManager>());
|
||||
_routeManager.OnRouteRemoved += RemoveRemoteSubscriptionsForRoute;
|
||||
_routeManager.OnRouteAccountRemoved += RemoveRemoteSubscriptionsForRouteAccount;
|
||||
}
|
||||
|
||||
if (options.Gateway != null)
|
||||
@@ -485,6 +642,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
{
|
||||
var (_, digest) = NatsConfParser.ParseFileWithDigest(options.ConfigFile);
|
||||
_configDigest = digest;
|
||||
_configTime = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -499,6 +657,79 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_cachedInfoLine = Encoding.ASCII.GetBytes($"INFO {infoJson}\r\n");
|
||||
}
|
||||
|
||||
private static string NormalizeAdvertiseUrl(string advertise, string defaultScheme)
|
||||
{
|
||||
if (advertise.Contains("://", StringComparison.Ordinal))
|
||||
return advertise;
|
||||
|
||||
return $"{defaultScheme}://{advertise}";
|
||||
}
|
||||
|
||||
private static bool IsWildcardHost(string host)
|
||||
=> host == "0.0.0.0" || host == "::";
|
||||
|
||||
internal static IReadOnlyList<string> GetNonLocalIPsIfHostIsIPAny(string host)
|
||||
{
|
||||
if (!IsWildcardHost(host))
|
||||
return [host];
|
||||
|
||||
var addresses = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var netIf in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (netIf.OperationalStatus != OperationalStatus.Up)
|
||||
continue;
|
||||
|
||||
IPInterfaceProperties? props;
|
||||
try
|
||||
{
|
||||
props = netIf.GetIPProperties();
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var uni in props.UnicastAddresses)
|
||||
{
|
||||
var addr = uni.Address;
|
||||
if (IPAddress.IsLoopback(addr) || addr.IsIPv6LinkLocal || addr.IsIPv6Multicast)
|
||||
continue;
|
||||
if (addr.AddressFamily is not (AddressFamily.InterNetwork or AddressFamily.InterNetworkV6))
|
||||
continue;
|
||||
addresses.Add(addr.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
if (addresses.Count == 0)
|
||||
addresses.Add("127.0.0.1");
|
||||
|
||||
return [.. addresses.OrderBy(static a => a, StringComparer.Ordinal)];
|
||||
}
|
||||
|
||||
private bool CloseClientById(ulong clientId, bool minimalFlush)
|
||||
{
|
||||
if (!_clients.TryGetValue(clientId, out var client))
|
||||
return false;
|
||||
|
||||
client.MarkClosed(ClientClosedReason.ServerShutdown);
|
||||
_ = client.FlushAndCloseAsync(minimalFlush);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void AddEndpoint(List<string> targets, string? host, int port)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host) || port <= 0)
|
||||
return;
|
||||
|
||||
targets.Add($"{host}:{port}");
|
||||
}
|
||||
|
||||
private static void AddEndpoint(List<string> targets, string? endpoint)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(endpoint))
|
||||
targets.Add(endpoint);
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _quitCts.Token);
|
||||
@@ -523,8 +754,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_logger.LogInformation("Listening for client connections on {Host}:{Port}", _options.Host, _options.Port);
|
||||
|
||||
// Warn about stub features
|
||||
if (_options.ProfPort > 0)
|
||||
_logger.LogWarning("Profiling endpoint not yet supported (port: {ProfPort})", _options.ProfPort);
|
||||
StartProfiler();
|
||||
|
||||
if (_options.MonitorPort > 0)
|
||||
{
|
||||
@@ -535,6 +765,11 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
WritePidFile();
|
||||
WritePortsFile();
|
||||
|
||||
WsAuthConfig.Apply(_options.WebSocket);
|
||||
var wsValidation = WebSocketOptionsValidator.Validate(_options);
|
||||
if (!wsValidation.IsValid)
|
||||
throw new InvalidOperationException($"Invalid websocket options: {string.Join("; ", wsValidation.Errors)}");
|
||||
|
||||
if (_options.WebSocket.Port >= 0)
|
||||
{
|
||||
_wsListener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
@@ -728,6 +963,14 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (client is null)
|
||||
{
|
||||
var earlyReason = _options.HasTls
|
||||
? ClientClosedReason.TlsHandshakeError
|
||||
: ClientClosedReason.ReadError;
|
||||
TrackEarlyClosedClient(socket, clientId, earlyReason);
|
||||
}
|
||||
|
||||
_logger.LogDebug(ex, "Failed to accept client {ClientId}", clientId);
|
||||
try { socket.Shutdown(SocketShutdown.Both); } catch { }
|
||||
socket.Dispose();
|
||||
@@ -887,6 +1130,18 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
account.SubList.ApplyRemoteSub(sub);
|
||||
}
|
||||
|
||||
private void RemoveRemoteSubscriptionsForRoute(string routeId)
|
||||
{
|
||||
foreach (var account in _accounts.Values)
|
||||
account.SubList.RemoveRemoteSubs(routeId);
|
||||
}
|
||||
|
||||
private void RemoveRemoteSubscriptionsForRouteAccount(string routeId, string accountName)
|
||||
{
|
||||
if (_accounts.TryGetValue(accountName, out var account))
|
||||
account.SubList.RemoveRemoteSubsForAccount(routeId, accountName);
|
||||
}
|
||||
|
||||
private void ProcessRoutedMessage(RouteMessage message)
|
||||
{
|
||||
DeliverRemoteMessage(message.Account, message.Subject, message.ReplyTo, message.Payload);
|
||||
@@ -942,19 +1197,42 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
&& subject.StartsWith("$JS.API", StringComparison.Ordinal)
|
||||
&& _jetStreamApiRouter != null)
|
||||
{
|
||||
// Pull consumer MSG.NEXT requires special handling: deliver individual
|
||||
// HMSG messages to the client's reply inbox instead of a single JSON blob.
|
||||
// Go reference: consumer.go:4276 processNextMsgRequest
|
||||
if (subject.StartsWith(JetStream.Api.JetStreamApiSubjects.ConsumerNext, StringComparison.Ordinal)
|
||||
&& _jetStreamConsumerManager != null
|
||||
&& _jetStreamStreamManager != null)
|
||||
{
|
||||
Interlocked.Increment(ref _stats.JetStreamApiTotal);
|
||||
DeliverPullFetchMessages(subject, replyTo, payload, sender);
|
||||
return;
|
||||
}
|
||||
|
||||
var response = _jetStreamApiRouter.Route(subject, payload.Span);
|
||||
Interlocked.Increment(ref _stats.JetStreamApiTotal);
|
||||
if (response.Error != null)
|
||||
Interlocked.Increment(ref _stats.JetStreamApiErrors);
|
||||
|
||||
var data = JsonSerializer.SerializeToUtf8Bytes(response);
|
||||
var data = JsonSerializer.SerializeToUtf8Bytes(response.ToWireFormat(), s_jetStreamJsonOptions);
|
||||
ProcessMessage(replyTo, null, default, data, sender);
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryCaptureJetStreamPublish(subject, payload, out var pubAck))
|
||||
{
|
||||
sender.RecordJetStreamPubAck(pubAck);
|
||||
|
||||
// Send pub ack response to the reply subject (request-reply pattern).
|
||||
// Go reference: server/jetstream.go — jsPubAckResponse sent to reply.
|
||||
if (replyTo != null)
|
||||
{
|
||||
var ackData = JsonSerializer.SerializeToUtf8Bytes(pubAck, s_jetStreamJsonOptions);
|
||||
ProcessMessage(replyTo, null, default, ackData, sender);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply subject transforms
|
||||
if (_subjectTransforms.Length > 0)
|
||||
{
|
||||
@@ -1049,6 +1327,94 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles $JS.API.CONSUMER.MSG.NEXT by delivering individual HMSG messages
|
||||
/// to the client's reply inbox. Go reference: consumer.go:4276 processNextMsgRequest.
|
||||
/// </summary>
|
||||
private void DeliverPullFetchMessages(string subject, string replyTo, ReadOnlyMemory<byte> payload, NatsClient sender)
|
||||
{
|
||||
var prefix = JetStream.Api.JetStreamApiSubjects.ConsumerNext;
|
||||
var remainder = subject[prefix.Length..];
|
||||
var split = remainder.Split('.', 2, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (split.Length != 2)
|
||||
{
|
||||
var notFoundHeader = System.Text.Encoding.UTF8.GetBytes("NATS/1.0 404 No Messages\r\n\r\n");
|
||||
ProcessMessage(replyTo, null, (ReadOnlyMemory<byte>)notFoundHeader, default, sender);
|
||||
return;
|
||||
}
|
||||
|
||||
var (streamName, consumerName) = (split[0], split[1]);
|
||||
|
||||
// Parse batch request
|
||||
int batch = 1;
|
||||
int expiresMs = 0;
|
||||
bool noWait = false;
|
||||
if (payload.Length > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(payload);
|
||||
if (doc.RootElement.TryGetProperty("batch", out var batchEl) && batchEl.TryGetInt32(out var b))
|
||||
batch = Math.Max(b, 1);
|
||||
if (doc.RootElement.TryGetProperty("no_wait", out var nwEl) && nwEl.ValueKind == System.Text.Json.JsonValueKind.True)
|
||||
noWait = true;
|
||||
if (doc.RootElement.TryGetProperty("expires", out var expEl) && expEl.TryGetInt64(out var expNs))
|
||||
expiresMs = (int)(expNs / 1_000_000);
|
||||
}
|
||||
catch (System.Text.Json.JsonException ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Malformed JSON in pull request payload, using defaults");
|
||||
}
|
||||
}
|
||||
|
||||
var fetchResult = _jetStreamConsumerManager!.FetchAsync(
|
||||
streamName, consumerName, new JetStream.Consumers.PullFetchRequest { Batch = batch, NoWait = noWait, ExpiresMs = expiresMs },
|
||||
_jetStreamStreamManager!, default).GetAwaiter().GetResult();
|
||||
|
||||
// Find the sender's inbox subscription so we can deliver directly.
|
||||
// Go reference: consumer.go deliverMsg — delivers directly to the client, bypassing pub/sub echo checks.
|
||||
var subList = sender.Account?.SubList ?? _globalAccount.SubList;
|
||||
var matchResult = subList.Match(replyTo);
|
||||
Subscription? inboxSub = null;
|
||||
foreach (var sub in matchResult.PlainSubs)
|
||||
{
|
||||
if (sub.Client == sender)
|
||||
{
|
||||
inboxSub = sub;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (inboxSub == null)
|
||||
return;
|
||||
|
||||
ReadOnlyMemory<byte> minHeaders = "NATS/1.0\r\n\r\n"u8.ToArray();
|
||||
int deliverySeq = 0;
|
||||
int numPending = fetchResult.Messages.Count;
|
||||
|
||||
foreach (var msg in fetchResult.Messages)
|
||||
{
|
||||
deliverySeq++;
|
||||
numPending--;
|
||||
|
||||
var tsNanos = new DateTimeOffset(msg.TimestampUtc).ToUnixTimeMilliseconds() * 1_000_000L;
|
||||
var ackReply = $"$JS.ACK.{streamName}.{consumerName}.1.{msg.Sequence}.{deliverySeq}.{tsNanos}.{numPending}";
|
||||
|
||||
// Send with the ORIGINAL stream subject (not the inbox) so the NATS client
|
||||
// can distinguish data messages from control/status messages.
|
||||
// Go reference: consumer.go deliverMsg — uses original subject on wire, inbox SID.
|
||||
DeliverMessage(inboxSub, msg.Subject, ackReply, minHeaders, msg.Payload);
|
||||
}
|
||||
|
||||
// Send terminal status to end the fetch
|
||||
ReadOnlyMemory<byte> statusHeader;
|
||||
if (fetchResult.Messages.Count == 0 || noWait)
|
||||
statusHeader = System.Text.Encoding.UTF8.GetBytes("NATS/1.0 404 No Messages\r\n\r\n");
|
||||
else
|
||||
statusHeader = System.Text.Encoding.UTF8.GetBytes("NATS/1.0 408 Request Timeout\r\n\r\n");
|
||||
DeliverMessage(inboxSub, replyTo, null, statusHeader, default);
|
||||
}
|
||||
|
||||
private void DeliverMessage(Subscription sub, string subject, string? replyTo,
|
||||
ReadOnlyMemory<byte> headers, ReadOnlyMemory<byte> payload)
|
||||
{
|
||||
@@ -1510,6 +1876,11 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_clients.TryRemove(client.Id, out _);
|
||||
_logger.LogDebug("Removed client {ClientId}", client.Id);
|
||||
|
||||
var (tlsPeerCertSubject, tlsPeerCertSubjectPkSha256, tlsPeerCertSha256) =
|
||||
TlsPeerCertMapper.ToClosedFields(client.TlsState?.PeerCert);
|
||||
var (jwt, issuerKey, tags) = ExtractJwtMetadata(client.ClientOpts?.JWT);
|
||||
var proxyKey = ExtractProxyKey(client.ClientOpts?.Username);
|
||||
|
||||
// Snapshot for closed-connections tracking (ring buffer auto-overwrites oldest when full)
|
||||
_closedClients.Add(new ClosedClient
|
||||
{
|
||||
@@ -1532,11 +1903,16 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
Rtt = client.Rtt,
|
||||
TlsVersion = client.TlsState?.TlsVersion ?? "",
|
||||
TlsCipherSuite = client.TlsState?.CipherSuite ?? "",
|
||||
TlsPeerCertSubject = client.TlsState?.PeerCert?.Subject ?? "",
|
||||
TlsPeerCertSubject = tlsPeerCertSubject,
|
||||
TlsPeerCertSubjectPkSha256 = tlsPeerCertSubjectPkSha256,
|
||||
TlsPeerCertSha256 = tlsPeerCertSha256,
|
||||
MqttClient = "", // populated when MQTT transport is implemented
|
||||
JwtIssuerKey = string.IsNullOrEmpty(client.ClientOpts?.JWT) ? "" : "present",
|
||||
JwtTags = "",
|
||||
Proxy = client.ClientOpts?.Username?.StartsWith("proxy:", StringComparison.Ordinal) == true ? "true" : "",
|
||||
Stalls = 0,
|
||||
Jwt = jwt,
|
||||
IssuerKey = issuerKey,
|
||||
NameTag = "",
|
||||
Tags = tags,
|
||||
ProxyKey = proxyKey,
|
||||
});
|
||||
|
||||
var subList = client.Account?.SubList ?? _globalAccount.SubList;
|
||||
@@ -1544,6 +1920,58 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
client.Account?.RemoveClient(client.Id);
|
||||
}
|
||||
|
||||
private void TrackEarlyClosedClient(Socket socket, ulong clientId, ClientClosedReason reason)
|
||||
{
|
||||
string ip = "";
|
||||
int port = 0;
|
||||
|
||||
if (socket.RemoteEndPoint is IPEndPoint endpoint)
|
||||
{
|
||||
ip = endpoint.Address.ToString();
|
||||
port = endpoint.Port;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
_closedClients.Add(new ClosedClient
|
||||
{
|
||||
Cid = clientId,
|
||||
Ip = ip,
|
||||
Port = port,
|
||||
Start = now,
|
||||
Stop = now,
|
||||
Reason = reason.ToReasonString(),
|
||||
});
|
||||
}
|
||||
|
||||
private static (string Jwt, string IssuerKey, string[] Tags) ExtractJwtMetadata(string? jwt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jwt))
|
||||
return ("", "", []);
|
||||
|
||||
var issuerKey = "";
|
||||
var tags = Array.Empty<string>();
|
||||
|
||||
var claims = NatsJwt.DecodeUserClaims(jwt);
|
||||
if (claims != null)
|
||||
{
|
||||
issuerKey = claims.Issuer ?? "";
|
||||
tags = claims.Nats?.Tags ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
return (jwt, issuerKey, tags);
|
||||
}
|
||||
|
||||
private static string ExtractProxyKey(string? username)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return "";
|
||||
|
||||
const string prefix = "proxy:";
|
||||
return username.StartsWith(prefix, StringComparison.Ordinal)
|
||||
? username[prefix.Length..]
|
||||
: "";
|
||||
}
|
||||
|
||||
private void WritePidFile()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_options.PidFile)) return;
|
||||
@@ -1670,6 +2098,7 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
// Apply changes to running options
|
||||
ApplyConfigChanges(changes, newOpts);
|
||||
_configDigest = digest;
|
||||
_configTime = DateTime.UtcNow;
|
||||
_logger.LogInformation("Config reloaded successfully ({Count} changes applied)", changes.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1859,6 +2288,9 @@ public sealed class NatsServer : IMessageRouter, ISubListAccess, IDisposable
|
||||
_options.SystemAccount = newOpts.SystemAccount;
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
=> $"NatsServer(ServerId={ServerId}, Name={ServerName}, Addr={Addr()}, Clients={ClientCount})";
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!IsShuttingDown)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Buffers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace NATS.Server.Protocol;
|
||||
@@ -80,7 +81,14 @@ public sealed class NatsParser
|
||||
|
||||
// Control line size check
|
||||
if (line.Length > NatsProtocol.MaxControlLineSize)
|
||||
throw new ProtocolViolationException("Maximum control line exceeded");
|
||||
{
|
||||
var snippetLength = (int)Math.Min(line.Length, NatsProtocol.MaxControlLineSnippetSize);
|
||||
var snippetBytes = new byte[snippetLength];
|
||||
line.Slice(0, snippetLength).CopyTo(snippetBytes);
|
||||
var snippet = ProtoSnippet(0, NatsProtocol.MaxControlLineSnippetSize, snippetBytes);
|
||||
throw new ProtocolViolationException(
|
||||
$"Maximum control line exceeded (max={NatsProtocol.MaxControlLineSize}, len={line.Length}, snip={snippet}...)");
|
||||
}
|
||||
|
||||
// Get line as contiguous span
|
||||
Span<byte> lineSpan = stackalloc byte[(int)line.Length];
|
||||
@@ -95,7 +103,7 @@ public sealed class NatsParser
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new ProtocolViolationException("Unknown protocol operation");
|
||||
throw new ProtocolViolationException($"Unknown protocol operation: {ProtoSnippet(lineSpan)}");
|
||||
}
|
||||
|
||||
byte b0 = (byte)(lineSpan[0] | 0x20); // lowercase
|
||||
@@ -192,9 +200,29 @@ public sealed class NatsParser
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new ProtocolViolationException("Unknown protocol operation");
|
||||
throw new ProtocolViolationException($"Unknown protocol operation: {ProtoSnippet(lineSpan)}");
|
||||
}
|
||||
|
||||
// Go reference: parser.go protoSnippet(start, max, buf).
|
||||
internal static string ProtoSnippet(int start, int max, ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
if (start >= buffer.Length)
|
||||
return "\"\"";
|
||||
|
||||
var stop = start + max;
|
||||
if (stop > buffer.Length)
|
||||
stop = buffer.Length - 1;
|
||||
|
||||
if (stop <= start)
|
||||
return "\"\"";
|
||||
|
||||
var slice = buffer[start..stop];
|
||||
return JsonSerializer.Serialize(Encoding.ASCII.GetString(slice));
|
||||
}
|
||||
|
||||
internal static string ProtoSnippet(ReadOnlySpan<byte> buffer) =>
|
||||
ProtoSnippet(0, NatsProtocol.ProtoSnippetSize, buffer);
|
||||
|
||||
private bool ParsePub(
|
||||
Span<byte> line,
|
||||
ref ReadOnlySequence<byte> buffer,
|
||||
|
||||
@@ -5,9 +5,46 @@ namespace NATS.Server.Protocol;
|
||||
public static class NatsProtocol
|
||||
{
|
||||
public const int MaxControlLineSize = 4096;
|
||||
public const int MaxControlLineSnippetSize = 128;
|
||||
public const int ProtoSnippetSize = 32;
|
||||
public const int MaxPayloadSize = 1024 * 1024; // 1MB
|
||||
public const int MaxPayloadMaxSize = 8 * 1024 * 1024; // 8MB
|
||||
public const long MaxPendingSize = 64 * 1024 * 1024; // 64MB default max pending
|
||||
public const string DefaultHost = "0.0.0.0";
|
||||
public const int DefaultPort = 4222;
|
||||
public const int DefaultHttpPort = 8222;
|
||||
public const string DefaultHttpBasePath = "/";
|
||||
public const int DefaultRoutePoolSize = 3;
|
||||
public const int DefaultLeafNodePort = 7422;
|
||||
public const int DefaultMaxConnections = 64 * 1024;
|
||||
public const int DefaultPingMaxOut = 2;
|
||||
public const int DefaultMaxClosedClients = 10_000;
|
||||
public const int DefaultConnectErrorReports = 3600;
|
||||
public const int DefaultReconnectErrorReports = 1;
|
||||
public const int DefaultAllowResponseMaxMsgs = 1;
|
||||
public const int DefaultServiceLatencySampling = 100;
|
||||
public const string DefaultSystemAccount = "$SYS";
|
||||
public const string DefaultGlobalAccount = "$G";
|
||||
public static readonly TimeSpan TlsTimeout = TimeSpan.FromSeconds(2);
|
||||
public static readonly TimeSpan DefaultTlsHandshakeFirstFallbackDelay = TimeSpan.FromMilliseconds(50);
|
||||
public static readonly TimeSpan AuthTimeout = TimeSpan.FromSeconds(2);
|
||||
public static readonly TimeSpan DefaultRouteConnect = TimeSpan.FromSeconds(1);
|
||||
public static readonly TimeSpan DefaultRouteConnectMax = TimeSpan.FromSeconds(30);
|
||||
public static readonly TimeSpan DefaultRouteReconnect = TimeSpan.FromSeconds(1);
|
||||
public static readonly TimeSpan DefaultRouteDial = TimeSpan.FromSeconds(1);
|
||||
public static readonly TimeSpan DefaultLeafNodeReconnect = TimeSpan.FromSeconds(1);
|
||||
public static readonly TimeSpan DefaultLeafTlsTimeout = TimeSpan.FromSeconds(2);
|
||||
public static readonly TimeSpan DefaultLeafNodeInfoWait = TimeSpan.FromSeconds(1);
|
||||
public static readonly TimeSpan DefaultRttMeasurementInterval = TimeSpan.FromHours(1);
|
||||
public static readonly TimeSpan DefaultAllowResponseExpiration = TimeSpan.FromMinutes(2);
|
||||
public static readonly TimeSpan DefaultServiceExportResponseThreshold = TimeSpan.FromMinutes(2);
|
||||
public static readonly TimeSpan DefaultAccountFetchTimeout = TimeSpan.FromMilliseconds(1900);
|
||||
public static readonly TimeSpan DefaultPingInterval = TimeSpan.FromMinutes(2);
|
||||
public static readonly TimeSpan DefaultFlushDeadline = TimeSpan.FromSeconds(10);
|
||||
public static readonly TimeSpan AcceptMinSleep = TimeSpan.FromMilliseconds(10);
|
||||
public static readonly TimeSpan AcceptMaxSleep = TimeSpan.FromSeconds(1);
|
||||
public static readonly TimeSpan DefaultLameDuckDuration = TimeSpan.FromMinutes(2);
|
||||
public static readonly TimeSpan DefaultLameDuckGracePeriod = TimeSpan.FromSeconds(10);
|
||||
public const string Version = "0.1.0";
|
||||
public const int ProtoVersion = 1;
|
||||
|
||||
|
||||
89
src/NATS.Server/Protocol/ProtoWire.cs
Normal file
89
src/NATS.Server/Protocol/ProtoWire.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
namespace NATS.Server.Protocol;
|
||||
|
||||
public static class ProtoWire
|
||||
{
|
||||
public const string ErrProtoInsufficient = "insufficient data to read a value";
|
||||
public const string ErrProtoOverflow = "too much data for a value";
|
||||
public const string ErrProtoInvalidFieldNumber = "invalid field number";
|
||||
|
||||
public static (int Number, int WireType, int Size) ScanField(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
var (number, wireType, tagSize) = ScanTag(buffer);
|
||||
var valueSize = ScanFieldValue(wireType, buffer[tagSize..]);
|
||||
return (number, wireType, tagSize + valueSize);
|
||||
}
|
||||
|
||||
public static (int Number, int WireType, int Size) ScanTag(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
var (tag, size) = ScanVarint(buffer);
|
||||
var fieldNumber = tag >> 3;
|
||||
if (fieldNumber > int.MaxValue || fieldNumber < 1)
|
||||
throw new ProtoWireException(ErrProtoInvalidFieldNumber);
|
||||
|
||||
return ((int)fieldNumber, (int)(tag & 0x7), size);
|
||||
}
|
||||
|
||||
public static int ScanFieldValue(int wireType, ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
return wireType switch
|
||||
{
|
||||
0 => ScanVarint(buffer).Size,
|
||||
5 => 4,
|
||||
1 => 8,
|
||||
2 => ScanBytes(buffer),
|
||||
_ => throw new ProtoWireException($"unsupported type: {wireType}"),
|
||||
};
|
||||
}
|
||||
|
||||
public static (ulong Value, int Size) ScanVarint(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
ulong value = 0;
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
if (i >= buffer.Length)
|
||||
throw new ProtoWireException(ErrProtoInsufficient);
|
||||
|
||||
var b = buffer[i];
|
||||
if (i == 9)
|
||||
{
|
||||
if (b > 1)
|
||||
throw new ProtoWireException(ErrProtoOverflow);
|
||||
|
||||
value |= (ulong)b << 63;
|
||||
return (value, 10);
|
||||
}
|
||||
|
||||
value |= (ulong)(b & 0x7F) << (i * 7);
|
||||
if ((b & 0x80) == 0)
|
||||
return (value, i + 1);
|
||||
}
|
||||
|
||||
throw new ProtoWireException(ErrProtoOverflow);
|
||||
}
|
||||
|
||||
public static int ScanBytes(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
var (length, lenSize) = ScanVarint(buffer);
|
||||
if (length > (ulong)buffer[lenSize..].Length)
|
||||
throw new ProtoWireException(ErrProtoInsufficient);
|
||||
|
||||
return lenSize + (int)length;
|
||||
}
|
||||
|
||||
public static byte[] EncodeVarint(ulong value)
|
||||
{
|
||||
Span<byte> scratch = stackalloc byte[10];
|
||||
var i = 0;
|
||||
while (value >= 0x80)
|
||||
{
|
||||
scratch[i++] = (byte)((value & 0x7F) | 0x80);
|
||||
value >>= 7;
|
||||
}
|
||||
|
||||
scratch[i++] = (byte)value;
|
||||
return scratch[..i].ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ProtoWireException(string message) : Exception(message);
|
||||
@@ -41,3 +41,9 @@ public sealed class CommitQueue<T>
|
||||
public void Complete()
|
||||
=> _channel.Writer.Complete();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Committed raft entries at a specific commit index.
|
||||
/// Go reference: raft.go CommittedEntry.
|
||||
/// </summary>
|
||||
public sealed record CommittedEntry(long Index, IReadOnlyList<RaftLogEntry> Entries);
|
||||
|
||||
20
src/NATS.Server/Raft/RaftConfig.cs
Normal file
20
src/NATS.Server/Raft/RaftConfig.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace NATS.Server.Raft;
|
||||
|
||||
/// <summary>
|
||||
/// RAFT runtime configuration model aligned with Go's raftConfig shape.
|
||||
/// Go reference: server/raft.go raftConfig (Name, Store, Log, Track, Observer,
|
||||
/// Recovering, ScaleUp).
|
||||
/// </summary>
|
||||
public sealed class RaftConfig
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
// Store/log abstractions are intentionally loose until full WAL/store parity is wired.
|
||||
public object? Store { get; set; }
|
||||
public object? Log { get; set; }
|
||||
|
||||
public bool Track { get; set; }
|
||||
public bool Observer { get; set; }
|
||||
public bool Recovering { get; set; }
|
||||
public bool ScaleUp { get; set; }
|
||||
}
|
||||
12
src/NATS.Server/Raft/RaftEntry.cs
Normal file
12
src/NATS.Server/Raft/RaftEntry.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace NATS.Server.Raft;
|
||||
|
||||
/// <summary>
|
||||
/// General raft entry shape (type + payload) used by proposal/state paths.
|
||||
/// Go reference: raft.go Entry.
|
||||
/// </summary>
|
||||
public readonly record struct RaftEntry(RaftEntryType Type, byte[] Data)
|
||||
{
|
||||
public RaftEntryWire ToWire() => new(Type, Data);
|
||||
|
||||
public static RaftEntry FromWire(RaftEntryWire wire) => new(wire.Type, wire.Data);
|
||||
}
|
||||
@@ -2,13 +2,31 @@ namespace NATS.Server.Raft;
|
||||
|
||||
public sealed class RaftNode : IDisposable
|
||||
{
|
||||
public const string NoLeader = "";
|
||||
public const string NoVote = "";
|
||||
|
||||
public static readonly TimeSpan MinCampaignTimeoutDefault = TimeSpan.FromMilliseconds(100);
|
||||
public static readonly TimeSpan MaxCampaignTimeoutDefault = TimeSpan.FromMilliseconds(800);
|
||||
public static readonly TimeSpan HbIntervalDefault = TimeSpan.FromSeconds(1);
|
||||
public static readonly TimeSpan LostQuorumIntervalDefault = TimeSpan.FromSeconds(10);
|
||||
public static readonly TimeSpan ObserverModeIntervalDefault = TimeSpan.FromHours(48);
|
||||
public static readonly TimeSpan PeerRemoveTimeoutDefault = TimeSpan.FromMinutes(5);
|
||||
|
||||
private int _votesReceived;
|
||||
private readonly List<RaftNode> _cluster = [];
|
||||
private readonly RaftReplicator _replicator = new();
|
||||
private readonly RaftSnapshotStore _snapshotStore = new();
|
||||
private readonly IRaftTransport? _transport;
|
||||
private readonly string? _persistDirectory;
|
||||
private readonly DateTime _createdUtc;
|
||||
private readonly HashSet<string> _members = new(StringComparer.Ordinal);
|
||||
private int _clusterSize;
|
||||
private bool _observerMode;
|
||||
private string _groupLeader = NoLeader;
|
||||
private DateTime? _leaderSinceUtc;
|
||||
private bool _hadPreviousLeader;
|
||||
private bool _isDeleted;
|
||||
private readonly TaskCompletionSource _stopSignal = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// B2: Election timer fields
|
||||
// Go reference: raft.go:1400-1450 (resetElectionTimeout), raft.go:1500-1550 (campaign logic)
|
||||
@@ -38,9 +56,17 @@ public sealed class RaftNode : IDisposable
|
||||
// Pre-vote: Go NATS server does not implement pre-vote (RFC 5849 §9.6). Skipped for parity.
|
||||
|
||||
public string Id { get; }
|
||||
public string GroupName { get; }
|
||||
public DateTime CreatedUtc => _createdUtc;
|
||||
public int Term => TermState.CurrentTerm;
|
||||
public bool IsLeader => Role == RaftRole.Leader;
|
||||
public DateTime? LeaderSince => _leaderSinceUtc;
|
||||
public string GroupLeader => _groupLeader;
|
||||
public bool Leaderless => string.IsNullOrEmpty(_groupLeader);
|
||||
public bool HadPreviousLeader => _hadPreviousLeader;
|
||||
public RaftRole Role { get; private set; } = RaftRole.Follower;
|
||||
public bool IsObserver => _observerMode;
|
||||
public bool IsDeleted => _isDeleted;
|
||||
public IReadOnlyCollection<string> Members => _members;
|
||||
public RaftTermState TermState { get; } = new();
|
||||
public long AppliedIndex { get; set; }
|
||||
@@ -99,31 +125,42 @@ public sealed class RaftNode : IDisposable
|
||||
private Random _random;
|
||||
|
||||
public RaftNode(string id, IRaftTransport? transport = null, string? persistDirectory = null,
|
||||
CompactionOptions? compactionOptions = null, Random? random = null)
|
||||
CompactionOptions? compactionOptions = null, Random? random = null, string? group = null)
|
||||
{
|
||||
Id = id;
|
||||
GroupName = string.IsNullOrWhiteSpace(group) ? id : group;
|
||||
_createdUtc = DateTime.UtcNow;
|
||||
_transport = transport;
|
||||
_persistDirectory = persistDirectory;
|
||||
_members.Add(id);
|
||||
_clusterSize = 1;
|
||||
CompactionOptions = compactionOptions;
|
||||
_random = random ?? Random.Shared;
|
||||
}
|
||||
|
||||
public void ConfigureCluster(IEnumerable<RaftNode> peers)
|
||||
{
|
||||
var configuredPeers = peers as ICollection<RaftNode> ?? peers.ToList();
|
||||
_cluster.Clear();
|
||||
_cluster.AddRange(peers);
|
||||
_cluster.AddRange(configuredPeers);
|
||||
_members.Clear();
|
||||
_peerStates.Clear();
|
||||
foreach (var peer in peers)
|
||||
foreach (var peer in configuredPeers)
|
||||
{
|
||||
_members.Add(peer.Id);
|
||||
// B3: Initialize peer state for all peers except self
|
||||
if (!string.Equals(peer.Id, Id, StringComparison.Ordinal))
|
||||
{
|
||||
_peerStates[peer.Id] = new RaftPeerState { PeerId = peer.Id };
|
||||
_peerStates[peer.Id] = new RaftPeerState
|
||||
{
|
||||
PeerId = peer.Id,
|
||||
Current = true,
|
||||
};
|
||||
_peerStates[peer.Id].RecalculateLag();
|
||||
}
|
||||
}
|
||||
|
||||
_clusterSize = Math.Max(configuredPeers.Count, 1);
|
||||
}
|
||||
|
||||
public void AddMember(string memberId) => _members.Add(memberId);
|
||||
@@ -132,6 +169,8 @@ public sealed class RaftNode : IDisposable
|
||||
|
||||
public void StartElection(int clusterSize)
|
||||
{
|
||||
_groupLeader = NoLeader;
|
||||
_leaderSinceUtc = null;
|
||||
Role = RaftRole.Candidate;
|
||||
TermState.CurrentTerm++;
|
||||
TermState.VotedFor = Id;
|
||||
@@ -167,6 +206,12 @@ public sealed class RaftNode : IDisposable
|
||||
|
||||
TermState.CurrentTerm = term;
|
||||
Role = RaftRole.Follower;
|
||||
if (!string.IsNullOrEmpty(fromPeerId))
|
||||
{
|
||||
_groupLeader = fromPeerId;
|
||||
_hadPreviousLeader = true;
|
||||
_leaderSinceUtc = null;
|
||||
}
|
||||
|
||||
// B2: Reset election timer on valid heartbeat
|
||||
ResetElectionTimeout();
|
||||
@@ -175,6 +220,7 @@ public sealed class RaftNode : IDisposable
|
||||
if (fromPeerId != null && _peerStates.TryGetValue(fromPeerId, out var peerState))
|
||||
{
|
||||
peerState.LastContact = DateTime.UtcNow;
|
||||
peerState.RefreshCurrent(TimeSpan.FromMilliseconds(ElectionTimeoutMaxMs));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +321,10 @@ public sealed class RaftNode : IDisposable
|
||||
ackPeerId =>
|
||||
{
|
||||
if (_peerStates.TryGetValue(ackPeerId, out var state))
|
||||
{
|
||||
state.LastContact = DateTime.UtcNow;
|
||||
state.RefreshCurrent(TimeSpan.FromMilliseconds(ElectionTimeoutMaxMs));
|
||||
}
|
||||
},
|
||||
ct);
|
||||
}
|
||||
@@ -347,6 +396,8 @@ public sealed class RaftNode : IDisposable
|
||||
peerState.MatchIndex = Math.Max(peerState.MatchIndex, entry.Index);
|
||||
peerState.NextIndex = entry.Index + 1;
|
||||
peerState.LastContact = DateTime.UtcNow;
|
||||
peerState.RecalculateLag();
|
||||
peerState.RefreshCurrent(TimeSpan.FromMilliseconds(ElectionTimeoutMaxMs));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +411,34 @@ public sealed class RaftNode : IDisposable
|
||||
return entry.Index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proposes a batch of commands in order and returns their resulting indexes.
|
||||
/// Go reference: raft.go ProposeMulti.
|
||||
/// </summary>
|
||||
public async ValueTask<IReadOnlyList<long>> ProposeMultiAsync(IEnumerable<string> commands, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(commands);
|
||||
|
||||
var indexes = new List<long>();
|
||||
foreach (var command in commands)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
indexes.Add(await ProposeAsync(command, ct));
|
||||
}
|
||||
|
||||
return indexes;
|
||||
}
|
||||
|
||||
public (long Entries, long Bytes) Applied(long index)
|
||||
{
|
||||
MarkProcessed(index);
|
||||
var entries = Math.Max(0, index - Log.BaseIndex);
|
||||
var bytes = Log.Entries
|
||||
.Where(e => e.Index <= index)
|
||||
.Sum(e => (long)System.Text.Encoding.UTF8.GetByteCount(e.Command ?? string.Empty));
|
||||
return (entries, bytes);
|
||||
}
|
||||
|
||||
// B4: Membership change proposals
|
||||
// Go reference: raft.go:961-1019 (proposeAddPeer, proposeRemovePeer)
|
||||
|
||||
@@ -397,7 +476,12 @@ public sealed class RaftNode : IDisposable
|
||||
if (!string.Equals(peerId, Id, StringComparison.Ordinal)
|
||||
&& !_peerStates.ContainsKey(peerId))
|
||||
{
|
||||
_peerStates[peerId] = new RaftPeerState { PeerId = peerId };
|
||||
_peerStates[peerId] = new RaftPeerState
|
||||
{
|
||||
PeerId = peerId,
|
||||
Current = true,
|
||||
};
|
||||
_peerStates[peerId].RecalculateLag();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,8 +792,47 @@ public sealed class RaftNode : IDisposable
|
||||
Role = RaftRole.Follower;
|
||||
_votesReceived = 0;
|
||||
TermState.VotedFor = null;
|
||||
_groupLeader = NoLeader;
|
||||
_leaderSinceUtc = null;
|
||||
}
|
||||
|
||||
public (long Index, long Commit, long Applied) Progress()
|
||||
{
|
||||
var index = Log.Entries.Count > 0 ? Log.Entries[^1].Index : Log.BaseIndex;
|
||||
return (index, CommitIndex, AppliedIndex);
|
||||
}
|
||||
|
||||
public (long Entries, long Bytes) Size()
|
||||
{
|
||||
var entries = (long)Log.Entries.Count;
|
||||
var bytes = Log.Entries.Sum(e => (long)System.Text.Encoding.UTF8.GetByteCount(e.Command ?? string.Empty));
|
||||
return (entries, bytes);
|
||||
}
|
||||
|
||||
public int ClusterSize()
|
||||
=> _clusterSize > 0 ? _clusterSize : Math.Max(_members.Count, 1);
|
||||
|
||||
public bool AdjustBootClusterSize(int clusterSize)
|
||||
{
|
||||
if (!Leaderless || HadPreviousLeader)
|
||||
return false;
|
||||
|
||||
_clusterSize = Math.Max(2, clusterSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool AdjustClusterSize(int clusterSize)
|
||||
{
|
||||
if (!IsLeader)
|
||||
return false;
|
||||
|
||||
_clusterSize = Math.Max(2, clusterSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SetObserver(bool enabled)
|
||||
=> _observerMode = enabled;
|
||||
|
||||
// B2: Election timer management
|
||||
// Go reference: raft.go:1400-1450 (resetElectionTimeout)
|
||||
|
||||
@@ -730,6 +853,14 @@ public sealed class RaftNode : IDisposable
|
||||
return TimeSpan.FromMilliseconds(ms);
|
||||
}
|
||||
|
||||
public TimeSpan RandomizedCampaignTimeout()
|
||||
{
|
||||
var min = (int)MinCampaignTimeoutDefault.TotalMilliseconds;
|
||||
var max = (int)MaxCampaignTimeoutDefault.TotalMilliseconds;
|
||||
var ms = min + _random.Next(0, Math.Max(1, max - min));
|
||||
return TimeSpan.FromMilliseconds(ms);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the election timeout timer with a new randomized interval.
|
||||
/// Called on heartbeat receipt and append entries from leader.
|
||||
@@ -987,7 +1118,38 @@ public sealed class RaftNode : IDisposable
|
||||
{
|
||||
var quorum = (clusterSize / 2) + 1;
|
||||
if (_votesReceived >= quorum)
|
||||
{
|
||||
Role = RaftRole.Leader;
|
||||
_groupLeader = Id;
|
||||
_leaderSinceUtc = DateTime.UtcNow;
|
||||
_hadPreviousLeader = true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
Role = RaftRole.Follower;
|
||||
_groupLeader = NoLeader;
|
||||
_leaderSinceUtc = null;
|
||||
StopElectionTimer();
|
||||
_stopSignal.TrySetResult();
|
||||
}
|
||||
|
||||
public void WaitForStop()
|
||||
{
|
||||
_stopSignal.Task.GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
Stop();
|
||||
_isDeleted = true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_persistDirectory))
|
||||
return;
|
||||
|
||||
if (Directory.Exists(_persistDirectory))
|
||||
Directory.Delete(_persistDirectory, recursive: true);
|
||||
}
|
||||
|
||||
public async Task PersistAsync(CancellationToken ct)
|
||||
@@ -1051,6 +1213,6 @@ public sealed class RaftNode : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
StopElectionTimer();
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,16 +31,46 @@ public sealed class RaftPeerState
|
||||
/// </summary>
|
||||
public bool Active { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Distance between next index and acknowledged match index.
|
||||
/// Go reference: raft.go Peer.Lag.
|
||||
/// </summary>
|
||||
public long Lag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Cached "current" flag based on last contact freshness checks.
|
||||
/// Go reference: raft.go Peer.Current.
|
||||
/// </summary>
|
||||
public bool Current { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Recomputes lag from current next/match indices.
|
||||
/// </summary>
|
||||
public void RecalculateLag()
|
||||
=> Lag = Math.Max(0, NextIndex - (MatchIndex + 1));
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes the cached Current flag using the provided freshness window.
|
||||
/// </summary>
|
||||
public void RefreshCurrent(TimeSpan window)
|
||||
=> Current = DateTime.UtcNow - LastContact < window;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this peer has been contacted within the election timeout window.
|
||||
/// Go reference: raft.go isCurrent check.
|
||||
/// </summary>
|
||||
public bool IsCurrent(TimeSpan electionTimeout)
|
||||
=> DateTime.UtcNow - LastContact < electionTimeout;
|
||||
{
|
||||
RefreshCurrent(electionTimeout);
|
||||
return Current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if this peer is both active and has been contacted within the health threshold.
|
||||
/// </summary>
|
||||
public bool IsHealthy(TimeSpan healthThreshold)
|
||||
=> Active && DateTime.UtcNow - LastContact < healthThreshold;
|
||||
{
|
||||
RefreshCurrent(healthThreshold);
|
||||
return Active && Current;
|
||||
}
|
||||
}
|
||||
|
||||
18
src/NATS.Server/Raft/RaftStateExtensions.cs
Normal file
18
src/NATS.Server/Raft/RaftStateExtensions.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace NATS.Server.Raft;
|
||||
|
||||
/// <summary>
|
||||
/// Go-style state string rendering for <see cref="RaftState"/>.
|
||||
/// Go reference: server/raft.go RaftState.String().
|
||||
/// </summary>
|
||||
public static class RaftStateExtensions
|
||||
{
|
||||
public static string String(this RaftState state) =>
|
||||
state switch
|
||||
{
|
||||
RaftState.Follower => "Follower",
|
||||
RaftState.Leader => "Leader",
|
||||
RaftState.Candidate => "Candidate",
|
||||
RaftState.Closed => "Closed",
|
||||
_ => "Unknown",
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,10 @@ namespace NATS.Server.Routes;
|
||||
|
||||
public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
||||
{
|
||||
// Go route protocol control lines.
|
||||
public const string ConnectProto = "CONNECT {0}";
|
||||
public const string InfoProto = "INFO {0}";
|
||||
|
||||
private readonly Socket _socket = socket;
|
||||
private readonly NetworkStream _stream = new(socket, ownsSocket: true);
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
@@ -23,6 +27,13 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
||||
/// </summary>
|
||||
public int PoolIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this route was solicited by this server (outbound dial).
|
||||
/// False for inbound/accepted routes.
|
||||
/// Go reference: server/route.go isSolicitedRoute.
|
||||
/// </summary>
|
||||
public bool IsSolicited { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// The pool size agreed upon during handshake negotiation with the remote peer.
|
||||
/// Defaults to 0 (no pooling / pre-negotiation state). Set after handshake completes.
|
||||
@@ -93,10 +104,18 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
||||
}
|
||||
|
||||
public async Task SendRsPlusAsync(string account, string subject, string? queue, CancellationToken ct)
|
||||
=> await SendRsPlusAsync(account, subject, queue, queueWeight: 0, ct);
|
||||
|
||||
public async Task SendRsPlusAsync(string account, string subject, string? queue, int queueWeight, CancellationToken ct)
|
||||
{
|
||||
var frame = queue is { Length: > 0 }
|
||||
? $"RS+ {account} {subject} {queue}"
|
||||
: $"RS+ {account} {subject}";
|
||||
string frame;
|
||||
if (queue is { Length: > 0 } && queueWeight > 0)
|
||||
frame = $"RS+ {account} {subject} {queue} {queueWeight}";
|
||||
else if (queue is { Length: > 0 })
|
||||
frame = $"RS+ {account} {subject} {queue}";
|
||||
else
|
||||
frame = $"RS+ {account} {subject}";
|
||||
|
||||
await WriteLineAsync(frame, ct);
|
||||
}
|
||||
|
||||
@@ -108,6 +127,81 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
||||
await WriteLineAsync(frame, ct);
|
||||
}
|
||||
|
||||
public async Task SendLsPlusAsync(string account, string subject, string? queue, CancellationToken ct)
|
||||
{
|
||||
var frame = queue is { Length: > 0 }
|
||||
? $"LS+ {account} {subject} {queue}"
|
||||
: $"LS+ {account} {subject}";
|
||||
await WriteLineAsync(frame, ct);
|
||||
}
|
||||
|
||||
public async Task SendLsMinusAsync(string account, string subject, string? queue, CancellationToken ct)
|
||||
{
|
||||
var frame = queue is { Length: > 0 }
|
||||
? $"LS- {account} {subject} {queue}"
|
||||
: $"LS- {account} {subject}";
|
||||
await WriteLineAsync(frame, ct);
|
||||
}
|
||||
|
||||
public async Task SendRouteSubProtosAsync(IEnumerable<RemoteSubscription> subscriptions, CancellationToken ct)
|
||||
{
|
||||
var protos = new List<string>();
|
||||
foreach (var sub in subscriptions)
|
||||
{
|
||||
if (sub.IsRemoval)
|
||||
continue;
|
||||
|
||||
if (sub.Queue is { Length: > 0 } && sub.QueueWeight > 0)
|
||||
protos.Add($"RS+ {sub.Account} {sub.Subject} {sub.Queue} {sub.QueueWeight}");
|
||||
else if (sub.Queue is { Length: > 0 })
|
||||
protos.Add($"RS+ {sub.Account} {sub.Subject} {sub.Queue}");
|
||||
else
|
||||
protos.Add($"RS+ {sub.Account} {sub.Subject}");
|
||||
}
|
||||
|
||||
await SendRouteSubOrUnSubProtosAsync(protos, ct);
|
||||
}
|
||||
|
||||
public async Task SendRouteUnSubProtosAsync(IEnumerable<RemoteSubscription> subscriptions, CancellationToken ct)
|
||||
{
|
||||
var protos = new List<string>();
|
||||
foreach (var sub in subscriptions)
|
||||
{
|
||||
if (sub.Queue is { Length: > 0 })
|
||||
protos.Add($"RS- {sub.Account} {sub.Subject} {sub.Queue}");
|
||||
else
|
||||
protos.Add($"RS- {sub.Account} {sub.Subject}");
|
||||
}
|
||||
|
||||
await SendRouteSubOrUnSubProtosAsync(protos, ct);
|
||||
}
|
||||
|
||||
public async Task SendRouteSubOrUnSubProtosAsync(IEnumerable<string> protocols, CancellationToken ct)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
foreach (var proto in protocols)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(proto))
|
||||
continue;
|
||||
sb.Append(proto).Append("\r\n");
|
||||
}
|
||||
|
||||
if (sb.Length == 0)
|
||||
return;
|
||||
|
||||
await _writeGate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var bytes = Encoding.ASCII.GetBytes(sb.ToString());
|
||||
await _stream.WriteAsync(bytes, ct);
|
||||
await _stream.FlushAsync(ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendRmsgAsync(string account, string subject, string? replyTo, ReadOnlyMemory<byte> payload, CancellationToken ct)
|
||||
{
|
||||
var replyToken = string.IsNullOrEmpty(replyTo) ? "-" : replyTo;
|
||||
@@ -164,7 +258,7 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
||||
break;
|
||||
}
|
||||
|
||||
if (line.StartsWith("RS+ ", StringComparison.Ordinal))
|
||||
if (line.StartsWith("RS+ ", StringComparison.Ordinal) || line.StartsWith("LS+ ", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue))
|
||||
@@ -174,10 +268,9 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("RS- ", StringComparison.Ordinal))
|
||||
if (line.StartsWith("RS- ", StringComparison.Ordinal) || line.StartsWith("LS- ", StringComparison.Ordinal))
|
||||
{
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (RemoteSubscriptionReceived != null && TryParseAccountScopedInterest(parts, out var parsedAccount, out var parsedSubject, out var queue))
|
||||
if (RemoteSubscriptionReceived != null && TryParseRemoteUnsub(line, out var parsedAccount, out var parsedSubject, out var queue))
|
||||
{
|
||||
await RemoteSubscriptionReceived(RemoteSubscription.Removal(parsedSubject, queue, RemoteServerId ?? string.Empty, parsedAccount));
|
||||
}
|
||||
@@ -320,6 +413,22 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryParseRemoteUnsub(string line, out string account, out string subject, out string? queue)
|
||||
{
|
||||
account = "$G";
|
||||
subject = string.Empty;
|
||||
queue = null;
|
||||
|
||||
if (!line.StartsWith("RS- ", StringComparison.Ordinal) && !line.StartsWith("LS- ", StringComparison.Ordinal))
|
||||
return false;
|
||||
|
||||
var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
return TryParseAccountScopedInterest(parts, out account, out subject, out queue);
|
||||
}
|
||||
|
||||
public bool IsSolicitedRoute()
|
||||
=> IsSolicited;
|
||||
|
||||
private static bool LooksLikeSubject(string token)
|
||||
=> token.Contains('.', StringComparison.Ordinal)
|
||||
|| token.Contains('*', StringComparison.Ordinal)
|
||||
@@ -329,6 +438,16 @@ public sealed class RouteConnection(Socket socket) : IAsyncDisposable
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
verbose = false,
|
||||
pedantic = false,
|
||||
echo = false,
|
||||
tls_required = false,
|
||||
headers = true,
|
||||
name = serverId,
|
||||
cluster = string.Empty,
|
||||
@dynamic = false,
|
||||
lnoc = false,
|
||||
lnocu = false,
|
||||
server_id = serverId,
|
||||
accounts = (accounts ?? []).ToArray(),
|
||||
topology = topologySnapshot ?? string.Empty,
|
||||
|
||||
@@ -11,6 +11,14 @@ namespace NATS.Server.Routes;
|
||||
public sealed class RouteManager : IAsyncDisposable
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, RouteManager> Managers = new(StringComparer.Ordinal);
|
||||
private static readonly TimeSpan RouteConnectDelay = TimeSpan.FromMilliseconds(250);
|
||||
private static readonly TimeSpan RouteConnectMaxDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
public const byte GossipDefault = 0;
|
||||
public const byte GossipDisabled = 1;
|
||||
public const byte GossipOverride = 2;
|
||||
public static readonly TimeSpan DefaultRouteMaxPingInterval = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly ClusterOptions _options;
|
||||
private readonly ServerStats _stats;
|
||||
private readonly string _serverId;
|
||||
@@ -98,6 +106,8 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// Go reference: server/route.go forwardNewRouteInfoToKnownServers.
|
||||
/// </summary>
|
||||
public event Action<List<string>>? OnForwardInfo;
|
||||
public event Action<string>? OnRouteRemoved;
|
||||
public event Action<string, string>? OnRouteAccountRemoved;
|
||||
|
||||
/// <summary>
|
||||
/// Processes connect_urls from a peer's INFO message. Any URLs not already
|
||||
@@ -113,10 +123,17 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
{
|
||||
foreach (var url in serverInfo.ConnectUrls)
|
||||
{
|
||||
if (!_knownRouteUrls.Contains(url))
|
||||
if (HasThisRouteConfigured(url))
|
||||
continue;
|
||||
|
||||
var normalized = NormalizeRouteUrl(url);
|
||||
if (_discoveredRoutes.Any(existing =>
|
||||
string.Equals(NormalizeRouteUrl(existing), normalized, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_discoveredRoutes.Add(url);
|
||||
continue;
|
||||
}
|
||||
|
||||
_discoveredRoutes.Add(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +154,41 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
{
|
||||
lock (_discoveredRoutes)
|
||||
{
|
||||
_knownRouteUrls.Add(url);
|
||||
_knownRouteUrls.Add(NormalizeRouteUrl(url));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the URL is already an explicit configured route or already
|
||||
/// known from startup/config processing.
|
||||
/// Go reference: server/route.go hasThisRouteConfigured.
|
||||
/// </summary>
|
||||
internal bool HasThisRouteConfigured(string routeUrl)
|
||||
{
|
||||
var normalized = NormalizeRouteUrl(routeUrl);
|
||||
lock (_discoveredRoutes)
|
||||
{
|
||||
if (_knownRouteUrls.Contains(normalized))
|
||||
return true;
|
||||
}
|
||||
|
||||
return _options.Routes.Any(r => string.Equals(NormalizeRouteUrl(r), normalized, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the route URL is still valid for reconnect attempts.
|
||||
/// Go reference: server/route.go routeStillValid.
|
||||
/// </summary>
|
||||
internal bool RouteStillValid(string routeUrl)
|
||||
{
|
||||
var normalized = NormalizeRouteUrl(routeUrl);
|
||||
if (HasThisRouteConfigured(normalized))
|
||||
return true;
|
||||
|
||||
lock (_discoveredRoutes)
|
||||
{
|
||||
return _discoveredRoutes.Any(existing =>
|
||||
string.Equals(NormalizeRouteUrl(existing), normalized, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +285,11 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
/// </summary>
|
||||
public void UnregisterAccountRoute(string account)
|
||||
{
|
||||
_accountRoutes.TryRemove(account, out _);
|
||||
if (!_accountRoutes.TryRemove(account, out var route))
|
||||
return;
|
||||
|
||||
if (route.RemoteServerId is { Length: > 0 } remoteServerId)
|
||||
OnRouteAccountRemoved?.Invoke(remoteServerId, account);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -346,6 +401,7 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
var poolSize = Math.Max(_options.PoolSize, 1);
|
||||
foreach (var route in _options.Routes.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
AddKnownRoute(route);
|
||||
for (var i = 0; i < poolSize; i++)
|
||||
{
|
||||
var poolIndex = i;
|
||||
@@ -461,14 +517,18 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
|
||||
private async Task ConnectToRouteWithRetryAsync(string route, int poolIndex, CancellationToken ct)
|
||||
{
|
||||
var attempt = 0;
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
if (!RouteStillValid(route))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var endPoint = ParseRouteEndpoint(route);
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
var socket = CreateRouteDialSocket();
|
||||
await socket.ConnectAsync(endPoint.Address, endPoint.Port, ct);
|
||||
var connection = new RouteConnection(socket) { PoolIndex = poolIndex };
|
||||
var connection = new RouteConnection(socket) { PoolIndex = poolIndex, IsSolicited = true };
|
||||
await connection.PerformOutboundHandshakeAsync(_serverId, ct);
|
||||
Register(connection);
|
||||
return;
|
||||
@@ -484,7 +544,8 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(250, ct);
|
||||
attempt++;
|
||||
await Task.Delay(ComputeRetryDelay(attempt), ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -493,6 +554,14 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
internal static Socket CreateRouteDialSocket()
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
// Go natsDialTimeout sets KeepAlive to -1 (disabled) on outbound route dials.
|
||||
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, false);
|
||||
return socket;
|
||||
}
|
||||
|
||||
private void Register(RouteConnection route)
|
||||
{
|
||||
var key = $"{route.RemoteServerId}:{route.RemoteEndpoint}:{Guid.NewGuid():N}";
|
||||
@@ -538,15 +607,27 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
finally
|
||||
{
|
||||
if (_routes.TryRemove(key, out _))
|
||||
{
|
||||
Interlocked.Decrement(ref _stats.Routes);
|
||||
if (route.RemoteServerId is { Length: > 0 } remoteServerId && !HasConnectedRouteForServerId(remoteServerId))
|
||||
OnRouteRemoved?.Invoke(remoteServerId);
|
||||
}
|
||||
|
||||
await route.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasConnectedRouteForServerId(string remoteServerId)
|
||||
{
|
||||
var prefix = remoteServerId + ":";
|
||||
return _routes.Any(kvp =>
|
||||
kvp.Key.StartsWith(prefix, StringComparison.Ordinal)
|
||||
|| string.Equals(kvp.Value.RemoteServerId, remoteServerId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static IPEndPoint ParseRouteEndpoint(string route)
|
||||
{
|
||||
var trimmed = route.Trim();
|
||||
var trimmed = NormalizeRouteUrl(route);
|
||||
var parts = trimmed.Split(':', 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 2)
|
||||
throw new FormatException($"Invalid route endpoint: '{route}'");
|
||||
@@ -578,18 +659,38 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
{
|
||||
var found = false;
|
||||
var prefix = serverId + ":";
|
||||
var removedRoutes = new List<RouteConnection>();
|
||||
|
||||
foreach (var key in _routes.Keys.ToArray())
|
||||
{
|
||||
if (!key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
if (_routes.TryRemove(key, out _))
|
||||
if (_routes.TryRemove(key, out var removed))
|
||||
{
|
||||
found = true;
|
||||
removedRoutes.Add(removed);
|
||||
Interlocked.Decrement(ref _stats.Routes);
|
||||
_ = removed.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
if (found)
|
||||
{
|
||||
_connectedServerIds.TryRemove(serverId, out _);
|
||||
UnregisterRouteByHash(serverId);
|
||||
|
||||
foreach (var kvp in _accountRoutes.ToArray())
|
||||
{
|
||||
if (removedRoutes.Contains(kvp.Value)
|
||||
|| string.Equals(kvp.Value.RemoteServerId, serverId, StringComparison.Ordinal))
|
||||
{
|
||||
_accountRoutes.TryRemove(kvp.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
OnRouteRemoved?.Invoke(serverId);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
@@ -644,6 +745,58 @@ public sealed class RouteManager : IAsyncDisposable
|
||||
|
||||
return new ClusterSplitResult(missing, unexpected, missing.Count > 0);
|
||||
}
|
||||
|
||||
internal bool HasSolicitedRoute(string remoteServerId)
|
||||
{
|
||||
var prefix = remoteServerId + ":";
|
||||
return _routes.Any(kvp =>
|
||||
kvp.Value.IsSolicited &&
|
||||
(kvp.Key.StartsWith(prefix, StringComparison.Ordinal)
|
||||
|| string.Equals(kvp.Value.RemoteServerId, remoteServerId, StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
internal bool UpgradeRouteToSolicited(string remoteServerId)
|
||||
{
|
||||
var prefix = remoteServerId + ":";
|
||||
foreach (var kvp in _routes)
|
||||
{
|
||||
var route = kvp.Value;
|
||||
var matches = kvp.Key.StartsWith(prefix, StringComparison.Ordinal)
|
||||
|| string.Equals(route.RemoteServerId, remoteServerId, StringComparison.Ordinal);
|
||||
if (!matches)
|
||||
continue;
|
||||
|
||||
route.IsSolicited = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal bool IsDuplicateServerName(string remoteServerId)
|
||||
=> _connectedServerIds.ContainsKey(remoteServerId);
|
||||
|
||||
private static TimeSpan ComputeRetryDelay(int attempt)
|
||||
{
|
||||
var factor = Math.Pow(2, Math.Clamp(attempt, 0, 8));
|
||||
var delayMs = RouteConnectDelay.TotalMilliseconds * factor;
|
||||
var boundedMs = Math.Min(delayMs, RouteConnectMaxDelay.TotalMilliseconds);
|
||||
return TimeSpan.FromMilliseconds(boundedMs);
|
||||
}
|
||||
|
||||
private static string NormalizeRouteUrl(string routeUrl)
|
||||
{
|
||||
var value = routeUrl.Trim();
|
||||
if (value.StartsWith("nats-route://", StringComparison.OrdinalIgnoreCase))
|
||||
value = value["nats-route://".Length..];
|
||||
else if (value.StartsWith("nats://", StringComparison.OrdinalIgnoreCase))
|
||||
value = value["nats://".Length..];
|
||||
|
||||
if (Uri.TryCreate($"nats://{value}", UriKind.Absolute, out var uri))
|
||||
return $"{uri.Host}:{uri.Port}";
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record RouteTopologySnapshot(
|
||||
|
||||
54
src/NATS.Server/Server/RateCounter.cs
Normal file
54
src/NATS.Server/Server/RateCounter.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
namespace NATS.Server.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Port of Go's rateCounter utility used for non-blocking allow/deny checks
|
||||
/// over a fixed one-second interval.
|
||||
/// Go reference: server/rate_counter.go
|
||||
/// </summary>
|
||||
public sealed class RateCounter
|
||||
{
|
||||
private readonly long _limit;
|
||||
private long _count;
|
||||
private ulong _blocked;
|
||||
private DateTime _end;
|
||||
private readonly TimeSpan _interval = TimeSpan.FromSeconds(1);
|
||||
private readonly Lock _mu = new();
|
||||
|
||||
public RateCounter(long limit)
|
||||
{
|
||||
_limit = Math.Max(1, limit);
|
||||
}
|
||||
|
||||
public bool Allow()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
lock (_mu)
|
||||
{
|
||||
if (now > _end)
|
||||
{
|
||||
_count = 0;
|
||||
_end = now + _interval;
|
||||
}
|
||||
else
|
||||
{
|
||||
_count++;
|
||||
}
|
||||
|
||||
var allow = _count < _limit;
|
||||
if (!allow)
|
||||
_blocked++;
|
||||
|
||||
return allow;
|
||||
}
|
||||
}
|
||||
|
||||
public ulong CountBlocked()
|
||||
{
|
||||
lock (_mu)
|
||||
{
|
||||
var blocked = _blocked;
|
||||
_blocked = 0;
|
||||
return blocked;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/NATS.Server/Server/ServerErrorConstants.cs
Normal file
27
src/NATS.Server/Server/ServerErrorConstants.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace NATS.Server.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Error string constants mirrored from Go server/errors.go.
|
||||
/// These are kept as literals for parity and can be used by validation
|
||||
/// paths that currently surface generic exceptions.
|
||||
/// </summary>
|
||||
public static class ServerErrorConstants
|
||||
{
|
||||
public const string ErrBadQualifier = "bad qualifier";
|
||||
public const string ErrTooManyAccountConnections = "maximum account active connections exceeded";
|
||||
public const string ErrTooManySubs = "maximum subscriptions exceeded";
|
||||
public const string ErrTooManySubTokens = "subject has exceeded number of tokens limit";
|
||||
public const string ErrReservedAccount = "reserved account";
|
||||
public const string ErrMissingService = "service missing";
|
||||
public const string ErrBadServiceType = "bad service response type";
|
||||
public const string ErrBadSampling = "bad sampling percentage, should be 1-100";
|
||||
public const string ErrAccountResolverUpdateTooSoon = "account resolver update too soon";
|
||||
public const string ErrAccountResolverSameClaims = "account resolver no new claims";
|
||||
public const string ErrStreamImportAuthorization = "stream import not authorized";
|
||||
public const string ErrStreamImportBadPrefix = "stream import prefix can not contain wildcard tokens";
|
||||
public const string ErrStreamImportDuplicate = "stream import already exists";
|
||||
public const string ErrServiceImportAuthorization = "service import not authorized";
|
||||
public const string ErrImportFormsCycle = "import forms a cycle";
|
||||
public const string ErrCycleSearchDepth = "search cycle depth exhausted";
|
||||
public const string ErrNoTransforms = "no matching transforms available";
|
||||
}
|
||||
82
src/NATS.Server/Server/ServerUtilities.cs
Normal file
82
src/NATS.Server/Server/ServerUtilities.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace NATS.Server.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Misc utility helpers ported from Go's server/util.go.
|
||||
/// </summary>
|
||||
public static class ServerUtilities
|
||||
{
|
||||
private static readonly Regex UrlAuthRegex = new(
|
||||
@"^(?<scheme>[a-zA-Z][a-zA-Z0-9+\-.]*://)(?<user>[^:@/]+):(?<pass>[^@/]+)@(?<rest>.+)$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Parse a host/port string with a default port fallback.
|
||||
/// Mirrors util.go parseHostPort behavior where 0/-1 port values fall back.
|
||||
/// </summary>
|
||||
public static (string Host, int Port) ParseHostPort(string hostPort, int defaultPort)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hostPort))
|
||||
throw new ArgumentException("no hostport specified", nameof(hostPort));
|
||||
|
||||
var input = hostPort.Trim();
|
||||
|
||||
if (input.StartsWith('['))
|
||||
{
|
||||
var endBracket = input.IndexOf(']');
|
||||
if (endBracket < 0)
|
||||
throw new FormatException($"Invalid host:port '{hostPort}'");
|
||||
|
||||
var host = input[1..endBracket].Trim();
|
||||
if (endBracket + 1 >= input.Length || input[endBracket + 1] != ':')
|
||||
return (host, defaultPort);
|
||||
|
||||
var portText = input[(endBracket + 2)..].Trim();
|
||||
if (!int.TryParse(portText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ipv6Port))
|
||||
throw new FormatException($"Invalid host:port '{hostPort}'");
|
||||
if (ipv6Port == 0 || ipv6Port == -1)
|
||||
ipv6Port = defaultPort;
|
||||
return (host, ipv6Port);
|
||||
}
|
||||
|
||||
var colonIdx = input.LastIndexOf(':');
|
||||
if (colonIdx < 0)
|
||||
return (input, defaultPort);
|
||||
|
||||
var parsedHost = input[..colonIdx].Trim();
|
||||
var parsedPortText = input[(colonIdx + 1)..].Trim();
|
||||
|
||||
if (parsedPortText.Length == 0)
|
||||
return (parsedHost, defaultPort);
|
||||
|
||||
if (!int.TryParse(parsedPortText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedPort))
|
||||
throw new FormatException($"Invalid host:port '{hostPort}'");
|
||||
|
||||
if (parsedPort == 0 || parsedPort == -1)
|
||||
parsedPort = defaultPort;
|
||||
|
||||
return (parsedHost, parsedPort);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redacts password in a single URL user-info section.
|
||||
/// </summary>
|
||||
public static string RedactUrlString(string url)
|
||||
{
|
||||
var match = UrlAuthRegex.Match(url);
|
||||
if (!match.Success)
|
||||
return url;
|
||||
|
||||
return $"{match.Groups["scheme"].Value}{match.Groups["user"].Value}:xxxxx@{match.Groups["rest"].Value}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redacts URL credentials for a URL list.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> RedactUrlList(IEnumerable<string> urls)
|
||||
{
|
||||
return urls.Select(RedactUrlString).ToArray();
|
||||
}
|
||||
}
|
||||
12
src/NATS.Server/SlopwatchSuppressAttribute.cs
Normal file
12
src/NATS.Server/SlopwatchSuppressAttribute.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
// Marker attribute recognised by the slopwatch static-analysis tool.
|
||||
// Apply to a method to suppress a specific slopwatch rule violation.
|
||||
// The justification must be 20+ characters explaining why the suppression is intentional.
|
||||
|
||||
namespace NATS.Server;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
|
||||
public sealed class SlopwatchSuppressAttribute(string ruleId, string justification) : Attribute
|
||||
{
|
||||
public string RuleId { get; } = ruleId;
|
||||
public string Justification { get; } = justification;
|
||||
}
|
||||
@@ -26,11 +26,77 @@ public sealed class SubList : IDisposable
|
||||
private ulong _inserts;
|
||||
private ulong _removes;
|
||||
private int _highFanoutNodes;
|
||||
private Action<bool>? _interestStateNotification;
|
||||
private readonly Dictionary<string, List<Action<bool>>> _queueInsertNotifications = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, List<Action<bool>>> _queueRemoveNotifications = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly record struct CachedResult(SubListResult Result, long Generation);
|
||||
internal readonly record struct RoutedSubKeyInfo(string RouteId, string Account, string Subject, string? Queue);
|
||||
|
||||
public event Action<InterestChange>? InterestChanged;
|
||||
|
||||
public SubList()
|
||||
: this(enableCache: true)
|
||||
{
|
||||
}
|
||||
|
||||
public SubList(bool enableCache)
|
||||
{
|
||||
if (!enableCache)
|
||||
_cache = null;
|
||||
}
|
||||
|
||||
public static SubList NewSublistNoCache() => new(enableCache: false);
|
||||
|
||||
public bool CacheEnabled() => _cache != null;
|
||||
|
||||
public void RegisterNotification(Action<bool> callback) => _interestStateNotification = callback;
|
||||
|
||||
public void ClearNotification() => _interestStateNotification = null;
|
||||
|
||||
public bool RegisterQueueNotification(string subject, string queue, Action<bool> callback)
|
||||
{
|
||||
if (callback == null || string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(queue))
|
||||
return false;
|
||||
if (SubjectMatch.SubjectHasWildcard(subject) || SubjectMatch.SubjectHasWildcard(queue))
|
||||
return false;
|
||||
|
||||
bool hasInterest;
|
||||
var key = QueueNotifyKey(subject, queue);
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
hasInterest = HasExactQueueInterestNoLock(subject, queue);
|
||||
var map = hasInterest ? _queueRemoveNotifications : _queueInsertNotifications;
|
||||
if (!AddQueueNotifyNoLock(map, key, callback))
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
|
||||
callback(hasInterest);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ClearQueueNotification(string subject, string queue, Action<bool> callback)
|
||||
{
|
||||
var key = QueueNotifyKey(subject, queue);
|
||||
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var removed = RemoveQueueNotifyNoLock(_queueRemoveNotifications, key, callback);
|
||||
removed |= RemoveQueueNotifyNoLock(_queueInsertNotifications, key, callback);
|
||||
return removed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
@@ -112,7 +178,7 @@ public sealed class SubList : IDisposable
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var key = $"{sub.RouteId}|{sub.Account}|{sub.Subject}|{sub.Queue}";
|
||||
var key = BuildRoutedSubKey(sub.RouteId, sub.Account, sub.Subject, sub.Queue);
|
||||
var changed = false;
|
||||
if (sub.IsRemoval)
|
||||
{
|
||||
@@ -149,6 +215,127 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateRemoteQSub(RemoteSubscription sub)
|
||||
{
|
||||
if (sub.Queue == null)
|
||||
return;
|
||||
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var key = BuildRoutedSubKey(sub.RouteId, sub.Account, sub.Subject, sub.Queue);
|
||||
if (!_remoteSubs.TryGetValue(key, out var existing))
|
||||
return;
|
||||
|
||||
var nextWeight = Math.Max(1, sub.QueueWeight);
|
||||
if (existing.QueueWeight == nextWeight)
|
||||
return;
|
||||
|
||||
_remoteSubs[key] = existing with { QueueWeight = nextWeight };
|
||||
Interlocked.Increment(ref _generation);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
internal static string BuildRoutedSubKey(string routeId, string account, string subject, string? queue)
|
||||
=> $"{routeId}|{account}|{subject}|{queue}";
|
||||
|
||||
internal static string? GetAccNameFromRoutedSubKey(string routedSubKey)
|
||||
=> GetRoutedSubKeyInfo(routedSubKey)?.Account;
|
||||
|
||||
internal static RoutedSubKeyInfo? GetRoutedSubKeyInfo(string routedSubKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(routedSubKey))
|
||||
return null;
|
||||
|
||||
var parts = routedSubKey.Split('|');
|
||||
if (parts.Length != 4)
|
||||
return null;
|
||||
|
||||
if (parts[0].Length == 0 || parts[1].Length == 0 || parts[2].Length == 0)
|
||||
return null;
|
||||
|
||||
var queue = parts[3].Length == 0 ? null : parts[3];
|
||||
return new RoutedSubKeyInfo(parts[0], parts[1], parts[2], queue);
|
||||
}
|
||||
|
||||
public int RemoveRemoteSubs(string routeId)
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var removed = 0;
|
||||
foreach (var kvp in _remoteSubs.ToArray())
|
||||
{
|
||||
var info = GetRoutedSubKeyInfo(kvp.Key);
|
||||
if (info == null || !string.Equals(info.Value.RouteId, routeId, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
if (_remoteSubs.Remove(kvp.Key))
|
||||
{
|
||||
removed++;
|
||||
InterestChanged?.Invoke(new InterestChange(
|
||||
InterestChangeKind.RemoteRemoved,
|
||||
kvp.Value.Subject,
|
||||
kvp.Value.Queue,
|
||||
kvp.Value.Account));
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0)
|
||||
Interlocked.Increment(ref _generation);
|
||||
|
||||
return removed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public int RemoveRemoteSubsForAccount(string routeId, string account)
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var removed = 0;
|
||||
foreach (var kvp in _remoteSubs.ToArray())
|
||||
{
|
||||
var info = GetRoutedSubKeyInfo(kvp.Key);
|
||||
if (info == null)
|
||||
continue;
|
||||
|
||||
if (!string.Equals(info.Value.RouteId, routeId, StringComparison.Ordinal)
|
||||
|| !string.Equals(info.Value.Account, account, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_remoteSubs.Remove(kvp.Key))
|
||||
{
|
||||
removed++;
|
||||
InterestChanged?.Invoke(new InterestChange(
|
||||
InterestChangeKind.RemoteRemoved,
|
||||
kvp.Value.Subject,
|
||||
kvp.Value.Queue,
|
||||
kvp.Value.Account));
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0)
|
||||
Interlocked.Increment(ref _generation);
|
||||
|
||||
return removed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasRemoteInterest(string subject)
|
||||
=> HasRemoteInterest("$G", subject);
|
||||
|
||||
@@ -183,6 +370,7 @@ public sealed class SubList : IDisposable
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var hadInterest = _count > 0;
|
||||
var level = _root;
|
||||
TrieNode? node = null;
|
||||
bool sawFwc = false;
|
||||
@@ -240,6 +428,10 @@ public sealed class SubList : IDisposable
|
||||
_count++;
|
||||
_inserts++;
|
||||
Interlocked.Increment(ref _generation);
|
||||
if (sub.Queue != null && _queueInsertNotifications.Count > 0)
|
||||
CheckForQueueInsertNotificationNoLock(sub.Subject, sub.Queue);
|
||||
if (!hadInterest && _count > 0)
|
||||
_interestStateNotification?.Invoke(true);
|
||||
InterestChanged?.Invoke(new InterestChange(
|
||||
InterestChangeKind.LocalAdded,
|
||||
sub.Subject,
|
||||
@@ -258,10 +450,15 @@ public sealed class SubList : IDisposable
|
||||
_lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var hadInterest = _count > 0;
|
||||
if (RemoveInternal(sub))
|
||||
{
|
||||
_removes++;
|
||||
Interlocked.Increment(ref _generation);
|
||||
if (sub.Queue != null && _queueRemoveNotifications.Count > 0)
|
||||
CheckForQueueRemoveNotificationNoLock(sub.Subject, sub.Queue);
|
||||
if (hadInterest && _count == 0)
|
||||
_interestStateNotification?.Invoke(false);
|
||||
InterestChanged?.Invoke(new InterestChange(
|
||||
InterestChangeKind.LocalRemoved,
|
||||
sub.Subject,
|
||||
@@ -455,6 +652,90 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static string QueueNotifyKey(string subject, string queue) => $"{subject} {queue}";
|
||||
|
||||
private static bool AddQueueNotifyNoLock(Dictionary<string, List<Action<bool>>> map, string key, Action<bool> callback)
|
||||
{
|
||||
if (!map.TryGetValue(key, out var callbacks))
|
||||
{
|
||||
callbacks = [];
|
||||
map[key] = callbacks;
|
||||
}
|
||||
else if (callbacks.Contains(callback))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
callbacks.Add(callback);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool RemoveQueueNotifyNoLock(Dictionary<string, List<Action<bool>>> map, string key, Action<bool> callback)
|
||||
{
|
||||
if (!map.TryGetValue(key, out var callbacks))
|
||||
return false;
|
||||
|
||||
var removed = callbacks.Remove(callback);
|
||||
if (callbacks.Count == 0)
|
||||
map.Remove(key);
|
||||
return removed;
|
||||
}
|
||||
|
||||
private bool HasExactQueueInterestNoLock(string subject, string queue)
|
||||
{
|
||||
var subs = new List<Subscription>();
|
||||
CollectAll(_root, subs);
|
||||
foreach (var sub in subs)
|
||||
{
|
||||
if (sub.Queue != null
|
||||
&& string.Equals(sub.Subject, subject, StringComparison.Ordinal)
|
||||
&& string.Equals(sub.Queue, queue, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void CheckForQueueInsertNotificationNoLock(string subject, string queue)
|
||||
{
|
||||
var key = QueueNotifyKey(subject, queue);
|
||||
if (!_queueInsertNotifications.TryGetValue(key, out var callbacks) || callbacks.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var callback in callbacks)
|
||||
callback(true);
|
||||
|
||||
if (!_queueRemoveNotifications.TryGetValue(key, out var removeCallbacks))
|
||||
{
|
||||
removeCallbacks = [];
|
||||
_queueRemoveNotifications[key] = removeCallbacks;
|
||||
}
|
||||
removeCallbacks.AddRange(callbacks);
|
||||
_queueInsertNotifications.Remove(key);
|
||||
}
|
||||
|
||||
private void CheckForQueueRemoveNotificationNoLock(string subject, string queue)
|
||||
{
|
||||
var key = QueueNotifyKey(subject, queue);
|
||||
if (!_queueRemoveNotifications.TryGetValue(key, out var callbacks) || callbacks.Count == 0)
|
||||
return;
|
||||
if (HasExactQueueInterestNoLock(subject, queue))
|
||||
return;
|
||||
|
||||
foreach (var callback in callbacks)
|
||||
callback(false);
|
||||
|
||||
if (!_queueInsertNotifications.TryGetValue(key, out var insertCallbacks))
|
||||
{
|
||||
insertCallbacks = [];
|
||||
_queueInsertNotifications[key] = insertCallbacks;
|
||||
}
|
||||
insertCallbacks.AddRange(callbacks);
|
||||
_queueRemoveNotifications.Remove(key);
|
||||
}
|
||||
|
||||
private void SweepCache()
|
||||
{
|
||||
_lock.EnterWriteLock();
|
||||
@@ -632,6 +913,9 @@ public sealed class SubList : IDisposable
|
||||
CacheHitRate = hitRate,
|
||||
MaxFanout = maxFanout,
|
||||
AvgFanout = cacheEntries > 0 ? (double)totalFanout / cacheEntries : 0.0,
|
||||
TotalFanout = (int)totalFanout,
|
||||
CacheEntries = cacheEntries,
|
||||
CacheHits = cacheHits,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -695,7 +979,11 @@ public sealed class SubList : IDisposable
|
||||
foreach (var sub in subs)
|
||||
{
|
||||
if (RemoveInternal(sub))
|
||||
{
|
||||
_removes++;
|
||||
if (sub.Queue != null && _queueRemoveNotifications.Count > 0)
|
||||
CheckForQueueRemoveNotificationNoLock(sub.Subject, sub.Queue);
|
||||
}
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _generation);
|
||||
@@ -724,6 +1012,34 @@ public sealed class SubList : IDisposable
|
||||
return subs;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Subscription> LocalSubs(bool includeLeafHubs = false)
|
||||
{
|
||||
var subs = new List<Subscription>();
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
CollectLocalSubs(_root, subs, includeLeafHubs);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
return subs;
|
||||
}
|
||||
|
||||
internal int NumLevels()
|
||||
{
|
||||
_lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
return VisitLevel(_root, 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
public SubListResult ReverseMatch(string subject)
|
||||
{
|
||||
var tokens = Tokenize(subject);
|
||||
@@ -857,6 +1173,82 @@ public sealed class SubList : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static void CollectLocalSubs(TrieLevel level, List<Subscription> subs, bool includeLeafHubs)
|
||||
{
|
||||
foreach (var (_, node) in level.Nodes)
|
||||
{
|
||||
AddNodeLocalSubs(node, subs, includeLeafHubs);
|
||||
if (node.Next != null)
|
||||
CollectLocalSubs(node.Next, subs, includeLeafHubs);
|
||||
}
|
||||
if (level.Pwc != null)
|
||||
{
|
||||
AddNodeLocalSubs(level.Pwc, subs, includeLeafHubs);
|
||||
if (level.Pwc.Next != null)
|
||||
CollectLocalSubs(level.Pwc.Next, subs, includeLeafHubs);
|
||||
}
|
||||
if (level.Fwc != null)
|
||||
{
|
||||
AddNodeLocalSubs(level.Fwc, subs, includeLeafHubs);
|
||||
if (level.Fwc.Next != null)
|
||||
CollectLocalSubs(level.Fwc.Next, subs, includeLeafHubs);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddNodeLocalSubs(TrieNode node, List<Subscription> subs, bool includeLeafHubs)
|
||||
{
|
||||
foreach (var sub in node.PlainSubs)
|
||||
AddLocalSub(sub, subs, includeLeafHubs);
|
||||
foreach (var (_, qset) in node.QueueSubs)
|
||||
foreach (var sub in qset)
|
||||
AddLocalSub(sub, subs, includeLeafHubs);
|
||||
}
|
||||
|
||||
private static void AddLocalSub(Subscription sub, List<Subscription> subs, bool includeLeafHubs)
|
||||
{
|
||||
if (sub.Client == null)
|
||||
return;
|
||||
|
||||
var kind = sub.Client.Kind;
|
||||
if (kind is global::NATS.Server.ClientKind.Client
|
||||
or global::NATS.Server.ClientKind.System
|
||||
or global::NATS.Server.ClientKind.JetStream
|
||||
or global::NATS.Server.ClientKind.Account
|
||||
|| (includeLeafHubs && kind == global::NATS.Server.ClientKind.Leaf))
|
||||
{
|
||||
subs.Add(sub);
|
||||
}
|
||||
}
|
||||
|
||||
private static int VisitLevel(TrieLevel? level, int depth)
|
||||
{
|
||||
if (level == null || (level.Nodes.Count == 0 && level.Pwc == null && level.Fwc == null))
|
||||
return depth;
|
||||
|
||||
depth++;
|
||||
var maxDepth = depth;
|
||||
foreach (var (_, node) in level.Nodes)
|
||||
{
|
||||
var childDepth = VisitLevel(node.Next, depth);
|
||||
if (childDepth > maxDepth)
|
||||
maxDepth = childDepth;
|
||||
}
|
||||
if (level.Pwc != null)
|
||||
{
|
||||
var pwcDepth = VisitLevel(level.Pwc.Next, depth);
|
||||
if (pwcDepth > maxDepth)
|
||||
maxDepth = pwcDepth;
|
||||
}
|
||||
if (level.Fwc != null)
|
||||
{
|
||||
var fwcDepth = VisitLevel(level.Fwc.Next, depth);
|
||||
if (fwcDepth > maxDepth)
|
||||
maxDepth = fwcDepth;
|
||||
}
|
||||
|
||||
return maxDepth;
|
||||
}
|
||||
|
||||
private static void ReverseMatchLevel(TrieLevel? level, string[] tokens, int tokenIndex,
|
||||
List<Subscription> plainSubs, List<List<Subscription>> queueSubs)
|
||||
{
|
||||
|
||||
@@ -2,12 +2,36 @@ namespace NATS.Server.Subscriptions;
|
||||
|
||||
public sealed class SubListStats
|
||||
{
|
||||
public uint NumSubs { get; init; }
|
||||
public uint NumCache { get; init; }
|
||||
public ulong NumInserts { get; init; }
|
||||
public ulong NumRemoves { get; init; }
|
||||
public ulong NumMatches { get; init; }
|
||||
public double CacheHitRate { get; init; }
|
||||
public uint MaxFanout { get; init; }
|
||||
public double AvgFanout { get; init; }
|
||||
public uint NumSubs { get; set; }
|
||||
public uint NumCache { get; set; }
|
||||
public ulong NumInserts { get; set; }
|
||||
public ulong NumRemoves { get; set; }
|
||||
public ulong NumMatches { get; set; }
|
||||
public double CacheHitRate { get; set; }
|
||||
public uint MaxFanout { get; set; }
|
||||
public double AvgFanout { get; set; }
|
||||
|
||||
internal int TotalFanout { get; set; }
|
||||
internal int CacheEntries { get; set; }
|
||||
internal ulong CacheHits { get; set; }
|
||||
|
||||
public void Add(SubListStats stat)
|
||||
{
|
||||
NumSubs += stat.NumSubs;
|
||||
NumCache += stat.NumCache;
|
||||
NumInserts += stat.NumInserts;
|
||||
NumRemoves += stat.NumRemoves;
|
||||
NumMatches += stat.NumMatches;
|
||||
CacheHits += stat.CacheHits;
|
||||
if (MaxFanout < stat.MaxFanout)
|
||||
MaxFanout = stat.MaxFanout;
|
||||
|
||||
TotalFanout += stat.TotalFanout;
|
||||
CacheEntries += stat.CacheEntries;
|
||||
if (TotalFanout > 0 && CacheEntries > 0)
|
||||
AvgFanout = (double)TotalFanout / CacheEntries;
|
||||
|
||||
if (NumMatches > 0)
|
||||
CacheHitRate = (double)CacheHits / NumMatches;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,10 @@ public static class SubjectMatch
|
||||
return IsValidSubject(subject) && IsLiteral(subject);
|
||||
}
|
||||
|
||||
public static bool SubjectHasWildcard(string subject) => !IsLiteral(subject);
|
||||
|
||||
public static bool IsValidLiteralSubject(string subject) => IsValidPublishSubject(subject);
|
||||
|
||||
/// <summary>
|
||||
/// Match a literal subject against a pattern that may contain wildcards.
|
||||
/// </summary>
|
||||
@@ -196,6 +200,55 @@ public static class SubjectMatch
|
||||
return true;
|
||||
}
|
||||
|
||||
// Go reference: sublist.go SubjectMatchesFilter / subjectIsSubsetMatch / isSubsetMatch / isSubsetMatchTokenized.
|
||||
// This is used by JetStream stores to evaluate subject filters with wildcard semantics.
|
||||
public static bool SubjectMatchesFilter(string subject, string filter) => SubjectIsSubsetMatch(subject, filter);
|
||||
|
||||
public static bool SubjectIsSubsetMatch(string subject, string test)
|
||||
{
|
||||
var subjectTokens = TokenizeSubject(subject);
|
||||
return IsSubsetMatch(subjectTokens, test);
|
||||
}
|
||||
|
||||
public static bool IsSubsetMatch(string[] tokens, string test)
|
||||
{
|
||||
var testTokens = TokenizeSubject(test);
|
||||
return IsSubsetMatchTokenized(tokens, testTokens);
|
||||
}
|
||||
|
||||
public static bool IsSubsetMatchTokenized(IReadOnlyList<string> tokens, IReadOnlyList<string> test)
|
||||
{
|
||||
for (var i = 0; i < test.Count; i++)
|
||||
{
|
||||
if (i >= tokens.Count)
|
||||
return false;
|
||||
|
||||
var t2 = test[i];
|
||||
if (t2.Length == 0)
|
||||
return false;
|
||||
|
||||
if (t2.Length == 1 && t2[0] == Fwc)
|
||||
return true;
|
||||
|
||||
var t1 = tokens[i];
|
||||
if (t1.Length == 0 || (t1.Length == 1 && t1[0] == Fwc))
|
||||
return false;
|
||||
|
||||
if (t1.Length == 1 && t1[0] == Pwc)
|
||||
{
|
||||
var bothPwc = t2.Length == 1 && t2[0] == Pwc;
|
||||
if (!bothPwc)
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(t2.Length == 1 && t2[0] == Pwc) && !string.Equals(t1, t2, StringComparison.Ordinal))
|
||||
return false;
|
||||
}
|
||||
|
||||
return tokens.Count == test.Count;
|
||||
}
|
||||
|
||||
private static bool TokensCanMatch(ReadOnlySpan<char> t1, ReadOnlySpan<char> t2)
|
||||
{
|
||||
if (t1.Length == 1 && (t1[0] == Pwc || t1[0] == Fwc))
|
||||
@@ -205,6 +258,8 @@ public static class SubjectMatch
|
||||
return t1.SequenceEqual(t2);
|
||||
}
|
||||
|
||||
private static string[] TokenizeSubject(string subject) => (subject ?? string.Empty).Split(Sep);
|
||||
|
||||
/// <summary>
|
||||
/// Validates subject. When checkRunes is true, also rejects null bytes.
|
||||
/// </summary>
|
||||
|
||||
@@ -108,6 +108,10 @@ public sealed partial class SubjectTransform
|
||||
{
|
||||
ops[i] = new TransformOp(TransformType.Partition, [], parsed.IntArg, parsed.StringArg);
|
||||
}
|
||||
else if (parsed.Type == TransformType.Random)
|
||||
{
|
||||
ops[i] = new TransformOp(TransformType.Random, [], parsed.IntArg, parsed.StringArg);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Other functions not allowed without wildcards in source
|
||||
@@ -119,6 +123,109 @@ public sealed partial class SubjectTransform
|
||||
return new SubjectTransform(source, destination, srcTokens, destTokens, ops);
|
||||
}
|
||||
|
||||
public static SubjectTransform? NewSubjectTransformWithStrict(string source, string destination, bool strict)
|
||||
{
|
||||
var transform = Create(source, destination);
|
||||
if (transform == null || !strict)
|
||||
return transform;
|
||||
|
||||
return UsesAllSourceWildcards(source, destination) ? transform : null;
|
||||
}
|
||||
|
||||
public static SubjectTransform? NewSubjectTransformStrict(string source, string destination)
|
||||
=> NewSubjectTransformWithStrict(source, destination, strict: true);
|
||||
|
||||
public static bool ValidateMapping(string destination)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(destination))
|
||||
return false;
|
||||
|
||||
var (valid, tokens, pwcCount, _) = SubjectInfo(destination);
|
||||
if (!valid || pwcCount > 0)
|
||||
return false;
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (ParseDestToken(token) == null)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static string TransformTokenize(string subject)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
return subject;
|
||||
|
||||
var tokens = subject.Split('.');
|
||||
var wildcard = 0;
|
||||
|
||||
for (var i = 0; i < tokens.Length; i++)
|
||||
{
|
||||
if (tokens[i] == "*")
|
||||
tokens[i] = $"${++wildcard}";
|
||||
}
|
||||
|
||||
return string.Join('.', tokens);
|
||||
}
|
||||
|
||||
public static string TransformUntokenize(string subject)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
return subject;
|
||||
|
||||
var tokens = subject.Split('.');
|
||||
for (var i = 0; i < tokens.Length; i++)
|
||||
{
|
||||
if (TryParseWildcardToken(tokens[i], out _))
|
||||
tokens[i] = "*";
|
||||
}
|
||||
|
||||
return string.Join('.', tokens);
|
||||
}
|
||||
|
||||
public SubjectTransform? Reverse()
|
||||
{
|
||||
var tokenizedSource = TransformTokenize(_source);
|
||||
var tokenizedDest = TransformTokenize(_dest);
|
||||
|
||||
var sourceTokens = tokenizedSource.Split('.');
|
||||
var destTokens = tokenizedDest.Split('.');
|
||||
|
||||
var oldToNewWildcard = new Dictionary<int, int>();
|
||||
var reverseWildcard = 0;
|
||||
foreach (var token in destTokens)
|
||||
{
|
||||
if (!TryParseWildcardToken(token, out var sourceWildcard))
|
||||
continue;
|
||||
|
||||
reverseWildcard++;
|
||||
if (!oldToNewWildcard.ContainsKey(sourceWildcard))
|
||||
oldToNewWildcard[sourceWildcard] = reverseWildcard;
|
||||
}
|
||||
|
||||
var reverseDestTokens = new string[sourceTokens.Length];
|
||||
for (var i = 0; i < sourceTokens.Length; i++)
|
||||
{
|
||||
var token = sourceTokens[i];
|
||||
if (!TryParseWildcardToken(token, out var sourceWildcard))
|
||||
{
|
||||
reverseDestTokens[i] = token;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!oldToNewWildcard.TryGetValue(sourceWildcard, out var mappedWildcard))
|
||||
return null;
|
||||
|
||||
reverseDestTokens[i] = $"${mappedWildcard}";
|
||||
}
|
||||
|
||||
var reverseSource = TransformUntokenize(tokenizedDest);
|
||||
var reverseDest = string.Join('.', reverseDestTokens);
|
||||
return Create(reverseSource, reverseDest);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches subject against source pattern, captures wildcard values, evaluates destination template.
|
||||
/// Returns null if subject doesn't match source.
|
||||
@@ -141,6 +248,14 @@ public sealed partial class SubjectTransform
|
||||
return TransformTokenized(subjectTokens);
|
||||
}
|
||||
|
||||
public string TransformSubject(string subject)
|
||||
{
|
||||
if (string.IsNullOrEmpty(subject))
|
||||
return string.Empty;
|
||||
|
||||
return TransformTokenized(subject.Split('.'));
|
||||
}
|
||||
|
||||
private string TransformTokenized(string[] tokens)
|
||||
{
|
||||
if (_ops.Length == 0)
|
||||
@@ -174,6 +289,10 @@ public sealed partial class SubjectTransform
|
||||
sb.Append(ComputePartition(tokens, op));
|
||||
break;
|
||||
|
||||
case TransformType.Random:
|
||||
sb.Append(GetRandomPartition(op.IntArg));
|
||||
break;
|
||||
|
||||
case TransformType.Split:
|
||||
ApplySplit(sb, tokens, op);
|
||||
break;
|
||||
@@ -252,6 +371,14 @@ public sealed partial class SubjectTransform
|
||||
return (hash % (uint)numBuckets).ToString();
|
||||
}
|
||||
|
||||
private static int GetRandomPartition(int numBuckets)
|
||||
{
|
||||
if (numBuckets <= 0)
|
||||
return 0;
|
||||
|
||||
return Random.Shared.Next(numBuckets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FNV-1a 32-bit hash. Offset basis: 2166136261, prime: 16777619.
|
||||
/// </summary>
|
||||
@@ -554,6 +681,19 @@ public sealed partial class SubjectTransform
|
||||
return new ParsedToken(TransformType.Partition, indexes, buckets, string.Empty);
|
||||
}
|
||||
|
||||
// random(numBuckets)
|
||||
args = GetFunctionArgs(RandomRegex(), token);
|
||||
if (args != null)
|
||||
{
|
||||
if (args.Length != 1)
|
||||
return null;
|
||||
|
||||
if (!TryParseInt32(args[0].Trim(), out int numBuckets))
|
||||
return null;
|
||||
|
||||
return new ParsedToken(TransformType.Random, [], numBuckets, string.Empty);
|
||||
}
|
||||
|
||||
// splitFromLeft(token, position)
|
||||
args = GetFunctionArgs(SplitFromLeftRegex(), token);
|
||||
if (args != null)
|
||||
@@ -623,6 +763,46 @@ public sealed partial class SubjectTransform
|
||||
return new ParsedToken(type, [idx], intArg, string.Empty);
|
||||
}
|
||||
|
||||
private static bool UsesAllSourceWildcards(string source, string destination)
|
||||
{
|
||||
var (srcValid, _, srcPwcCount, _) = SubjectInfo(source);
|
||||
if (!srcValid || srcPwcCount == 0)
|
||||
return true;
|
||||
|
||||
var (_, destTokens, _, _) = SubjectInfo(destination);
|
||||
var used = new HashSet<int>();
|
||||
|
||||
foreach (var token in destTokens)
|
||||
{
|
||||
var parsed = ParseDestToken(token);
|
||||
if (parsed == null)
|
||||
return false;
|
||||
|
||||
foreach (var wildcardIndex in parsed.WildcardIndexes)
|
||||
{
|
||||
if (wildcardIndex >= 1 && wildcardIndex <= srcPwcCount)
|
||||
used.Add(wildcardIndex);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 1; i <= srcPwcCount; i++)
|
||||
{
|
||||
if (!used.Contains(i))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseWildcardToken(string token, out int wildcardIndex)
|
||||
{
|
||||
wildcardIndex = 0;
|
||||
if (token.Length < 2 || token[0] != '$')
|
||||
return false;
|
||||
|
||||
return int.TryParse(token.AsSpan(1), out wildcardIndex) && wildcardIndex > 0;
|
||||
}
|
||||
|
||||
private static bool TryParseInt32(string s, out int result)
|
||||
{
|
||||
// Parse as long first to detect overflow
|
||||
@@ -655,6 +835,9 @@ public sealed partial class SubjectTransform
|
||||
[GeneratedRegex(@"\{\{\s*[pP]artition\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex PartitionRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[rR]andom\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex RandomRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{\s*[sS]plit[fF]rom[lL]eft\s*\((.*)\)\s*\}\}")]
|
||||
private static partial Regex SplitFromLeftRegex();
|
||||
|
||||
@@ -684,6 +867,7 @@ public sealed partial class SubjectTransform
|
||||
None,
|
||||
Wildcard,
|
||||
Partition,
|
||||
Random,
|
||||
Split,
|
||||
SplitFromLeft,
|
||||
SplitFromRight,
|
||||
|
||||
192
src/NATS.Server/Tls/OcspPeerConfig.cs
Normal file
192
src/NATS.Server/Tls/OcspPeerConfig.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using NATS.Server.Configuration;
|
||||
|
||||
namespace NATS.Server.Tls;
|
||||
|
||||
[JsonConverter(typeof(StatusAssertionJsonConverter))]
|
||||
public enum StatusAssertion
|
||||
{
|
||||
Good = 0,
|
||||
Revoked = 1,
|
||||
Unknown = 2,
|
||||
}
|
||||
|
||||
public static class StatusAssertionMaps
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, StatusAssertion> StatusAssertionStrToVal =
|
||||
new Dictionary<string, StatusAssertion>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["good"] = StatusAssertion.Good,
|
||||
["revoked"] = StatusAssertion.Revoked,
|
||||
["unknown"] = StatusAssertion.Unknown,
|
||||
};
|
||||
|
||||
public static readonly IReadOnlyDictionary<StatusAssertion, string> StatusAssertionValToStr =
|
||||
new Dictionary<StatusAssertion, string>
|
||||
{
|
||||
[StatusAssertion.Good] = "good",
|
||||
[StatusAssertion.Revoked] = "revoked",
|
||||
[StatusAssertion.Unknown] = "unknown",
|
||||
};
|
||||
|
||||
public static readonly IReadOnlyDictionary<int, StatusAssertion> StatusAssertionIntToVal =
|
||||
new Dictionary<int, StatusAssertion>
|
||||
{
|
||||
[0] = StatusAssertion.Good,
|
||||
[1] = StatusAssertion.Revoked,
|
||||
[2] = StatusAssertion.Unknown,
|
||||
};
|
||||
|
||||
public static string GetStatusAssertionStr(int sa)
|
||||
{
|
||||
var value = StatusAssertionIntToVal.TryGetValue(sa, out var mapped)
|
||||
? mapped
|
||||
: StatusAssertion.Unknown;
|
||||
return StatusAssertionValToStr[value];
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StatusAssertionJsonConverter : JsonConverter<StatusAssertion>
|
||||
{
|
||||
public override StatusAssertion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.String)
|
||||
{
|
||||
var str = reader.GetString();
|
||||
if (str is not null && StatusAssertionMaps.StatusAssertionStrToVal.TryGetValue(str, out var mapped))
|
||||
return mapped;
|
||||
return StatusAssertion.Unknown;
|
||||
}
|
||||
|
||||
if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var v))
|
||||
{
|
||||
return StatusAssertionMaps.StatusAssertionIntToVal.TryGetValue(v, out var mapped)
|
||||
? mapped
|
||||
: StatusAssertion.Unknown;
|
||||
}
|
||||
|
||||
return StatusAssertion.Unknown;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, StatusAssertion value, JsonSerializerOptions options)
|
||||
{
|
||||
if (!StatusAssertionMaps.StatusAssertionValToStr.TryGetValue(value, out var str))
|
||||
str = StatusAssertionMaps.StatusAssertionValToStr[StatusAssertion.Unknown];
|
||||
writer.WriteStringValue(str);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ChainLink
|
||||
{
|
||||
public X509Certificate2? Leaf { get; set; }
|
||||
public X509Certificate2? Issuer { get; set; }
|
||||
public IReadOnlyList<Uri>? OCSPWebEndpoints { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OcspResponseInfo
|
||||
{
|
||||
public DateTime ThisUpdate { get; init; }
|
||||
public DateTime? NextUpdate { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CertInfo
|
||||
{
|
||||
[JsonPropertyName("subject")]
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("issuer")]
|
||||
public string Issuer { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fingerprint")]
|
||||
public string Fingerprint { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("raw")]
|
||||
public byte[] Raw { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class OCSPPeerConfig
|
||||
{
|
||||
public static readonly TimeSpan DefaultAllowedClockSkew = TimeSpan.FromSeconds(30);
|
||||
public static readonly TimeSpan DefaultOCSPResponderTimeout = TimeSpan.FromSeconds(2);
|
||||
public static readonly TimeSpan DefaultTTLUnsetNextUpdate = TimeSpan.FromHours(1);
|
||||
|
||||
public bool Verify { get; set; }
|
||||
public double Timeout { get; set; } = DefaultOCSPResponderTimeout.TotalSeconds;
|
||||
public double ClockSkew { get; set; } = DefaultAllowedClockSkew.TotalSeconds;
|
||||
public bool WarnOnly { get; set; }
|
||||
public bool UnknownIsGood { get; set; }
|
||||
public bool AllowWhenCAUnreachable { get; set; }
|
||||
public double TTLUnsetNextUpdate { get; set; } = DefaultTTLUnsetNextUpdate.TotalSeconds;
|
||||
|
||||
public static OCSPPeerConfig NewOCSPPeerConfig() => new();
|
||||
|
||||
public static OCSPPeerConfig Parse(IReadOnlyDictionary<string, object?> values)
|
||||
{
|
||||
var cfg = NewOCSPPeerConfig();
|
||||
foreach (var (key, rawValue) in values)
|
||||
{
|
||||
switch (key.ToLowerInvariant())
|
||||
{
|
||||
case "verify":
|
||||
cfg.Verify = ParseBool(rawValue, key);
|
||||
break;
|
||||
case "allowed_clockskew":
|
||||
ApplyIfNonNegative(rawValue, key, v => cfg.ClockSkew = v);
|
||||
break;
|
||||
case "ca_timeout":
|
||||
ApplyIfNonNegative(rawValue, key, v => cfg.Timeout = v);
|
||||
break;
|
||||
case "cache_ttl_when_next_update_unset":
|
||||
ApplyIfNonNegative(rawValue, key, v => cfg.TTLUnsetNextUpdate = v);
|
||||
break;
|
||||
case "warn_only":
|
||||
cfg.WarnOnly = ParseBool(rawValue, key);
|
||||
break;
|
||||
case "unknown_is_good":
|
||||
cfg.UnknownIsGood = ParseBool(rawValue, key);
|
||||
break;
|
||||
case "allow_when_ca_unreachable":
|
||||
cfg.AllowWhenCAUnreachable = ParseBool(rawValue, key);
|
||||
break;
|
||||
default:
|
||||
throw new FormatException($"error parsing tls peer config, unknown field [{key}]");
|
||||
}
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private static bool ParseBool(object? rawValue, string key)
|
||||
{
|
||||
if (rawValue is bool b)
|
||||
return b;
|
||||
throw new FormatException($"error parsing tls peer config, unknown field [{key}]");
|
||||
}
|
||||
|
||||
private static void ApplyIfNonNegative(object? rawValue, string key, Action<double> apply)
|
||||
{
|
||||
var parsed = ParseSeconds(rawValue, key);
|
||||
if (parsed >= 0)
|
||||
apply(parsed);
|
||||
}
|
||||
|
||||
private static double ParseSeconds(object? rawValue, string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
return rawValue switch
|
||||
{
|
||||
long l => l,
|
||||
double d => d,
|
||||
string s => ConfigProcessor.ParseDuration(s).TotalSeconds,
|
||||
_ => throw new FormatException("unexpected type"),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new FormatException($"error parsing tls peer config, conversion error: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/NATS.Server/Tls/OcspPeerMessages.cs
Normal file
85
src/NATS.Server/Tls/OcspPeerMessages.cs
Normal file
@@ -0,0 +1,85 @@
|
||||
namespace NATS.Server.Tls;
|
||||
|
||||
public static class OcspPeerMessages
|
||||
{
|
||||
// Returned errors
|
||||
public const string ErrIllegalPeerOptsConfig = "expected map to define OCSP peer options, got [%T]";
|
||||
public const string ErrIllegalCacheOptsConfig = "expected map to define OCSP peer cache options, got [%T]";
|
||||
public const string ErrParsingPeerOptFieldGeneric = "error parsing tls peer config, unknown field [%q]";
|
||||
public const string ErrParsingPeerOptFieldTypeConversion = "error parsing tls peer config, conversion error: %s";
|
||||
public const string ErrParsingCacheOptFieldTypeConversion = "error parsing OCSP peer cache config, conversion error: %s";
|
||||
public const string ErrUnableToPlugTLSEmptyConfig = "unable to plug TLS verify connection, config is nil";
|
||||
public const string ErrMTLSRequired = "OCSP peer verification for client connections requires TLS verify (mTLS) to be enabled";
|
||||
public const string ErrUnableToPlugTLSClient = "unable to register client OCSP verification";
|
||||
public const string ErrUnableToPlugTLSServer = "unable to register server OCSP verification";
|
||||
public const string ErrCannotWriteCompressed = "error writing to compression writer: %w";
|
||||
public const string ErrCannotReadCompressed = "error reading compression reader: %w";
|
||||
public const string ErrTruncatedWrite = "short write on body (%d != %d)";
|
||||
public const string ErrCannotCloseWriter = "error closing compression writer: %w";
|
||||
public const string ErrParsingCacheOptFieldGeneric = "error parsing OCSP peer cache config, unknown field [%q]";
|
||||
public const string ErrUnknownCacheType = "error parsing OCSP peer cache config, unknown type [%s]";
|
||||
public const string ErrInvalidChainlink = "invalid chain link";
|
||||
public const string ErrBadResponderHTTPStatus = "bad OCSP responder http status: [%d]";
|
||||
public const string ErrNoAvailOCSPServers = "no available OCSP servers";
|
||||
public const string ErrFailedWithAllRequests = "exhausted OCSP responders: %w";
|
||||
|
||||
// Direct logged errors
|
||||
public const string ErrLoadCacheFail = "Unable to load OCSP peer cache: %s";
|
||||
public const string ErrSaveCacheFail = "Unable to save OCSP peer cache: %s";
|
||||
public const string ErrBadCacheTypeConfig = "Unimplemented OCSP peer cache type [%v]";
|
||||
public const string ErrResponseCompressFail = "Unable to compress OCSP response for key [%s]: %s";
|
||||
public const string ErrResponseDecompressFail = "Unable to decompress OCSP response for key [%s]: %s";
|
||||
public const string ErrPeerEmptyNoEvent = "Peer certificate is nil, cannot send OCSP peer reject event";
|
||||
public const string ErrPeerEmptyAutoReject = "Peer certificate is nil, rejecting OCSP peer";
|
||||
|
||||
// Debug information
|
||||
public const string DbgPlugTLSForKind = "Plugging TLS OCSP peer for [%s]";
|
||||
public const string DbgNumServerChains = "Peer OCSP enabled: %d TLS server chain(s) will be evaluated";
|
||||
public const string DbgNumClientChains = "Peer OCSP enabled: %d TLS client chain(s) will be evaluated";
|
||||
public const string DbgLinksInChain = "Chain [%d]: %d total link(s)";
|
||||
public const string DbgSelfSignedValid = "Chain [%d] is self-signed, thus peer is valid";
|
||||
public const string DbgValidNonOCSPChain = "Chain [%d] has no OCSP eligible links, thus peer is valid";
|
||||
public const string DbgChainIsOCSPEligible = "Chain [%d] has %d OCSP eligible link(s)";
|
||||
public const string DbgChainIsOCSPValid = "Chain [%d] is OCSP valid for all eligible links, thus peer is valid";
|
||||
public const string DbgNoOCSPValidChains = "No OCSP valid chains, thus peer is invalid";
|
||||
public const string DbgCheckingCacheForCert = "Checking OCSP peer cache for [%s], key [%s]";
|
||||
public const string DbgCurrentResponseCached = "Cached OCSP response is current, status [%s]";
|
||||
public const string DbgExpiredResponseCached = "Cached OCSP response is expired, status [%s]";
|
||||
public const string DbgOCSPValidPeerLink = "OCSP verify pass for [%s]";
|
||||
public const string DbgCachingResponse = "Caching OCSP response for [%s], key [%s]";
|
||||
public const string DbgAchievedCompression = "OCSP response compression ratio: [%f]";
|
||||
public const string DbgCacheHit = "OCSP peer cache hit for key [%s]";
|
||||
public const string DbgCacheMiss = "OCSP peer cache miss for key [%s]";
|
||||
public const string DbgPreservedRevocation = "Revoked OCSP response for key [%s] preserved by cache policy";
|
||||
public const string DbgDeletingCacheResponse = "Deleting OCSP peer cached response for key [%s]";
|
||||
public const string DbgStartingCache = "Starting OCSP peer cache";
|
||||
public const string DbgStoppingCache = "Stopping OCSP peer cache";
|
||||
public const string DbgLoadingCache = "Loading OCSP peer cache [%s]";
|
||||
public const string DbgNoCacheFound = "No OCSP peer cache found, starting with empty cache";
|
||||
public const string DbgSavingCache = "Saving OCSP peer cache [%s]";
|
||||
public const string DbgCacheSaved = "Saved OCSP peer cache successfully (%d bytes)";
|
||||
public const string DbgMakingCARequest = "Trying OCSP responder url [%s]";
|
||||
public const string DbgResponseExpired = "OCSP response NextUpdate [%s] is before now [%s] with clockskew [%s]";
|
||||
public const string DbgResponseTTLExpired = "OCSP response cache expiry [%s] is before now [%s] with clockskew [%s]";
|
||||
public const string DbgResponseFutureDated = "OCSP response ThisUpdate [%s] is before now [%s] with clockskew [%s]";
|
||||
public const string DbgCacheSaveTimerExpired = "OCSP peer cache save timer expired";
|
||||
public const string DbgCacheDirtySave = "OCSP peer cache is dirty, saving";
|
||||
|
||||
public const string MsgTLSClientRejectConnection = "client not OCSP valid";
|
||||
public const string MsgTLSServerRejectConnection = "server not OCSP valid";
|
||||
public const string ErrCAResponderCalloutFail = "Attempt to obtain OCSP response from CA responder for [%s] failed: %s";
|
||||
public const string ErrNewCAResponseNotCurrent = "New OCSP CA response obtained for [%s] but not current";
|
||||
public const string ErrCAResponseParseFailed = "Could not parse OCSP CA response for [%s]: %s";
|
||||
public const string ErrOCSPInvalidPeerLink = "OCSP verify fail for [%s] with CA status [%s]";
|
||||
public const string MsgAllowWhenCAUnreachableOccurred = "Failed to obtain OCSP CA response for [%s] but AllowWhenCAUnreachable set; no cached revocation so allowing";
|
||||
public const string MsgAllowWhenCAUnreachableOccurredCachedRevoke = "Failed to obtain OCSP CA response for [%s] but AllowWhenCAUnreachable set; cached revocation exists so rejecting";
|
||||
public const string MsgAllowWarnOnlyOccurred = "OCSP verify fail for [%s] but WarnOnly is true so allowing";
|
||||
public const string MsgCacheOnline = "OCSP peer cache online, type [%s]";
|
||||
public const string MsgCacheOffline = "OCSP peer cache offline, type [%s]";
|
||||
public const string MsgFailedOCSPResponseFetch = "Failed OCSP response fetch";
|
||||
public const string MsgOCSPResponseNotEffective = "OCSP response not in effectivity window";
|
||||
public const string MsgFailedOCSPResponseParse = "Failed OCSP response parse";
|
||||
public const string MsgOCSPResponseInvalidStatus = "Invalid OCSP response status: %s";
|
||||
public const string MsgOCSPResponseDelegationInvalid = "Invalid OCSP response delegation: %s";
|
||||
public const string MsgCachedOCSPResponseInvalid = "Invalid cached OCSP response for [%s] with fingerprint [%s]";
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Net.Security;
|
||||
using System.Formats.Asn1;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
@@ -7,6 +9,10 @@ namespace NATS.Server.Tls;
|
||||
|
||||
public static class TlsHelper
|
||||
{
|
||||
private const string AuthorityInfoAccessOid = "1.3.6.1.5.5.7.1.1";
|
||||
private const string OcspAccessMethodOid = "1.3.6.1.5.5.7.48.1";
|
||||
private const string OcspSigningEkuOid = "1.3.6.1.5.5.7.3.9";
|
||||
|
||||
public static X509Certificate2 LoadCertificate(string certPath, string? keyPath)
|
||||
{
|
||||
if (keyPath != null)
|
||||
@@ -16,9 +22,48 @@ public static class TlsHelper
|
||||
|
||||
public static X509Certificate2Collection LoadCaCertificates(string caPath)
|
||||
{
|
||||
var collection = new X509Certificate2Collection();
|
||||
collection.ImportFromPemFile(caPath);
|
||||
return collection;
|
||||
var pem = File.ReadAllText(caPath);
|
||||
return ParseCertPem(pem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses one or more PEM blocks and requires all blocks to be CERTIFICATE.
|
||||
/// Mirrors Go parseCertPEM behavior by rejecting unexpected block types.
|
||||
/// </summary>
|
||||
public static X509Certificate2Collection ParseCertPem(string pemData)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pemData))
|
||||
throw new InvalidDataException("PEM data is empty.");
|
||||
|
||||
var beginMatches = Regex.Matches(pemData, "-----BEGIN ([^-]+)-----");
|
||||
if (beginMatches.Count == 0)
|
||||
throw new InvalidDataException("No PEM certificate block found.");
|
||||
|
||||
foreach (Match match in beginMatches)
|
||||
{
|
||||
var label = match.Groups[1].Value;
|
||||
if (!string.Equals(label, "CERTIFICATE", StringComparison.Ordinal))
|
||||
throw new InvalidDataException($"unexpected PEM certificate type: {label}");
|
||||
}
|
||||
|
||||
var certs = new X509Certificate2Collection();
|
||||
var certMatches = Regex.Matches(
|
||||
pemData,
|
||||
"-----BEGIN CERTIFICATE-----\\s*(?<body>[A-Za-z0-9+/=\\r\\n]+?)\\s*-----END CERTIFICATE-----",
|
||||
RegexOptions.Singleline);
|
||||
|
||||
foreach (Match certMatch in certMatches)
|
||||
{
|
||||
var body = certMatch.Groups["body"].Value;
|
||||
var normalized = Regex.Replace(body, "\\s+", "", RegexOptions.Singleline);
|
||||
var der = Convert.FromBase64String(normalized);
|
||||
certs.Add(X509CertificateLoader.LoadCertificate(der));
|
||||
}
|
||||
|
||||
if (certs.Count == 0)
|
||||
throw new InvalidDataException("No PEM certificate block found.");
|
||||
|
||||
return certs;
|
||||
}
|
||||
|
||||
public static SslServerAuthenticationOptions BuildServerAuthOptions(NatsOptions opts)
|
||||
@@ -92,9 +137,198 @@ public static class TlsHelper
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
public static string GenerateFingerprint(X509Certificate2 cert)
|
||||
{
|
||||
var hash = SHA256.HashData(cert.RawData);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Uri> GetWebEndpoints(IEnumerable<string> uris)
|
||||
{
|
||||
var urls = new List<Uri>();
|
||||
foreach (var uri in uris)
|
||||
{
|
||||
if (!Uri.TryCreate(uri, UriKind.Absolute, out var endpoint))
|
||||
continue;
|
||||
if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
urls.Add(endpoint);
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
public static string GetSubjectDNForm(X509Certificate2? cert)
|
||||
{
|
||||
return cert?.SubjectName.Name ?? string.Empty;
|
||||
}
|
||||
|
||||
public static string GetIssuerDNForm(X509Certificate2? cert)
|
||||
{
|
||||
return cert?.IssuerName.Name ?? string.Empty;
|
||||
}
|
||||
|
||||
public static bool MatchesPinnedCert(X509Certificate2 cert, HashSet<string> pinned)
|
||||
{
|
||||
var hash = GetCertificateHash(cert);
|
||||
return pinned.Contains(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a chain link is eligible for OCSP validation by ensuring the leaf
|
||||
/// certificate includes at least one valid HTTP(S) OCSP AIA endpoint.
|
||||
/// </summary>
|
||||
public static bool CertOCSPEligible(ChainLink? link)
|
||||
{
|
||||
if (link?.Leaf is null)
|
||||
return false;
|
||||
|
||||
if (link.Leaf.RawData is null || link.Leaf.RawData.Length == 0)
|
||||
return false;
|
||||
|
||||
var aiaUris = GetOcspResponderUris(link.Leaf);
|
||||
if (aiaUris.Count == 0)
|
||||
return false;
|
||||
|
||||
var urls = GetWebEndpoints(aiaUris);
|
||||
if (urls.Count == 0)
|
||||
return false;
|
||||
|
||||
link.OCSPWebEndpoints = urls;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the positional issuer certificate for a leaf in a verified chain.
|
||||
/// </summary>
|
||||
public static X509Certificate2? GetLeafIssuerCert(IReadOnlyList<X509Certificate2>? chain, int leafPos)
|
||||
{
|
||||
if (chain is null || chain.Count == 0 || leafPos < 0 || leafPos >= chain.Count - 1)
|
||||
return null;
|
||||
return chain[leafPos + 1];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Equivalent to Go certstore.GetLeafIssuer: verifies the leaf against the
|
||||
/// supplied trust root and returns the first issuer in the verified chain.
|
||||
/// </summary>
|
||||
public static X509Certificate2? GetLeafIssuer(X509Certificate2 leaf, X509Certificate2 trustedRoot)
|
||||
{
|
||||
using var chain = new X509Chain();
|
||||
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
chain.ChainPolicy.CustomTrustStore.Add(trustedRoot);
|
||||
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
||||
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
|
||||
|
||||
if (!chain.Build(leaf) || chain.ChainElements.Count < 2)
|
||||
return null;
|
||||
|
||||
return X509CertificateLoader.LoadCertificate(chain.ChainElements[1].Certificate.RawData);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks OCSP response currency semantics with clock skew and fallback TTL.
|
||||
/// </summary>
|
||||
public static bool OcspResponseCurrent(OcspResponseInfo response, OCSPPeerConfig opts)
|
||||
{
|
||||
var skew = TimeSpan.FromSeconds(opts.ClockSkew);
|
||||
if (skew < TimeSpan.Zero)
|
||||
skew = OCSPPeerConfig.DefaultAllowedClockSkew;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (response.NextUpdate.HasValue && response.NextUpdate.Value < now - skew)
|
||||
return false;
|
||||
|
||||
if (!response.NextUpdate.HasValue)
|
||||
{
|
||||
var ttl = TimeSpan.FromSeconds(opts.TTLUnsetNextUpdate);
|
||||
if (ttl < TimeSpan.Zero)
|
||||
ttl = OCSPPeerConfig.DefaultTTLUnsetNextUpdate;
|
||||
|
||||
if (response.ThisUpdate + ttl < now - skew)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.ThisUpdate > now + skew)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates OCSP delegated signer semantics. Direct issuer signatures are valid;
|
||||
/// delegated certificates must include id-kp-OCSPSigning EKU.
|
||||
/// </summary>
|
||||
public static bool ValidDelegationCheck(X509Certificate2? issuer, X509Certificate2? responderCertificate)
|
||||
{
|
||||
if (issuer is null)
|
||||
return false;
|
||||
|
||||
if (responderCertificate is null)
|
||||
return true;
|
||||
|
||||
if (responderCertificate.Thumbprint == issuer.Thumbprint)
|
||||
return true;
|
||||
|
||||
foreach (var extension in responderCertificate.Extensions)
|
||||
{
|
||||
if (extension is not X509EnhancedKeyUsageExtension eku)
|
||||
continue;
|
||||
foreach (var oid in eku.EnhancedKeyUsages)
|
||||
{
|
||||
if (oid.Value == OcspSigningEkuOid)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
[SlopwatchSuppress("SW003", "AsnContentException on a malformed AIA extension is intentionally swallowed; invalid extension shape means no usable OCSP URI")]
|
||||
private static IReadOnlyList<string> GetOcspResponderUris(X509Certificate2 cert)
|
||||
{
|
||||
var uris = new List<string>();
|
||||
|
||||
foreach (var extension in cert.Extensions)
|
||||
{
|
||||
if (!string.Equals(extension.Oid?.Value, AuthorityInfoAccessOid, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(extension.RawData, AsnEncodingRules.DER);
|
||||
var seq = reader.ReadSequence();
|
||||
while (seq.HasData)
|
||||
{
|
||||
var accessDescription = seq.ReadSequence();
|
||||
var accessMethod = accessDescription.ReadObjectIdentifier();
|
||||
if (!string.Equals(accessMethod, OcspAccessMethodOid, StringComparison.Ordinal))
|
||||
{
|
||||
accessDescription.ThrowIfNotEmpty();
|
||||
continue;
|
||||
}
|
||||
|
||||
var uri = accessDescription.ReadCharacterString(
|
||||
UniversalTagNumber.IA5String,
|
||||
new Asn1Tag(TagClass.ContextSpecific, 6));
|
||||
|
||||
accessDescription.ThrowIfNotEmpty();
|
||||
if (!string.IsNullOrWhiteSpace(uri))
|
||||
uris.Add(uri);
|
||||
}
|
||||
|
||||
seq.ThrowIfNotEmpty();
|
||||
reader.ThrowIfNotEmpty();
|
||||
}
|
||||
catch (AsnContentException ex)
|
||||
{
|
||||
// Invalid AIA extension shape should behave as "no usable OCSP URI" — swallow is intentional.
|
||||
_ = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
return uris;
|
||||
}
|
||||
}
|
||||
|
||||
93
src/NATS.Server/WebSocket/WebSocketOptionsValidator.cs
Normal file
93
src/NATS.Server/WebSocket/WebSocketOptionsValidator.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
namespace NATS.Server.WebSocket;
|
||||
|
||||
/// <summary>
|
||||
/// Validates websocket options against server-wide auth/TLS/operator settings.
|
||||
/// Go reference: websocket.go validateWebsocketOptions.
|
||||
/// </summary>
|
||||
public static class WebSocketOptionsValidator
|
||||
{
|
||||
private static readonly HashSet<string> ReservedResponseHeaders = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Upgrade",
|
||||
"Connection",
|
||||
"Sec-WebSocket-Accept",
|
||||
"Sec-WebSocket-Extensions",
|
||||
"Sec-WebSocket-Protocol",
|
||||
};
|
||||
|
||||
public static WebSocketOptionsValidationResult Validate(NatsOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var ws = options.WebSocket;
|
||||
var errors = new List<string>();
|
||||
|
||||
if (ws.Port < 0)
|
||||
return new WebSocketOptionsValidationResult(true, errors);
|
||||
|
||||
if (!ws.NoTls)
|
||||
{
|
||||
var hasCert = !string.IsNullOrWhiteSpace(ws.TlsCert);
|
||||
var hasKey = !string.IsNullOrWhiteSpace(ws.TlsKey);
|
||||
if (!hasCert || !hasKey)
|
||||
errors.Add("WebSocket TLS listener requires both TlsCert and TlsKey when NoTls is false.");
|
||||
}
|
||||
|
||||
if (ws.AllowedOrigins is { Count: > 0 })
|
||||
{
|
||||
foreach (var origin in ws.AllowedOrigins)
|
||||
{
|
||||
if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
errors.Add($"Invalid websocket allowed origin: '{origin}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ws.NoAuthUser) && options.Users is { Count: > 0 })
|
||||
{
|
||||
var match = options.Users.Any(u => string.Equals(u.Username, ws.NoAuthUser, StringComparison.Ordinal));
|
||||
if (!match)
|
||||
errors.Add("WebSocket NoAuthUser must match one of the configured users.");
|
||||
}
|
||||
|
||||
if ((!string.IsNullOrWhiteSpace(ws.Username) || !string.IsNullOrWhiteSpace(ws.Token))
|
||||
&& ((options.Users?.Count ?? 0) > 0 || (options.NKeys?.Count ?? 0) > 0))
|
||||
{
|
||||
errors.Add("WebSocket Username/Token cannot be set when users or nkeys are configured.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ws.JwtCookie) && (options.TrustedKeys == null || options.TrustedKeys.Length == 0))
|
||||
{
|
||||
errors.Add("WebSocket JwtCookie requires trusted operators (TrustedKeys).");
|
||||
}
|
||||
|
||||
if (options.TlsPinnedCerts is { Count: > 0 })
|
||||
{
|
||||
if (ws.NoTls)
|
||||
errors.Add("WebSocket TLSPinnedCerts require TLS (NoTls must be false).");
|
||||
|
||||
foreach (var pin in options.TlsPinnedCerts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pin) || !pin.All(Uri.IsHexDigit))
|
||||
errors.Add($"Invalid websocket pinned cert hash: '{pin}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (ws.Headers is { Count: > 0 })
|
||||
{
|
||||
foreach (var headerName in ws.Headers.Keys)
|
||||
{
|
||||
if (ReservedResponseHeaders.Contains(headerName))
|
||||
errors.Add($"WebSocket header '{headerName}' is reserved and cannot be overridden.");
|
||||
}
|
||||
}
|
||||
|
||||
return new WebSocketOptionsValidationResult(errors.Count == 0, errors);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WebSocketOptionsValidationResult(
|
||||
bool IsValid,
|
||||
IReadOnlyList<string> Errors);
|
||||
19
src/NATS.Server/WebSocket/WsAuthConfig.cs
Normal file
19
src/NATS.Server/WebSocket/WsAuthConfig.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace NATS.Server.WebSocket;
|
||||
|
||||
public static class WsAuthConfig
|
||||
{
|
||||
public static bool ComputeAuthOverride(WebSocketOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
return !string.IsNullOrWhiteSpace(options.Username)
|
||||
|| !string.IsNullOrWhiteSpace(options.Token)
|
||||
|| !string.IsNullOrWhiteSpace(options.NoAuthUser);
|
||||
}
|
||||
|
||||
public static void Apply(WebSocketOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
options.AuthOverride = ComputeAuthOverride(options);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ namespace NATS.Server.WebSocket;
|
||||
/// </summary>
|
||||
public static class WsUpgrade
|
||||
{
|
||||
// Go test hook parity: when true, force rejection of no-masking requests.
|
||||
public static bool RejectNoMaskingForTest { get; set; }
|
||||
|
||||
public static async Task<WsUpgradeResult> TryUpgradeAsync(
|
||||
Stream inputStream, Stream outputStream, WebSocketOptions options,
|
||||
CancellationToken ct = default)
|
||||
@@ -72,6 +75,9 @@ public static class WsUpgrade
|
||||
headers.TryGetValue(WsConstants.NoMaskingHeader, out var nmVal) &&
|
||||
string.Equals(nmVal.Trim(), WsConstants.NoMaskingValue, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (noMasking && RejectNoMaskingForTest)
|
||||
return await FailAsync(outputStream, 400, "invalid value for no-masking");
|
||||
|
||||
// Browser detection
|
||||
bool browser = false;
|
||||
bool noCompFrag = false;
|
||||
@@ -179,6 +185,41 @@ public static class WsUpgrade
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a random base64-encoded 16-byte websocket challenge key.
|
||||
/// Go reference: wsMakeChallengeKey().
|
||||
/// </summary>
|
||||
public static string MakeChallengeKey()
|
||||
{
|
||||
Span<byte> nonce = stackalloc byte[16];
|
||||
RandomNumberGenerator.Fill(nonce);
|
||||
return Convert.ToBase64String(nonce);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the URL uses the ws:// scheme.
|
||||
/// Go reference: isWSURL().
|
||||
/// </summary>
|
||||
public static bool IsWsUrl(string? url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
return string.Equals(uri.Scheme, "ws", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the URL uses the wss:// scheme.
|
||||
/// Go reference: isWSSURL().
|
||||
/// </summary>
|
||||
public static bool IsWssUrl(string? url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
return string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a bearer token from an Authorization header value.
|
||||
/// Supports both "Bearer {token}" and bare "{token}" formats.
|
||||
|
||||
Reference in New Issue
Block a user