Files
natsnet/dotnet/src/ZB.MOM.NatsNet.Server/LeafNode/LeafNodeHandler.cs

178 lines
6.8 KiB
C#

// Copyright 2019-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
using System.Net;
using System.Text;
using ZB.MOM.NatsNet.Server.Auth;
using ZB.MOM.NatsNet.Server.Internal;
namespace ZB.MOM.NatsNet.Server;
internal static class LeafNodeHandler
{
internal static readonly TimeSpan LeafNodeReconnectDelayAfterLoopDetected = TimeSpan.FromSeconds(30);
internal static readonly TimeSpan LeafNodeReconnectAfterPermViolation = TimeSpan.FromSeconds(30);
internal static readonly TimeSpan LeafNodeReconnectDelayAfterClusterNameSame = TimeSpan.FromSeconds(30);
internal const string LeafNodeLoopDetectionSubjectPrefix = "$LDS.";
public static Exception? ValidateLeafNode(ServerOptions options)
{
var authErr = ValidateLeafNodeAuthOptions(options);
if (authErr != null)
return authErr;
foreach (var remote in options.LeafNode.Remotes)
{
if (string.IsNullOrWhiteSpace(remote.LocalAccount))
remote.LocalAccount = ServerConstants.DefaultGlobalAccount;
var (warnings, proxyErr) = ValidateLeafNodeProxyOptions(remote);
_ = warnings;
if (proxyErr != null)
return proxyErr;
}
if (!string.IsNullOrWhiteSpace(options.LeafNode.MinVersion))
{
var minVersionErr = CheckLeafMinVersionConfig(options.LeafNode.MinVersion);
if (minVersionErr != null)
return minVersionErr;
}
return null;
}
public static Exception? CheckLeafMinVersionConfig(string minVersion)
{
var (ok, err) = ServerUtilities.VersionAtLeastCheckError(minVersion, 2, 8, 0);
if (err != null)
return new InvalidOperationException($"invalid leafnode minimum version: {err.Message}", err);
if (!ok)
return new InvalidOperationException("the minimum version should be at least 2.8.0");
return null;
}
public static Exception? ValidateLeafNodeAuthOptions(ServerOptions options)
{
var users = options.LeafNode.Users;
if (users is null || users.Count == 0)
return null;
if (!string.IsNullOrWhiteSpace(options.LeafNode.Username))
return new InvalidOperationException("can not have a single user/pass and a users array");
if (!string.IsNullOrWhiteSpace(options.LeafNode.Nkey))
return new InvalidOperationException("can not have a single nkey and a users array");
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var user in users)
{
if (!seen.Add(user.Username))
return new InvalidOperationException($"duplicate user {user.Username} detected in leafnode authorization");
}
return null;
}
public static (List<string> Warnings, Exception? Error) ValidateLeafNodeProxyOptions(RemoteLeafOpts remote)
{
var warnings = new List<string>();
if (string.IsNullOrWhiteSpace(remote.Proxy.Url))
return (warnings, null);
if (!Uri.TryCreate(remote.Proxy.Url, UriKind.Absolute, out var proxyUri))
return (warnings, new InvalidOperationException("invalid proxy URL"));
if (!string.Equals(proxyUri.Scheme, "http", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(proxyUri.Scheme, "https", StringComparison.OrdinalIgnoreCase))
return (warnings, new InvalidOperationException($"proxy URL scheme must be http or https, got: {proxyUri.Scheme}"));
if (string.IsNullOrWhiteSpace(proxyUri.Host))
return (warnings, new InvalidOperationException("proxy URL must specify a host"));
if (remote.Proxy.Timeout < TimeSpan.Zero)
return (warnings, new InvalidOperationException("proxy timeout must be >= 0"));
var userPresent = !string.IsNullOrWhiteSpace(remote.Proxy.Username);
var passPresent = !string.IsNullOrWhiteSpace(remote.Proxy.Password);
if (userPresent != passPresent)
return (warnings, new InvalidOperationException("proxy username and password must both be specified or both be empty"));
return (warnings, null);
}
public static LeafNodeCfg NewLeafNodeCfg(RemoteLeafOpts remote)
{
var cfg = new LeafNodeCfg
{
RemoteOpts = remote,
Urls = [.. remote.Urls],
CurUrl = remote.Urls.Count > 0 ? remote.Urls[0] : null,
Perms = new Permissions
{
Publish = new SubjectPermission { Deny = [.. remote.DenyImports] },
Subscribe = new SubjectPermission { Deny = [.. remote.DenyExports] },
},
};
cfg.SaveUserPassword(cfg.CurUrl);
cfg.SaveTLSHostname(cfg.CurUrl);
return cfg;
}
public static Exception? EstablishHTTPProxyTunnel(Stream stream, Uri target, RemoteLeafProxyOpts proxy, CancellationToken cancellationToken = default)
{
_ = cancellationToken;
if (stream is null)
return new InvalidOperationException("proxy tunnel requires an open stream");
if (target is null)
return new InvalidOperationException("proxy tunnel requires a target URL");
var hostPort = target.IsDefaultPort ? target.Host : $"{target.Host}:{target.Port}";
var builder = new StringBuilder();
builder.Append("CONNECT ").Append(hostPort).Append(" HTTP/1.1\r\n");
builder.Append("Host: ").Append(hostPort).Append("\r\n");
if (!string.IsNullOrWhiteSpace(proxy.Username) || !string.IsNullOrWhiteSpace(proxy.Password))
{
var raw = Encoding.UTF8.GetBytes($"{proxy.Username}:{proxy.Password}");
builder.Append("Proxy-Authorization: Basic ")
.Append(Convert.ToBase64String(raw))
.Append("\r\n");
}
builder.Append("\r\n");
var data = Encoding.ASCII.GetBytes(builder.ToString());
stream.Write(data, 0, data.Length);
stream.Flush();
return null;
}
public static string KeyFromSub(Internal.Subscription sub)
{
var subject = Encoding.ASCII.GetString(sub.Subject);
if (sub.Queue is not { Length: > 0 })
return subject;
return $"{subject} {Encoding.ASCII.GetString(sub.Queue)}";
}
public static string KeyFromSubWithOrigin(Internal.Subscription sub, string? origin = null)
{
var subject = Encoding.ASCII.GetString(sub.Subject);
var queue = sub.Queue is { Length: > 0 } q ? Encoding.ASCII.GetString(q) : string.Empty;
var hasOrigin = !string.IsNullOrWhiteSpace(origin);
return (hasOrigin, queue.Length > 0) switch
{
(false, false) => $"R {subject}",
(false, true) => $"R {subject} {queue}",
(true, false) => $"L {subject} {origin}",
(true, true) => $"L {subject} {queue} {origin}",
};
}
}