Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Hostname CLUSTER MEET #1048

Merged
merged 9 commits into from
Feb 28, 2025
7 changes: 6 additions & 1 deletion libs/cluster/Server/Gossip.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,12 @@ public async Task TryMeetAsync(string address, int port, bool acquireLock = true

if (gsn == null)
{
gsn = new GarnetServerNode(clusterProvider, new IPEndPoint(IPAddress.Parse(address), port), tlsOptions?.TlsClientOptions, logger: logger);
var endpoint = await Format.TryCreateEndpoint(address, port, useForBind: true, logger: logger);
if (endpoint == null)
{
logger?.LogError("Could not parse endpoint {address} {port}", address, port);
}
gsn = new GarnetServerNode(clusterProvider, endpoint, tlsOptions?.TlsClientOptions, logger: logger);
created = true;
}

Expand Down
216 changes: 134 additions & 82 deletions libs/common/Format.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@

using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

#if UNIX_SOCKET
Expand All @@ -33,132 +32,185 @@ internal static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string s) =>
public static class Format
{
/// <summary>
/// Parse Endpoint in the form address:port
/// Try to create an endpoint from address and port
/// </summary>
/// <param name="addressWithPort"></param>
/// <param name="endpoint"></param>
/// <param name="addressOrHostname">This could be an address or a hostname that the method tries to resolve</param>
/// <param name="port"></param>
/// <param name="useForBind">Binding does not poll connection because is supposed to be called from the server side</param>
/// <param name="logger"></param>
/// <returns></returns>
/// <exception cref="PlatformNotSupportedException"></exception>
#nullable enable
public static bool TryParseEndPoint(string addressWithPort, [NotNullWhen(true)] out EndPoint? endpoint)
#nullable disable
public static async Task<EndPoint> TryCreateEndpoint(string addressOrHostname, int port, bool useForBind = false, ILogger logger = null)
{
string addressPart;
#pragma warning disable CS8600 // Converting null literal or possible null value to non-nullable type.
string portPart = null;
#pragma warning restore CS8600 // Converting null literal or possible null value to non-nullable type.
if (addressWithPort.IsNullOrEmpty())
{
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type.
endpoint = null;
#pragma warning restore CS8625 // Cannot convert null literal to non-nullable reference type.
return false;
}
IPEndPoint endpoint = null;
if (string.IsNullOrEmpty(addressOrHostname) || string.IsNullOrWhiteSpace(addressOrHostname))
return new IPEndPoint(IPAddress.Any, port);

if (IPAddress.TryParse(addressOrHostname, out var ipAddress))
return new IPEndPoint(ipAddress, port);

if (addressWithPort[0] == '!')
// Sanity check, there should be at least one ip address available
try
{
if (addressWithPort.Length == 1)
var ipAddresses = Dns.GetHostAddresses(addressOrHostname);
if (ipAddresses.Length == 0)
{
endpoint = null;
return false;
logger?.LogError("No IP address found for hostname:{hostname}", addressOrHostname);
return null;
}

#if UNIX_SOCKET
endpoint = new UnixDomainSocketEndPoint(addressWithPort.Substring(1));
return true;
#else
throw new PlatformNotSupportedException("Unix domain sockets require .NET Core 3 or above");
#endif
}
var lastColonIndex = addressWithPort.LastIndexOf(':');
if (lastColonIndex > 0)
{
// IPv4 with port or IPv6
var closingIndex = addressWithPort.LastIndexOf(']');
if (closingIndex > 0)
if (useForBind)
{
// IPv6 with brackets
addressPart = addressWithPort.Substring(1, closingIndex - 1);
if (closingIndex < lastColonIndex)
foreach (var entry in ipAddresses)
{
// IPv6 with port [::1]:80
portPart = addressWithPort.Substring(lastColonIndex + 1);
endpoint = new IPEndPoint(entry, port);
var IsListening = await IsReachable(endpoint);
if (IsListening) break;
}
}
else
{
// IPv6 without port or IPv4
var firstColonIndex = addressWithPort.IndexOf(':');
if (firstColonIndex != lastColonIndex)
var machineHostname = GetHostName();

// Hostname does match the one acquired from machine name
if (!addressOrHostname.Equals(machineHostname, StringComparison.OrdinalIgnoreCase))
{
// IPv6 ::1
addressPart = addressWithPort;
logger?.LogError("Provided hostname does not much acquired machine name {addressOrHostname} {machineHostname}!", addressOrHostname, machineHostname);
return null;
}
else
{
// IPv4 with port 127.0.0.1:123
addressPart = addressWithPort.Substring(0, firstColonIndex);
portPart = addressWithPort.Substring(firstColonIndex + 1);

if (ipAddresses.Length > 1) {
logger?.LogError("Error hostname resolved to multiple endpoints. Garnet does not support multiple endpoints!");
return null;
}

return new IPEndPoint(ipAddresses[0], port);
}
logger?.LogError("No reachable IP address found for hostname:{hostname}", addressOrHostname);
}
else
catch (Exception ex)
{
// IPv4 without port
addressPart = addressWithPort;
logger?.LogError(ex, "Error while trying to resolve hostname:{hostname}", addressOrHostname);
}

int? port = 0;
if (portPart != null)
return endpoint;

async Task<bool> IsReachable(IPEndPoint endpoint)
{
if (TryParseInt32(portPart, out var portVal))
{
port = portVal;
}
else
using (var tcpClient = new TcpClient())
{
// Invalid port, return
endpoint = null;
return false;
try
{
await tcpClient.ConnectAsync(endpoint.Address, endpoint.Port);
logger?.LogTrace("Reachable {ip} {port}", endpoint.Address, endpoint.Port);
return true;
}
catch
{
logger?.LogTrace("Unreachable {ip} {port}", endpoint.Address, endpoint.Port);
return false;
}
}
}
}

if (IPAddress.TryParse(addressPart, out IPAddress address))
/// <summary>
/// Try to
/// </summary>
/// <param name="address"></param>
/// <param name="port"></param>
/// <param name="logger"></param>
/// <returns></returns>
public static async Task<IPEndPoint> TryValidateAndConnectAddress2(string address, int port, ILogger logger = null)
{
IPEndPoint endpoint = null;
if (!IPAddress.TryParse(address, out var ipAddress))
{
endpoint = new IPEndPoint(address, port ?? 0);
return true;
// Try to identify reachable IP address from hostname
var hostEntry = Dns.GetHostEntry(address);
foreach (var entry in hostEntry.AddressList)
{
endpoint = new IPEndPoint(entry, port);
var IsListening = await IsReachable(endpoint);
if (IsListening) break;
}
}
else
{
IPHostEntry host = Dns.GetHostEntryAsync(addressPart).Result;
var ip = host.AddressList.First(x => x.AddressFamily == AddressFamily.InterNetwork);
endpoint = new IPEndPoint(ip, port ?? 0);
return true;
// If address is valid create endpoint
endpoint = new IPEndPoint(ipAddress, port);
}

async Task<bool> IsReachable(IPEndPoint endpoint)
{
using (var tcpClient = new TcpClient())
{
try
{
await tcpClient.ConnectAsync(endpoint.Address, endpoint.Port);
logger?.LogTrace("Reachable {ip} {port}", endpoint.Address, endpoint.Port);
return true;
}
catch
{
logger?.LogTrace("Unreachable {ip} {port}", endpoint.Address, endpoint.Port);
return false;
}
}
}

return endpoint;
}

/// <summary>
/// TryParseInt32
/// Parse address (hostname) and port to endpoint
/// </summary>
/// <param name="s"></param>
/// <param name="value"></param>
/// <param name="address"></param>
/// <param name="port"></param>
/// <param name="endpoint"></param>
/// <returns></returns>
public static bool TryParseInt32(string s, out int value) =>
int.TryParse(s, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out value);
public static bool TryValidateAddress(string address, int port, out EndPoint endpoint)
{
endpoint = null;

if (string.IsNullOrWhiteSpace(address))
{
endpoint = new IPEndPoint(IPAddress.Any, port);
return true;
}

if (IPAddress.TryParse(address, out var ipAddress))
{
endpoint = new IPEndPoint(ipAddress, port);
return true;
}

var machineHostname = GetHostName();

// Hostname does match then one acquired from machine name
if (!address.Equals(machineHostname, StringComparison.OrdinalIgnoreCase))
return false;

// Sanity check, there should be at least one ip address available
var ipAddresses = Dns.GetHostAddresses(address);
if (ipAddresses.Length == 0)
return false;

// Listen to any since we were given a valid hostname
endpoint = new IPEndPoint(IPAddress.Any, port);
return true;
}

/// <summary>
/// Resolve host from Ip
/// </summary>
/// <param name="logger"></param>
/// <returns></returns>
#nullable enable
public static string GetHostName(ILogger? logger = null)
#nullable disable
public static string GetHostName(ILogger logger = null)
{
try
{
var serverName = Environment.MachineName; //host name sans domain
var fqhn = Dns.GetHostEntry(serverName).HostName; //fully qualified hostname
var serverName = Environment.MachineName; // host name sans domain
var fqhn = Dns.GetHostEntry(serverName).HostName; // fully qualified hostname
return fqhn;
}
catch (SocketException ex)
Expand Down
34 changes: 17 additions & 17 deletions libs/host/Configuration/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using CommandLine;
using Garnet.common;
using Garnet.server;
using Garnet.server.Auth.Aad;
using Garnet.server.Auth.Settings;
Expand Down Expand Up @@ -572,19 +573,28 @@ internal sealed class Options
[Value(0)]
public IList<string> UnparsedArguments { get; set; }

/// <summary>
/// Logger instance used for runtime option validation
/// </summary>
public ILogger runtimeLogger { get; set; }

/// <summary>
/// Check the validity of all options with an explicit ValidationAttribute
/// </summary>
/// <param name="invalidOptions">List of invalid options</param>
/// <param name="logger">Logger</param>
/// <returns>True if all property values are valid</returns>
public bool IsValid(out List<string> invalidOptions, ILogger logger)
public bool IsValid(out List<string> invalidOptions, ILogger logger = null)
{
invalidOptions = new List<string>();
bool isValid = true;
invalidOptions = [];
var isValid = true;

foreach (PropertyInfo prop in typeof(Options).GetProperties())
this.runtimeLogger = logger;
foreach (var prop in typeof(Options).GetProperties())
{
if (prop.Name.Equals("runtimeLogger"))
continue;

// Ignore if property is not decorated with the OptionsAttribute or the ValidationAttribute
var validationAttr = prop.GetCustomAttributes(typeof(ValidationAttribute)).FirstOrDefault();
if (!Attribute.IsDefined(prop, typeof(OptionAttribute)) || validationAttr == null)
Expand Down Expand Up @@ -631,19 +641,9 @@ public GarnetServerOptions GetServerOptions(ILogger logger = null)
}
else
{
IPAddress address;
if (string.IsNullOrEmpty(Address))
{
address = IPAddress.Any;
}
else
{
if (Address.Equals("localhost", StringComparison.CurrentCultureIgnoreCase))
address = IPAddress.Loopback;
else
address = IPAddress.Parse(Address);
}
endpoint = new IPEndPoint(address, Port);
endpoint = Format.TryCreateEndpoint(Address, Port, useForBind: false).Result;
if (endpoint == null)
throw new GarnetException($"Invalid endpoint format {Address} {Port}.");
}

// Unix file permission octal to UnixFileMode
Expand Down
15 changes: 11 additions & 4 deletions libs/host/Configuration/OptionsValidators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Net;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security;
using System.Text;
using System.Text.RegularExpressions;
using Garnet.common;
using Microsoft.Extensions.Logging;

namespace Garnet
{
Expand All @@ -22,6 +22,11 @@ namespace Garnet
[AttributeUsage(AttributeTargets.Property)]
internal class OptionValidationAttribute : ValidationAttribute
{
/// <summary>
/// Logger to use for validation
/// </summary>
public ILogger Logger { get; set; }

/// <summary>
/// Determines if current property is required to have a value
/// </summary>
Expand Down Expand Up @@ -355,11 +360,13 @@ protected override ValidationResult IsValid(object value, ValidationContext vali
if (TryInitialValidation<string>(value, validationContext, out var initValidationResult, out var ipAddress))
return initValidationResult;

if (ipAddress.Equals(Localhost, StringComparison.CurrentCultureIgnoreCase) || IPAddress.TryParse(ipAddress, out _))
var logger = ((Options)validationContext.ObjectInstance).runtimeLogger;
if (ipAddress.Equals(Localhost, StringComparison.CurrentCultureIgnoreCase) ||
Format.TryCreateEndpoint(ipAddress, 0, useForBind: false, logger: logger).Result != null)
return ValidationResult.Success;

var baseError = validationContext.MemberName != null ? base.FormatErrorMessage(validationContext.MemberName) : string.Empty;
var errorMessage = $"{baseError} Expected string in IPv4 / IPv6 format (e.g. 127.0.0.1 / 0:0:0:0:0:0:0:1) or 'localhost'. Actual value: {ipAddress}";
var errorMessage = $"{baseError} Expected string in IPv4 / IPv6 format (e.g. 127.0.0.1 / 0:0:0:0:0:0:0:1) or 'localhost' or valid hostname. Actual value: {ipAddress}";
return new ValidationResult(errorMessage, [validationContext.MemberName]);
}
}
Expand Down
Loading