4
0

ProxmoxApiClient.cs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. using System.Net.Security;
  2. namespace RackPeek.Domain.Discovery;
  3. /// <summary>
  4. /// Talks to the Proxmox VE API with an API token. A token is used rather than a
  5. /// password because it can be given a read-only role and revoked on its own.
  6. /// </summary>
  7. public sealed class ProxmoxApiClient : IProxmoxClient, IDisposable {
  8. public const string QemuEndpoint = "qemu";
  9. public const string LxcEndpoint = "lxc";
  10. public const string TokenIdEnvironmentVariable = "RPK_PVE_TOKEN_ID";
  11. public const string TokenSecretEnvironmentVariable = "RPK_PVE_TOKEN_SECRET";
  12. private readonly HttpClient _httpClient;
  13. /// <param name="allowUntrustedCertificate">
  14. /// Proxmox ships with a self-signed certificate and most installations keep it,
  15. /// so this is needed more often than not. It is opt-in all the same.
  16. /// </param>
  17. public ProxmoxApiClient(
  18. string host,
  19. string tokenId,
  20. string tokenSecret,
  21. bool allowUntrustedCertificate = false,
  22. HttpClient? httpClient = null) {
  23. Endpoint = Normalise(host);
  24. _httpClient = httpClient ?? new HttpClient(Handler(allowUntrustedCertificate));
  25. _httpClient.BaseAddress = new Uri(Endpoint + "/api2/json/");
  26. _httpClient.Timeout = TimeSpan.FromSeconds(30);
  27. // Proxmox expects the whole token as one opaque Authorization value.
  28. _httpClient.DefaultRequestHeaders.TryAddWithoutValidation(
  29. "Authorization",
  30. $"PVEAPIToken={tokenId}={tokenSecret}");
  31. }
  32. public string Endpoint { get; }
  33. public void Dispose() => _httpClient.Dispose();
  34. public async Task<string> GetIdentityScopeAsync(CancellationToken cancellationToken = default) {
  35. // A standalone host has no cluster, and Proxmox answers 5xx rather than an empty
  36. // list, so a failure here is expected and means "not clustered".
  37. try {
  38. var json = await GetAsync("cluster/status", cancellationToken);
  39. var clusterName = ProxmoxResponseParser.ParseIdentityScope(json, string.Empty);
  40. // Only fall back to the node list when there is no cluster name — the
  41. // fallback costs a second call, and on a cluster it would be thrown away.
  42. return clusterName.Length > 0 ? clusterName : await FirstNodeAsync(cancellationToken);
  43. }
  44. catch (HttpRequestException) {
  45. // Either there is no cluster, or the token may not read it. Either way the
  46. // node is a sound scope: a vmid is unique within it.
  47. return await FirstNodeAsync(cancellationToken);
  48. }
  49. }
  50. public async Task<IReadOnlyList<ProxmoxNode>> GetNodesAsync(CancellationToken cancellationToken = default) =>
  51. ProxmoxResponseParser.ParseNodes(await GetAsync("nodes", cancellationToken));
  52. public async Task<ProxmoxNode> EnrichAsync(
  53. ProxmoxNode node,
  54. CancellationToken cancellationToken = default) {
  55. try {
  56. // The three endpoints are independent, so the round trips overlap.
  57. Task<string> status = GetAsync($"nodes/{Uri.EscapeDataString(node.Name)}/status", cancellationToken);
  58. Task<IReadOnlyList<ProxmoxDisk>> disks = GetDisksAsync(node.Name, cancellationToken);
  59. Task<IReadOnlyList<ProxmoxGpu>> gpus = GetGpusAsync(node.Name, cancellationToken);
  60. ProxmoxNode detail = ProxmoxResponseParser.ParseNodeStatus(await status, node.Name);
  61. return node with {
  62. Cores = detail.Cores > 0 ? detail.Cores : node.Cores,
  63. MemoryBytes = detail.MemoryBytes > 0 ? detail.MemoryBytes : node.MemoryBytes,
  64. Version = detail.Version,
  65. CpuModel = detail.CpuModel,
  66. Sockets = detail.Sockets,
  67. PhysicalCores = detail.PhysicalCores,
  68. Disks = await disks,
  69. Gpus = await gpus
  70. };
  71. }
  72. catch (HttpRequestException) {
  73. return node;
  74. }
  75. }
  76. public async Task<IReadOnlyList<ProxmoxGuest>> GetGuestsAsync(
  77. string node,
  78. string endpoint,
  79. CancellationToken cancellationToken = default) {
  80. var json = await GetAsync($"nodes/{Uri.EscapeDataString(node)}/{endpoint}", cancellationToken);
  81. return ProxmoxResponseParser.ParseGuests(
  82. json,
  83. node,
  84. endpoint == LxcEndpoint ? ProxmoxResponseParser.ContainerType : ProxmoxResponseParser.VmType);
  85. }
  86. public async Task<IReadOnlyList<ProxmoxDisk>> GetDisksAsync(
  87. string node,
  88. CancellationToken cancellationToken = default) {
  89. try {
  90. return ProxmoxResponseParser.ParseDisks(
  91. await GetAsync($"nodes/{Uri.EscapeDataString(node)}/disks/list", cancellationToken));
  92. }
  93. catch (HttpRequestException) {
  94. return [];
  95. }
  96. }
  97. public async Task<IReadOnlyList<ProxmoxGpu>> GetGpusAsync(
  98. string node,
  99. CancellationToken cancellationToken = default) {
  100. try {
  101. return ProxmoxResponseParser.ParseGpus(
  102. await GetAsync($"nodes/{Uri.EscapeDataString(node)}/hardware/pci", cancellationToken));
  103. }
  104. catch (HttpRequestException) {
  105. return [];
  106. }
  107. }
  108. public async Task<ProxmoxGuestConfig> GetGuestConfigAsync(
  109. string node,
  110. string endpoint,
  111. int vmId,
  112. CancellationToken cancellationToken = default) {
  113. // A guest can disappear between listing and reading it; that is not worth failing
  114. // the whole run over, so it simply contributes nothing.
  115. try {
  116. var json = await GetAsync(
  117. $"nodes/{Uri.EscapeDataString(node)}/{endpoint}/{vmId}/config",
  118. cancellationToken);
  119. return ProxmoxResponseParser.ParseGuestConfig(json);
  120. }
  121. catch (HttpRequestException) {
  122. return new ProxmoxGuestConfig(null, null, [], []);
  123. }
  124. }
  125. private async Task<string> FirstNodeAsync(CancellationToken cancellationToken) {
  126. IReadOnlyList<ProxmoxNode> nodes = await GetNodesAsync(cancellationToken);
  127. return nodes.FirstOrDefault()?.Name ?? "proxmox";
  128. }
  129. private async Task<string> GetAsync(string path, CancellationToken cancellationToken) {
  130. using HttpResponseMessage response = await _httpClient.GetAsync(path, cancellationToken);
  131. if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
  132. throw new HttpRequestException(
  133. "Proxmox rejected the API token (401). Check the token id is of the form " +
  134. "user@realm!tokenname and that the secret matches.");
  135. if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
  136. throw new HttpRequestException(
  137. $"The API token is not permitted to read {path} (403). In the Proxmox UI: " +
  138. "Datacenter -> Permissions -> Add -> API Token Permission, path '/', " +
  139. "role PVEAuditor, with Propagate ticked.");
  140. response.EnsureSuccessStatusCode();
  141. return await response.Content.ReadAsStringAsync(cancellationToken);
  142. }
  143. private static HttpClientHandler Handler(bool allowUntrustedCertificate) {
  144. var handler = new HttpClientHandler();
  145. if (allowUntrustedCertificate)
  146. handler.ServerCertificateCustomValidationCallback =
  147. (_, _, _, _) => true;
  148. return handler;
  149. }
  150. private static string Normalise(string host) {
  151. var trimmed = host.Trim().TrimEnd('/');
  152. if (trimmed.Contains("://", StringComparison.Ordinal))
  153. return trimmed;
  154. // A bare name gets the default scheme and port, but "pve.lan:8006" already
  155. // carries a port — appending another would make the URL unparseable.
  156. return trimmed.Contains(':', StringComparison.Ordinal)
  157. ? $"https://{trimmed}"
  158. : $"https://{trimmed}:8006";
  159. }
  160. }