diff --git a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs index aa74f55c..3c5b270e 100644 --- a/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Bootstrap/GeneralUpdateBootstrap.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -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; @@ -88,6 +90,7 @@ public class GeneralUpdateBootstrap : AbstractBootstrap _syncedCustomHeaders = new(); /// /// When silent mode is active, provides access to the background polling orchestrator. @@ -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(); + 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) { @@ -497,15 +517,9 @@ private void ConfigureStrategy(IStrategy roleStrategy) var downloadExecutor = ResolveExtension(); Func? downloadPipelineFactory = null; - var pipelineType = ResolveExtensionType(); - 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(); + if (downloadPipeline != null) + downloadPipelineFactory = _ => downloadPipeline; switch (roleStrategy) { diff --git a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs index c70de9b0..92c39ac1 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/AbstractBootstrap.cs @@ -260,6 +260,22 @@ protected T GetOption(Option? option) return (TBootstrap)this; } + /// + /// 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 . + /// + /// The authentication provider instance. + /// The current instance for chaining. + /// Thrown when is null. + public TBootstrap HttpAuth(Security.IHttpAuthProvider provider) + { + _instances[typeof(Security.IHttpAuthProvider)] = + provider ?? throw new ArgumentNullException(nameof(provider)); + return (TBootstrap)this; + } + /// /// Registers an HTTP authentication provider for attaching authentication credentials /// to download requests. diff --git a/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs b/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs index ae9e110e..e5dffa23 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/ConfigurationMapper.cs @@ -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; } diff --git a/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs b/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs index ec6eb15e..f76eb71b 100644 --- a/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs +++ b/src/c#/GeneralUpdate.Core/Configuration/UpdateConfiguration.cs @@ -231,5 +231,19 @@ public abstract class UpdateConfiguration /// The unique product identifier for the current application. /// public string ProductId { get; set; } + + /// + /// 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. + /// + /// + /// Example: add ["X-Tenant-Id"] = "default-tenant" for multi-tenant servers. + /// Headers set here are synced to + /// at configuration time. + /// + /// + public Dictionary CustomHeaders { get; set; } } } diff --git a/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs b/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs index 6a177128..8290c468 100644 --- a/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs +++ b/src/c#/GeneralUpdate.Core/Download/Reporting/IUpdateReporter.cs @@ -4,6 +4,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using GeneralUpdate.Core.Network; namespace GeneralUpdate.Core.Download.Reporting; @@ -111,11 +112,13 @@ public HttpClient Client /// /// Parameterless constructor required by the extension resolution mechanism. - /// Uses a default HttpClient and empty ReportUrl (no-op until configured). + /// Uses the shared instance and empty ReportUrl + /// (no-op until configured). The shared HttpClient honours the global SSL policy set + /// via . /// public HttpUpdateReporter() { - _client = new HttpClient(); + _client = HttpClientProvider.Shared; _reportUrl = string.Empty; } @@ -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) diff --git a/src/c#/GeneralUpdate.Core/Network/HttpClientProvider.cs b/src/c#/GeneralUpdate.Core/Network/HttpClientProvider.cs index 824e3371..d2bca7b9 100644 --- a/src/c#/GeneralUpdate.Core/Network/HttpClientProvider.cs +++ b/src/c#/GeneralUpdate.Core/Network/HttpClientProvider.cs @@ -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; /// -/// Provides a shared static instance with configurable SSL validation. -/// Reusing a single HttpClient prevents socket exhaustion. -/// Do NOT dispose clients obtained from here. +/// Provides a shared static 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. /// /// /// @@ -18,15 +22,27 @@ namespace GeneralUpdate.Core.Network; /// (rejects any certificate with SSL errors). /// /// -/// This class mirrors the SSL validation pattern used by , -/// ensuring that both API calls and file downloads respect the same global SSL policy. +/// The global set via is +/// applied by so that components like +/// participate in the same authentication scheme as . +/// Extra headers set via are applied after the auth provider. /// /// 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 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(); @@ -67,4 +83,66 @@ public static void SetSslValidationPolicy(ISslValidationPolicy policy) /// Gets the shared instance. Do NOT dispose. /// public static HttpClient Shared => _shared; + + /// + /// Gets or sets the global default HTTP authentication provider. + /// When set, every HTTP call from , + /// , and other components using + /// will carry the configured credentials. + /// + public static IHttpAuthProvider? DefaultAuthProvider + { + get => _defaultAuthProvider; + set => _defaultAuthProvider = value; + } + + /// + /// Extra HTTP headers applied to every outgoing request by . + /// Use this for headers that are not part of the authentication scheme itself but + /// are required by the target server — for example X-Tenant-Id for tenant scoping. + /// + /// + /// 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. + /// + public static ConcurrentDictionary ExtraHeaders { get; } + = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// 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. + /// + /// + /// + /// Extra headers (see ) 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. Content-Type), it is set on request.Content.Headers instead + /// of request.Headers to avoid . + /// + /// + /// The HTTP request message to authenticate. + /// A cancellation token for the authentication operation. + /// A task representing the asynchronous operation. + 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); + } + } + } } diff --git a/src/c#/GeneralUpdate.Core/Network/VersionService.cs b/src/c#/GeneralUpdate.Core/Network/VersionService.cs index e2965e20..7c7835a0 100644 --- a/src/c#/GeneralUpdate.Core/Network/VersionService.cs +++ b/src/c#/GeneralUpdate.Core/Network/VersionService.cs @@ -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; /// /// Static constructor: initializes the static members of . @@ -111,18 +110,25 @@ public static void SetSslValidationPolicy(ISslValidationPolicy policy) /// Sets the global default HTTP authentication provider. /// /// + /// + /// Delegates to so that + /// and other components using + /// share the same global authentication. + /// + /// /// When a global authentication provider is set, all requests made via the static APIs /// ( /// and ) /// will preferentially use this provider, overriding the authentication instance /// created by . + /// /// /// Passing null clears the global authentication provider, reverting to the factory method. /// /// /// The global authentication provider instance, or null to clear the global configuration. public static void SetDefaultAuthProvider(IHttpAuthProvider? provider) - => _globalAuthProvider = provider; + => HttpClientProvider.DefaultAuthProvider = provider; private static bool SharedCertValidation(HttpRequestMessage m, X509Certificate2? c, X509Chain? ch, SslPolicyErrors e) @@ -134,16 +140,16 @@ private static bool SharedCertValidation(HttpRequestMessage m, X509Certificate2? /// /// Instance methods ( and ) use /// this instance's authentication provider and timeout settings. - /// When is null, (no authentication) is used by default. + /// When is null, (no authentication) is used by default. /// - /// The HTTP authentication provider. If null, is used. - /// The request timeout. If null, defaults to 30 seconds. - /// The maximum number of retry attempts. Defaults to 3. - public VersionService(IHttpAuthProvider? auth = null, TimeSpan? timeout = null, int maxRetries = 3) + /// The HTTP authentication provider. If null, is used. + /// The request timeout. If null, defaults to 30 seconds. + /// The maximum number of retry attempts. Defaults to 3. + 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; } /// @@ -185,7 +191,7 @@ public static Task 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); } @@ -288,9 +294,9 @@ private async Task PostAsync(string url, Dictionary p, for (int attempt = 0; ; attempt++) { try { return await SendAsync(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); } } @@ -343,7 +349,7 @@ private async Task SendAsync(string url, Dictionary 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); @@ -354,7 +360,7 @@ private async Task SendAsync(string url, Dictionary p, } /// - /// Determines whether an exception is retryable. + /// Determines whether an exception should be retried. /// /// The exception to evaluate. /// true if the exception is retryable; otherwise false. @@ -375,7 +381,7 @@ private async Task SendAsync(string url, Dictionary p, /// /// /// - 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; diff --git a/tests/ClientTest/Program.cs b/tests/ClientTest/Program.cs index 7590d8c9..7a3dba76 100644 --- a/tests/ClientTest/Program.cs +++ b/tests/ClientTest/Program.cs @@ -1,9 +1,12 @@ +using System.Collections.Generic; + using GeneralUpdate.Core; using GeneralUpdate.Core.Configuration; using GeneralUpdate.Core.Download; using GeneralUpdate.Core.Download.Reporting; using GeneralUpdate.Core.Event; using GeneralUpdate.Core.Hooks; +using GeneralUpdate.Core.Security; try { @@ -47,7 +50,10 @@ static async Task RunUpdateTestAsync() ?? "dfeb5833-975e-4afb-88f1-6278ee9aeff6"; Console.WriteLine($"UpdateUrl: {updateUrl}"); + Console.WriteLine($"ReportUrl: {reportUrl}"); + Console.WriteLine($"TenantId: default-tenant"); Console.WriteLine(); + Console.WriteLine("NOTE: Configure the GeneralSpacestation server with"); Console.WriteLine(" the desired test data before running."); Console.WriteLine(" - Chain data: TbPackets (IsCrossVersion=false)"); @@ -67,7 +73,19 @@ static async Task RunUpdateTestAsync() // - Both: apply upgrade packages → IPC → launch Upgrade process → exit // - None: no-op await new GeneralUpdateBootstrap() - .SetSource(updateUrl, appSecretKey, reportUrl) + .SetConfig(new UpdateRequest + { + UpdateUrl = updateUrl, + AppSecretKey = appSecretKey, + ReportUrl = reportUrl, + AuthScheme = AuthScheme.Basic, + BasicUsername = "test", + BasicPassword = "test", + CustomHeaders = new Dictionary + { + ["X-Tenant-Id"] = "default-tenant" + } + }) .SetOption(Option.AppType, AppType.Client) .Hooks() .AddListenerMultiDownloadStatistics(OnDownloadStatistics) diff --git a/tests/ClientTest/generalupdate.manifest.json b/tests/ClientTest/generalupdate.manifest.json index 79be2a0a..f31e76ba 100644 --- a/tests/ClientTest/generalupdate.manifest.json +++ b/tests/ClientTest/generalupdate.manifest.json @@ -4,6 +4,6 @@ "appType": "Client", "updateAppName": "UpgradeTest.exe", "upgradeClientVersion": "2.0.0.0", - "productId": "2d974e2a-31e6-4887-9bb1-b4689e98c77a", + "productId": "8d74e7eb-160c-4a4b-a5dd-c71a7bb08eeb", "updatePath": "update/" }