Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
Expand All @@ -14,6 +15,7 @@
using GeneralUpdate.Core.Ipc;
using GeneralUpdate.Core.Differential;
using GeneralUpdate.Core.Pipeline;
using GeneralUpdate.Core.Network;
using GeneralUpdate.Differential.Differ;

namespace GeneralUpdate.Core;
Expand Down Expand Up @@ -88,6 +90,7 @@ public class GeneralUpdateBootstrap : AbstractBootstrap<GeneralUpdateBootstrap,
private CancellationTokenSource? _cts;
private DiffPipelineBuilder? _diffPipelineBuilder;
private Silent.SilentPollOrchestrator? _silentOrchestrator;
private List<string> _syncedCustomHeaders = new();

/// <summary>
/// When silent mode is active, provides access to the background polling orchestrator.
Expand Down Expand Up @@ -198,6 +201,23 @@ public GeneralUpdateBootstrap SetConfig(UpdateRequest configInfo)
configInfo.Validate();
_configInfo = ConfigurationMapper.MapToUpdateContext(configInfo);

// Sync custom headers to the global HTTP client so they are applied
// to every request (Verification + Report) without manual extra setup.
// First clear stale headers from a previous SetConfig call, then apply
// the current ones — avoids leaking headers between bootstrap instances.
var oldHeaders = _syncedCustomHeaders;
_syncedCustomHeaders = new List<string>();
foreach (var key in oldHeaders)
HttpClientProvider.ExtraHeaders.TryRemove(key, out _);
if (_configInfo.CustomHeaders is { Count: > 0 })
{
foreach (var kv in _configInfo.CustomHeaders)
{
HttpClientProvider.ExtraHeaders[kv.Key] = kv.Value;
_syncedCustomHeaders.Add(kv.Key);
}
}

var appType = GetOption(Option.AppType);
if (appType != AppType.Upgrade)
{
Expand Down Expand Up @@ -497,15 +517,9 @@ private void ConfigureStrategy(IStrategy roleStrategy)
var downloadExecutor = ResolveExtension<Download.Abstractions.IDownloadExecutor>();

Func<string?, Download.Abstractions.IDownloadPipeline>? downloadPipelineFactory = null;
var pipelineType = ResolveExtensionType<Download.Abstractions.IDownloadPipeline>();
if (pipelineType != null)
{
var stringCtor = pipelineType.GetConstructor([typeof(string)]);
if (stringCtor != null)
downloadPipelineFactory = hash => (Download.Abstractions.IDownloadPipeline)stringCtor.Invoke([hash]);
else
downloadPipelineFactory = _ => (Download.Abstractions.IDownloadPipeline)Activator.CreateInstance(pipelineType);
}
var downloadPipeline = ResolveExtension<Download.Abstractions.IDownloadPipeline>();
if (downloadPipeline != null)
downloadPipelineFactory = _ => downloadPipeline;

switch (roleStrategy)
{
Expand Down
16 changes: 16 additions & 0 deletions src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,22 @@ protected T GetOption<T>(Option<T>? option)
return (TBootstrap)this;
}

/// <summary>
/// Registers a pre-constructed authentication provider instance.
/// Use this overload when the provider requires constructor parameters
/// (e.g. Basic Auth with username/password, or a custom provider with
/// a tenant header). For parameterless providers, prefer <see cref="HttpAuth{T}"/>.
/// </summary>
/// <param name="provider">The authentication provider instance.</param>
/// <returns>The current <typeparamref name="TBootstrap"/> instance for chaining.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="provider"/> is null.</exception>
public TBootstrap HttpAuth(Security.IHttpAuthProvider provider)
{
_instances[typeof(Security.IHttpAuthProvider)] =
provider ?? throw new ArgumentNullException(nameof(provider));
return (TBootstrap)this;
}

/// <summary>
/// Registers an HTTP authentication provider for attaching authentication credentials
/// to download requests.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ public static UpdateContext MapToUpdateContext(UpdateRequest source, UpdateConte
target.UpdateUrl = source.UpdateUrl;
target.UpgradeClientVersion = source.UpgradeClientVersion;
target.ProductId = source.ProductId;
target.CustomHeaders = source.CustomHeaders;

return target;
}
Expand Down
14 changes: 14 additions & 0 deletions src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,5 +231,19 @@ public abstract class UpdateConfiguration
/// The unique product identifier for the current application.
/// </summary>
public string ProductId { get; set; }

/// <summary>
/// Custom HTTP headers to include in every request made by the update framework
/// (both version validation and status reporting).
/// Headers are applied after the authentication provider, so they can supplement
/// or override authentication headers if needed.
/// </summary>
/// <remarks>
/// Example: add <c>["X-Tenant-Id"] = "default-tenant"</c> for multi-tenant servers.
/// Headers set here are synced to <see cref="Network.HttpClientProvider.ExtraHeaders"/>
/// at configuration time.
/// </remarks>
/// </remarks>
public Dictionary<string, string> CustomHeaders { get; set; }
}
}
12 changes: 10 additions & 2 deletions src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using GeneralUpdate.Core.Network;

namespace GeneralUpdate.Core.Download.Reporting;

Expand Down Expand Up @@ -111,11 +112,13 @@ public HttpClient Client

/// <summary>
/// Parameterless constructor required by the extension resolution mechanism.
/// Uses a default HttpClient and empty ReportUrl (no-op until configured).
/// Uses the shared <see cref="HttpClientProvider.Shared"/> instance and empty ReportUrl
/// (no-op until configured). The shared HttpClient honours the global SSL policy set
/// via <see cref="HttpClientProvider.SetSslValidationPolicy"/>.
/// </summary>
public HttpUpdateReporter()
{
_client = new HttpClient();
_client = HttpClientProvider.Shared;
_reportUrl = string.Empty;
}

Expand All @@ -142,6 +145,11 @@ public async Task ReportAsync(UpdateReport report, CancellationToken token = def
using var request = new HttpRequestMessage(HttpMethod.Post, _reportUrl);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");

// Apply the global authentication provider (if one was set via
// VersionService.SetDefaultAuthProvider or HttpClientProvider.DefaultAuthProvider)
// so that report requests carry the same credentials as version validation requests.
await HttpClientProvider.ApplyAuthAsync(request, token).ConfigureAwait(false);

await _client.SendAsync(request, token).ConfigureAwait(false);
}
catch (Exception ex)
Expand Down
88 changes: 83 additions & 5 deletions src/c#/GeneralUpdate.Core/Network/HttpClientProvider.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
using GeneralUpdate.Core.Security;

namespace GeneralUpdate.Core.Network;

/// <summary>
/// Provides a shared static <see cref="HttpClient"/> instance with configurable SSL validation.
/// Reusing a single HttpClient prevents socket exhaustion.
/// Do NOT dispose clients obtained from here.
/// Provides a shared static <see cref="HttpClient"/> instance with configurable SSL validation
/// and a global HTTP authentication provider. Reusing a single HttpClient prevents socket
/// exhaustion. Do NOT dispose clients obtained from here.
/// </summary>
/// <remarks>
/// <para>
Expand All @@ -18,15 +22,27 @@ namespace GeneralUpdate.Core.Network;
/// <see cref="StrictSslValidationPolicy"/> (rejects any certificate with SSL errors).
/// </para>
/// <para>
/// This class mirrors the SSL validation pattern used by <see cref="VersionService"/>,
/// ensuring that both API calls and file downloads respect the same global SSL policy.
/// The global <see cref="IHttpAuthProvider"/> set via <see cref="DefaultAuthProvider"/> is
/// applied by <see cref="ApplyAuthAsync"/> so that components like <see cref="HttpUpdateReporter"/>
/// participate in the same authentication scheme as <see cref="VersionService"/>.
/// Extra headers set via <see cref="ExtraHeaders"/> are applied after the auth provider.
/// </para>
/// </remarks>
public static class HttpClientProvider
{
private static ISslValidationPolicy _sslPolicy = new StrictSslValidationPolicy();
private static IHttpAuthProvider? _defaultAuthProvider;
private static readonly HttpClient _shared;

// Headers that belong on HttpContentMessage.Headers, not HttpRequestHeaders.
// Setting them via request.Headers throws InvalidOperationException.
private static readonly HashSet<string> ContentHeaderNames = new(StringComparer.OrdinalIgnoreCase)
{
"Content-Type", "Content-Length", "Content-Encoding", "Content-Language",
"Content-Location", "Content-Disposition", "Content-Range", "Content-MD5",
"Last-Modified", "Expires", "Allow"
};

static HttpClientProvider()
{
var handler = new HttpClientHandler();
Expand Down Expand Up @@ -67,4 +83,66 @@ public static void SetSslValidationPolicy(ISslValidationPolicy policy)
/// Gets the shared <see cref="HttpClient"/> instance. Do NOT dispose.
/// </summary>
public static HttpClient Shared => _shared;

/// <summary>
/// Gets or sets the global default HTTP authentication provider.
/// When set, every HTTP call from <see cref="VersionService"/>,
/// <see cref="HttpUpdateReporter"/>, and other components using
/// <see cref="ApplyAuthAsync"/> will carry the configured credentials.
/// </summary>
public static IHttpAuthProvider? DefaultAuthProvider
{
get => _defaultAuthProvider;
set => _defaultAuthProvider = value;
}

/// <summary>
/// Extra HTTP headers applied to every outgoing request by <see cref="ApplyAuthAsync"/>.
/// Use this for headers that are not part of the authentication scheme itself but
/// are required by the target server — for example <c>X-Tenant-Id</c> for tenant scoping.
/// </summary>
/// <remarks>
/// The key is the header name, the value is the header value.
/// Existing headers of the same name on the request are overwritten.
/// This dictionary is thread-safe.
/// </remarks>
public static ConcurrentDictionary<string, string> ExtraHeaders { get; }
= new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);

/// <summary>
/// Applies the global default authentication provider and extra headers
/// to the specified request. When no global provider is configured,
/// the auth step is a no-op.
/// </summary>
/// <remarks>
/// <para>
/// Extra headers (see <see cref="ExtraHeaders"/>) are applied after the auth provider,
/// so they can supplement authentication headers and also appear on their own when
/// no auth provider is configured. If an extra header key matches a content header
/// (e.g. <c>Content-Type</c>), it is set on <c>request.Content.Headers</c> instead
/// of <c>request.Headers</c> to avoid <see cref="InvalidOperationException"/>.
/// </para>
/// </remarks>
/// <param name="request">The HTTP request message to authenticate.</param>
/// <param name="token">A cancellation token for the authentication operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public static async Task ApplyAuthAsync(HttpRequestMessage request, CancellationToken token = default)
{
if (_defaultAuthProvider != null)
await _defaultAuthProvider.ApplyAuthAsync(request, token).ConfigureAwait(false);

foreach (var kv in ExtraHeaders)
{
if (ContentHeaderNames.Contains(kv.Key) && request.Content != null)
{
request.Content.Headers.Remove(kv.Key);
request.Content.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
}
else
{
request.Headers.Remove(kv.Key);
request.Headers.Add(kv.Key, kv.Value);
}
}
}
}
42 changes: 24 additions & 18 deletions src/c#/GeneralUpdate.Core/Network/VersionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,10 @@ public class VersionService
{
private static readonly HttpClient _sharedClient;
private static volatile ISslValidationPolicy _globalSslPolicy = new StrictSslValidationPolicy();
private static IHttpAuthProvider? _globalAuthProvider;

private readonly IHttpAuthProvider _auth;
private readonly IHttpAuthProvider _authProvider;
private readonly TimeSpan _timeout;
private readonly int _maxRetries;
private readonly int _maxRetryAttempts;

/// <summary>
/// Static constructor: initializes the static members of <see cref="VersionService"/>.
Expand Down Expand Up @@ -111,18 +110,25 @@ public static void SetSslValidationPolicy(ISslValidationPolicy policy)
/// Sets the global default HTTP authentication provider.
/// </summary>
/// <remarks>
/// <para>
/// Delegates to <see cref="HttpClientProvider.DefaultAuthProvider"/> so that
/// <see cref="HttpUpdateReporter"/> and other components using
/// <see cref="HttpClientProvider.Shared"/> share the same global authentication.
/// </para>
/// <para>
/// When a global authentication provider is set, all requests made via the static APIs
/// (<see cref="Validate(string, string, AppType, string, PlatformType, string, string, string, CancellationToken)"/>
/// and <see cref="Report(string, int, int, int?, string, string, CancellationToken)"/>)
/// will preferentially use this provider, overriding the authentication instance
/// created by <see cref="HttpAuthProviderFactory.Create"/>.
/// </para>
/// <para>
/// Passing null clears the global authentication provider, reverting to the factory method.
/// </para>
/// </remarks>
/// <param name="provider">The global authentication provider instance, or null to clear the global configuration.</param>
public static void SetDefaultAuthProvider(IHttpAuthProvider? provider)
=> _globalAuthProvider = provider;
=> HttpClientProvider.DefaultAuthProvider = provider;

private static bool SharedCertValidation(HttpRequestMessage m, X509Certificate2? c,
X509Chain? ch, SslPolicyErrors e)
Expand All @@ -134,16 +140,16 @@ private static bool SharedCertValidation(HttpRequestMessage m, X509Certificate2?
/// <remarks>
/// Instance methods (<see cref="ValidateAsync"/> and <see cref="ReportAsync"/>) use
/// this instance's authentication provider and timeout settings.
/// When <paramref name="auth"/> is null, <see cref="NoOpAuthProvider"/> (no authentication) is used by default.
/// When <paramref name="authProvider"/> is null, <see cref="NoOpAuthProvider"/> (no authentication) is used by default.
/// </remarks>
/// <param name="auth">The HTTP authentication provider. If null, <see cref="NoOpAuthProvider"/> is used.</param>
/// <param name="timeout">The request timeout. If null, defaults to 30 seconds.</param>
/// <param name="maxRetries">The maximum number of retry attempts. Defaults to 3.</param>
public VersionService(IHttpAuthProvider? auth = null, TimeSpan? timeout = null, int maxRetries = 3)
/// <param name="authProvider">The HTTP authentication provider. If null, <see cref="NoOpAuthProvider"/> is used.</param>
/// <param name="requestTimeout">The request timeout. If null, defaults to 30 seconds.</param>
/// <param name="maxRetryAttempts">The maximum number of retry attempts. Defaults to 3.</param>
public VersionService(IHttpAuthProvider? authProvider = null, TimeSpan? requestTimeout = null, int maxRetryAttempts = 3)
{
_auth = auth ?? new NoOpAuthProvider();
_timeout = timeout ?? TimeSpan.FromSeconds(30);
_maxRetries = maxRetries;
_authProvider = authProvider ?? new NoOpAuthProvider();
_timeout = requestTimeout ?? TimeSpan.FromSeconds(30);
_maxRetryAttempts = maxRetryAttempts;
}

/// <summary>
Expand Down Expand Up @@ -185,7 +191,7 @@ public static Task<VersionRespDTO> Validate(string url, string version,
AppType appType, string appKey, PlatformType platform, string productId,
string scheme = null, string token = null, Security.AuthScheme authScheme = Security.AuthScheme.Hmac, string basicUsername = null, string basicPassword = null, CancellationToken ct = default)
{
var auth = _globalAuthProvider ?? HttpAuthProviderFactory.Create(scheme, token, appKey, authScheme, basicUsername, basicPassword);
var auth = HttpClientProvider.DefaultAuthProvider ?? HttpAuthProviderFactory.Create(scheme, token, appKey, authScheme, basicUsername, basicPassword);
return new VersionService(auth).ValidateAsync(url, version, (int)appType, appKey, (int)platform, productId, ct);
}

Expand Down Expand Up @@ -288,9 +294,9 @@ private async Task<T> PostAsync<T>(string url, Dictionary<string, object> p,
for (int attempt = 0; ; attempt++)
{
try { return await SendAsync<T>(url, p, ti, t).ConfigureAwait(false); }
catch (Exception ex) when (attempt < _maxRetries - 1 && IsRetryable(ex))
catch (Exception ex) when (attempt < _maxRetryAttempts - 1 && ShouldRetry(ex))
{
GeneralTracer.Warn($"HTTP attempt {attempt + 1}/{_maxRetries} failed, retrying. {ex.Message}");
GeneralTracer.Warn($"HTTP attempt {attempt + 1}/{_maxRetryAttempts} failed, retrying. {ex.Message}");
await Task.Delay(TimeSpan.FromMilliseconds(Math.Pow(2, attempt) * 1000), t).ConfigureAwait(false);
}
}
Expand Down Expand Up @@ -343,7 +349,7 @@ private async Task<T> SendAsync<T>(string url, Dictionary<string, object> p,
req.Headers.Accept.ParseAdd("application/json");
var json = JsonSerializer.Serialize(p, HttpParameterJsonContext.Default.DictionaryStringObject);
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
await _auth.ApplyAuthAsync(req, t).ConfigureAwait(false);
await _authProvider.ApplyAuthAsync(req, t).ConfigureAwait(false);

using var cts = CancellationTokenSource.CreateLinkedTokenSource(t);
cts.CancelAfter(_timeout);
Expand All @@ -354,7 +360,7 @@ private async Task<T> SendAsync<T>(string url, Dictionary<string, object> p,
}

/// <summary>
/// Determines whether an exception is retryable.
/// Determines whether an exception should be retried.
/// </summary>
/// <param name="ex">The exception to evaluate.</param>
/// <returns><c>true</c> if the exception is retryable; otherwise <c>false</c>.</returns>
Expand All @@ -375,7 +381,7 @@ private async Task<T> SendAsync<T>(string url, Dictionary<string, object> p,
/// </list>
/// </para>
/// </remarks>
private static bool IsRetryable(Exception ex)
private static bool ShouldRetry(Exception ex)
{
if (ex is OperationCanceledException) return false;
if (ex is TaskCanceledException or TimeoutException or System.IO.IOException) return true;
Expand Down
Loading
Loading