Files
PSProxmoxVE/src/PSProxmoxVE.Core/Client/PveHttpClient.cs
T
Clint Branham f3b06171b2 fix: add -TimeoutSeconds for long-running HTTP calls
PveHttpClient was constructed without setting HttpClient.Timeout, so
.NET's 100s default applied to every request. Multi-GB ISO uploads via
Send-PveFile on a real LAN reliably tripped this with TaskCanceledException
after 100 seconds, and there was no way to override it.

- PveSession gains a Timeout (TimeSpan) property, defaulting to 100s.
- PveHttpClient accepts an optional per-instance timeout override that
  takes precedence over the session timeout.
- Connect-PveServer exposes -TimeoutSeconds to set the session default.
- Send-PveFile and Invoke-PveStorageDownload expose -TimeoutSeconds with
  a 30-minute implicit default so large uploads/downloads do not trip
  the 100s default. -TimeoutSeconds 0 means Timeout.InfiniteTimeSpan.

Tracked as F087. Closes #59.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 18:08:26 -05:00

481 lines
21 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using PSProxmoxVE.Core.Authentication;
using PSProxmoxVE.Core.Exceptions;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
namespace PSProxmoxVE.Core.Client
{
/// <summary>
/// Low-level HTTP client for communicating with the Proxmox VE API.
/// Handles authentication headers, error parsing, and the ISO upload workaround.
/// </summary>
public class PveHttpClient : IPveHttpClient
{
#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type
private readonly PveSession? _session;
#pragma warning restore CS8625
private readonly string _baseUrl;
private readonly HttpClient _httpClient;
private bool _disposed;
private const string ApiTokenPrefix = "PVEAPIToken=";
private const string AuthCookieName = "PVEAuthCookie=";
private const string CsrfHeaderName = "CSRFPreventionToken";
/// <summary>
/// Creates an HTTP client authenticated with the specified PVE session.
/// </summary>
/// <param name="session">The authenticated PVE session providing credentials and base URL.</param>
/// <param name="timeoutOverride">
/// Optional per-instance timeout override. When supplied, takes precedence over
/// <see cref="PveSession.Timeout"/>. Pass <see cref="System.Threading.Timeout.InfiniteTimeSpan"/>
/// to disable the timeout entirely (useful for multi-GB uploads/downloads).
/// </param>
public PveHttpClient(PveSession session, TimeSpan? timeoutOverride = null)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_baseUrl = session.BaseUrl;
var handler = new HttpClientHandler();
if (session.SkipCertificateCheck)
{
handler.ServerCertificateCustomValidationCallback =
(HttpRequestMessage _, X509Certificate2 _, X509Chain _, SslPolicyErrors _) => true;
}
_httpClient = new HttpClient(handler);
_httpClient.Timeout = timeoutOverride ?? session.Timeout;
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
/// <summary>
/// Creates a bare HTTP client for pre-session use (e.g. initial authentication).
/// No auth headers are added to requests made with this constructor.
/// </summary>
internal PveHttpClient(string hostname, int port, bool skipCertificateCheck, TimeSpan? timeout = null)
{
if (string.IsNullOrWhiteSpace(hostname))
throw new ArgumentException("Hostname cannot be null or empty.", nameof(hostname));
_session = null;
_baseUrl = $"https://{hostname}:{port}";
var handler = new HttpClientHandler();
if (skipCertificateCheck)
{
handler.ServerCertificateCustomValidationCallback =
(HttpRequestMessage _, X509Certificate2 _, X509Chain _, SslPolicyErrors _) => true;
}
_httpClient = new HttpClient(handler);
if (timeout.HasValue)
_httpClient.Timeout = timeout.Value;
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
// -------------------------------------------------------------------------
// Core request methods
// -------------------------------------------------------------------------
/// <summary>Performs a GET request against the specified API resource path.</summary>
/// <param name="resource">Relative resource path, e.g. "/nodes/pve/status"</param>
/// <returns>Raw JSON response body</returns>
public async Task<string> GetAsync(string resource)
{
var request = BuildRequest(HttpMethod.Get, resource);
return await SendAsync(request, resource, "GET").ConfigureAwait(false);
}
/// <summary>Performs a POST request against the specified API resource path.</summary>
/// <param name="resource">Relative resource path</param>
/// <param name="data">Form fields to send as application/x-www-form-urlencoded body</param>
/// <returns>Raw JSON response body</returns>
public async Task<string> PostAsync(string resource, Dictionary<string, string>? data = null)
{
var request = BuildRequest(HttpMethod.Post, resource, mutating: true);
if (data != null)
request.Content = BuildFormContent(data);
return await SendAsync(request, resource, "POST").ConfigureAwait(false);
}
/// <summary>Performs a PUT request against the specified API resource path.</summary>
/// <param name="resource">Relative resource path</param>
/// <param name="data">Form fields to send as application/x-www-form-urlencoded body</param>
/// <returns>Raw JSON response body</returns>
public async Task<string> PutAsync(string resource, Dictionary<string, string>? data = null)
{
var request = BuildRequest(HttpMethod.Put, resource, mutating: true);
if (data != null)
request.Content = BuildFormContent(data);
return await SendAsync(request, resource, "PUT").ConfigureAwait(false);
}
/// <summary>Performs a DELETE request against the specified API resource path.</summary>
/// <param name="resource">Relative resource path</param>
/// <returns>Raw JSON response body</returns>
public async Task<string> DeleteAsync(string resource)
{
var request = BuildRequest(HttpMethod.Delete, resource, mutating: true);
return await SendAsync(request, resource, "DELETE").ConfigureAwait(false);
}
// -------------------------------------------------------------------------
// Form body encoding
// -------------------------------------------------------------------------
/// <summary>
/// Builds form-encoded content using minimal encoding that matches curl behavior.
/// .NET's <see cref="FormUrlEncodedContent"/> over-encodes characters like
/// <c>:</c> and <c>!</c> which PVE's internal API consumers (e.g. cluster join)
/// do not properly URL-decode.
/// </summary>
private static StringContent BuildFormContent(Dictionary<string, string> data)
{
var sb = new StringBuilder();
foreach (var kvp in data)
{
if (sb.Length > 0) sb.Append('&');
sb.Append(EncodeFormValue(kvp.Key));
sb.Append('=');
sb.Append(EncodeFormValue(kvp.Value));
}
return new StringContent(sb.ToString(), Encoding.UTF8, "application/x-www-form-urlencoded");
}
/// <summary>
/// Encodes a form value with minimal percent-encoding — only characters that
/// would break form parsing are encoded. This matches curl's <c>-d</c> behavior
/// and avoids over-encoding that PVE does not handle correctly.
/// </summary>
private static string EncodeFormValue(string value)
{
var sb = new StringBuilder(value.Length);
foreach (char c in value)
{
switch (c)
{
case '&': sb.Append("%26"); break;
case '=': sb.Append("%3D"); break;
case '+': sb.Append("%2B"); break;
case ' ': sb.Append('+'); break;
case '%': sb.Append("%25"); break;
default: sb.Append(c); break;
}
}
return sb.ToString();
}
// -------------------------------------------------------------------------
// Synchronous wrappers
// -------------------------------------------------------------------------
/// <summary>Synchronous wrapper for <see cref="GetAsync"/>.</summary>
public string Get(string resource) =>
GetAsync(resource).GetAwaiter().GetResult();
/// <summary>Synchronous wrapper for <see cref="PostAsync"/>.</summary>
public string Post(string resource, Dictionary<string, string>? data = null) =>
PostAsync(resource, data).GetAwaiter().GetResult();
/// <summary>Synchronous wrapper for <see cref="PutAsync"/>.</summary>
public string Put(string resource, Dictionary<string, string>? data = null) =>
PutAsync(resource, data).GetAwaiter().GetResult();
/// <summary>Synchronous wrapper for <see cref="DeleteAsync"/>.</summary>
public string Delete(string resource) =>
DeleteAsync(resource).GetAwaiter().GetResult();
// -------------------------------------------------------------------------
// ISO / file upload
// -------------------------------------------------------------------------
/// <summary>
/// Uploads a file (e.g. an ISO) to a Proxmox VE storage endpoint using
/// MultipartFormDataContent.
///
/// IMPORTANT — Bugzilla 7389 workaround:
/// https://bugzilla.proxmox.com/show_bug.cgi?id=7389
///
/// The Proxmox VE API rejects uploads when the multipart body contains
/// "Content-Type" or "Content-Transfer-Encoding" headers on text parts,
/// and also rejects a quoted boundary in the Content-Type header.
///
/// Workaround:
/// * Override the multipart Content-Type header to use an unquoted boundary.
/// * Set ContentType = null on all StringContent text parts before adding them.
/// * Use StreamContent for the file part (no Content-Transfer-Encoding added).
/// </summary>
/// <param name="resource">Relative API resource path, e.g. "nodes/pve/storage/local/upload"</param>
/// <param name="filePath">Absolute local path to the file to upload</param>
/// <param name="formFields">Additional form fields (e.g. content type)</param>
/// <param name="checksum">Optional file checksum value</param>
/// <param name="checksumAlgorithm">Optional checksum algorithm name (e.g. "sha256")</param>
/// <param name="progressCallback">
/// Optional callback invoked periodically with (bytesSent, totalBytes).
/// May be called from a background thread.
/// </param>
/// <returns>Raw JSON response body</returns>
public async Task<string> UploadFileAsync(
string resource,
string filePath,
Dictionary<string, string>? formFields = null,
string? checksum = null,
string? checksumAlgorithm = null,
Action<long, long>? progressCallback = null)
{
if (string.IsNullOrWhiteSpace(filePath))
throw new ArgumentException("File path must not be null or empty.", nameof(filePath));
if (!File.Exists(filePath))
throw new FileNotFoundException("Upload file not found.", filePath);
var boundary = GenerateBoundary();
var fileName = Path.GetFileName(filePath);
var totalBytes = new FileInfo(filePath).Length;
var multipart = new MultipartFormDataContent(boundary);
// Override Content-Type to use unquoted boundary (PVE rejects quoted boundaries).
multipart.Headers.ContentType =
MediaTypeHeaderValue.Parse($"multipart/form-data; boundary={boundary}");
// Add text form fields.
// IMPORTANT — BZ 7389 workaround: PVE requires quoted name= values in
// Content-Disposition (e.g. name="content"), but .NET's
// MultipartFormDataContent.Add(part, name) emits name=content (unquoted),
// which PVE rejects with a broken-pipe / stream-copy error.
// Fix: set ContentDisposition manually with embedded quotes, then call
// multipart.Add(part) without a name so the header is not overwritten.
if (formFields != null)
{
foreach (var kvp in formFields)
{
var part = new StringContent(kvp.Value, Encoding.UTF8);
part.Headers.ContentType = null;
part.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
{
Name = $"\"{kvp.Key}\""
};
multipart.Add(part);
}
}
if (!string.IsNullOrEmpty(checksumAlgorithm) && !string.IsNullOrEmpty(checksum))
{
var algPart = new StringContent(checksumAlgorithm!, Encoding.UTF8);
algPart.Headers.ContentType = null;
algPart.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "\"checksum-algorithm\"" };
multipart.Add(algPart);
}
if (!string.IsNullOrEmpty(checksum))
{
var csPart = new StringContent(checksum!, Encoding.UTF8);
csPart.Headers.ContentType = null;
csPart.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") { Name = "\"checksum\"" };
multipart.Add(csPart);
}
// 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);
try
{
Stream uploadStream = progressCallback != null
? (Stream)new ProgressStream(fileStream, totalBytes, progressCallback)
: fileStream;
// ContentDisposition MUST come before ContentType in the part headers.
// PVE closes the connection if ContentType appears first (server-side parse order sensitivity).
var fileContent = new StreamContent(uploadStream);
fileContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data")
{
Name = "\"filename\"",
FileName = $"\"{fileName}\""
};
fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("application/octet-stream");
multipart.Add(fileContent);
var request = BuildRequest(HttpMethod.Post, resource, mutating: true);
request.Content = multipart;
return await SendAsync(request, resource, "POST").ConfigureAwait(false);
}
finally
{
fileStream.Dispose();
}
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private HttpRequestMessage BuildRequest(HttpMethod method, string resource, bool mutating = false)
{
var url = _baseUrl + resource;
var request = new HttpRequestMessage(method, url);
if (_session == null)
return request;
if (_session.AuthMode == PveAuthMode.ApiToken)
{
request.Headers.TryAddWithoutValidation("Authorization", $"{ApiTokenPrefix}{_session.ApiToken}");
}
else
{
// Ticket auth
request.Headers.Add("Cookie", $"{AuthCookieName}{_session.Ticket}");
if (mutating && !string.IsNullOrEmpty(_session.CsrfToken))
request.Headers.Add(CsrfHeaderName, _session.CsrfToken);
}
return request;
}
private async Task<string> SendAsync(HttpRequestMessage request, string resource, string httpMethod)
{
HttpResponseMessage response;
try
{
response = await _httpClient.SendAsync(request).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
throw new PveApiException(HttpStatusCode.ServiceUnavailable,
ex.Message, resource, httpMethod, ex);
}
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorMessage = ExtractErrorMessage(body, response.ReasonPhrase ?? response.StatusCode.ToString());
throw new PveApiException(response.StatusCode, errorMessage, resource, httpMethod);
}
return body;
}
/// <summary>
/// Attempts to extract a human-readable error message from the PVE API JSON response.
/// PVE wraps errors in <c>{"errors": {"field": "message", ...}}</c> inside the standard
/// <c>{"data": null, "errors": {...}}</c> envelope.
/// </summary>
private static string ExtractErrorMessage(string body, string fallback)
{
if (string.IsNullOrWhiteSpace(body))
return fallback;
try
{
var json = JObject.Parse(body);
// Top-level "errors" object
var errors = json["errors"] as JObject;
if (errors != null && errors.Count > 0)
{
var parts = new List<string>();
foreach (var prop in errors.Properties())
parts.Add($"{prop.Name}: {prop.Value}");
return string.Join("; ", parts);
}
// Some endpoints return a plain string in "message"
var message = json["message"]?.ToString();
if (!string.IsNullOrWhiteSpace(message))
return message!;
}
catch (Newtonsoft.Json.JsonException)
{
// If parsing fails, fall through to returning the raw body (truncated)
}
// Return raw body, truncated to avoid enormous exception messages
return body.Length > 512 ? body.Substring(0, 512) + "..." : body;
}
/// <summary>Generates a random 32-character alphanumeric boundary string using a cryptographic RNG.</summary>
private static string GenerateBoundary()
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var bytes = new byte[32];
using (var rng = RandomNumberGenerator.Create())
rng.GetBytes(bytes);
var sb = new StringBuilder(32);
for (int i = 0; i < 32; i++)
sb.Append(chars[bytes[i] % chars.Length]);
return sb.ToString();
}
/// <inheritdoc />
public void Dispose()
{
if (!_disposed)
{
_httpClient.Dispose();
_disposed = true;
}
}
// -------------------------------------------------------------------------
// Helper: progress-reporting stream wrapper for uploads
// -------------------------------------------------------------------------
/// <summary>
/// Wraps an inner stream and invokes a progress callback as bytes are read,
/// enabling upload progress reporting for file uploads.
/// </summary>
private sealed class ProgressStream : Stream
{
private readonly Stream _inner;
private readonly long _totalBytes;
private readonly Action<long, long> _callback;
private long _bytesRead;
public ProgressStream(Stream inner, long totalBytes, Action<long, long> callback)
{
_inner = inner;
_totalBytes = totalBytes;
_callback = callback;
}
public override bool CanRead => _inner.CanRead;
public override bool CanSeek => _inner.CanSeek;
public override bool CanWrite => _inner.CanWrite;
public override long Length => _inner.Length;
public override long Position { get => _inner.Position; set => _inner.Position = value; }
public override void Flush() => _inner.Flush();
public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin);
public override void SetLength(long value) => _inner.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => _inner.Write(buffer, offset, count);
public override int Read(byte[] buffer, int offset, int count)
{
var n = _inner.Read(buffer, offset, count);
if (n > 0) { _bytesRead += n; _callback(_bytesRead, _totalBytes); }
return n;
}
protected override void Dispose(bool disposing)
{
if (disposing) _inner.Dispose();
base.Dispose(disposing);
}
}
}
}