diff --git a/src/PSProxmoxVE.Core/Authentication/PveAuthMode.cs b/src/PSProxmoxVE.Core/Authentication/PveAuthMode.cs new file mode 100644 index 0000000..2691ba3 --- /dev/null +++ b/src/PSProxmoxVE.Core/Authentication/PveAuthMode.cs @@ -0,0 +1,9 @@ +namespace PSProxmoxVE.Core.Authentication +{ + /// Authentication mode for Proxmox VE API + public enum PveAuthMode + { + Ticket, + ApiToken + } +} diff --git a/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs b/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs new file mode 100644 index 0000000..47ee43a --- /dev/null +++ b/src/PSProxmoxVE.Core/Authentication/PveAuthenticator.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Newtonsoft.Json.Linq; +using PSProxmoxVE.Core.Client; + +namespace PSProxmoxVE.Core.Authentication +{ + /// Provides methods to authenticate against a Proxmox VE server + public static class PveAuthenticator + { + // Pattern: USER@REALM!TOKENID=UUID + private static readonly Regex ApiTokenRegex = new Regex( + @"^[^@]+@[^!]+![^=]+=.+$", + RegexOptions.Compiled); + + /// + /// Authenticates using username and password, obtaining a ticket and CSRF token. + /// The username must be in the form user@realm (e.g. root@pam). + /// + public static PveSession AuthenticateWithCredentials( + string hostname, + int port, + bool skipCertificateCheck, + string username, + string password) + { + if (string.IsNullOrWhiteSpace(hostname)) + throw new ArgumentException("Hostname cannot be null or empty.", nameof(hostname)); + if (string.IsNullOrWhiteSpace(username)) + throw new ArgumentException("Username cannot be null or empty.", nameof(username)); + if (!username.Contains("@")) + throw new ArgumentException("Username must include a realm (e.g. 'root@pam').", nameof(username)); + if (password == null) + throw new ArgumentNullException(nameof(password)); + + var formData = new Dictionary + { + { "username", username }, + { "password", password } + }; + + string responseBody; + using (var httpClient = new PveHttpClient(hostname, port, skipCertificateCheck)) + { + 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 = data["ticket"]?.Value() + ?? throw new InvalidOperationException("Response did not contain a ticket."); + var csrfToken = data["CSRFPreventionToken"]?.Value() + ?? throw new InvalidOperationException("Response did not contain a CSRFPreventionToken."); + + var ticketExpiry = DateTime.UtcNow.AddHours(2); + + var session = new PveSession(hostname, port, skipCertificateCheck, ticket, csrfToken, ticketExpiry); + + session.ServerVersion = GetVersion(session); + + return session; + } + + /// + /// Authenticates using a Proxmox VE API token. + /// The token must be in the format USER@REALM!TOKENID=UUID (e.g. root@pam!mytoken=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx). + /// + public static PveSession AuthenticateWithApiToken( + string hostname, + int port, + bool skipCertificateCheck, + string apiToken) + { + if (string.IsNullOrWhiteSpace(hostname)) + throw new ArgumentException("Hostname cannot be null or empty.", nameof(hostname)); + if (string.IsNullOrWhiteSpace(apiToken)) + throw new ArgumentException("API token cannot be null or empty.", nameof(apiToken)); + if (!ApiTokenRegex.IsMatch(apiToken)) + throw new ArgumentException( + "API token must be in the format 'USER@REALM!TOKENID=UUID' (e.g. root@pam!mytoken=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).", + nameof(apiToken)); + + var session = new PveSession(hostname, port, skipCertificateCheck, apiToken); + + session.ServerVersion = GetVersion(session); + + return session; + } + + /// Retrieves and parses the server version from the PVE API + private static PveVersion GetVersion(PveSession session) + { + string responseBody; + using (var httpClient = new PveHttpClient(session)) + { + responseBody = httpClient.Get("/api2/json/version"); + } + + var json = JObject.Parse(responseBody); + var data = json["data"] ?? throw new InvalidOperationException("Version response did not contain a 'data' field."); + + var versionString = data["version"]?.Value() + ?? throw new InvalidOperationException("Version response did not contain a 'version' field."); + + return PveVersion.Parse(versionString); + } + } +} diff --git a/src/PSProxmoxVE.Core/Authentication/PveSession.cs b/src/PSProxmoxVE.Core/Authentication/PveSession.cs new file mode 100644 index 0000000..cdbb587 --- /dev/null +++ b/src/PSProxmoxVE.Core/Authentication/PveSession.cs @@ -0,0 +1,66 @@ +using System; +using PSProxmoxVE.Core.Models; + +namespace PSProxmoxVE.Core.Authentication +{ + /// Represents an authenticated session to a Proxmox VE server + public class PveSession + { + public string Hostname { get; } + public int Port { get; } + public bool SkipCertificateCheck { get; } + public PveAuthMode AuthMode { get; } + public string? ApiToken { get; } + public string? Ticket { get; } + public string? CsrfToken { get; } + public DateTime TicketExpiry { get; } + public PveVersion ServerVersion { get; internal set; } + + /// Returns true if the ticket has expired (only relevant for Ticket auth mode) + public bool IsExpired + { + get + { + if (AuthMode == PveAuthMode.ApiToken) + return false; + return DateTime.UtcNow >= TicketExpiry; + } + } + + /// Base URL for the Proxmox VE API + public string BaseUrl => $"https://{Hostname}:{Port}/api2/json"; + + /// Creates a session using ticket-based authentication + internal PveSession( + string hostname, + int port, + bool skipCertificateCheck, + string ticket, + string csrfToken, + DateTime ticketExpiry) + { + Hostname = hostname; + Port = port; + SkipCertificateCheck = skipCertificateCheck; + AuthMode = PveAuthMode.Ticket; + Ticket = ticket; + CsrfToken = csrfToken; + TicketExpiry = ticketExpiry; + } + + /// Creates a session using API token authentication + internal PveSession( + string hostname, + int port, + bool skipCertificateCheck, + string apiToken) + { + Hostname = hostname; + Port = port; + SkipCertificateCheck = skipCertificateCheck; + AuthMode = PveAuthMode.ApiToken; + ApiToken = apiToken; + TicketExpiry = DateTime.MaxValue; + } + } +} diff --git a/src/PSProxmoxVE.Core/Authentication/PveVersion.cs b/src/PSProxmoxVE.Core/Authentication/PveVersion.cs new file mode 100644 index 0000000..ebcdac3 --- /dev/null +++ b/src/PSProxmoxVE.Core/Authentication/PveVersion.cs @@ -0,0 +1,44 @@ +using System; + +namespace PSProxmoxVE.Core.Authentication +{ + /// Represents a Proxmox VE server version (major.minor only) + public class PveVersion + { + public int Major { get; } + public int Minor { get; } + + public PveVersion(int major, int minor) + { + Major = major; + Minor = minor; + } + + /// Parse version from PVE API format "major.minor-patchlevel" e.g. "9.1-1" + public static PveVersion Parse(string versionString) + { + if (string.IsNullOrWhiteSpace(versionString)) + throw new ArgumentException("Version string cannot be null or empty.", nameof(versionString)); + + // Strip patchlevel: "9.1-1" -> "9.1" + var dashIndex = versionString.IndexOf('-'); + var majorMinor = dashIndex >= 0 ? versionString.Substring(0, dashIndex) : versionString; + + var parts = majorMinor.Split('.'); + if (parts.Length < 2) + throw new FormatException($"Invalid PVE version format: '{versionString}'. Expected 'major.minor' or 'major.minor-patchlevel'."); + + if (!int.TryParse(parts[0], out var major) || !int.TryParse(parts[1], out var minor)) + throw new FormatException($"Invalid PVE version format: '{versionString}'. Major and minor must be integers."); + + return new PveVersion(major, minor); + } + + public bool IsAtLeast(int major, int minor) + { + return Major > major || (Major == major && Minor >= minor); + } + + public override string ToString() => $"{Major}.{Minor}"; + } +} diff --git a/src/PSProxmoxVE.Core/Exceptions/PveApiException.cs b/src/PSProxmoxVE.Core/Exceptions/PveApiException.cs new file mode 100644 index 0000000..1d76a80 --- /dev/null +++ b/src/PSProxmoxVE.Core/Exceptions/PveApiException.cs @@ -0,0 +1,29 @@ +using System; +using System.Net; + +namespace PSProxmoxVE.Core.Exceptions +{ + /// Exception thrown when the Proxmox VE API returns an error response + public class PveApiException : Exception + { + public HttpStatusCode StatusCode { get; } + public string Resource { get; } + public string HttpMethod { get; } + + public PveApiException(HttpStatusCode statusCode, string message, string resource, string httpMethod) + : base($"PVE API error ({(int)statusCode} {statusCode}) on {httpMethod} {resource}: {message}") + { + StatusCode = statusCode; + Resource = resource; + HttpMethod = httpMethod; + } + + public PveApiException(HttpStatusCode statusCode, string message, string resource, string httpMethod, Exception innerException) + : base($"PVE API error ({(int)statusCode} {statusCode}) on {httpMethod} {resource}: {message}", innerException) + { + StatusCode = statusCode; + Resource = resource; + HttpMethod = httpMethod; + } + } +} diff --git a/src/PSProxmoxVE.Core/Exceptions/PveAuthenticationException.cs b/src/PSProxmoxVE.Core/Exceptions/PveAuthenticationException.cs new file mode 100644 index 0000000..6d4112c --- /dev/null +++ b/src/PSProxmoxVE.Core/Exceptions/PveAuthenticationException.cs @@ -0,0 +1,18 @@ +using System; + +namespace PSProxmoxVE.Core.Exceptions +{ + /// Exception thrown when authentication to a Proxmox VE server fails + public class PveAuthenticationException : Exception + { + public PveAuthenticationException(string message) + : base(message) + { + } + + public PveAuthenticationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/src/PSProxmoxVE.Core/Exceptions/PveNotConnectedException.cs b/src/PSProxmoxVE.Core/Exceptions/PveNotConnectedException.cs new file mode 100644 index 0000000..296bedd --- /dev/null +++ b/src/PSProxmoxVE.Core/Exceptions/PveNotConnectedException.cs @@ -0,0 +1,18 @@ +using System; + +namespace PSProxmoxVE.Core.Exceptions +{ + /// Exception thrown when an operation is attempted without an active Proxmox VE session + public class PveNotConnectedException : Exception + { + public PveNotConnectedException() + : base("No active Proxmox VE session. Run Connect-PveServer first.") + { + } + + public PveNotConnectedException(Exception innerException) + : base("No active Proxmox VE session. Run Connect-PveServer first.", innerException) + { + } + } +} diff --git a/src/PSProxmoxVE.Core/Exceptions/PveSessionExpiredException.cs b/src/PSProxmoxVE.Core/Exceptions/PveSessionExpiredException.cs new file mode 100644 index 0000000..eb78e03 --- /dev/null +++ b/src/PSProxmoxVE.Core/Exceptions/PveSessionExpiredException.cs @@ -0,0 +1,18 @@ +using System; + +namespace PSProxmoxVE.Core.Exceptions +{ + /// Exception thrown when the Proxmox VE session ticket has expired + public class PveSessionExpiredException : Exception + { + public PveSessionExpiredException() + : base("Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session.") + { + } + + public PveSessionExpiredException(Exception innerException) + : base("Your Proxmox VE session has expired. Please run Connect-PveServer to establish a new session.", innerException) + { + } + } +} diff --git a/src/PSProxmoxVE.Core/Exceptions/PveTaskFailedException.cs b/src/PSProxmoxVE.Core/Exceptions/PveTaskFailedException.cs new file mode 100644 index 0000000..0b99ffd --- /dev/null +++ b/src/PSProxmoxVE.Core/Exceptions/PveTaskFailedException.cs @@ -0,0 +1,25 @@ +using System; + +namespace PSProxmoxVE.Core.Exceptions +{ + /// Exception thrown when a Proxmox VE task completes with a failed exit status + public class PveTaskFailedException : Exception + { + public string Upid { get; } + public string ExitStatus { get; } + + public PveTaskFailedException(string upid, string exitStatus) + : base($"Task {upid} failed with exit status: {exitStatus}") + { + Upid = upid; + ExitStatus = exitStatus; + } + + public PveTaskFailedException(string upid, string exitStatus, Exception innerException) + : base($"Task {upid} failed with exit status: {exitStatus}", innerException) + { + Upid = upid; + ExitStatus = exitStatus; + } + } +} diff --git a/src/PSProxmoxVE.Core/Exceptions/PveTaskTimeoutException.cs b/src/PSProxmoxVE.Core/Exceptions/PveTaskTimeoutException.cs new file mode 100644 index 0000000..3d5d725 --- /dev/null +++ b/src/PSProxmoxVE.Core/Exceptions/PveTaskTimeoutException.cs @@ -0,0 +1,25 @@ +using System; + +namespace PSProxmoxVE.Core.Exceptions +{ + /// Exception thrown when a Proxmox VE task does not complete within the allowed timeout period + public class PveTaskTimeoutException : Exception + { + public string Upid { get; } + public TimeSpan Timeout { get; } + + public PveTaskTimeoutException(string upid, TimeSpan timeout) + : base($"Task {upid} did not complete within {timeout.TotalSeconds} seconds.") + { + Upid = upid; + Timeout = timeout; + } + + public PveTaskTimeoutException(string upid, TimeSpan timeout, Exception innerException) + : base($"Task {upid} did not complete within {timeout.TotalSeconds} seconds.", innerException) + { + Upid = upid; + Timeout = timeout; + } + } +} diff --git a/src/PSProxmoxVE.Core/Exceptions/PveVersionException.cs b/src/PSProxmoxVE.Core/Exceptions/PveVersionException.cs new file mode 100644 index 0000000..e2606a8 --- /dev/null +++ b/src/PSProxmoxVE.Core/Exceptions/PveVersionException.cs @@ -0,0 +1,29 @@ +using System; +using PSProxmoxVE.Core.Authentication; + +namespace PSProxmoxVE.Core.Exceptions +{ + /// Exception thrown when the connected Proxmox VE server does not meet the minimum version requirement for an operation + public class PveVersionException : Exception + { + public int RequiredMajor { get; } + public int RequiredMinor { get; } + public PveVersion ActualVersion { get; } + + public PveVersionException(int requiredMajor, int requiredMinor, PveVersion actualVersion) + : base($"This operation requires Proxmox VE {requiredMajor}.{requiredMinor} or later. Connected server is version {actualVersion}.") + { + RequiredMajor = requiredMajor; + RequiredMinor = requiredMinor; + ActualVersion = actualVersion; + } + + public PveVersionException(int requiredMajor, int requiredMinor, PveVersion actualVersion, Exception innerException) + : base($"This operation requires Proxmox VE {requiredMajor}.{requiredMinor} or later. Connected server is version {actualVersion}.", innerException) + { + RequiredMajor = requiredMajor; + RequiredMinor = requiredMinor; + ActualVersion = actualVersion; + } + } +}