Skip to content

Commit

Permalink
Containers: insecure registries: allow https (ignore cert errors), an…
Browse files Browse the repository at this point in the history
…d accept config from envvar. (#41506)
  • Loading branch information
tmds authored Jun 19, 2024
1 parent fda398e commit 00becb5
Show file tree
Hide file tree
Showing 10 changed files with 419 additions and 65 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
}

int retryCount = 0;
List<Exception>? requestExceptions = null;

while (retryCount < MaxRequestRetries)
{
Expand Down Expand Up @@ -364,8 +365,11 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
}
catch (HttpRequestException e) when (e.InnerException is IOException ioe && ioe.InnerException is SocketException se)
{
requestExceptions ??= new();
requestExceptions.Add(e);

retryCount += 1;
_logger.LogInformation("Encountered a SocketException with message \"{message}\". Pausing before retry.", se.Message);
_logger.LogInformation("Encountered a HttpRequestException {error} with message \"{message}\". Pausing before retry.", e.HttpRequestError, se.Message);
_logger.LogTrace("Exception details: {ex}", se);
await Task.Delay(TimeSpan.FromSeconds(1.0 * Math.Pow(2, retryCount)), cancellationToken).ConfigureAwait(false);

Expand All @@ -374,7 +378,7 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
}
}

throw new ApplicationException(Resource.GetString(nameof(Strings.TooManyRetries)));
throw new ApplicationException(Resource.GetString(nameof(Strings.TooManyRetries)), new AggregateException(requestExceptions!));
}

[GeneratedRegex("(?<key>\\w+)=\"(?<value>[^\"]*)\"(?:,|$)")]
Expand Down
24 changes: 0 additions & 24 deletions src/Containers/Microsoft.NET.Build.Containers/ContainerHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,30 +144,6 @@ internal static bool IsValidImageTag(string imageTag)
return ReferenceParser.anchoredTagRegexp.IsMatch(imageTag);
}


/// <summary>
/// Given an already-validated registry domain, this is our hueristic to determine what HTTP protocol should be used to interact with it.
/// If the domain is localhost, we default to HTTP. Otherwise, we check the Docker config to see if the registry is marked as insecure.
/// This is primarily for testing - in the real world almost all usage should be through HTTPS!
/// </summary>
internal static Uri TryExpandRegistryToUri(string alreadyValidatedDomain)
{
string prefix = "https";
if (alreadyValidatedDomain.StartsWith("localhost", StringComparison.Ordinal))
{
prefix = "http";
}

//check the docker config to see if the registry is marked as insecure
else if (DockerCli.IsInsecureRegistry(alreadyValidatedDomain))
{
prefix = "http";
}


return new Uri($"{prefix}://{alreadyValidatedDomain}");
}

/// <summary>
/// Ensures a given environment variable is valid.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net;
using Microsoft.Extensions.Logging;
using Microsoft.NET.Build.Containers.Resources;

namespace Microsoft.NET.Build.Containers;

/// <summary>
/// A delegating handler that falls back from https to http for a specific hostname.
/// </summary>
internal sealed partial class FallbackToHttpMessageHandler : DelegatingHandler
{
private readonly string _host;
private readonly int _port;
private readonly ILogger _logger;
private bool _fallbackToHttp;

public FallbackToHttpMessageHandler(string host, int port, HttpMessageHandler innerHandler, ILogger logger) : base(innerHandler)
{
_host = host;
_port = port;
_logger = logger;
}

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri is null)
{
throw new ArgumentException(Resource.GetString(nameof(Strings.NoRequestUriSpecified)), nameof(request));
}

bool canFallback = request.RequestUri.Host == _host && request.RequestUri.Port == _port && request.RequestUri.Scheme == "https";
do
{
try
{
if (canFallback && _fallbackToHttp)
{
FallbackToHttp(request);
canFallback = false;
}

return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException re) when (canFallback && ShouldAttemptFallbackToHttp(re))
{
string uri = request.RequestUri.ToString();
try
{
// Try falling back.
_logger.LogTrace("Attempt to fall back to http for {uri}.", uri);
FallbackToHttp(request);
HttpResponseMessage response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);

// Fall back was successful. Use http for all new requests.
_logger.LogTrace("Fall back to http for {uri} was successful.", uri);
_fallbackToHttp = true;

return response;
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Fall back to http for {uri} failed with message \"{message}\".", uri, ex.Message);
}

// Falling back didn't work, throw original exception.
throw;
}
} while (true);
}

internal static bool ShouldAttemptFallbackToHttp(HttpRequestException exception)
{
return exception.HttpRequestError == HttpRequestError.SecureConnectionError;
}

private static void FallbackToHttp(HttpRequestMessage request)
{
var uriBuilder = new UriBuilder(request.RequestUri!);
uriBuilder.Scheme = "http";
request.RequestUri = uriBuilder.Uri;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ public static bool IsInsecureRegistry(string registryDomain)
{
if (property.Value.ValueKind == JsonValueKind.Object && property.Value.TryGetProperty("Secure", out var secure) && !secure.GetBoolean())
{
if (property.Name.Equals(registryDomain, StringComparison.Ordinal))
if (property.Name.Equals(registryDomain, StringComparison.OrdinalIgnoreCase))
{
return true;
}
Expand All @@ -248,7 +248,7 @@ public static bool IsInsecureRegistry(string registryDomain)
{
if (property.Value.ValueKind == JsonValueKind.Object && property.Value.TryGetProperty("Insecure", out var insecure) && insecure.GetBoolean())
{
if (property.Name.Equals(registryDomain, StringComparison.Ordinal))
if (property.Name.Equals(registryDomain, StringComparison.OrdinalIgnoreCase))
{
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,11 @@ internal class DefaultRegistryAPI : IRegistryAPI
// Making this a round 30 for convenience.
private static TimeSpan LongRequestTimeout = TimeSpan.FromMinutes(30);

internal DefaultRegistryAPI(string registryName, Uri baseUri, ILogger logger)
internal DefaultRegistryAPI(string registryName, Uri baseUri, bool isInsecureRegistry, ILogger logger)
{
bool isAmazonECRRegistry = baseUri.IsAmazonECRRegistry();
_baseUri = baseUri;
_logger = logger;
_client = CreateClient(registryName, baseUri, logger, isAmazonECRRegistry);
_client = CreateClient(registryName, baseUri, isInsecureRegistry, logger);
Manifest = new DefaultManifestOperations(_baseUri, registryName, _client, _logger);
Blob = new DefaultBlobOperations(_baseUri, registryName, _client, _logger);
}
Expand All @@ -36,28 +35,13 @@ internal DefaultRegistryAPI(string registryName, Uri baseUri, ILogger logger)

public IManifestOperations Manifest { get; }

private static HttpClient CreateClient(string registryName, Uri baseUri, ILogger logger, bool isAmazonECRRegistry = false)
private static HttpClient CreateClient(string registryName, Uri baseUri, bool isInsecureRegistry, ILogger logger)
{
var innerHandler = new SocketsHttpHandler()
{
UseCookies = false,
// the rest of the HTTP stack has an very long timeout (see below) but we should still have a reasonable timeout for the initial connection
ConnectTimeout = TimeSpan.FromSeconds(30)
};

// Ignore certificate for https localhost repository.
if (baseUri.Host == "localhost" && baseUri.Scheme == "https")
{
innerHandler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions()
{
RemoteCertificateValidationCallback = (object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors)
=> (sender as SslStream)?.TargetHostName == "localhost"
};
}
HttpMessageHandler innerHandler = CreateHttpHandler(baseUri, isInsecureRegistry, logger);

HttpMessageHandler clientHandler = new AuthHandshakeMessageHandler(registryName, innerHandler, logger);

if (isAmazonECRRegistry)
if (baseUri.IsAmazonECRRegistry())
{
clientHandler = new AmazonECRMessageHandler(clientHandler);
}
Expand All @@ -71,4 +55,45 @@ private static HttpClient CreateClient(string registryName, Uri baseUri, ILogger

return client;
}

private static HttpMessageHandler CreateHttpHandler(Uri baseUri, bool allowInsecure, ILogger logger)
{
var socketsHttpHandler = new SocketsHttpHandler()
{
UseCookies = false,
// the rest of the HTTP stack has an very long timeout (see below) but we should still have a reasonable timeout for the initial connection
ConnectTimeout = TimeSpan.FromSeconds(30)
};

if (!allowInsecure)
{
return socketsHttpHandler;
}

socketsHttpHandler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions()
{
RemoteCertificateValidationCallback = IgnoreCertificateErrorsForSpecificHost(baseUri.Host)
};

return new FallbackToHttpMessageHandler(baseUri.Host, baseUri.Port, socketsHttpHandler, logger);
}

private static RemoteCertificateValidationCallback IgnoreCertificateErrorsForSpecificHost(string host)
{
return (object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) =>
{
if (sslPolicyErrors == SslPolicyErrors.None)
{
return true;
}

// Ignore certificate errors for the hostname.
if ((sender as SslStream)?.TargetHostName == host)
{
return true;
}

return false;
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ internal sealed class Registry
public string RegistryName { get; }

internal Registry(string registryName, ILogger logger, IRegistryAPI? registryAPI = null, RegistrySettings? settings = null) :
this(ContainerHelpers.TryExpandRegistryToUri(registryName), logger, registryAPI, settings)
this(new Uri($"https://{registryName}"), logger, registryAPI, settings)
{ }

internal Registry(Uri baseUri, ILogger logger, IRegistryAPI? registryAPI = null, RegistrySettings? settings = null)
Expand All @@ -86,8 +86,8 @@ internal Registry(Uri baseUri, ILogger logger, IRegistryAPI? registryAPI = null,
BaseUri = baseUri;

_logger = logger;
_settings = settings ?? new RegistrySettings();
_registryAPI = registryAPI ?? new DefaultRegistryAPI(RegistryName, BaseUri, logger);
_settings = settings ?? new RegistrySettings(RegistryName);
_registryAPI = registryAPI ?? new DefaultRegistryAPI(RegistryName, BaseUri, _settings.IsInsecure, logger);
}

private static string DeriveRegistryName(Uri baseUri)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ namespace Microsoft.NET.Build.Containers;

internal class RegistrySettings
{
public RegistrySettings(string? registryName = null, IEnvironmentProvider? environment = null)
{
environment ??= new EnvironmentProvider();

ChunkedUploadSizeBytes = environment.GetEnvironmentVariableAsNullableInt(EnvVariables.ChunkedUploadSizeBytes);
ForceChunkedUpload = environment.GetEnvironmentVariableAsBool(EnvVariables.ForceChunkedUpload, defaultValue: false);
ParallelUploadEnabled = environment.GetEnvironmentVariableAsBool(EnvVariables.ParallelUploadEnabled, defaultValue: true);

if (registryName is not null)
{
IsInsecure = IsInsecureRegistry(environment, registryName);
}
}

private const int DefaultChunkSizeBytes = 1024 * 64;
private const int FiveMegs = 5_242_880;

Expand All @@ -17,26 +31,56 @@ internal class RegistrySettings
/// <remarks>
/// Our default of 64KB is very conservative, so raising this to 1MB or more can speed up layer uploads reasonably well.
/// </remarks>
internal int? ChunkedUploadSizeBytes { get; init; } = Env.GetEnvironmentVariableAsNullableInt(EnvVariables.ChunkedUploadSizeBytes);
internal int? ChunkedUploadSizeBytes { get; init; }

/// <summary>
/// Allows to force chunked upload for debugging purposes.
/// </summary>
internal bool ForceChunkedUpload { get; init; } = Env.GetEnvironmentVariableAsBool(EnvVariables.ForceChunkedUpload, defaultValue: false);
internal bool ForceChunkedUpload { get; init; }

/// <summary>
/// Whether we should upload blobs in parallel (enabled by default, but disabled for certain registries in conjunction with the explicit support check below).
/// </summary>
/// <remarks>
/// Enabling this can swamp some registries, so this is an escape hatch.
/// </remarks>
internal bool ParallelUploadEnabled { get; init; } = Env.GetEnvironmentVariableAsBool(EnvVariables.ParallelUploadEnabled, defaultValue: true);
internal bool ParallelUploadEnabled { get; init; }

/// <summary>
/// Allows ignoring https certificate errors and changing to http when the endpoint is not an https endpoint.
/// </summary>
internal bool IsInsecure { get; init; }

internal struct EnvVariables
{
internal const string ChunkedUploadSizeBytes = "SDK_CONTAINER_REGISTRY_CHUNKED_UPLOAD_SIZE_BYTES";

internal const string ForceChunkedUpload = "SDK_CONTAINER_DEBUG_REGISTRY_FORCE_CHUNKED_UPLOAD";
internal const string ParallelUploadEnabled = "SDK_CONTAINER_REGISTRY_PARALLEL_UPLOAD";

internal const string InsecureRegistries = "SDK_CONTAINER_INSECURE_REGISTRIES";
}

private static bool IsInsecureRegistry(IEnvironmentProvider environment, string registryName)
{
// Always allow insecure access to 'localhost'.
if (registryName.StartsWith("localhost:", StringComparison.OrdinalIgnoreCase) ||
registryName.Equals("localhost", StringComparison.OrdinalIgnoreCase))
{
return true;
}

// SDK_CONTAINER_INSECURE_REGISTRIES is a semicolon separated list of insecure registry names.
string? insecureRegistriesEnv = environment.GetEnvironmentVariable(EnvVariables.InsecureRegistries);
if (insecureRegistriesEnv is not null)
{
string[] insecureRegistries = insecureRegistriesEnv.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (Array.Exists(insecureRegistries, registry => registryName.Equals(registry, StringComparison.OrdinalIgnoreCase)))
{
return true;
}
}

return DockerCli.IsInsecureRegistry(registryName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public static async Task StartAndPopulateDockerRegistry(ITestOutputHelper testOu
using var reader = new StringReader(processResult.StdOut!);
s_registryContainerId = reader.ReadLine();

EnsureRegistryLoaded(LocalRegistry, s_registryContainerId, logger, testOutput);
EnsureRegistryLoaded(new Uri($"http://{LocalRegistry}"), s_registryContainerId, logger, testOutput);

foreach (string? tag in new[] { Net6ImageTag, Net7ImageTag, Net8ImageTag, Net9PreviewImageTag })
{
Expand Down Expand Up @@ -119,13 +119,13 @@ public static void ShutdownDockerRegistry(ITestOutputHelper testOutput)
}
}

private static void EnsureRegistryLoaded(string registryBaseUri, string? containerRegistryId, ILogger logger, ITestOutputHelper testOutput)
private static void EnsureRegistryLoaded(Uri registryBaseUri, string? containerRegistryId, ILogger logger, ITestOutputHelper testOutput)
{
const int registryLoadMaxRetry = 10;
const int registryLoadTimeout = 1000; //ms

using HttpClient client = new();
using HttpRequestMessage request = new(HttpMethod.Get, new Uri(ContainerHelpers.TryExpandRegistryToUri(registryBaseUri), "/v2/"));
using HttpRequestMessage request = new(HttpMethod.Get, new Uri(registryBaseUri, "/v2/"));

logger.LogInformation("Checking if the registry '{registry}' is available.", registryBaseUri);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public async Task WriteToPrivateBasicRegistry()
// login to that registry
ContainerCli.LoginCommand(_testOutput, "--username", "testuser", "--password", "testpassword", registryName).Execute().Should().Pass();
// push an image to that registry using username/password
Registry localAuthed = new(new Uri($"https://{registryName}"), logger, settings: new() { ParallelUploadEnabled = false, ForceChunkedUpload = true });
Registry localAuthed = new(new Uri($"https://{registryName}"), logger, settings: new(registryName) { ParallelUploadEnabled = false, ForceChunkedUpload = true });
var ridgraphfile = ToolsetUtils.GetRuntimeGraphFilePath();
Registry mcr = new(DockerRegistryManager.BaseImageSource, logger);

Expand Down
Loading

0 comments on commit 00becb5

Please sign in to comment.