quorate/packages/fsharp-runner/oci/Transport.cs

220 lines
7.8 KiB
C#
Raw Permalink Normal View History

using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
namespace Quorate.Oci;
internal sealed class RegistryTransport : IDisposable
{
private readonly ConcurrentDictionary<RepositoryName, AuthorizationState> authorization = [];
private readonly AuthenticationHeaderValue basic;
private readonly HttpClient client;
private readonly Uri endpoint;
private readonly HttpMessageHandler? ownedHandler;
public RegistryTransport(Uri endpoint, RegistryAuth auth, X509Certificate2 root)
: this(endpoint, auth, CreateHandler(root), true)
{
}
public RegistryTransport(Uri endpoint, RegistryAuth auth, HttpMessageHandler handler)
: this(endpoint, auth, handler, false)
{
}
private RegistryTransport(
Uri endpoint,
RegistryAuth auth,
HttpMessageHandler handler,
bool ownsHandler)
{
ValidateArguments(endpoint, auth);
ArgumentNullException.ThrowIfNull(handler);
this.endpoint = endpoint;
basic = auth.Basic();
ownedHandler = ownsHandler ? handler : null;
client = new HttpClient(handler, false) { Timeout = TimeSpan.FromMinutes(15) };
}
public Uri Api(RepositoryName repository, string path) => new(endpoint, $"/v2/{repository}/{path}");
public static Uri ResolveLocation(HttpResponseMessage response)
{
Uri location = response.Headers.Location
?? throw new InvalidDataException("Registry upload location is unavailable.");
Uri request = response.RequestMessage?.RequestUri
?? throw new InvalidDataException("Registry request URI is unavailable.");
Uri resolved = location.IsAbsoluteUri ? location : new Uri(request, location);
if (resolved.Scheme != request.Scheme || resolved.IdnHost != request.IdnHost || resolved.Port != request.Port)
{
throw new InvalidDataException("Registry upload location changes authority.");
}
return resolved;
}
public async Task<HttpResponseMessage> SendAsync(
RepositoryName repository,
Func<HttpRequestMessage> create,
CancellationToken cancellationToken,
HttpStatusCode? allowed = null)
{
AuthorizationState state = authorization.GetOrAdd(repository, static _ => new AuthorizationState());
for (int attempt = 0; attempt < 3; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
int generation = state.Generation;
AuthenticationHeaderValue current = state.Value ?? basic;
using HttpRequestMessage request = create();
request.Headers.Authorization = current;
HttpResponseMessage response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode != HttpStatusCode.Unauthorized)
{
if (!response.IsSuccessStatusCode && response.StatusCode != allowed)
{
string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
HttpStatusCode status = response.StatusCode;
response.Dispose();
throw new HttpRequestException($"Registry returned {(int)status}: {body}", null, status);
}
return response;
}
AuthenticationHeaderValue challenge;
using (response)
{
challenge = response.Headers.WwwAuthenticate.Single(header =>
header.Scheme.Equals("Bearer", StringComparison.OrdinalIgnoreCase));
}
await state.Gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (state.Generation == generation)
{
AuthenticationHeaderValue value = await GetBearerAsync(
BearerChallenge.Parse(challenge),
cancellationToken).ConfigureAwait(false);
state.Update(value);
}
}
finally
{
state.Gate.Release();
}
}
throw new InvalidDataException($"Registry authentication did not converge for {repository}.");
}
public void Dispose()
{
foreach (AuthorizationState state in authorization.Values)
{
state.Dispose();
}
client.Dispose();
ownedHandler?.Dispose();
}
public static HttpRequestMessage Request(HttpMethod method, Uri uri) => new(method, uri)
{
Version = HttpVersion.Version20,
VersionPolicy = HttpVersionPolicy.RequestVersionOrLower
};
private async Task<AuthenticationHeaderValue> GetBearerAsync(
BearerChallenge challenge,
CancellationToken cancellationToken)
{
var query = new List<string>();
if (challenge.Service is not null)
{
query.Add($"service={Uri.EscapeDataString(challenge.Service)}");
}
if (challenge.Scope is not null)
{
query.Add($"scope={Uri.EscapeDataString(challenge.Scope)}");
}
var builder = new UriBuilder(challenge.Realm) { Query = string.Join('&', query) };
using HttpRequestMessage request = Request(HttpMethod.Get, builder.Uri);
request.Headers.Authorization = basic;
using HttpResponseMessage response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using Stream content = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
BearerTokenModel token = await JsonSerializer.DeserializeAsync(
content,
OciJsonContext.Default.BearerTokenModel,
cancellationToken).ConfigureAwait(false)
?? throw new InvalidDataException("Registry token response is empty.");
string? value = token.Token ?? token.AccessToken;
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidDataException("Registry token is unavailable.");
}
return new AuthenticationHeaderValue("Bearer", value);
}
private static SocketsHttpHandler CreateHandler(X509Certificate2 root)
{
ArgumentNullException.ThrowIfNull(root);
var policy = new X509ChainPolicy
{
TrustMode = X509ChainTrustMode.CustomRootTrust,
RevocationMode = X509RevocationMode.NoCheck
};
policy.CustomTrustStore.Add(root);
return new SocketsHttpHandler
{
AllowAutoRedirect = false,
SslOptions = new SslClientAuthenticationOptions
{
CertificateChainPolicy = policy
}
};
}
private static void ValidateArguments(Uri endpoint, RegistryAuth auth)
{
ArgumentNullException.ThrowIfNull(endpoint);
ArgumentNullException.ThrowIfNull(auth);
if (!endpoint.IsAbsoluteUri || endpoint.Scheme != Uri.UriSchemeHttps)
{
throw new ArgumentException("Registry endpoint must use HTTPS.", nameof(endpoint));
}
}
private sealed class AuthorizationState : IDisposable
{
private int generation;
private AuthenticationHeaderValue? value;
public SemaphoreSlim Gate { get; } = new(1, 1);
public AuthenticationHeaderValue? Value => Volatile.Read(ref value);
public int Generation => Volatile.Read(ref generation);
public void Update(AuthenticationHeaderValue authorization)
{
Volatile.Write(ref value, authorization);
Interlocked.Increment(ref generation);
}
public void Dispose() => Gate.Dispose();
}
}