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>
This commit is contained in:
goodolclint-claude[bot]
2026-09-03 20:50:07 +00:00
committed by GitHub
parent 0026ef2b57
commit 8d955a0ea8
7 changed files with 956 additions and 67 deletions
@@ -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<string>()
?? throw new InvalidOperationException("Response did not contain a ticket.");
var csrfToken = data["CSRFPreventionToken"]?.Value<string>()
?? 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;
@@ -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
/// <summary>Represents an authenticated session to a Proxmox VE server.</summary>
public class PveSession
{
/// <summary>How long PVE honours a ticket from the moment it is issued.</summary>
internal static readonly TimeSpan TicketLifetime = TimeSpan.FromHours(2);
private readonly object _ticketLock = new object();
private TicketState? _ticket;
private Task<TicketState>? _renewal;
/// <summary>The hostname or IP address of the Proxmox VE server.</summary>
public string Hostname { get; }
@@ -21,14 +30,17 @@ namespace PSProxmoxVE.Core.Authentication
/// <summary>The API token string, when using API token authentication.</summary>
public string? ApiToken { get; }
/// <summary>The user (user@realm) the ticket was issued to; null for API token sessions.</summary>
public string? Username { get; }
/// <summary>The session ticket cookie value, when using ticket authentication.</summary>
public string? Ticket { get; }
public string? Ticket => ReadTicket()?.Ticket;
/// <summary>The CSRF prevention token, when using ticket authentication.</summary>
public string? CsrfToken { get; }
public string? CsrfToken => ReadTicket()?.CsrfToken;
/// <summary>The UTC expiry time for the session ticket.</summary>
public DateTime TicketExpiry { get; }
public DateTime TicketExpiry => ReadTicket()?.Expiry ?? DateTime.MaxValue;
/// <summary>The Proxmox VE version detected on the server at connection time.</summary>
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);
}
/// <summary>Creates a session using API token authentication</summary>
@@ -84,7 +96,94 @@ namespace PSProxmoxVE.Core.Authentication
SkipCertificateCheck = skipCertificateCheck;
AuthMode = PveAuthMode.ApiToken;
ApiToken = apiToken;
TicketExpiry = DateTime.MaxValue;
}
/// <summary>The current ticket credential as one consistent snapshot; null for API token sessions.</summary>
internal TicketState? ReadTicket()
{
lock (_ticketLock)
return _ticket;
}
/// <summary>
/// Single-flight entry for replacing <paramref name="stale"/>. Returns the task every
/// caller awaits for the outcome. When <paramref name="claimed"/> comes back non-null
/// the caller owns the renewal and must finish it with <see cref="CompleteRenewal"/> or
/// <see cref="FailRenewal"/>; otherwise it is joining one already in flight, or
/// <paramref name="stale"/> has already been replaced and the task is the replacement.
/// </summary>
internal Task<TicketState> JoinOrClaimRenewal(TicketState stale, out TaskCompletionSource<TicketState>? claimed)
{
lock (_ticketLock)
{
claimed = null;
if (!ReferenceEquals(_ticket, stale))
return Task.FromResult(_ticket!);
if (_renewal != null)
return _renewal;
claimed = new TaskCompletionSource<TicketState>(TaskCreationOptions.RunContinuationsAsynchronously);
_renewal = claimed.Task;
return _renewal;
}
}
/// <summary>Installs <paramref name="renewed"/> and releases everyone awaiting the claimed renewal.</summary>
internal void CompleteRenewal(TaskCompletionSource<TicketState> claimed, TicketState renewed)
{
if (renewed == null) throw new ArgumentNullException(nameof(renewed));
lock (_ticketLock)
{
_ticket = renewed;
_renewal = null;
}
claimed.SetResult(renewed);
}
/// <summary>Leaves the ticket as it was and hands <paramref name="failure"/> to everyone awaiting the claimed renewal.</summary>
internal void FailRenewal(TaskCompletionSource<TicketState> claimed, Exception failure)
{
lock (_ticketLock)
_renewal = null;
claimed.SetException(failure);
}
/// <summary>
/// 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.
/// </summary>
internal sealed class TicketState
{
public string Ticket { get; }
public string CsrfToken { get; }
public DateTime Expiry { get; }
/// <summary>The instant after which a request should renew before sending: half the lifetime before expiry.</summary>
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;
}
/// <summary>Parses the <c>data</c> envelope of a <c>POST /access/ticket</c> response.</summary>
/// <param name="responseBody">The raw JSON response body.</param>
/// <param name="issuedAt">When the ticket was issued; the expiry is <see cref="TicketLifetime"/> later.</param>
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<string>()
?? throw new InvalidOperationException("Response did not contain a ticket.");
var csrfToken = data["CSRFPreventionToken"]?.Value<string>()
?? throw new InvalidOperationException("Response did not contain a CSRFPreventionToken.");
return new TicketState(ticket, csrfToken, issuedAt + TicketLifetime);
}
}
}
}
+170 -42
View File
@@ -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<TimeSpan, Task> _guestLockRetryDelay;
private readonly Func<DateTime> _utcNow;
private const string ApiTokenPrefix = "PVEAPIToken=";
private const string AuthCookieName = "PVEAuthCookie=";
private const string CsrfHeaderName = "CSRFPreventionToken";
private const string TicketResource = "access/ticket";
/// <summary>
/// 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 <paramref name="handler"/> is null. Null uses
/// <see cref="PveHandlerCache.Shared"/>, the same as the public constructor.
/// </param>
/// <param name="utcNow">
/// Clock the ticket half-life check reads. Null uses <see cref="DateTime.UtcNow"/>,
/// the same as the public constructor.
/// </param>
internal PveHttpClient(
PveSession session,
TimeSpan? timeoutOverride,
TimeSpan? guestLockRetryWindow,
HttpMessageHandler? handler,
Func<TimeSpan, Task>? guestLockRetryDelay = null,
PveHandlerCache? handlerCache = null)
PveHandlerCache? handlerCache = null,
Func<DateTime>? 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
/// <returns>Raw JSON response body</returns>
public async Task<string> 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
/// <returns>Raw JSON response body</returns>
public async Task<string> PostAsync(string resource, Dictionary<string, string>? 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<string> PostAsync(string resource, IEnumerable<KeyValuePair<string, string>> 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
/// <returns>Raw JSON response body</returns>
public async Task<string> PutAsync(string resource, Dictionary<string, string>? 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
/// <returns>Raw JSON response body</returns>
public async Task<string> 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)
/// <summary>
/// Builds a request signed with <paramref name="ticket"/> 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.
/// </summary>
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
/// <summary>
/// Sends a request, rebuilding it from <paramref name="buildRequest"/> for each attempt
/// while PVE rejects it for a guest's config flock. An <see cref="HttpRequestMessage"/>
/// 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 <see cref="HttpRequestMessage"/>
/// cannot be resent, which is why this takes a factory rather than a request.
/// </summary>
private Task<string> SendAsync(Func<HttpRequestMessage> buildRequest, string resource, string httpMethod) =>
private Task<string> SendAsync(Func<PveSession.TicketState?, HttpRequestMessage> buildRequest, string resource, string httpMethod) =>
GuestLockRetry.ExecuteAsync(
() => SendOnceAsync(buildRequest(), resource, httpMethod), _guestLockRetryWindow, _guestLockRetryDelay);
() => SendRenewingAsync(buildRequest, resource, httpMethod), _guestLockRetryWindow, _guestLockRetryDelay);
private async Task<string> SendOnceAsync(HttpRequestMessage request, string resource, string httpMethod)
private async Task<string> SendRenewingAsync(Func<PveSession.TicketState?, HttpRequestMessage> 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);
}
}
}
/// <summary>
/// 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.
/// </summary>
private async Task<PveSession.TicketState?> 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);
}
/// <summary>
/// Trades <paramref name="stale"/> for a fresh ticket by posting it as the password to
/// <c>/access/ticket</c>, 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.
/// </summary>
/// <param name="stale">The ticket the caller holds and wants replaced.</param>
/// <param name="rejection">
/// The 401 that prompted this renewal, when reactive. A failed reactive renewal throws
/// <see cref="PveSessionExpiredException"/> 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.
/// </param>
private async Task<PveSession.TicketState> 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);
}
}
/// <summary>
/// The renewal POST itself. It goes straight to <see cref="SendOnceAsync"/> 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.
/// </summary>
private async Task<PveSession.TicketState> PostTicketRenewalAsync(PveSession session, PveSession.TicketState stale)
{
var request = new HttpRequestMessage(HttpMethod.Post, _baseUrl + TicketResource);
request.Content = BuildFormContent(new Dictionary<string, string>
{
["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<string> 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)
@@ -5,16 +5,30 @@ namespace PSProxmoxVE.Core.Exceptions
/// <summary>Exception thrown when the Proxmox VE session ticket has expired.</summary>
public class PveSessionExpiredException : Exception
{
private const string DefaultMessage =
"Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session.";
/// <summary>Initializes a new instance indicating the session has expired.</summary>
public PveSessionExpiredException()
: base("Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session.")
: base(DefaultMessage)
{
}
/// <summary>Initializes a new instance indicating the session has expired, with an inner exception.</summary>
/// <param name="innerException">The exception that caused this failure.</param>
public PveSessionExpiredException(Exception innerException)
: base("Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session.", innerException)
: base(DefaultMessage, innerException)
{
}
/// <summary>
/// Initializes a new instance indicating the session has expired, appending
/// <paramref name="detail"/> to the message, with an inner exception.
/// </summary>
/// <param name="detail">What was attempted to keep the session alive, and how it failed.</param>
/// <param name="innerException">The exception that caused this failure.</param>
public PveSessionExpiredException(string detail, Exception innerException)
: base(DefaultMessage + " " + detail, innerException)
{
}
}
@@ -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);
@@ -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<ArgumentNullException>(() =>
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()
{
@@ -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<DateTime>? 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<string, string> { ["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<PveSessionExpiredException>(() => 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<PveSessionExpiredException>(
() => client.GetAsync("nodes/pve9a/status"));
var inner = Assert.IsType<PveApiException>(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<PveSessionExpiredException>(
() => client.GetAsync("nodes/pve9a/status"));
var inner = Assert.IsType<PveApiException>(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<PveSessionExpiredException>(
() => client.GetAsync("nodes/pve9a/status"));
var inner = Assert.IsType<PveApiException>(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<PveSessionExpiredException>(
() => client.GetAsync("nodes/pve9a/status"));
var inner = Assert.IsType<PveApiException>(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<PveSessionExpiredException>(
() => client.GetAsync("nodes/pve9a/status"));
var inner = Assert.IsType<PveApiException>(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<PveSessionExpiredException>(
() => 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<PveApiException>(
() => 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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseTicketPost = new TaskCompletionSource<bool>(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<bool>(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<string, string> { ["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<Recorded, CancellationToken, Task<(HttpStatusCode status, string body)>> _respond;
private readonly object _sync = new object();
private readonly List<Recorded> _requests = new List<Recorded>();
public IReadOnlyList<Recorded> 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<Recorded, (HttpStatusCode status, string body)> respond)
{
_respond = (r, _) => Task.FromResult(respond(r));
}
public RecordingHandler(Func<Recorded, CancellationToken, Task<(HttpStatusCode status, string body)>> respond)
{
_respond = respond;
}
protected override async Task<HttpResponseMessage> 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;
}
/// <summary>
/// Lets several clients, each of which disposes its own handler, share one recorder.
/// </summary>
private sealed class PassThroughHandler : HttpMessageHandler
{
private readonly HttpMessageInvoker _inner;
public PassThroughHandler(HttpMessageHandler inner)
{
_inner = new HttpMessageInvoker(inner, disposeHandler: false);
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) =>
_inner.SendAsync(request, cancellationToken);
}
}
}