Files
natsnet/dotnet/src/ZB.MOM.NatsNet.Server/Internal/ServerUtilities.cs

514 lines
18 KiB
C#

// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Adapted from server/util.go in the NATS server Go source.
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ZB.MOM.NatsNet.Server.Internal;
/// <summary>
/// General-purpose server utility methods.
/// Mirrors server/util.go.
/// </summary>
public static class ServerUtilities
{
// Semver validation regex — mirrors semVerRe in const.go.
private static readonly Regex SemVerRe = new(
@"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$",
RegexOptions.Compiled);
// -------------------------------------------------------------------------
// Version helpers
// -------------------------------------------------------------------------
/// <summary>
/// Parses a semver string into major/minor/patch components.
/// Returns an error if the string is not a valid semver.
/// Mirrors <c>versionComponents</c>.
/// </summary>
public static (int major, int minor, int patch, Exception? err) VersionComponents(string version)
{
var m = SemVerRe.Match(version);
if (!m.Success)
return (0, 0, 0, new InvalidOperationException("invalid semver"));
if (!int.TryParse(m.Groups[1].Value, out var major) ||
!int.TryParse(m.Groups[2].Value, out var minor) ||
!int.TryParse(m.Groups[3].Value, out var patch))
return (-1, -1, -1, new InvalidOperationException("invalid semver component"));
return (major, minor, patch, null);
}
/// <summary>
/// Returns (true, nil) if <paramref name="version"/> is at least major.minor.patch.
/// Mirrors <c>versionAtLeastCheckError</c>.
/// </summary>
public static (bool ok, Exception? err) VersionAtLeastCheckError(
string version, int emajor, int eminor, int epatch)
{
var (major, minor, patch, err) = VersionComponents(version);
if (err != null) return (false, err);
if (major > emajor) return (true, null);
if (major == emajor && minor > eminor) return (true, null);
if (major == emajor && minor == eminor && patch >= epatch) return (true, null);
return (false, null);
}
/// <summary>
/// Returns true if <paramref name="version"/> is at least major.minor.patch.
/// Mirrors <c>versionAtLeast</c>.
/// </summary>
public static bool VersionAtLeast(string version, int emajor, int eminor, int epatch)
{
var (ok, _) = VersionAtLeastCheckError(version, emajor, eminor, epatch);
return ok;
}
// -------------------------------------------------------------------------
// Integer parsing helpers (used for NATS protocol parsing)
// -------------------------------------------------------------------------
/// <summary>
/// Parses a decimal positive integer from ASCII bytes.
/// Returns -1 on error or if the input contains non-digit characters.
/// Mirrors <c>parseSize</c>.
/// </summary>
public static int ParseSize(ReadOnlySpan<byte> d)
{
const int MaxParseSizeLen = 9; // 999M
if (d.IsEmpty || d.Length > MaxParseSizeLen) return -1;
var n = 0;
foreach (var dec in d)
{
if (dec < '0' || dec > '9') return -1;
n = n * 10 + (dec - '0');
}
return n;
}
/// <summary>
/// Parses a decimal positive int64 from ASCII bytes.
/// Returns -1 on error.
/// Mirrors <c>parseInt64</c>.
/// </summary>
public static long ParseInt64(ReadOnlySpan<byte> d)
{
if (d.IsEmpty) return -1;
long n = 0;
foreach (var dec in d)
{
if (dec < '0' || dec > '9') return -1;
n = n * 10 + (dec - '0');
}
return n;
}
// -------------------------------------------------------------------------
// Duration / network helpers
// -------------------------------------------------------------------------
/// <summary>
/// Converts float64 seconds to a <see cref="TimeSpan"/>.
/// Mirrors <c>secondsToDuration</c>.
/// </summary>
public static TimeSpan SecondsToDuration(double seconds) =>
TimeSpan.FromSeconds(seconds);
/// <summary>
/// Splits "host:port" into components, using <paramref name="defaultPort"/>
/// when no port (or port 0 / -1) is present.
/// Mirrors <c>parseHostPort</c>.
/// </summary>
public static (string host, int port, Exception? err) ParseHostPort(string hostPort, int defaultPort)
{
if (string.IsNullOrEmpty(hostPort))
return ("", -1, new InvalidOperationException("no hostport specified"));
// Try splitting; if port is missing, append the default and retry.
string host, sPort;
try
{
var ep = ParseEndpoint(hostPort);
host = ep.host;
sPort = ep.port;
}
catch
{
try
{
var ep = ParseEndpoint($"{hostPort}:{defaultPort}");
host = ep.host;
sPort = ep.port;
}
catch (Exception ex)
{
return ("", -1, ex);
}
}
if (!int.TryParse(sPort.Trim(), out var port))
return ("", -1, new InvalidOperationException($"invalid port: {sPort}"));
if (port == 0 || port == -1)
port = defaultPort;
return (host.Trim(), port, null);
}
private static (string host, string port) ParseEndpoint(string hostPort)
{
// net.SplitHostPort equivalent — handles IPv6 [::1]:port
if (hostPort.StartsWith('['))
{
var closeIdx = hostPort.IndexOf(']');
if (closeIdx < 0 || closeIdx + 1 >= hostPort.Length || hostPort[closeIdx + 1] != ':')
throw new InvalidOperationException($"missing port in address {hostPort}");
return (hostPort[1..closeIdx], hostPort[(closeIdx + 2)..]);
}
var lastColon = hostPort.LastIndexOf(':');
if (lastColon < 0)
throw new InvalidOperationException($"missing port in address {hostPort}");
var host = hostPort[..lastColon];
var port = hostPort[(lastColon + 1)..];
// Reject bare IPv6 addresses (multiple colons without brackets).
if (host.Contains(':'))
throw new InvalidOperationException($"too many colons in address {hostPort}");
return (host, port);
}
/// <summary>
/// Returns true if two <see cref="Uri"/> instances represent the same URL.
/// Mirrors <c>urlsAreEqual</c>.
/// </summary>
public static bool UrlsAreEqual(Uri? u1, Uri? u2) =>
u1 == u2 || (u1 != null && u2 != null && u1.ToString() == u2.ToString());
// -------------------------------------------------------------------------
// Comma formatting
// -------------------------------------------------------------------------
/// <summary>
/// Formats an int64 with comma thousands separators.
/// Mirrors <c>comma</c> in util.go.
/// </summary>
public static string Comma(long v)
{
if (v == long.MinValue) return "-9,223,372,036,854,775,808";
var sign = "";
if (v < 0) { sign = "-"; v = -v; }
var parts = new string[7];
var j = parts.Length - 1;
while (v > 999)
{
var part = (v % 1000).ToString();
parts[j--] = part.Length switch { 2 => "0" + part, 1 => "00" + part, _ => part };
v /= 1000;
}
parts[j] = v.ToString();
return sign + string.Join(",", parts.Skip(j));
}
// -------------------------------------------------------------------------
// TCP helpers
// -------------------------------------------------------------------------
/// <summary>
/// Creates a TCP listener with keepalives disabled (NATS server default).
/// Mirrors <c>natsListen</c>.
/// </summary>
public static System.Net.Sockets.TcpListener NatsListen(string address, int port)
{
// .NET TcpListener does not set keepalive by default; the socket can be
// further configured after creation if needed.
var listener = new System.Net.Sockets.TcpListener(IPAddress.Parse(address), port);
return listener;
}
/// <summary>
/// Opens a TCP connection with the given timeout and keepalives disabled.
/// Mirrors <c>natsDialTimeout</c>.
/// </summary>
public static async Task<System.Net.Sockets.TcpClient> NatsDialTimeoutAsync(
string host, int port, TimeSpan timeout)
{
var client = new System.Net.Sockets.TcpClient();
// Disable keepalive to match Go 1.12 behavior.
client.Client.SetSocketOption(
System.Net.Sockets.SocketOptionLevel.Socket,
System.Net.Sockets.SocketOptionName.KeepAlive,
false);
using var cts = new CancellationTokenSource(timeout);
await client.ConnectAsync(host, port, cts.Token);
return client;
}
/// <summary>
/// Parity wrapper for Go <c>natsDialTimeout</c>.
/// Accepts a network label (tcp/tcp4/tcp6) and host:port address.
/// </summary>
public static Task<System.Net.Sockets.TcpClient> NatsDialTimeout(
string network, string address, TimeSpan timeout)
{
if (!string.Equals(network, "tcp", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(network, "tcp4", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(network, "tcp6", StringComparison.OrdinalIgnoreCase))
throw new NotSupportedException($"unsupported network: {network}");
var (host, port, err) = ParseHostPort(address, defaultPort: 0);
if (err != null || port <= 0)
throw new InvalidOperationException($"invalid dial address: {address}", err);
return NatsDialTimeoutAsync(host, port, timeout);
}
// -------------------------------------------------------------------------
// URL redaction
// -------------------------------------------------------------------------
/// <summary>
/// Returns a copy of <paramref name="unredacted"/> where any URL that
/// contains a password has its password replaced with "xxxxx".
/// Mirrors <c>redactURLList</c>.
/// </summary>
public static Uri[] RedactUrlList(Uri[] unredacted)
{
var r = new Uri[unredacted.Length];
var needCopy = false;
for (var i = 0; i < unredacted.Length; i++)
{
var u = unredacted[i];
if (u?.UserInfo?.Contains(':') == true)
{
needCopy = true;
var ui = u.UserInfo;
var colon = ui.IndexOf(':');
var username = ui[..colon];
var b = new UriBuilder(u) { Password = "xxxxx", UserName = username };
r[i] = b.Uri;
}
else
{
r[i] = u!;
}
}
return needCopy ? r : unredacted;
}
/// <summary>
/// Returns the URL string with the password component redacted ("xxxxx").
/// Returns the original string if no password is present or it cannot be parsed.
/// Mirrors <c>redactURLString</c>.
/// </summary>
public static string RedactUrlString(string raw)
{
if (!raw.Contains('@')) return raw;
if (!Uri.TryCreate(raw, UriKind.Absolute, out var u)) return raw;
if (!u.UserInfo.Contains(':')) return raw;
var colon = u.UserInfo.IndexOf(':');
var username = u.UserInfo[..colon];
var b = new UriBuilder(u) { Password = "xxxxx", UserName = username };
var result = b.Uri.ToString();
// UriBuilder adds a trailing slash for authority-only URLs; strip it if the input had none.
if (!raw.EndsWith('/') && result.EndsWith('/'))
result = result[..^1];
return result;
}
/// <summary>
/// Returns the Host part of each URL in the list.
/// Mirrors <c>getURLsAsString</c>.
/// </summary>
public static string[] GetUrlsAsString(Uri[] urls)
{
var result = new string[urls.Length];
for (var i = 0; i < urls.Length; i++)
result[i] = urls[i].Authority; // host:port
return result;
}
// -------------------------------------------------------------------------
// RefCountedUrlSet wrappers (Go parity mapping)
// -------------------------------------------------------------------------
/// <summary>
/// Parity wrapper for <see cref="RefCountedUrlSet.AddUrl"/>.
/// Mirrors <c>refCountedUrlSet.addUrl</c>.
/// </summary>
public static bool AddUrl(RefCountedUrlSet urlSet, string urlStr)
{
ArgumentNullException.ThrowIfNull(urlSet);
return urlSet.AddUrl(urlStr);
}
/// <summary>
/// Parity wrapper for <see cref="RefCountedUrlSet.RemoveUrl"/>.
/// Mirrors <c>refCountedUrlSet.removeUrl</c>.
/// </summary>
public static bool RemoveUrl(RefCountedUrlSet urlSet, string urlStr)
{
ArgumentNullException.ThrowIfNull(urlSet);
return urlSet.RemoveUrl(urlStr);
}
/// <summary>
/// Parity wrapper for <see cref="RefCountedUrlSet.GetAsStringSlice"/>.
/// Mirrors <c>refCountedUrlSet.getAsStringSlice</c>.
/// </summary>
public static string[] GetAsStringSlice(RefCountedUrlSet urlSet)
{
ArgumentNullException.ThrowIfNull(urlSet);
return urlSet.GetAsStringSlice();
}
// -------------------------------------------------------------------------
// INFO helpers
// -------------------------------------------------------------------------
/// <summary>
/// Serialises <paramref name="info"/> into an INFO line (<c>INFO {...}\r\n</c>).
/// Mirrors <c>generateInfoJSON</c>.
/// </summary>
public static byte[] GenerateInfoJSON(global::ZB.MOM.NatsNet.Server.ServerInfo info)
{
var json = JsonSerializer.Serialize(info);
return Encoding.UTF8.GetBytes($"INFO {json}\r\n");
}
// -------------------------------------------------------------------------
// Copy helpers
// -------------------------------------------------------------------------
/// <summary>
/// Returns a copy of <paramref name="src"/>, or null if src is empty.
/// Mirrors <c>copyBytes</c>.
/// </summary>
public static byte[]? CopyBytes(byte[]? src)
{
if (src == null || src.Length == 0) return null;
var dst = new byte[src.Length];
src.CopyTo(dst, 0);
return dst;
}
/// <summary>
/// Returns a copy of <paramref name="src"/>, or null if src is null.
/// Mirrors <c>copyStrings</c>.
/// </summary>
public static string[]? CopyStrings(string[]? src)
{
if (src == null) return null;
var dst = new string[src.Length];
src.CopyTo(dst, 0);
return dst;
}
// -------------------------------------------------------------------------
// Parallel task queue
// -------------------------------------------------------------------------
/// <summary>
/// Creates a bounded channel onto which tasks can be posted for parallel
/// execution across a pool of dedicated threads. Close the returned channel
/// to signal workers to stop (after queued items complete).
/// Mirrors <c>parallelTaskQueue</c>.
/// </summary>
public static System.Threading.Channels.ChannelWriter<Action> CreateParallelTaskQueue(int maxParallelism = 0)
{
var mp = maxParallelism <= 0 ? Environment.ProcessorCount : Math.Max(Environment.ProcessorCount, maxParallelism);
var channel = System.Threading.Channels.Channel.CreateBounded<Action>(mp);
for (var i = 0; i < mp; i++)
{
Task.Run(async () =>
{
await foreach (var fn in channel.Reader.ReadAllAsync())
fn();
});
}
return channel.Writer;
}
/// <summary>
/// Parity wrapper for <see cref="CreateParallelTaskQueue"/>.
/// Mirrors <c>parallelTaskQueue</c>.
/// </summary>
public static System.Threading.Channels.ChannelWriter<Action> ParallelTaskQueue(int maxParallelism = 0) =>
CreateParallelTaskQueue(maxParallelism);
}
// -------------------------------------------------------------------------
// RefCountedUrlSet (mirrors refCountedUrlSet map[string]int in util.go)
// -------------------------------------------------------------------------
/// <summary>
/// A reference-counted set of URL strings used for gossip URL management.
/// Mirrors <c>refCountedUrlSet</c> in server/util.go.
/// </summary>
public sealed class RefCountedUrlSet
{
private readonly Dictionary<string, int> _map = new();
/// <summary>
/// Adds <paramref name="urlStr"/>. Returns true if it was added for the first time.
/// Mirrors <c>refCountedUrlSet.addUrl</c>.
/// </summary>
public bool AddUrl(string urlStr)
{
_map.TryGetValue(urlStr, out var count);
_map[urlStr] = count + 1;
return count == 0;
}
/// <summary>
/// Decrements the reference count for <paramref name="urlStr"/>.
/// Returns true if this was the last reference (entry removed).
/// Mirrors <c>refCountedUrlSet.removeUrl</c>.
/// </summary>
public bool RemoveUrl(string urlStr)
{
if (!_map.TryGetValue(urlStr, out var count)) return false;
if (count == 1) { _map.Remove(urlStr); return true; }
_map[urlStr] = count - 1;
return false;
}
/// <summary>
/// Returns the unique URL strings currently in the set.
/// Mirrors <c>refCountedUrlSet.getAsStringSlice</c>.
/// </summary>
public string[] GetAsStringSlice() => [.. _map.Keys];
}