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:
Joseph Doherty
2026-03-12 14:09:23 -04:00
parent 79c1ee8776
commit c30e67a69d
226 changed files with 17801 additions and 709 deletions

View File

@@ -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).

View File

@@ -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 },
};
}
}

View File

@@ -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;

View File

@@ -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; }
}

View File

@@ -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;

View File

@@ -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; }
}

View 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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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,
};

View File

@@ -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; }

View File

@@ -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;

View File

@@ -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('.');

View File

@@ -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>

View File

@@ -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>;

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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>

View File

@@ -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))

View File

@@ -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))

View 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;
}

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -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))

View 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();
}

View File

@@ -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; }

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View 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; }
}

View File

@@ -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");

View File

@@ -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);

View 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}";
}
}

View File

@@ -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; } = "";
}

View File

@@ -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.

View File

@@ -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..]
: "";
}
}

View 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,
}

View File

@@ -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) =>
{

View 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();
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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)

View File

@@ -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;

View 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; }
}

View 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";
}

View File

@@ -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.

View File

@@ -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))

View File

@@ -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; }
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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;

View 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);

View File

@@ -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);

View 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; }
}

View 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);
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View 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",
};
}

View File

@@ -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,

View File

@@ -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(

View 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;
}
}
}

View 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";
}

View 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();
}
}

View 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;
}

View File

@@ -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)
{

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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,

View 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);
}
}
}

View 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]";
}

View File

@@ -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;
}
}

View 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);

View 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);
}
}

View File

@@ -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.