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;
+ }
+ }
+ }
+}