diff --git a/src/Containers/Microsoft.NET.Build.Containers/AuthHandshakeMessageHandler.cs b/src/Containers/Microsoft.NET.Build.Containers/AuthHandshakeMessageHandler.cs index 10ff6c46a554..543db320b5c4 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/AuthHandshakeMessageHandler.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/AuthHandshakeMessageHandler.cs @@ -333,6 +333,7 @@ protected override async Task SendAsync(HttpRequestMessage } int retryCount = 0; + List? requestExceptions = null; while (retryCount < MaxRequestRetries) { @@ -364,8 +365,11 @@ protected override async Task 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); @@ -374,7 +378,7 @@ protected override async Task SendAsync(HttpRequestMessage } } - throw new ApplicationException(Resource.GetString(nameof(Strings.TooManyRetries))); + throw new ApplicationException(Resource.GetString(nameof(Strings.TooManyRetries)), new AggregateException(requestExceptions!)); } [GeneratedRegex("(?\\w+)=\"(?[^\"]*)\"(?:,|$)")] diff --git a/src/Containers/Microsoft.NET.Build.Containers/ContainerHelpers.cs b/src/Containers/Microsoft.NET.Build.Containers/ContainerHelpers.cs index 7fff1a2cec24..81cfde3bab90 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/ContainerHelpers.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/ContainerHelpers.cs @@ -144,30 +144,6 @@ internal static bool IsValidImageTag(string imageTag) return ReferenceParser.anchoredTagRegexp.IsMatch(imageTag); } - - /// - /// 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! - /// - 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}"); - } - /// /// Ensures a given environment variable is valid. /// diff --git a/src/Containers/Microsoft.NET.Build.Containers/FallbackToHttpMessageHandler.cs b/src/Containers/Microsoft.NET.Build.Containers/FallbackToHttpMessageHandler.cs new file mode 100644 index 000000000000..5f7754d58bb1 --- /dev/null +++ b/src/Containers/Microsoft.NET.Build.Containers/FallbackToHttpMessageHandler.cs @@ -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; + +/// +/// A delegating handler that falls back from https to http for a specific hostname. +/// +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 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; + } +} diff --git a/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/DockerCli.cs b/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/DockerCli.cs index dadb0888bf22..978dd7618ad7 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/DockerCli.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/DockerCli.cs @@ -230,7 +230,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; } @@ -246,7 +246,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; } diff --git a/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultRegistryAPI.cs b/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultRegistryAPI.cs index 885879b29b07..71b937233fe4 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultRegistryAPI.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultRegistryAPI.cs @@ -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); } @@ -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); } @@ -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; + }; + } } diff --git a/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs b/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs index 21ba5d8c9271..bc8cf15e6b10 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs @@ -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) @@ -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) diff --git a/src/Containers/Microsoft.NET.Build.Containers/Registry/RegistrySettings.cs b/src/Containers/Microsoft.NET.Build.Containers/Registry/RegistrySettings.cs index 3ac8f41ff254..19e5b6eef6a3 100644 --- a/src/Containers/Microsoft.NET.Build.Containers/Registry/RegistrySettings.cs +++ b/src/Containers/Microsoft.NET.Build.Containers/Registry/RegistrySettings.cs @@ -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; @@ -17,12 +31,12 @@ internal class RegistrySettings /// /// Our default of 64KB is very conservative, so raising this to 1MB or more can speed up layer uploads reasonably well. /// - internal int? ChunkedUploadSizeBytes { get; init; } = Env.GetEnvironmentVariableAsNullableInt(EnvVariables.ChunkedUploadSizeBytes); + internal int? ChunkedUploadSizeBytes { get; init; } /// /// Allows to force chunked upload for debugging purposes. /// - internal bool ForceChunkedUpload { get; init; } = Env.GetEnvironmentVariableAsBool(EnvVariables.ForceChunkedUpload, defaultValue: false); + internal bool ForceChunkedUpload { get; init; } /// /// Whether we should upload blobs in parallel (enabled by default, but disabled for certain registries in conjunction with the explicit support check below). @@ -30,7 +44,12 @@ internal class RegistrySettings /// /// Enabling this can swamp some registries, so this is an escape hatch. /// - internal bool ParallelUploadEnabled { get; init; } = Env.GetEnvironmentVariableAsBool(EnvVariables.ParallelUploadEnabled, defaultValue: true); + internal bool ParallelUploadEnabled { get; init; } + + /// + /// Allows ignoring https certificate errors and changing to http when the endpoint is not an https endpoint. + /// + internal bool IsInsecure { get; init; } internal struct EnvVariables { @@ -38,5 +57,30 @@ internal struct EnvVariables 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); } } diff --git a/src/Tests/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryManager.cs b/src/Tests/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryManager.cs index 534a4a78056d..22447bc5df00 100644 --- a/src/Tests/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryManager.cs +++ b/src/Tests/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryManager.cs @@ -62,7 +62,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 }) { @@ -116,13 +116,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); diff --git a/src/Tests/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryTests.cs b/src/Tests/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryTests.cs index 18638ee17b84..3529e5edec41 100644 --- a/src/Tests/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryTests.cs +++ b/src/Tests/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryTests.cs @@ -45,7 +45,8 @@ public async Task WriteToPrivateBasicRegistry() var registryAuthDir = new DirectoryInfo(Path.Combine(registryDir.FullName, "auth")); var registryCertsDir = new DirectoryInfo(Path.Combine(registryDir.FullName, "certs")); var registryName = "localhost:5555"; - try { + try + { if (!registryCertsDir.Exists) { registryCertsDir.Create(); @@ -73,12 +74,12 @@ 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 Registry(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 Registry(DockerRegistryManager.BaseImageSource, logger); var sourceImage = new SourceImageReference(mcr, DockerRegistryManager.RuntimeBaseImage, DockerRegistryManager.Net6ImageTag); - var destinationImage = new DestinationImageReference(localAuthed, DockerRegistryManager.RuntimeBaseImage,new[] { DockerRegistryManager.Net6ImageTag }); + var destinationImage = new DestinationImageReference(localAuthed, DockerRegistryManager.RuntimeBaseImage, new[] { DockerRegistryManager.Net6ImageTag }); ImageBuilder? downloadedImage = await mcr.GetImageManifestAsync( DockerRegistryManager.RuntimeBaseImage, DockerRegistryManager.Net6ImageTag, diff --git a/src/Tests/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs b/src/Tests/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs index 3c3fba3d2cd6..11c80a75c845 100644 --- a/src/Tests/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs +++ b/src/Tests/Microsoft.NET.Build.Containers.UnitTests/RegistryTests.cs @@ -2,8 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Net; +using System.Net.Security; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Microsoft.DotNet.Cli.Utils; using Microsoft.Extensions.Logging; using Microsoft.NET.Build.Containers.Resources; +using System.Net.Sockets; using Moq; namespace Microsoft.NET.Build.Containers.UnitTests; @@ -77,7 +82,7 @@ public async Task RegistriesThatProvideUploadSizePrefersFullUploadWhenChunkSizeI var layerDigest = "sha256:fafafafafafafafafafafafafafafafa"; var mockLayer = new Mock(MockBehavior.Strict); var chunkSizeLessThanContentLength = 10000; - var registryUri = ContainerHelpers.TryExpandRegistryToUri("public.ecr.aws"); + var registryUri = new Uri("https://public.ecr.aws");; mockLayer .Setup(l => l.OpenBackingFile()).Returns(new MemoryStream(new byte[100000])); mockLayer @@ -111,7 +116,7 @@ public async Task RegistriesThatFailAtomicUploadFallbackToChunked() var mockLayer = new Mock(MockBehavior.Strict); var contentLength = 100000; var chunkSizeLessThanContentLength = 100000; - var registryUri = ContainerHelpers.TryExpandRegistryToUri("public.ecr.aws"); + var registryUri = new Uri("https://public.ecr.aws");; mockLayer .Setup(l => l.OpenBackingFile()).Returns(new MemoryStream(new byte[contentLength])); mockLayer @@ -145,7 +150,7 @@ public async Task ChunkedUploadCalculatesChunksCorrectly() var mockLayer = new Mock(MockBehavior.Strict); var contentLength = 1000000; var chunkSize = 100000; - var registryUri = ContainerHelpers.TryExpandRegistryToUri("public.ecr.aws"); + var registryUri = new Uri("https://public.ecr.aws");; mockLayer .Setup(l => l.OpenBackingFile()).Returns(new MemoryStream(new byte[contentLength])); mockLayer @@ -219,7 +224,7 @@ public async Task PushAsync_ForceChunkedUpload() Mock mockLayer = new(MockBehavior.Strict); int contentLength = 1000000; int chunkSize = 100000; - var registryUri = ContainerHelpers.TryExpandRegistryToUri("public.ecr.aws"); + var registryUri = new Uri("https://public.ecr.aws");; mockLayer .Setup(l => l.OpenBackingFile()).Returns(new MemoryStream(new byte[contentLength])); mockLayer @@ -319,7 +324,7 @@ public async Task CanParseRegistryDeclaredChunkSize_None() public async Task UploadBlobChunkedAsync_NormalFlow() { ILogger logger = _loggerFactory.CreateLogger(nameof(UploadBlobChunkedAsync_NormalFlow)); - var registryUri = ContainerHelpers.TryExpandRegistryToUri("public.ecr.aws"); + var registryUri = new Uri("https://public.ecr.aws");; int contentLength = 50000000; int chunkSize = 10000000; @@ -352,7 +357,7 @@ public async Task UploadBlobChunkedAsync_NormalFlow() public async Task UploadBlobChunkedAsync_Failure() { ILogger logger = _loggerFactory.CreateLogger(nameof(UploadBlobChunkedAsync_NormalFlow)); - var registryUri = ContainerHelpers.TryExpandRegistryToUri("public.ecr.aws"); + var registryUri = new Uri("https://public.ecr.aws");; int contentLength = 50000000; int chunkSize = 10000000; @@ -384,11 +389,226 @@ public async Task UploadBlobChunkedAsync_Failure() api.Verify(api => api.Blob.Upload.UploadChunkAsync(It.IsIn(absoluteUploadUri, uploadPath), It.IsAny(), It.IsAny()), Times.Exactly(1)); } + [InlineData(true, true, true)] + [InlineData(false, true, true)] + [InlineData(true, false, true)] + [InlineData(false, false, true)] + [InlineData(false, false, false)] + [Theory] + public async Task InsecureRegistry(bool isInsecureRegistry, bool serverIsHttps, bool httpServerCloseAbortive) + { + ILogger logger = _loggerFactory.CreateLogger(nameof(InsecureRegistry)); + + // Start a dummy HTTP server that response with 200 OK. + using TcpListener listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + IPEndPoint endpoint = (listener.LocalEndpoint as IPEndPoint)!; + Uri registryUri = new Uri($"https://{endpoint.Address}:{endpoint.Port}"); + SslServerAuthenticationOptions? sslOptions = null!; + if (serverIsHttps) + { + var key = RSA.Create(2048); + var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + X509Certificate2 serverCertificate = request.CreateSelfSigned(DateTimeOffset.Now, DateTimeOffset.Now.AddYears(1)); + + // https://stackoverflow.com/questions/72096812/loading-x509certificate2-from-pem-file-results-in-no-credentials-are-available/72101855#72101855 + serverCertificate = new X509Certificate2(serverCertificate.Export(X509ContentType.Pfx)); + + sslOptions = new SslServerAuthenticationOptions() + { + ServerCertificate = serverCertificate, + ClientCertificateRequired = false + }; + } + _ = Task.Run(async () => + { + while (true) + { + using TcpClient client = await listener.AcceptTcpClientAsync(); + try + { + using Stream stream = serverIsHttps ? new SslStream(client.GetStream(), leaveInnerStreamOpen: false) : client.GetStream(); + if (stream is SslStream sslStream) + { + await sslStream.AuthenticateAsServerAsync(sslOptions!, default(CancellationToken)); + } + byte[] buffer = new byte[10]; + await stream.ReadAtLeastAsync(buffer, buffer.Length); // Wait for the request. + // Repond if we see '/v2/' in the buffer (since we expect that as part of the request path). + if (buffer.AsSpan().IndexOf("/v2/"u8) != 0) + { + await stream.WriteAsync("HTTP/1.0 200 OK\r\nContent-Length: 0\r\n\r\n"u8.ToArray()); + } + else + { + if (httpServerCloseAbortive) + { + client.GetStream().Close(timeout: 0); + } + } + } + catch + { } + } + }); + + RegistrySettings settings = new() + { + IsInsecure = isInsecureRegistry + }; + Registry registry = new(registryUri, logger, settings: settings); + + // Make a request. + Task getManifest = registry.GetImageManifestAsync(repositoryName: "dotnet/runtime", reference: "latest", runtimeIdentifier: "linux-x64", manifestPicker: null!, cancellationToken: default!); + + if (isInsecureRegistry) + { + // Falls back to http (when serverIsHttps is false) or ignores https certificate errors (when serverIsHttps is true). + // Results in throwing: CONTAINER2003: The manifest for dotnet/runtime:latest from registry hwas an unknown type. + await Assert.ThrowsAsync(() => getManifest); + } + else + { + // Does not fall back and throws HttpRequestException with SecureConnectionError. + Exception? exception = await Assert.ThrowsAnyAsync(() => getManifest); + try + { + // The AuthHandshakeMessageHandler may reach its retry limit and throw an ApplicationException. + if (exception is ApplicationException) + { + // Find the exception for the first failed attempt. + exception = (exception.InnerException as AggregateException)?.InnerExceptions.FirstOrDefault(); + Assert.NotNull(exception); + } + + Assert.IsType(exception); + HttpRequestException requestException = (HttpRequestException)exception; + Assert.Equal(HttpRequestError.SecureConnectionError, requestException.HttpRequestError); + + // The FallbackToHttpMessageHandler should fall back (if this registry was configured as insecure). + Assert.True(FallbackToHttpMessageHandler.ShouldAttemptFallbackToHttp(requestException)); + } + catch + { + // Log a message describing the exception. + StringBuilder sb = new(); + sb.AppendLine("Exception is not fallback exception:"); + while (exception != null) + { + switch (exception) + { + case SocketException socketException: + sb.AppendLine($"{nameof(SocketException)}({socketException.SocketErrorCode}) - {exception.Message}"); + break; + case HttpRequestException requestException: + sb.AppendLine($"{nameof(HttpRequestException)}({requestException.HttpRequestError}) - {exception.Message}"); + break; + default: + sb.AppendLine($"{exception.GetType().Name} - {exception.Message}"); + break; + } + + exception = exception.InnerException; + } + logger.LogError(sb.ToString()); + + throw; + } + } + } + + [InlineData("localhost", null, true)] + [InlineData("localhost:5000", null, true)] + [InlineData("public.ecr.aws", null, false)] + [InlineData("public.ecr.aws", "public.ecr.aws", true)] + [InlineData("public.ecr.aws", "Public.ecr.aws", true)] // ignore case + [InlineData("public.ecr.aws", "public.ecr.aws;docker.io", true)] // multiple registries + [InlineData("public.ecr.aws", ";public.ecr.aws ; docker.io ", true)] // ignore whitespace + [InlineData("public.ecr.aws", "public.ecr.aws2;docker.io ", false)] // full name match + [Theory] + public void IsRegistryInsecure(string registryName, string? insecureRegistriesEnvvar, bool expectedInsecure) + { + var environment = new Dictionary(); + if (insecureRegistriesEnvvar is not null) + { + environment["SDK_CONTAINER_INSECURE_REGISTRIES"] = insecureRegistriesEnvvar; + } + var registrySettings = new RegistrySettings(registryName, new MockEnvironmentProvider(environment)); + + Assert.Equal(expectedInsecure, registrySettings.IsInsecure); + } private static NextChunkUploadInformation ChunkUploadSuccessful(Uri requestUri, Uri uploadUrl, int? contentLength, HttpStatusCode code = HttpStatusCode.Accepted) { return new(uploadUrl); } + private class MockEnvironmentProvider : IEnvironmentProvider + { + private readonly IDictionary _environmentVariables; + + public MockEnvironmentProvider(IDictionary environmentVariables) + { + _environmentVariables = environmentVariables; + } + + public bool GetEnvironmentVariableAsBool(string name, bool defaultValue) + { + string? str = GetEnvironmentVariable(name); + if (string.IsNullOrEmpty(str)) + { + return defaultValue; + } + + switch (str.ToLowerInvariant()) + { + case "true": + case "1": + case "yes": + return true; + case "false": + case "0": + case "no": + return false; + default: + return defaultValue; + } + } + + public string? GetEnvironmentVariable(string name) + { + string? value; + _environmentVariables.TryGetValue(name, out value); + return value; + } + + public string? GetEnvironmentVariable(string variable, EnvironmentVariableTarget target) + => GetEnvironmentVariable(variable); + + public int? GetEnvironmentVariableAsNullableInt(string variable) + { + if (GetEnvironmentVariable(variable) is string strValue && int.TryParse(strValue, out int intValue)) + { + return intValue; + } + + return null; + } + + public void SetEnvironmentVariable(string variable, string value, EnvironmentVariableTarget target) + => throw new NotImplementedException(); + + public IEnumerable ExecutableExtensions + => throw new NotImplementedException(); + + public string GetCommandPath(string commandName, params string[] extensions) + => throw new NotImplementedException(); + + public string GetCommandPathFromRootPath(string rootPath, string commandName, params string[] extensions) + => throw new NotImplementedException(); + + public string GetCommandPathFromRootPath(string rootPath, string commandName, IEnumerable extensions) + => throw new NotImplementedException(); + } }