Skip to content

Commit 00becb5

Browse files
authored
Containers: insecure registries: allow https (ignore cert errors), and accept config from envvar. (#41506)
1 parent fda398e commit 00becb5

File tree

10 files changed

+419
-65
lines changed

10 files changed

+419
-65
lines changed

src/Containers/Microsoft.NET.Build.Containers/AuthHandshakeMessageHandler.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
333333
}
334334

335335
int retryCount = 0;
336+
List<Exception>? requestExceptions = null;
336337

337338
while (retryCount < MaxRequestRetries)
338339
{
@@ -364,8 +365,11 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
364365
}
365366
catch (HttpRequestException e) when (e.InnerException is IOException ioe && ioe.InnerException is SocketException se)
366367
{
368+
requestExceptions ??= new();
369+
requestExceptions.Add(e);
370+
367371
retryCount += 1;
368-
_logger.LogInformation("Encountered a SocketException with message \"{message}\". Pausing before retry.", se.Message);
372+
_logger.LogInformation("Encountered a HttpRequestException {error} with message \"{message}\". Pausing before retry.", e.HttpRequestError, se.Message);
369373
_logger.LogTrace("Exception details: {ex}", se);
370374
await Task.Delay(TimeSpan.FromSeconds(1.0 * Math.Pow(2, retryCount)), cancellationToken).ConfigureAwait(false);
371375

@@ -374,7 +378,7 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
374378
}
375379
}
376380

377-
throw new ApplicationException(Resource.GetString(nameof(Strings.TooManyRetries)));
381+
throw new ApplicationException(Resource.GetString(nameof(Strings.TooManyRetries)), new AggregateException(requestExceptions!));
378382
}
379383

380384
[GeneratedRegex("(?<key>\\w+)=\"(?<value>[^\"]*)\"(?:,|$)")]

src/Containers/Microsoft.NET.Build.Containers/ContainerHelpers.cs

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -144,30 +144,6 @@ internal static bool IsValidImageTag(string imageTag)
144144
return ReferenceParser.anchoredTagRegexp.IsMatch(imageTag);
145145
}
146146

147-
148-
/// <summary>
149-
/// Given an already-validated registry domain, this is our hueristic to determine what HTTP protocol should be used to interact with it.
150-
/// If the domain is localhost, we default to HTTP. Otherwise, we check the Docker config to see if the registry is marked as insecure.
151-
/// This is primarily for testing - in the real world almost all usage should be through HTTPS!
152-
/// </summary>
153-
internal static Uri TryExpandRegistryToUri(string alreadyValidatedDomain)
154-
{
155-
string prefix = "https";
156-
if (alreadyValidatedDomain.StartsWith("localhost", StringComparison.Ordinal))
157-
{
158-
prefix = "http";
159-
}
160-
161-
//check the docker config to see if the registry is marked as insecure
162-
else if (DockerCli.IsInsecureRegistry(alreadyValidatedDomain))
163-
{
164-
prefix = "http";
165-
}
166-
167-
168-
return new Uri($"{prefix}://{alreadyValidatedDomain}");
169-
}
170-
171147
/// <summary>
172148
/// Ensures a given environment variable is valid.
173149
/// </summary>
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Net;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.NET.Build.Containers.Resources;
7+
8+
namespace Microsoft.NET.Build.Containers;
9+
10+
/// <summary>
11+
/// A delegating handler that falls back from https to http for a specific hostname.
12+
/// </summary>
13+
internal sealed partial class FallbackToHttpMessageHandler : DelegatingHandler
14+
{
15+
private readonly string _host;
16+
private readonly int _port;
17+
private readonly ILogger _logger;
18+
private bool _fallbackToHttp;
19+
20+
public FallbackToHttpMessageHandler(string host, int port, HttpMessageHandler innerHandler, ILogger logger) : base(innerHandler)
21+
{
22+
_host = host;
23+
_port = port;
24+
_logger = logger;
25+
}
26+
27+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
28+
{
29+
if (request.RequestUri is null)
30+
{
31+
throw new ArgumentException(Resource.GetString(nameof(Strings.NoRequestUriSpecified)), nameof(request));
32+
}
33+
34+
bool canFallback = request.RequestUri.Host == _host && request.RequestUri.Port == _port && request.RequestUri.Scheme == "https";
35+
do
36+
{
37+
try
38+
{
39+
if (canFallback && _fallbackToHttp)
40+
{
41+
FallbackToHttp(request);
42+
canFallback = false;
43+
}
44+
45+
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
46+
}
47+
catch (HttpRequestException re) when (canFallback && ShouldAttemptFallbackToHttp(re))
48+
{
49+
string uri = request.RequestUri.ToString();
50+
try
51+
{
52+
// Try falling back.
53+
_logger.LogTrace("Attempt to fall back to http for {uri}.", uri);
54+
FallbackToHttp(request);
55+
HttpResponseMessage response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
56+
57+
// Fall back was successful. Use http for all new requests.
58+
_logger.LogTrace("Fall back to http for {uri} was successful.", uri);
59+
_fallbackToHttp = true;
60+
61+
return response;
62+
}
63+
catch (Exception ex)
64+
{
65+
_logger.LogInformation(ex, "Fall back to http for {uri} failed with message \"{message}\".", uri, ex.Message);
66+
}
67+
68+
// Falling back didn't work, throw original exception.
69+
throw;
70+
}
71+
} while (true);
72+
}
73+
74+
internal static bool ShouldAttemptFallbackToHttp(HttpRequestException exception)
75+
{
76+
return exception.HttpRequestError == HttpRequestError.SecureConnectionError;
77+
}
78+
79+
private static void FallbackToHttp(HttpRequestMessage request)
80+
{
81+
var uriBuilder = new UriBuilder(request.RequestUri!);
82+
uriBuilder.Scheme = "http";
83+
request.RequestUri = uriBuilder.Uri;
84+
}
85+
}

src/Containers/Microsoft.NET.Build.Containers/LocalDaemons/DockerCli.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ public static bool IsInsecureRegistry(string registryDomain)
232232
{
233233
if (property.Value.ValueKind == JsonValueKind.Object && property.Value.TryGetProperty("Secure", out var secure) && !secure.GetBoolean())
234234
{
235-
if (property.Name.Equals(registryDomain, StringComparison.Ordinal))
235+
if (property.Name.Equals(registryDomain, StringComparison.OrdinalIgnoreCase))
236236
{
237237
return true;
238238
}
@@ -248,7 +248,7 @@ public static bool IsInsecureRegistry(string registryDomain)
248248
{
249249
if (property.Value.ValueKind == JsonValueKind.Object && property.Value.TryGetProperty("Insecure", out var insecure) && insecure.GetBoolean())
250250
{
251-
if (property.Name.Equals(registryDomain, StringComparison.Ordinal))
251+
if (property.Name.Equals(registryDomain, StringComparison.OrdinalIgnoreCase))
252252
{
253253
return true;
254254
}

src/Containers/Microsoft.NET.Build.Containers/Registry/DefaultRegistryAPI.cs

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,11 @@ internal class DefaultRegistryAPI : IRegistryAPI
2222
// Making this a round 30 for convenience.
2323
private static TimeSpan LongRequestTimeout = TimeSpan.FromMinutes(30);
2424

25-
internal DefaultRegistryAPI(string registryName, Uri baseUri, ILogger logger)
25+
internal DefaultRegistryAPI(string registryName, Uri baseUri, bool isInsecureRegistry, ILogger logger)
2626
{
27-
bool isAmazonECRRegistry = baseUri.IsAmazonECRRegistry();
2827
_baseUri = baseUri;
2928
_logger = logger;
30-
_client = CreateClient(registryName, baseUri, logger, isAmazonECRRegistry);
29+
_client = CreateClient(registryName, baseUri, isInsecureRegistry, logger);
3130
Manifest = new DefaultManifestOperations(_baseUri, registryName, _client, _logger);
3231
Blob = new DefaultBlobOperations(_baseUri, registryName, _client, _logger);
3332
}
@@ -36,28 +35,13 @@ internal DefaultRegistryAPI(string registryName, Uri baseUri, ILogger logger)
3635

3736
public IManifestOperations Manifest { get; }
3837

39-
private static HttpClient CreateClient(string registryName, Uri baseUri, ILogger logger, bool isAmazonECRRegistry = false)
38+
private static HttpClient CreateClient(string registryName, Uri baseUri, bool isInsecureRegistry, ILogger logger)
4039
{
41-
var innerHandler = new SocketsHttpHandler()
42-
{
43-
UseCookies = false,
44-
// 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
45-
ConnectTimeout = TimeSpan.FromSeconds(30)
46-
};
47-
48-
// Ignore certificate for https localhost repository.
49-
if (baseUri.Host == "localhost" && baseUri.Scheme == "https")
50-
{
51-
innerHandler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions()
52-
{
53-
RemoteCertificateValidationCallback = (object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors)
54-
=> (sender as SslStream)?.TargetHostName == "localhost"
55-
};
56-
}
40+
HttpMessageHandler innerHandler = CreateHttpHandler(baseUri, isInsecureRegistry, logger);
5741

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

60-
if (isAmazonECRRegistry)
44+
if (baseUri.IsAmazonECRRegistry())
6145
{
6246
clientHandler = new AmazonECRMessageHandler(clientHandler);
6347
}
@@ -71,4 +55,45 @@ private static HttpClient CreateClient(string registryName, Uri baseUri, ILogger
7155

7256
return client;
7357
}
58+
59+
private static HttpMessageHandler CreateHttpHandler(Uri baseUri, bool allowInsecure, ILogger logger)
60+
{
61+
var socketsHttpHandler = new SocketsHttpHandler()
62+
{
63+
UseCookies = false,
64+
// 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
65+
ConnectTimeout = TimeSpan.FromSeconds(30)
66+
};
67+
68+
if (!allowInsecure)
69+
{
70+
return socketsHttpHandler;
71+
}
72+
73+
socketsHttpHandler.SslOptions = new System.Net.Security.SslClientAuthenticationOptions()
74+
{
75+
RemoteCertificateValidationCallback = IgnoreCertificateErrorsForSpecificHost(baseUri.Host)
76+
};
77+
78+
return new FallbackToHttpMessageHandler(baseUri.Host, baseUri.Port, socketsHttpHandler, logger);
79+
}
80+
81+
private static RemoteCertificateValidationCallback IgnoreCertificateErrorsForSpecificHost(string host)
82+
{
83+
return (object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) =>
84+
{
85+
if (sslPolicyErrors == SslPolicyErrors.None)
86+
{
87+
return true;
88+
}
89+
90+
// Ignore certificate errors for the hostname.
91+
if ((sender as SslStream)?.TargetHostName == host)
92+
{
93+
return true;
94+
}
95+
96+
return false;
97+
};
98+
}
7499
}

src/Containers/Microsoft.NET.Build.Containers/Registry/Registry.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ internal sealed class Registry
7171
public string RegistryName { get; }
7272

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

7777
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,
8686
BaseUri = baseUri;
8787

8888
_logger = logger;
89-
_settings = settings ?? new RegistrySettings();
90-
_registryAPI = registryAPI ?? new DefaultRegistryAPI(RegistryName, BaseUri, logger);
89+
_settings = settings ?? new RegistrySettings(RegistryName);
90+
_registryAPI = registryAPI ?? new DefaultRegistryAPI(RegistryName, BaseUri, _settings.IsInsecure, logger);
9191
}
9292

9393
private static string DeriveRegistryName(Uri baseUri)

src/Containers/Microsoft.NET.Build.Containers/Registry/RegistrySettings.cs

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ namespace Microsoft.NET.Build.Containers;
88

99
internal class RegistrySettings
1010
{
11+
public RegistrySettings(string? registryName = null, IEnvironmentProvider? environment = null)
12+
{
13+
environment ??= new EnvironmentProvider();
14+
15+
ChunkedUploadSizeBytes = environment.GetEnvironmentVariableAsNullableInt(EnvVariables.ChunkedUploadSizeBytes);
16+
ForceChunkedUpload = environment.GetEnvironmentVariableAsBool(EnvVariables.ForceChunkedUpload, defaultValue: false);
17+
ParallelUploadEnabled = environment.GetEnvironmentVariableAsBool(EnvVariables.ParallelUploadEnabled, defaultValue: true);
18+
19+
if (registryName is not null)
20+
{
21+
IsInsecure = IsInsecureRegistry(environment, registryName);
22+
}
23+
}
24+
1125
private const int DefaultChunkSizeBytes = 1024 * 64;
1226
private const int FiveMegs = 5_242_880;
1327

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

2236
/// <summary>
2337
/// Allows to force chunked upload for debugging purposes.
2438
/// </summary>
25-
internal bool ForceChunkedUpload { get; init; } = Env.GetEnvironmentVariableAsBool(EnvVariables.ForceChunkedUpload, defaultValue: false);
39+
internal bool ForceChunkedUpload { get; init; }
2640

2741
/// <summary>
2842
/// Whether we should upload blobs in parallel (enabled by default, but disabled for certain registries in conjunction with the explicit support check below).
2943
/// </summary>
3044
/// <remarks>
3145
/// Enabling this can swamp some registries, so this is an escape hatch.
3246
/// </remarks>
33-
internal bool ParallelUploadEnabled { get; init; } = Env.GetEnvironmentVariableAsBool(EnvVariables.ParallelUploadEnabled, defaultValue: true);
47+
internal bool ParallelUploadEnabled { get; init; }
48+
49+
/// <summary>
50+
/// Allows ignoring https certificate errors and changing to http when the endpoint is not an https endpoint.
51+
/// </summary>
52+
internal bool IsInsecure { get; init; }
3453

3554
internal struct EnvVariables
3655
{
3756
internal const string ChunkedUploadSizeBytes = "SDK_CONTAINER_REGISTRY_CHUNKED_UPLOAD_SIZE_BYTES";
3857

3958
internal const string ForceChunkedUpload = "SDK_CONTAINER_DEBUG_REGISTRY_FORCE_CHUNKED_UPLOAD";
4059
internal const string ParallelUploadEnabled = "SDK_CONTAINER_REGISTRY_PARALLEL_UPLOAD";
60+
61+
internal const string InsecureRegistries = "SDK_CONTAINER_INSECURE_REGISTRIES";
62+
}
63+
64+
private static bool IsInsecureRegistry(IEnvironmentProvider environment, string registryName)
65+
{
66+
// Always allow insecure access to 'localhost'.
67+
if (registryName.StartsWith("localhost:", StringComparison.OrdinalIgnoreCase) ||
68+
registryName.Equals("localhost", StringComparison.OrdinalIgnoreCase))
69+
{
70+
return true;
71+
}
72+
73+
// SDK_CONTAINER_INSECURE_REGISTRIES is a semicolon separated list of insecure registry names.
74+
string? insecureRegistriesEnv = environment.GetEnvironmentVariable(EnvVariables.InsecureRegistries);
75+
if (insecureRegistriesEnv is not null)
76+
{
77+
string[] insecureRegistries = insecureRegistriesEnv.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
78+
if (Array.Exists(insecureRegistries, registry => registryName.Equals(registry, StringComparison.OrdinalIgnoreCase)))
79+
{
80+
return true;
81+
}
82+
}
83+
84+
return DockerCli.IsInsecureRegistry(registryName);
4185
}
4286
}

test/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryManager.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public static async Task StartAndPopulateDockerRegistry(ITestOutputHelper testOu
6565
using var reader = new StringReader(processResult.StdOut!);
6666
s_registryContainerId = reader.ReadLine();
6767

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

7070
foreach (string? tag in new[] { Net6ImageTag, Net7ImageTag, Net8ImageTag, Net9PreviewImageTag })
7171
{
@@ -119,13 +119,13 @@ public static void ShutdownDockerRegistry(ITestOutputHelper testOutput)
119119
}
120120
}
121121

122-
private static void EnsureRegistryLoaded(string registryBaseUri, string? containerRegistryId, ILogger logger, ITestOutputHelper testOutput)
122+
private static void EnsureRegistryLoaded(Uri registryBaseUri, string? containerRegistryId, ILogger logger, ITestOutputHelper testOutput)
123123
{
124124
const int registryLoadMaxRetry = 10;
125125
const int registryLoadTimeout = 1000; //ms
126126

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

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

test/Microsoft.NET.Build.Containers.IntegrationTests/DockerRegistryTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public async Task WriteToPrivateBasicRegistry()
7474
// login to that registry
7575
ContainerCli.LoginCommand(_testOutput, "--username", "testuser", "--password", "testpassword", registryName).Execute().Should().Pass();
7676
// push an image to that registry using username/password
77-
Registry localAuthed = new(new Uri($"https://{registryName}"), logger, settings: new() { ParallelUploadEnabled = false, ForceChunkedUpload = true });
77+
Registry localAuthed = new(new Uri($"https://{registryName}"), logger, settings: new(registryName) { ParallelUploadEnabled = false, ForceChunkedUpload = true });
7878
var ridgraphfile = ToolsetUtils.GetRuntimeGraphFilePath();
7979
Registry mcr = new(DockerRegistryManager.BaseImageSource, logger);
8080

0 commit comments

Comments
 (0)