diff --git a/src/PSProxmoxVE.Core/Client/PveHttpClient.cs b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs new file mode 100644 index 0000000..8672c2a --- /dev/null +++ b/src/PSProxmoxVE.Core/Client/PveHttpClient.cs @@ -0,0 +1,468 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using PSProxmoxVE.Core.Authentication; +using PSProxmoxVE.Core.Exceptions; + +#if NET48 +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +#endif + +namespace PSProxmoxVE.Core.Client +{ + /// + /// Low-level HTTP client for communicating with the Proxmox VE API. + /// Handles authentication headers, error parsing, and the ISO upload workaround. + /// + public class PveHttpClient : IDisposable + { +#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; + + public PveHttpClient(PveSession session) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + _baseUrl = session.BaseUrl; + +#if NET48 + var handler = new HttpClientHandler(); + if (session.SkipCertificateCheck) + { + handler.ServerCertificateCustomValidationCallback = + (HttpRequestMessage _, X509Certificate2 _, X509Chain _, SslPolicyErrors _) => true; + } + _httpClient = new HttpClient(handler); +#else + if (session.SkipCertificateCheck) + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + (_, _, _, _) => true + }; + _httpClient = new HttpClient(handler); + } + else + { + _httpClient = new HttpClient(); + } +#endif + + _httpClient.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + } + + /// + /// Creates a bare HTTP client for pre-session use (e.g. initial authentication). + /// No auth headers are added to requests made with this constructor. + /// + internal PveHttpClient(string hostname, int port, bool skipCertificateCheck) + { + if (string.IsNullOrWhiteSpace(hostname)) + throw new ArgumentException("Hostname cannot be null or empty.", nameof(hostname)); + + _session = null; + _baseUrl = $"https://{hostname}:{port}"; + +#if NET48 + var handler = new HttpClientHandler(); + if (skipCertificateCheck) + { + handler.ServerCertificateCustomValidationCallback = + (HttpRequestMessage _, X509Certificate2 _, X509Chain _, SslPolicyErrors _) => true; + } + _httpClient = new HttpClient(handler); +#else + if (skipCertificateCheck) + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = + (_, _, _, _) => true + }; + _httpClient = new HttpClient(handler); + } + else + { + _httpClient = new HttpClient(); + } +#endif + + _httpClient.DefaultRequestHeaders.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/json")); + } + + // ------------------------------------------------------------------------- + // Core request methods + // ------------------------------------------------------------------------- + + /// Performs a GET request against the specified API resource path. + /// Relative resource path, e.g. "/nodes/pve/status" + /// Raw JSON response body + public async Task GetAsync(string resource) + { + var request = BuildRequest(HttpMethod.Get, resource); + return await SendAsync(request, resource, "GET").ConfigureAwait(false); + } + + /// Performs a POST request against the specified API resource path. + /// Relative resource path + /// Form fields to send as application/x-www-form-urlencoded body + /// Raw JSON response body + public async Task PostAsync(string resource, Dictionary? data = null) + { + var request = BuildRequest(HttpMethod.Post, resource, mutating: true); + if (data != null) + request.Content = new FormUrlEncodedContent(data); + return await SendAsync(request, resource, "POST").ConfigureAwait(false); + } + + /// Performs a PUT request against the specified API resource path. + /// Relative resource path + /// Form fields to send as application/x-www-form-urlencoded body + /// Raw JSON response body + public async Task PutAsync(string resource, Dictionary? data = null) + { + var request = BuildRequest(HttpMethod.Put, resource, mutating: true); + if (data != null) + request.Content = new FormUrlEncodedContent(data); + return await SendAsync(request, resource, "PUT").ConfigureAwait(false); + } + + /// Performs a DELETE request against the specified API resource path. + /// Relative resource path + /// Raw JSON response body + public async Task DeleteAsync(string resource) + { + var request = BuildRequest(HttpMethod.Delete, resource, mutating: true); + return await SendAsync(request, resource, "DELETE").ConfigureAwait(false); + } + + // ------------------------------------------------------------------------- + // Synchronous wrappers + // ------------------------------------------------------------------------- + + /// Synchronous wrapper for . + public string Get(string resource) => + GetAsync(resource).GetAwaiter().GetResult(); + + /// Synchronous wrapper for . + public string Post(string resource, Dictionary? data = null) => + PostAsync(resource, data).GetAwaiter().GetResult(); + + /// Synchronous wrapper for . + public string Put(string resource, Dictionary? data = null) => + PutAsync(resource, data).GetAwaiter().GetResult(); + + /// Synchronous wrapper for . + public string Delete(string resource) => + DeleteAsync(resource).GetAwaiter().GetResult(); + + // ------------------------------------------------------------------------- + // ISO / file upload + // ------------------------------------------------------------------------- + + /// + /// Uploads a file (e.g. an ISO) to a Proxmox VE storage endpoint using a manually + /// constructed multipart/form-data body. + /// + /// 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 the file part, + /// which is exactly what .NET's MultipartFormDataContent emits by default. + /// To work around this, this method constructs the raw multipart body manually: + /// + /// * Text (form field) parts: only Content-Disposition — no Content-Type, + /// no Content-Transfer-Encoding. + /// * File (binary) part: Content-Disposition + Content-Type: application/octet-stream + /// — no Content-Transfer-Encoding. + /// + /// Do NOT refactor this to use MultipartFormDataContent unless the PVE API + /// behaviour has been verified to have changed. + /// + /// Relative API resource path, e.g. "/nodes/pve/storage/local/upload" + /// Absolute local path to the file to upload + /// Additional form fields (e.g. content type) + /// Optional file checksum value + /// Optional checksum algorithm name (e.g. "sha256") + /// + /// Optional callback invoked periodically with (bytesSent, totalBytes). + /// May be called from a background thread. + /// + /// Raw JSON response body + public async Task UploadFileAsync( + string resource, + string filePath, + Dictionary? formFields = null, + string? checksum = null, + string? checksumAlgorithm = null, + Action? 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); + + // Build a random 32-char alphanumeric boundary + var boundary = GenerateBoundary(); + var fileName = Path.GetFileName(filePath); + var fileInfo = new FileInfo(filePath); + var totalBytes = fileInfo.Length; + + // Build the multipart body as a MemoryStream / streaming approach. + // We write preamble + form fields into a MemoryStream, then stream the + // file content, then write the closing boundary — all via a custom + // stream that concatenates them. + + var preamble = BuildMultipartPreamble(boundary, fileName, formFields, checksum, checksumAlgorithm); + var closing = Encoding.UTF8.GetBytes($"\r\n--{boundary}--\r\n"); + + // Total upload size (preamble + file content + closing) + var uploadSize = preamble.Length + totalBytes + closing.Length; + + var content = new PushStreamContent(async (outputStream) => + { + // Write preamble + await outputStream.WriteAsync(preamble, 0, preamble.Length).ConfigureAwait(false); + + // Stream file in 4 MB chunks, reporting progress + const int chunkSize = 4 * 1024 * 1024; + var buffer = new byte[chunkSize]; + long bytesSent = preamble.Length; + + using (var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, chunkSize, useAsync: true)) + { + int bytesRead; + while ((bytesRead = await fileStream.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) + { + await outputStream.WriteAsync(buffer, 0, bytesRead).ConfigureAwait(false); + bytesSent += bytesRead; + progressCallback?.Invoke(bytesSent, uploadSize); + } + } + + // Write closing boundary + await outputStream.WriteAsync(closing, 0, closing.Length).ConfigureAwait(false); + }, uploadSize); + + content.Headers.ContentType = MediaTypeHeaderValue.Parse($"multipart/form-data; boundary=\"{boundary}\""); + + var request = BuildRequest(HttpMethod.Post, resource, mutating: true); + request.Content = content; + + return await SendAsync(request, resource, "POST").ConfigureAwait(false); + } + + // ------------------------------------------------------------------------- + // 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.Add("Authorization", $"PVEAPIToken={_session.ApiToken}"); + } + else + { + // Ticket auth + request.Headers.Add("Cookie", $"PVEAuthCookie={_session.Ticket}"); + if (mutating && !string.IsNullOrEmpty(_session.CsrfToken)) + request.Headers.Add("CSRFPreventionToken", _session.CsrfToken); + } + + return request; + } + + private async Task 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; + } + + /// + /// Attempts to extract a human-readable error message from the PVE API JSON response. + /// PVE wraps errors in {"errors": {"field": "message", ...}} inside the standard + /// {"data": null, "errors": {...}} envelope. + /// + 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(); + 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 + { + // 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; + } + + /// Generates a random 32-character alphanumeric boundary string. + private static string GenerateBoundary() + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + var rng = new Random(); + var sb = new StringBuilder(32); + for (int i = 0; i < 32; i++) + sb.Append(chars[rng.Next(chars.Length)]); + return sb.ToString(); + } + + /// + /// Builds the multipart preamble bytes: all form fields + the beginning of the file part + /// (Content-Disposition + Content-Type only; no Content-Transfer-Encoding per bugzilla 7389). + /// + private static byte[] BuildMultipartPreamble( + string boundary, + string fileName, + Dictionary? formFields, + string? checksum, + string? checksumAlgorithm) + { + var sb = new StringBuilder(); + + // Text form fields — Content-Disposition only, no Content-Type + if (formFields != null) + { + foreach (var kvp in formFields) + { + sb.Append($"--{boundary}\r\n"); + sb.Append($"Content-Disposition: form-data; name=\"{kvp.Key}\"\r\n"); + sb.Append("\r\n"); + sb.Append(kvp.Value); + sb.Append("\r\n"); + } + } + + if (!string.IsNullOrEmpty(checksum) && !string.IsNullOrEmpty(checksumAlgorithm)) + { + sb.Append($"--{boundary}\r\n"); + sb.Append($"Content-Disposition: form-data; name=\"checksum-algorithm\"\r\n"); + sb.Append("\r\n"); + sb.Append(checksumAlgorithm); + sb.Append("\r\n"); + + sb.Append($"--{boundary}\r\n"); + sb.Append($"Content-Disposition: form-data; name=\"checksum\"\r\n"); + sb.Append("\r\n"); + sb.Append(checksum); + sb.Append("\r\n"); + } + else if (!string.IsNullOrEmpty(checksum)) + { + sb.Append($"--{boundary}\r\n"); + sb.Append($"Content-Disposition: form-data; name=\"checksum\"\r\n"); + sb.Append("\r\n"); + sb.Append(checksum); + sb.Append("\r\n"); + } + + // File part header — Content-Disposition + Content-Type; no Content-Transfer-Encoding + sb.Append($"--{boundary}\r\n"); + sb.Append($"Content-Disposition: form-data; name=\"filename\"; filename=\"{fileName}\"\r\n"); + sb.Append("Content-Type: application/octet-stream\r\n"); + sb.Append("\r\n"); + + return Encoding.UTF8.GetBytes(sb.ToString()); + } + + public void Dispose() + { + if (!_disposed) + { + _httpClient.Dispose(); + _disposed = true; + } + } + + // ------------------------------------------------------------------------- + // Helper: push-stream HttpContent for streaming uploads + // ------------------------------------------------------------------------- + + /// + /// An implementation that writes its body by invoking a + /// caller-supplied async delegate, enabling streaming without buffering the entire + /// payload into memory. + /// + private sealed class PushStreamContent : HttpContent + { + private readonly Func _onStreamAvailable; + private readonly long _contentLength; + + public PushStreamContent(Func onStreamAvailable, long contentLength) + { + _onStreamAvailable = onStreamAvailable; + _contentLength = contentLength; + } + + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context) + { + await _onStreamAvailable(stream).ConfigureAwait(false); + } + + protected override bool TryComputeLength(out long length) + { + length = _contentLength; + return _contentLength >= 0; + } + } + } +}