From 8d955a0ea8776f7d3415e31ab24f00e32da8d56e Mon Sep 17 00:00:00 2001 From: "goodolclint-claude[bot]" <323206664+goodolclint-claude[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:50:07 +0000 Subject: [PATCH] fix: renew ticket sessions inside PveHttpClient before expiry and once on a 401 (#230) * fix: renew ticket sessions inside PveHttpClient before expiry and once on a 401 A ticket session died two hours after Connect-PveServer, and any -Wait that crossed that line surfaced as a raw 401 from the status poll. PveHttpClient now renews a ticket past half its lifetime by posting it as the password to /access/ticket, and after a 401 on a ticket-mode request renews once and retries once; a failed renewal is PveSessionExpiredException with the 401 inner. Renewals are single-flighted per session through an in-flight task so concurrent callers share one POST and its outcome, the credential is one immutable snapshot so no request mixes two tickets, and the renewal POST is bounded by the session timeout rather than the calling client's. API-token sessions never renew. Implements ADR 0027. * fix: keep the cluster-join password re-auth fallback across the client's ticket renewal A cluster join rotates the auth key, so the 401 the status poll gets back now reaches Add-PveClusterMember as PveSessionExpiredException after the client's own renewal fails. Widen the fallback's catch so the password re-authentication, the only path that survives a key rotation, still runs. --------- Co-authored-by: goodolclint-claude[bot] <323206664+goodolclint-claude[bot]@users.noreply.github.com> --- .../Authentication/PveAuthenticator.cs | 12 +- .../Authentication/PveSession.cs | 113 +++- src/PSProxmoxVE.Core/Client/PveHttpClient.cs | 212 +++++-- .../Exceptions/PveSessionExpiredException.cs | 18 +- .../Cluster/AddPveClusterMemberCmdlet.cs | 3 +- .../Authentication/PveSessionTests.cs | 93 ++- .../Client/PveHttpClientTicketRenewalTests.cs | 572 ++++++++++++++++++ 7 files changed, 956 insertions(+), 67 deletions(-) create mode 100644 tests/PSProxmoxVE.Core.Tests/Client/PveHttpClientTicketRenewalTests.cs diff --git a/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs b/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs index 7436c02..0531ee6 100644 --- a/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs +++ b/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs @@ -56,17 +56,9 @@ namespace PSProxmoxVE.Core.Authentication responseBody = httpClient.Post("/api2/json/access/ticket", formData); } - var json = JObject.Parse(responseBody); - var data = json["data"] ?? throw new InvalidOperationException("Response did not contain a 'data' field."); + var ticket = PveSession.TicketState.FromTicketResponse(responseBody, DateTime.UtcNow); - var ticket = data["ticket"]?.Value() - ?? throw new InvalidOperationException("Response did not contain a ticket."); - var csrfToken = data["CSRFPreventionToken"]?.Value() - ?? throw new InvalidOperationException("Response did not contain a CSRFPreventionToken."); - - var ticketExpiry = DateTime.UtcNow.AddHours(2); - - var session = new PveSession(hostname, port, skipCertificateCheck, ticket, csrfToken, ticketExpiry); + var session = new PveSession(hostname, port, skipCertificateCheck, username, ticket.Ticket, ticket.CsrfToken, ticket.Expiry); if (timeout.HasValue) session.Timeout = timeout.Value; diff --git a/src/PSProxmoxVE.Core/Authentication/PveSession.cs b/src/PSProxmoxVE.Core/Authentication/PveSession.cs index 10c3f6e..648d4b1 100644 --- a/src/PSProxmoxVE.Core/Authentication/PveSession.cs +++ b/src/PSProxmoxVE.Core/Authentication/PveSession.cs @@ -1,4 +1,6 @@ using System; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; using PSProxmoxVE.Core.Models; namespace PSProxmoxVE.Core.Authentication @@ -6,6 +8,13 @@ namespace PSProxmoxVE.Core.Authentication /// Represents an authenticated session to a Proxmox VE server. public class PveSession { + /// How long PVE honours a ticket from the moment it is issued. + internal static readonly TimeSpan TicketLifetime = TimeSpan.FromHours(2); + + private readonly object _ticketLock = new object(); + private TicketState? _ticket; + private Task? _renewal; + /// The hostname or IP address of the Proxmox VE server. public string Hostname { get; } @@ -21,14 +30,17 @@ namespace PSProxmoxVE.Core.Authentication /// The API token string, when using API token authentication. public string? ApiToken { get; } + /// The user (user@realm) the ticket was issued to; null for API token sessions. + public string? Username { get; } + /// The session ticket cookie value, when using ticket authentication. - public string? Ticket { get; } + public string? Ticket => ReadTicket()?.Ticket; /// The CSRF prevention token, when using ticket authentication. - public string? CsrfToken { get; } + public string? CsrfToken => ReadTicket()?.CsrfToken; /// The UTC expiry time for the session ticket. - public DateTime TicketExpiry { get; } + public DateTime TicketExpiry => ReadTicket()?.Expiry ?? DateTime.MaxValue; /// The Proxmox VE version detected on the server at connection time. public PveVersion? ServerVersion { get; internal set; } @@ -59,6 +71,7 @@ namespace PSProxmoxVE.Core.Authentication string hostname, int port, bool skipCertificateCheck, + string username, string ticket, string csrfToken, DateTime ticketExpiry) @@ -67,9 +80,8 @@ namespace PSProxmoxVE.Core.Authentication Port = port; SkipCertificateCheck = skipCertificateCheck; AuthMode = PveAuthMode.Ticket; - Ticket = ticket; - CsrfToken = csrfToken; - TicketExpiry = ticketExpiry; + Username = username ?? throw new ArgumentNullException(nameof(username)); + _ticket = new TicketState(ticket, csrfToken, ticketExpiry); } /// Creates a session using API token authentication @@ -84,7 +96,94 @@ namespace PSProxmoxVE.Core.Authentication SkipCertificateCheck = skipCertificateCheck; AuthMode = PveAuthMode.ApiToken; ApiToken = apiToken; - TicketExpiry = DateTime.MaxValue; + } + + /// The current ticket credential as one consistent snapshot; null for API token sessions. + internal TicketState? ReadTicket() + { + lock (_ticketLock) + return _ticket; + } + + /// + /// Single-flight entry for replacing . Returns the task every + /// caller awaits for the outcome. When comes back non-null + /// the caller owns the renewal and must finish it with or + /// ; otherwise it is joining one already in flight, or + /// has already been replaced and the task is the replacement. + /// + internal Task JoinOrClaimRenewal(TicketState stale, out TaskCompletionSource? claimed) + { + lock (_ticketLock) + { + claimed = null; + if (!ReferenceEquals(_ticket, stale)) + return Task.FromResult(_ticket!); + if (_renewal != null) + return _renewal; + + claimed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _renewal = claimed.Task; + return _renewal; + } + } + + /// Installs and releases everyone awaiting the claimed renewal. + internal void CompleteRenewal(TaskCompletionSource claimed, TicketState renewed) + { + if (renewed == null) throw new ArgumentNullException(nameof(renewed)); + lock (_ticketLock) + { + _ticket = renewed; + _renewal = null; + } + claimed.SetResult(renewed); + } + + /// Leaves the ticket as it was and hands to everyone awaiting the claimed renewal. + internal void FailRenewal(TaskCompletionSource claimed, Exception failure) + { + lock (_ticketLock) + _renewal = null; + claimed.SetException(failure); + } + + /// + /// One issued ticket: the cookie value, its CSRF token and its expiry. Immutable, so a + /// request built from a snapshot never mixes fields from two different tickets. + /// + internal sealed class TicketState + { + public string Ticket { get; } + public string CsrfToken { get; } + public DateTime Expiry { get; } + + /// The instant after which a request should renew before sending: half the lifetime before expiry. + public DateTime RenewAfter => Expiry - TimeSpan.FromTicks(TicketLifetime.Ticks / 2); + + public TicketState(string ticket, string csrfToken, DateTime expiry) + { + Ticket = ticket ?? throw new ArgumentNullException(nameof(ticket)); + CsrfToken = csrfToken ?? throw new ArgumentNullException(nameof(csrfToken)); + Expiry = expiry; + } + + /// Parses the data envelope of a POST /access/ticket response. + /// The raw JSON response body. + /// When the ticket was issued; the expiry is later. + public static TicketState FromTicketResponse(string responseBody, DateTime issuedAt) + { + var json = JObject.Parse(responseBody); + if (!(json["data"] is JObject data)) + throw new InvalidOperationException("Response did not contain a 'data' field."); + + var ticket = data["ticket"]?.Value() + ?? throw new InvalidOperationException("Response did not contain a ticket."); + var csrfToken = data["CSRFPreventionToken"]?.Value() + ?? throw new InvalidOperationException("Response did not contain a CSRFPreventionToken."); + + return new TicketState(ticket, csrfToken, issuedAt + TicketLifetime); + } } } } diff --git a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs index 2f8ad84..611f05c 100644 --- a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs +++ b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs @@ -6,6 +6,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; +using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; using PSProxmoxVE.Core.Authentication; @@ -28,10 +29,12 @@ namespace PSProxmoxVE.Core.Client private readonly TimeSpan _guestLockRetryWindow; private readonly Func _guestLockRetryDelay; + private readonly Func _utcNow; private const string ApiTokenPrefix = "PVEAPIToken="; private const string AuthCookieName = "PVEAuthCookie="; private const string CsrfHeaderName = "CSRFPreventionToken"; + private const string TicketResource = "access/ticket"; /// /// Creates an HTTP client authenticated with the specified PVE session. @@ -82,18 +85,24 @@ namespace PSProxmoxVE.Core.Client /// Cache to take the handler from when is null. Null uses /// , the same as the public constructor. /// + /// + /// Clock the ticket half-life check reads. Null uses , + /// the same as the public constructor. + /// internal PveHttpClient( PveSession session, TimeSpan? timeoutOverride, TimeSpan? guestLockRetryWindow, HttpMessageHandler? handler, Func? guestLockRetryDelay = null, - PveHandlerCache? handlerCache = null) + PveHandlerCache? handlerCache = null, + Func? utcNow = null) { _session = session ?? throw new ArgumentNullException(nameof(session)); _baseUrl = session.BaseUrl; _guestLockRetryWindow = guestLockRetryWindow ?? GuestLockRetry.DefaultWindow; _guestLockRetryDelay = guestLockRetryDelay ?? Task.Delay; + _utcNow = utcNow ?? (() => DateTime.UtcNow); var ownsHandler = handler != null; _handler = handler ?? (handlerCache ?? PveHandlerCache.Shared) @@ -121,6 +130,7 @@ namespace PSProxmoxVE.Core.Client _baseUrl = $"https://{hostname}:{port}"; _guestLockRetryWindow = GuestLockRetry.DefaultWindow; _guestLockRetryDelay = Task.Delay; + _utcNow = () => DateTime.UtcNow; _handler = PveHandlerCache.Shared.Get(hostname, port, skipCertificateCheck); _httpClient = new HttpClient(_handler, disposeHandler: false); @@ -140,7 +150,7 @@ namespace PSProxmoxVE.Core.Client /// Raw JSON response body public async Task GetAsync(string resource) { - return await SendAsync(() => BuildRequest(HttpMethod.Get, resource), resource, "GET") + return await SendAsync(ticket => BuildRequest(HttpMethod.Get, resource, ticket), resource, "GET") .ConfigureAwait(false); } @@ -150,9 +160,9 @@ namespace PSProxmoxVE.Core.Client /// Raw JSON response body public async Task PostAsync(string resource, Dictionary? data = null) { - return await SendAsync(() => + return await SendAsync(ticket => { - var request = BuildRequest(HttpMethod.Post, resource, mutating: true); + var request = BuildRequest(HttpMethod.Post, resource, ticket, mutating: true); if (data != null) request.Content = BuildFormContent(data); return request; @@ -169,9 +179,9 @@ namespace PSProxmoxVE.Core.Client public async Task PostAsync(string resource, IEnumerable> data) { if (data == null) throw new ArgumentNullException(nameof(data)); - return await SendAsync(() => + return await SendAsync(ticket => { - var request = BuildRequest(HttpMethod.Post, resource, mutating: true); + var request = BuildRequest(HttpMethod.Post, resource, ticket, mutating: true); request.Content = BuildFormContent(data); return request; }, resource, "POST").ConfigureAwait(false); @@ -183,9 +193,9 @@ namespace PSProxmoxVE.Core.Client /// Raw JSON response body public async Task PutAsync(string resource, Dictionary? data = null) { - return await SendAsync(() => + return await SendAsync(ticket => { - var request = BuildRequest(HttpMethod.Put, resource, mutating: true); + var request = BuildRequest(HttpMethod.Put, resource, ticket, mutating: true); if (data != null) request.Content = BuildFormContent(data); return request; @@ -197,7 +207,7 @@ namespace PSProxmoxVE.Core.Client /// Raw JSON response body public async Task DeleteAsync(string resource) { - return await SendAsync(() => BuildRequest(HttpMethod.Delete, resource, mutating: true), resource, "DELETE") + return await SendAsync(ticket => BuildRequest(HttpMethod.Delete, resource, ticket, mutating: true), resource, "DELETE") .ConfigureAwait(false); } @@ -354,6 +364,8 @@ namespace PSProxmoxVE.Core.Client multipart.Add(csPart); } + var ticket = await CurrentTicketAsync().ConfigureAwait(false); + // File part — StreamContent does not add Content-Transfer-Encoding. var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4 * 1024 * 1024, useAsync: true); @@ -374,7 +386,7 @@ namespace PSProxmoxVE.Core.Client fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("application/octet-stream"); multipart.Add(fileContent); - var request = BuildRequest(HttpMethod.Post, resource, mutating: true); + var request = BuildRequest(HttpMethod.Post, resource, ticket, mutating: true); request.Content = multipart; return await SendOnceAsync(request, resource, "POST").ConfigureAwait(false); @@ -389,7 +401,12 @@ namespace PSProxmoxVE.Core.Client // Private helpers // ------------------------------------------------------------------------- - private HttpRequestMessage BuildRequest(HttpMethod method, string resource, bool mutating = false) + /// + /// Builds a request signed with on a ticket-mode session, or + /// with the API token otherwise. The ticket is passed in rather than read from the + /// session so the caller knows which ticket the request carried when it comes back 401. + /// + private HttpRequestMessage BuildRequest(HttpMethod method, string resource, PveSession.TicketState? ticket, bool mutating = false) { var url = _baseUrl + resource; var request = new HttpRequestMessage(method, url); @@ -401,12 +418,11 @@ namespace PSProxmoxVE.Core.Client { request.Headers.TryAddWithoutValidation("Authorization", $"{ApiTokenPrefix}{_session.ApiToken}"); } - else + else if (ticket != null) { - // Ticket auth - request.Headers.Add("Cookie", $"{AuthCookieName}{_session.Ticket}"); - if (mutating && !string.IsNullOrEmpty(_session.CsrfToken)) - request.Headers.Add(CsrfHeaderName, _session.CsrfToken); + request.Headers.Add("Cookie", $"{AuthCookieName}{ticket.Ticket}"); + if (mutating && !string.IsNullOrEmpty(ticket.CsrfToken)) + request.Headers.Add(CsrfHeaderName, ticket.CsrfToken); } return request; @@ -414,40 +430,152 @@ namespace PSProxmoxVE.Core.Client /// /// Sends a request, rebuilding it from for each attempt - /// while PVE rejects it for a guest's config flock. An + /// while PVE rejects it for a guest's config flock, and once more after a ticket renewal + /// when a ticket-mode session is rejected with 401. An /// cannot be resent, which is why this takes a factory rather than a request. /// - private Task SendAsync(Func buildRequest, string resource, string httpMethod) => + private Task SendAsync(Func buildRequest, string resource, string httpMethod) => GuestLockRetry.ExecuteAsync( - () => SendOnceAsync(buildRequest(), resource, httpMethod), _guestLockRetryWindow, _guestLockRetryDelay); + () => SendRenewingAsync(buildRequest, resource, httpMethod), _guestLockRetryWindow, _guestLockRetryDelay); - private async Task SendOnceAsync(HttpRequestMessage request, string resource, string httpMethod) + private async Task SendRenewingAsync(Func buildRequest, string resource, string httpMethod) + { + var ticket = await CurrentTicketAsync().ConfigureAwait(false); + try + { + return await SendOnceAsync(buildRequest(ticket), resource, httpMethod).ConfigureAwait(false); + } + catch (PveApiException ex) when (ticket != null && ex.StatusCode == HttpStatusCode.Unauthorized) + { + var renewed = await RenewTicketAsync(ticket, ex).ConfigureAwait(false); + try + { + return await SendOnceAsync(buildRequest(renewed), resource, httpMethod).ConfigureAwait(false); + } + catch (PveApiException again) when (again.StatusCode == HttpStatusCode.Unauthorized) + { + throw new PveSessionExpiredException("The renewed ticket was rejected too.", again); + } + } + } + + /// + /// The ticket a request should carry now: null for a bare client or an API token session, + /// otherwise the session's current ticket, renewed first if it is past half its lifetime. + /// A ticket past its full lifetime has nothing left to trade, so it is not sent anywhere. + /// + private async Task CurrentTicketAsync() + { + if (_session == null || _session.AuthMode != PveAuthMode.Ticket) + return null; + + var ticket = _session.ReadTicket()!; + var now = _utcNow(); + if (now < ticket.RenewAfter) + return ticket; + if (now >= ticket.Expiry) + throw new PveSessionExpiredException(); + + return await RenewTicketAsync(ticket, rejection: null).ConfigureAwait(false); + } + + /// + /// Trades for a fresh ticket by posting it as the password to + /// /access/ticket, and installs the result on the session. Concurrent callers on + /// the same session share one POST and its outcome, success or failure; a caller whose + /// ticket was already replaced gets the replacement without any POST. + /// + /// The ticket the caller holds and wants replaced. + /// + /// The 401 that prompted this renewal, when reactive. A failed reactive renewal throws + /// around it. A failed proactive renewal does + /// so only when the ticket endpoint itself answers 401 or the ticket has meanwhile + /// expired; any other failure says nothing about the ticket, which is still valid, so + /// the caller keeps using it. + /// + private async Task RenewTicketAsync(PveSession.TicketState stale, PveApiException? rejection) + { + var session = _session!; + var renewal = session.JoinOrClaimRenewal(stale, out var claimed); + if (claimed != null) + { + try + { + session.CompleteRenewal(claimed, await PostTicketRenewalAsync(session, stale).ConfigureAwait(false)); + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + session.FailRenewal(claimed, ex); + } + } + + try + { + return await renewal.ConfigureAwait(false); + } + catch (Exception ex) when (ex is PveApiException or Newtonsoft.Json.JsonException or InvalidOperationException) + { + if (rejection != null) + throw new PveSessionExpiredException($"Ticket renewal failed: {ex.Message}", rejection); + if (ex is PveApiException api && api.StatusCode == HttpStatusCode.Unauthorized) + throw new PveSessionExpiredException(ex); + if (_utcNow() < stale.Expiry) + return stale; + throw new PveSessionExpiredException($"Ticket renewal failed: {ex.Message}", ex); + } + } + + /// + /// The renewal POST itself. It goes straight to so it carries + /// no cookie and cannot recurse into renewal or the guest-lock retry, and it is bounded by + /// the session's own timeout rather than this client's, which an upload may have set to + /// infinite. + /// + private async Task PostTicketRenewalAsync(PveSession session, PveSession.TicketState stale) + { + var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + TicketResource); + request.Content = BuildFormContent(new Dictionary + { + ["username"] = session.Username!, + ["password"] = stale.Ticket, + }); + + var issuedAt = _utcNow(); + var body = await SendOnceAsync(request, TicketResource, "POST", session.Timeout).ConfigureAwait(false); + return PveSession.TicketState.FromTicketResponse(body, issuedAt); + } + + private async Task SendOnceAsync(HttpRequestMessage request, string resource, string httpMethod, TimeSpan? timeout = null) { HttpResponseMessage response; string body; - try + using (var cts = timeout.HasValue ? new CancellationTokenSource(timeout.Value) : null) { - response = await _httpClient.SendAsync(request).ConfigureAwait(false); - body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - } - catch (TaskCanceledException ex) - { - // PveHttpClient.SendAsync passes no CancellationToken to HttpClient.SendAsync, - // so a TaskCanceledException reaching here can only be HttpClient.Timeout - // firing — on .NET Framework, on .NET Core, and on .NET 5+ (where it also - // carries a TimeoutException inner). Wrap it uniformly across frameworks. - var seconds = _httpClient.Timeout == System.Threading.Timeout.InfiniteTimeSpan - ? "infinite" - : _httpClient.Timeout.TotalSeconds.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + "s"; - throw new PveApiException(HttpStatusCode.RequestTimeout, - $"Request timed out after {seconds}.", resource, httpMethod, ex); - } - catch (HttpRequestException ex) - { - // Covers both a failed connection and a stream drop mid-body-read, so every - // HttpRequestException PveHttpClient can throw arrives as PveApiException. - throw new PveApiException(HttpStatusCode.ServiceUnavailable, - ex.Message, resource, httpMethod, ex); + try + { + response = await _httpClient.SendAsync(request, cts?.Token ?? CancellationToken.None).ConfigureAwait(false); + body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } + catch (TaskCanceledException ex) + { + // The only token ever handed to HttpClient.SendAsync is the per-call timeout + // above, so a TaskCanceledException reaching here is always a timeout — that + // one or HttpClient.Timeout — on .NET Framework, on .NET Core, and on .NET 5+ + // (where it also carries a TimeoutException inner). Wrap it uniformly. + var limit = timeout ?? _httpClient.Timeout; + var seconds = limit == System.Threading.Timeout.InfiniteTimeSpan + ? "infinite" + : limit.TotalSeconds.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) + "s"; + throw new PveApiException(HttpStatusCode.RequestTimeout, + $"Request timed out after {seconds}.", resource, httpMethod, ex); + } + catch (HttpRequestException ex) + { + // Covers both a failed connection and a stream drop mid-body-read, so every + // HttpRequestException PveHttpClient can throw arrives as PveApiException. + throw new PveApiException(HttpStatusCode.ServiceUnavailable, + ex.Message, resource, httpMethod, ex); + } } if (!response.IsSuccessStatusCode) diff --git a/src/PSProxmoxVE.Core/Exceptions/PveSessionExpiredException.cs b/src/PSProxmoxVE.Core/Exceptions/PveSessionExpiredException.cs index fb753e0..422c1cf 100644 --- a/src/PSProxmoxVE.Core/Exceptions/PveSessionExpiredException.cs +++ b/src/PSProxmoxVE.Core/Exceptions/PveSessionExpiredException.cs @@ -5,16 +5,30 @@ namespace PSProxmoxVE.Core.Exceptions /// Exception thrown when the Proxmox VE session ticket has expired. public class PveSessionExpiredException : Exception { + private const string DefaultMessage = + "Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session."; + /// Initializes a new instance indicating the session has expired. public PveSessionExpiredException() - : base("Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session.") + : base(DefaultMessage) { } /// Initializes a new instance indicating the session has expired, with an inner exception. /// The exception that caused this failure. public PveSessionExpiredException(Exception innerException) - : base("Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session.", innerException) + : base(DefaultMessage, innerException) + { + } + + /// + /// Initializes a new instance indicating the session has expired, appending + /// to the message, with an inner exception. + /// + /// What was attempted to keep the session alive, and how it failed. + /// The exception that caused this failure. + public PveSessionExpiredException(string detail, Exception innerException) + : base(DefaultMessage + " " + detail, innerException) { } } diff --git a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs index 25fe5fc..e9c9f7c 100644 --- a/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs +++ b/src/PSProxmoxVE/Cmdlets/Cluster/AddPveClusterMemberCmdlet.cs @@ -112,7 +112,8 @@ namespace PSProxmoxVE.Cmdlets.Cluster { return taskService.WaitForTask(session, nodeName, upid); } - catch (PveApiException ex) when (ex.StatusCode == HttpStatusCode.Unauthorized) + catch (Exception ex) when (ex is PveSessionExpiredException + || (ex is PveApiException api && api.StatusCode == HttpStatusCode.Unauthorized)) { WriteVerbose("Session expired during join — waiting for auth services to restart..."); var newSession = ReauthenticateWithRetry(session, plainPassword); diff --git a/tests/PSProxmoxVE.Core.Tests/Authentication/PveSessionTests.cs b/tests/PSProxmoxVE.Core.Tests/Authentication/PveSessionTests.cs index cd70cd9..ab7bbea 100644 --- a/tests/PSProxmoxVE.Core.Tests/Authentication/PveSessionTests.cs +++ b/tests/PSProxmoxVE.Core.Tests/Authentication/PveSessionTests.cs @@ -13,7 +13,7 @@ namespace PSProxmoxVE.Core.Tests.Authentication public void TicketSession_NotExpired_OnCreation() { var expiry = DateTime.UtcNow.AddHours(2); - var session = new PveSession(TestHostname, TestPort, false, "PVE:root@pam:TICKET", "CSRFTOKEN", expiry); + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:TICKET", "CSRFTOKEN", expiry); Assert.False(session.IsExpired); } @@ -23,7 +23,7 @@ namespace PSProxmoxVE.Core.Tests.Authentication { // Simulate a ticket that already expired an hour ago var expiry = DateTime.UtcNow.AddHours(-1); - var session = new PveSession(TestHostname, TestPort, false, "PVE:root@pam:OLDTICKET", "CSRFTOKEN", expiry); + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:OLDTICKET", "CSRFTOKEN", expiry); Assert.True(session.IsExpired); } @@ -49,7 +49,7 @@ namespace PSProxmoxVE.Core.Tests.Authentication public void TicketSession_AuthMode_IsTicket() { var expiry = DateTime.UtcNow.AddHours(2); - var session = new PveSession(TestHostname, TestPort, false, "PVE:root@pam:TICKET", "CSRFTOKEN", expiry); + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:TICKET", "CSRFTOKEN", expiry); Assert.Equal(PveAuthMode.Ticket, session.AuthMode); } @@ -58,7 +58,7 @@ namespace PSProxmoxVE.Core.Tests.Authentication public void SeparateSessions_DoNotShareState() { var expiry1 = DateTime.UtcNow.AddHours(2); - var session1 = new PveSession("host1", 8006, false, "PVE:root@pam:TICKET1", "CSRF1", expiry1); + var session1 = new PveSession("host1", 8006, false, "root@pam", "PVE:root@pam:TICKET1", "CSRF1", expiry1); var session2 = new PveSession("host2", 8006, false, "root@pam!token=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); Assert.NotEqual(session1.Hostname, session2.Hostname); @@ -77,12 +77,95 @@ namespace PSProxmoxVE.Core.Tests.Authentication const string ticket = "PVE:root@pam:ABCD1234"; const string csrf = "CSRFPREVENTION"; - var session = new PveSession(TestHostname, TestPort, false, ticket, csrf, expiry); + var session = new PveSession(TestHostname, TestPort, false, "root@pam", ticket, csrf, expiry); Assert.Equal(ticket, session.Ticket); Assert.Equal(csrf, session.CsrfToken); } + [Fact] + public void TicketSession_StoresUsername() + { + var session = new PveSession(TestHostname, TestPort, false, "admin@pve", "PVE:admin@pve:TICKET", "CSRF", DateTime.UtcNow.AddHours(2)); + + Assert.Equal("admin@pve", session.Username); + } + + [Fact] + public void ApiTokenSession_HasNoUsername() + { + var session = new PveSession(TestHostname, TestPort, false, "root@pam!mytoken=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + Assert.Null(session.Username); + } + + [Fact] + public void TicketSession_RejectsNullUsername() + { + Assert.Throws(() => + new PveSession(TestHostname, TestPort, false, null!, "PVE:root@pam:TICKET", "CSRF", DateTime.UtcNow.AddHours(2))); + } + + [Fact] + public void Renewal_SecondCallerJoinsTheClaimedRenewalAndSharesItsResult() + { + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:OLD", "CSRF-OLD", DateTime.UtcNow.AddHours(2)); + var stale = session.ReadTicket()!; + + var first = session.JoinOrClaimRenewal(stale, out var claimed); + var second = session.JoinOrClaimRenewal(stale, out var secondClaim); + + Assert.NotNull(claimed); + Assert.Null(secondClaim); + Assert.Same(first, second); + Assert.False(first.IsCompleted); + + var renewed = new PveSession.TicketState("PVE:root@pam:NEW", "CSRF-NEW", DateTime.UtcNow.AddHours(2)); + session.CompleteRenewal(claimed!, renewed); + + Assert.Same(renewed, first.Result); + Assert.Same(renewed, session.ReadTicket()); + Assert.Equal("PVE:root@pam:NEW", session.Ticket); + } + + [Fact] + public void Renewal_SecondCallerJoinsTheClaimedRenewalAndSharesItsFailure() + { + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:OLD", "CSRF-OLD", DateTime.UtcNow.AddHours(2)); + var stale = session.ReadTicket()!; + + var first = session.JoinOrClaimRenewal(stale, out var claimed); + var second = session.JoinOrClaimRenewal(stale, out _); + var failure = new InvalidOperationException("boom"); + session.FailRenewal(claimed!, failure); + + Assert.True(first.IsFaulted); + Assert.True(second.IsFaulted); + Assert.Same(failure, first.Exception!.InnerException); + Assert.Same(failure, second.Exception!.InnerException); + Assert.Same(stale, session.ReadTicket()); + + var retry = session.JoinOrClaimRenewal(stale, out var retryClaim); + Assert.NotNull(retryClaim); + Assert.False(retry.IsCompleted); + } + + [Fact] + public void Renewal_CallerHoldingAReplacedTicketGetsTheReplacementWithoutClaiming() + { + var session = new PveSession(TestHostname, TestPort, false, "root@pam", "PVE:root@pam:OLD", "CSRF-OLD", DateTime.UtcNow.AddHours(2)); + var stale = session.ReadTicket()!; + var renewed = new PveSession.TicketState("PVE:root@pam:NEW", "CSRF-NEW", DateTime.UtcNow.AddHours(2)); + session.JoinOrClaimRenewal(stale, out var claimed); + session.CompleteRenewal(claimed!, renewed); + + var late = session.JoinOrClaimRenewal(stale, out var lateClaim); + + Assert.Null(lateClaim); + Assert.True(late.IsCompleted); + Assert.Same(renewed, late.Result); + } + [Fact] public void ApiTokenSession_StoresApiToken() { diff --git a/tests/PSProxmoxVE.Core.Tests/Client/PveHttpClientTicketRenewalTests.cs b/tests/PSProxmoxVE.Core.Tests/Client/PveHttpClientTicketRenewalTests.cs new file mode 100644 index 0000000..d5da60d --- /dev/null +++ b/tests/PSProxmoxVE.Core.Tests/Client/PveHttpClientTicketRenewalTests.cs @@ -0,0 +1,572 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using PSProxmoxVE.Core.Authentication; +using PSProxmoxVE.Core.Client; +using PSProxmoxVE.Core.Exceptions; +using Xunit; + +namespace PSProxmoxVE.Core.Tests.Client +{ + public class PveHttpClientTicketRenewalTests + { + private const string Username = "root@pam"; + + // A real ticket ends in a base64 signature, so the fixture carries the three base64 + // characters the form encoder rewrites; the expected body below is the encoded form. + private const string OldTicket = "PVE:root@pam:68B7ABCD::aB+c/d=="; + private const string OldTicketEncoded = "PVE:root@pam:68B7ABCD::aB%2Bc/d%3D%3D"; + private const string OldCsrf = "CSRF-OLD"; + private const string NewTicket = "PVE:root@pam:68B7BEEF::eF+g/h=="; + private const string NewCsrf = "CSRF-NEW"; + + private const string RenewalBody = "username=" + Username + "&password=" + OldTicketEncoded; + private const string TicketOk = + "{\"data\":{\"ticket\":\"" + NewTicket + "\",\"CSRFPreventionToken\":\"" + NewCsrf + "\",\"username\":\"root@pam\"}}"; + private const string Unauthorized = "{\"data\":null,\"message\":\"authentication failure\"}"; + private const string DataOk = "{\"data\":{}}"; + + private static readonly DateTime Now = new DateTime(2026, 9, 3, 12, 0, 0, DateTimeKind.Utc); + + private static Task NoDelay(TimeSpan _) => Task.CompletedTask; + + private static PveSession TicketSession(DateTime expiry) => + new PveSession("pve.example.com", 8006, false, Username, OldTicket, OldCsrf, expiry); + + private static PveSession TokenSession() => + new PveSession("pve.example.com", 8006, false, "root@pam!token=aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + + private static PveHttpClient NewClient(PveSession session, HttpMessageHandler handler, + Func? clock = null, TimeSpan? timeoutOverride = null) => + new PveHttpClient(session, timeoutOverride, guestLockRetryWindow: null, handler, NoDelay, + handlerCache: null, utcNow: clock ?? (() => Now)); + + private static bool IsTicketPost(Recorded r) => + r.Method == HttpMethod.Post && r.Uri.EndsWith("/access/ticket", StringComparison.Ordinal); + + [Fact] + public async Task PutAsync_RenewsOnceAfter401AndRetriesWithTheNewTicket() + { + var session = TicketSession(Now.AddHours(2)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var result = await client.PutAsync("nodes/pve9a/qemu/100/config", + new Dictionary { ["cores"] = "2" }); + Assert.Equal(DataOk, result); + } + + Assert.Equal(3, handler.Requests.Count); + + var first = handler.Requests[0]; + Assert.Equal(HttpMethod.Put, first.Method); + Assert.Equal("PVEAuthCookie=" + OldTicket, first.Cookie); + Assert.Equal(OldCsrf, first.Csrf); + + var renewal = handler.Requests[1]; + Assert.True(IsTicketPost(renewal)); + Assert.Equal(RenewalBody, renewal.Body); + Assert.Null(renewal.Cookie); + Assert.Null(renewal.Csrf); + + var retry = handler.Requests[2]; + Assert.Equal(HttpMethod.Put, retry.Method); + Assert.Equal(first.Uri, retry.Uri); + Assert.Equal("cores=2", retry.Body); + Assert.Equal("PVEAuthCookie=" + NewTicket, retry.Cookie); + Assert.Equal(NewCsrf, retry.Csrf); + + Assert.Equal(NewTicket, session.Ticket); + Assert.Equal(NewCsrf, session.CsrfToken); + Assert.Equal(Now.AddHours(2), session.TicketExpiry); + } + + [Fact] + public async Task GetAsync_RenewsBeforeSendingWhenTheTicketIsPastHalfLife() + { + var session = TicketSession(Now.AddMinutes(59)); + var handler = new RecordingHandler( + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + await client.GetAsync("nodes/pve9a/status"); + await client.GetAsync("nodes/pve9a/status"); + } + + Assert.Equal(3, handler.Requests.Count); + Assert.True(IsTicketPost(handler.Requests[0])); + Assert.Equal(RenewalBody, handler.Requests[0].Body); + Assert.Equal(HttpMethod.Get, handler.Requests[1].Method); + Assert.Equal("PVEAuthCookie=" + NewTicket, handler.Requests[1].Cookie); + Assert.Equal(HttpMethod.Get, handler.Requests[2].Method); + Assert.Equal("PVEAuthCookie=" + NewTicket, handler.Requests[2].Cookie); + Assert.Equal(1, handler.Requests.Count(IsTicketPost)); + } + + [Fact] + public async Task GetAsync_DoesNotRenewBeforeHalfLife() + { + var session = TicketSession(Now.AddMinutes(61)); + var handler = new RecordingHandler((HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + await client.GetAsync("nodes/pve9a/status"); + } + + var only = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Get, only.Method); + Assert.Equal("PVEAuthCookie=" + OldTicket, only.Cookie); + Assert.Equal(OldTicket, session.Ticket); + } + + [Fact] + public async Task GetAsync_RenewsExactlyAtHalfLife() + { + var session = TicketSession(Now.AddHours(1)); + var handler = new RecordingHandler( + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + await client.GetAsync("nodes/pve9a/status"); + } + + Assert.Equal(2, handler.Requests.Count); + Assert.True(IsTicketPost(handler.Requests[0])); + } + + [Fact] + public async Task GetAsync_ExpiredTicketThrowsSessionExpiredWithoutSendingAnything() + { + var session = TicketSession(Now.AddMinutes(-1)); + var handler = new RecordingHandler((HttpStatusCode.OK, TicketOk)); + + using (var client = NewClient(session, handler)) + { + await Assert.ThrowsAsync(() => client.GetAsync("nodes/pve9a/status")); + } + + Assert.Empty(handler.Requests); + } + + [Fact] + public async Task GetAsync_ThrowsSessionExpiredWhenTheRenewalIsRejectedToo() + { + var session = TicketSession(Now.AddHours(2)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + + var inner = Assert.IsType(ex.InnerException); + Assert.Equal(HttpStatusCode.Unauthorized, inner.StatusCode); + Assert.Equal("nodes/pve9a/status", inner.Resource); + Assert.Equal("GET", inner.HttpMethod); + Assert.Contains("Ticket renewal failed", ex.Message); + } + + Assert.Equal(2, handler.Requests.Count); + Assert.True(IsTicketPost(handler.Requests[1])); + Assert.Equal(OldTicket, session.Ticket); + } + + [Fact] + public async Task GetAsync_ReactiveRenewalThatFailsForAnotherReasonStillReportsTheOriginal401() + { + var session = TicketSession(Now.AddHours(2)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.ServiceUnavailable, "{\"data\":null}")); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + + var inner = Assert.IsType(ex.InnerException); + Assert.Equal(HttpStatusCode.Unauthorized, inner.StatusCode); + Assert.Contains("503", ex.Message); + } + + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task GetAsync_ReactiveRenewalWithAMalformedBodyReportsTheOriginal401() + { + var session = TicketSession(Now.AddHours(2)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, "{\"data\":null}")); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + + var inner = Assert.IsType(ex.InnerException); + Assert.Equal(HttpStatusCode.Unauthorized, inner.StatusCode); + } + + Assert.Equal(2, handler.Requests.Count); + Assert.Equal(OldTicket, session.Ticket); + } + + [Fact] + public async Task GetAsync_RetriesOnlyOnceAfterRenewal() + { + var session = TicketSession(Now.AddHours(2)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + var inner = Assert.IsType(ex.InnerException); + Assert.Equal(HttpStatusCode.Unauthorized, inner.StatusCode); + Assert.Equal("nodes/pve9a/status", inner.Resource); + } + + Assert.Equal(3, handler.Requests.Count); + Assert.Equal(1, handler.Requests.Count(IsTicketPost)); + Assert.Equal("PVEAuthCookie=" + NewTicket, handler.Requests[2].Cookie); + } + + [Fact] + public async Task GetAsync_ProactiveRenewalThatFailsForAnotherReasonKeepsUsingTheCurrentTicket() + { + var session = TicketSession(Now.AddMinutes(30)); + var handler = new RecordingHandler( + (HttpStatusCode.ServiceUnavailable, "{\"data\":null}"), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var result = await client.GetAsync("nodes/pve9a/status"); + Assert.Equal(DataOk, result); + } + + Assert.Equal(2, handler.Requests.Count); + Assert.True(IsTicketPost(handler.Requests[0])); + Assert.Equal(HttpMethod.Get, handler.Requests[1].Method); + Assert.Equal("PVEAuthCookie=" + OldTicket, handler.Requests[1].Cookie); + Assert.Equal(OldTicket, session.Ticket); + } + + [Fact] + public async Task GetAsync_ProactiveRenewalRejectedWith401ThrowsSessionExpired() + { + var session = TicketSession(Now.AddMinutes(30)); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + var inner = Assert.IsType(ex.InnerException); + Assert.Equal(HttpStatusCode.Unauthorized, inner.StatusCode); + Assert.Equal("access/ticket", inner.Resource); + } + + var only = Assert.Single(handler.Requests); + Assert.True(IsTicketPost(only)); + } + + [Fact] + public async Task GetAsync_RenewalIsBoundedByTheSessionTimeoutNotTheClientOverride() + { + var session = TicketSession(Now.AddHours(2)); + session.Timeout = TimeSpan.FromMilliseconds(200); + var handler = new RecordingHandler(async (request, cancellationToken) => + { + if (IsTicketPost(request)) + { + await Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, cancellationToken); + } + return (HttpStatusCode.Unauthorized, Unauthorized); + }); + + using (var client = NewClient(session, handler, timeoutOverride: System.Threading.Timeout.InfiniteTimeSpan)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + Assert.Contains("timed out after 0.2s", ex.Message); + } + + Assert.Equal(2, handler.Requests.Count); + } + + [Fact] + public async Task GetAsync_ApiTokenSessionSurfacesThe401AndNeverPostsATicket() + { + var session = TokenSession(); + var handler = new RecordingHandler( + (HttpStatusCode.Unauthorized, Unauthorized), + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk)); + + using (var client = NewClient(session, handler)) + { + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("nodes/pve9a/status")); + Assert.Equal(HttpStatusCode.Unauthorized, ex.StatusCode); + } + + var only = Assert.Single(handler.Requests); + Assert.Equal(HttpMethod.Get, only.Method); + Assert.StartsWith("PVEAPIToken=", only.Authorization); + Assert.Null(only.Cookie); + } + + [Fact] + public async Task GetAsync_ACallerArrivingDuringARenewalJoinsItInsteadOfPostingAgain() + { + var session = TicketSession(Now.AddMinutes(10)); + var ticketPostArrived = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseTicketPost = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var handler = new RecordingHandler(async (request, _) => + { + if (IsTicketPost(request)) + { + ticketPostArrived.TrySetResult(true); + await releaseTicketPost.Task; + return (HttpStatusCode.OK, TicketOk); + } + return (HttpStatusCode.OK, DataOk); + }); + + // The second client's first clock read happens after it has taken its ticket + // snapshot, so awaiting it proves the snapshot is the stale one. + var secondRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var shared = new PassThroughHandler(handler); + using (var first = NewClient(session, shared)) + using (var second = NewClient(session, shared, clock: () => { secondRead.TrySetResult(true); return Now; })) + { + var a = first.GetAsync("nodes/pve9a/status"); + await ticketPostArrived.Task; + + var b = second.GetAsync("nodes/pve9a/qemu/100/status/current"); + await secondRead.Task; + + releaseTicketPost.TrySetResult(true); + await Task.WhenAll(a, b); + } + + Assert.Equal(1, handler.Requests.Count(IsTicketPost)); + var gets = handler.Requests.Where(r => r.Method == HttpMethod.Get).ToList(); + Assert.Equal(2, gets.Count); + Assert.All(gets, g => Assert.Equal("PVEAuthCookie=" + NewTicket, g.Cookie)); + } + + [Fact] + public async Task GetAsync_ACallerHoldingAnAlreadyReplacedTicketTakesTheReplacementWithoutPosting() + { + var session = TicketSession(Now.AddMinutes(10)); + var handler = new RecordingHandler(request => + IsTicketPost(request) ? (HttpStatusCode.OK, TicketOk) : (HttpStatusCode.OK, DataOk)); + + var shared = new PassThroughHandler(handler); + using (var first = NewClient(session, shared)) + { + // The second client's clock runs between its ticket snapshot and its renewal + // attempt; completing the first client's request there replaces the ticket + // underneath the second client. + var renewedByFirst = false; + using (var second = NewClient(session, shared, clock: () => + { + if (!renewedByFirst) + { + renewedByFirst = true; + first.Get("nodes/pve9a/status"); + } + return Now; + })) + { + await second.GetAsync("nodes/pve9a/qemu/100/status/current"); + } + } + + Assert.Equal(1, handler.Requests.Count(IsTicketPost)); + var gets = handler.Requests.Where(r => r.Method == HttpMethod.Get).ToList(); + Assert.Equal(2, gets.Count); + Assert.All(gets, g => Assert.Equal("PVEAuthCookie=" + NewTicket, g.Cookie)); + } + + [Fact] + public async Task GetAsync_ManyParallelRequestsOnOnePastHalfLifeSessionProduceOneTicketPost() + { + const int parallelism = 16; + var session = TicketSession(Now.AddMinutes(10)); + var handler = new RecordingHandler(request => + { + if (IsTicketPost(request)) + { + Thread.Sleep(50); + return (HttpStatusCode.OK, TicketOk); + } + return (HttpStatusCode.OK, DataOk); + }); + + using (var client = NewClient(session, handler)) + using (var start = new ManualResetEventSlim(false)) + { + var workers = Enumerable.Range(0, parallelism) + .Select(i => Task.Factory.StartNew(() => + { + start.Wait(); + return client.GetAsync($"nodes/pve9a/qemu/{100 + i}/status/current"); + }, TaskCreationOptions.LongRunning).Unwrap()) + .ToArray(); + start.Set(); + await Task.WhenAll(workers); + } + + Assert.Equal(1, handler.Requests.Count(IsTicketPost)); + var gets = handler.Requests.Where(r => r.Method == HttpMethod.Get).ToList(); + Assert.Equal(parallelism, gets.Count); + Assert.All(gets, g => Assert.Equal("PVEAuthCookie=" + NewTicket, g.Cookie)); + Assert.Equal(NewTicket, session.Ticket); + } + + [Fact] + public async Task UploadFileAsync_RenewsBeforeSendingWhenTheTicketIsPastHalfLife() + { + var session = TicketSession(Now.AddMinutes(30)); + var handler = new RecordingHandler( + (HttpStatusCode.OK, TicketOk), + (HttpStatusCode.OK, DataOk)); + + var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".iso"); + File.WriteAllText(path, "not really an iso"); + try + { + using (var client = NewClient(session, handler)) + { + await client.UploadFileAsync("nodes/pve9a/storage/local/upload", path, + new Dictionary { ["content"] = "iso" }); + } + } + finally + { + File.Delete(path); + } + + Assert.Equal(2, handler.Requests.Count); + Assert.True(IsTicketPost(handler.Requests[0])); + var upload = handler.Requests[1]; + Assert.Equal(HttpMethod.Post, upload.Method); + Assert.Equal("PVEAuthCookie=" + NewTicket, upload.Cookie); + Assert.Equal(NewCsrf, upload.Csrf); + } + + private sealed class Recorded + { + public HttpMethod Method { get; set; } = HttpMethod.Get; + public string Uri { get; set; } = string.Empty; + public string Body { get; set; } = string.Empty; + public string? Cookie { get; set; } + public string? Csrf { get; set; } + public string? Authorization { get; set; } + } + + private sealed class RecordingHandler : HttpMessageHandler + { + private readonly Func> _respond; + private readonly object _sync = new object(); + private readonly List _requests = new List(); + + public IReadOnlyList Requests + { + get { lock (_sync) return _requests.ToList(); } + } + + public RecordingHandler(params (HttpStatusCode status, string body)[] responses) + { + var index = 0; + _respond = (_, __) => + { + var i = Interlocked.Increment(ref index) - 1; + if (i >= responses.Length) + throw new InvalidOperationException("RecordingHandler ran out of responses."); + return Task.FromResult(responses[i]); + }; + } + + public RecordingHandler(Func respond) + { + _respond = (r, _) => Task.FromResult(respond(r)); + } + + public RecordingHandler(Func> respond) + { + _respond = respond; + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var recorded = new Recorded + { + Method = request.Method, + Uri = request.RequestUri!.ToString(), + Body = request.Content == null + ? string.Empty + : await request.Content.ReadAsStringAsync().ConfigureAwait(false), + Cookie = Header(request, "Cookie"), + Csrf = Header(request, "CSRFPreventionToken"), + Authorization = Header(request, "Authorization"), + }; + lock (_sync) _requests.Add(recorded); + + var (status, body) = await _respond(recorded, cancellationToken).ConfigureAwait(false); + return new HttpResponseMessage(status) { Content = new StringContent(body) }; + } + + private static string? Header(HttpRequestMessage request, string name) => + request.Headers.TryGetValues(name, out var values) ? string.Join(",", values) : null; + } + + /// + /// Lets several clients, each of which disposes its own handler, share one recorder. + /// + private sealed class PassThroughHandler : HttpMessageHandler + { + private readonly HttpMessageInvoker _inner; + + public PassThroughHandler(HttpMessageHandler inner) + { + _inner = new HttpMessageInvoker(inner, disposeHandler: false); + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) => + _inner.SendAsync(request, cancellationToken); + } + } +}