// 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(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 Warnings, Exception? Error) ValidateLeafNodeProxyOptions(RemoteLeafOpts remote) { var warnings = new List(); 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}", }; } }