DockerApiClient.cs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. using System.Net.Sockets;
  2. namespace RackPeek.Domain.Discovery;
  3. /// <summary>
  4. /// Talks to the Docker Engine API over either a unix socket or TCP. Podman's socket
  5. /// speaks the same API, so <c>--docker-host unix:///run/user/1000/podman/podman.sock</c>
  6. /// works without anything extra.
  7. /// </summary>
  8. public sealed class DockerApiClient : IDockerClient, IDisposable {
  9. public const string DefaultSocketPath = "/var/run/docker.sock";
  10. private readonly HttpClient _httpClient;
  11. public DockerApiClient(string? dockerHost = null) {
  12. var host = string.IsNullOrWhiteSpace(dockerHost)
  13. ? Environment.GetEnvironmentVariable("DOCKER_HOST")
  14. : dockerHost;
  15. (_httpClient, Endpoint) = Create(host);
  16. _httpClient.Timeout = TimeSpan.FromSeconds(30);
  17. }
  18. public string Endpoint { get; }
  19. /// <summary>
  20. /// Whether the endpoint is a socket on this machine. Over TCP the engine is some
  21. /// other machine, so facts probed locally must not be attributed to it.
  22. /// </summary>
  23. public bool IsLocal => Endpoint.StartsWith("unix://", StringComparison.OrdinalIgnoreCase);
  24. /// <summary>
  25. /// The host part of a TCP endpoint — where the containers actually live — or null
  26. /// for a local socket. A name or an address, exactly as the user dialled it.
  27. /// </summary>
  28. public string? RemoteHost => IsLocal
  29. ? null
  30. : new Uri(Endpoint.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase)).DnsSafeHost;
  31. public void Dispose() => _httpClient.Dispose();
  32. public async Task<IReadOnlyList<DockerContainer>> ListContainersAsync(
  33. CancellationToken cancellationToken = default) {
  34. using HttpResponseMessage response =
  35. await _httpClient.GetAsync("/containers/json", cancellationToken);
  36. response.EnsureSuccessStatusCode();
  37. var json = await response.Content.ReadAsStringAsync(cancellationToken);
  38. return DockerContainerParser.Parse(json);
  39. }
  40. public async Task<DockerEngineInfo?> GetInfoAsync(CancellationToken cancellationToken = default) {
  41. try {
  42. using HttpResponseMessage response = await _httpClient.GetAsync("/info", cancellationToken);
  43. if (!response.IsSuccessStatusCode)
  44. return null;
  45. return DockerEngineInfoParser.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
  46. }
  47. catch (Exception ex) when (
  48. ex is HttpRequestException or IOException or TimeoutException
  49. || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) {
  50. // /info being unreadable never fails discovery; the caller degrades instead.
  51. return null;
  52. }
  53. }
  54. /// <summary>
  55. /// The address the user dialled, as IPv4: taken verbatim when it is a literal,
  56. /// resolved once when it is a name. The inventory schema holds IPv4 only, so an
  57. /// IPv6-only endpoint yields null and the caller falls back.
  58. /// </summary>
  59. public static async Task<string?> ResolveIpv4Async(string host, CancellationToken cancellationToken = default) {
  60. if (System.Net.IPAddress.TryParse(host, out System.Net.IPAddress? literal))
  61. return literal.AddressFamily == AddressFamily.InterNetwork ? host : null;
  62. try {
  63. System.Net.IPAddress[] addresses = await System.Net.Dns.GetHostAddressesAsync(host, cancellationToken);
  64. return addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)?.ToString();
  65. }
  66. catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException) {
  67. // Unresolvable, malformed, or no DNS on this platform (the browser console):
  68. // the address is a nicety, never worth failing discovery over.
  69. return null;
  70. }
  71. }
  72. private static (HttpClient Client, string Endpoint) Create(string? dockerHost) {
  73. if (string.IsNullOrWhiteSpace(dockerHost))
  74. return (UnixSocketClient(DefaultSocketPath), $"unix://{DefaultSocketPath}");
  75. if (dockerHost.StartsWith("unix://", StringComparison.OrdinalIgnoreCase)) {
  76. var path = dockerHost["unix://".Length..];
  77. return (UnixSocketClient(path), dockerHost);
  78. }
  79. // tcp:// is the scheme people have in DOCKER_HOST, but it is plain HTTP on the wire.
  80. var uri = new Uri(dockerHost.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase));
  81. return (new HttpClient { BaseAddress = uri }, dockerHost);
  82. }
  83. private static HttpClient UnixSocketClient(string socketPath) {
  84. var handler = new SocketsHttpHandler {
  85. ConnectCallback = async (_, cancellationToken) => {
  86. var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
  87. try {
  88. await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), cancellationToken);
  89. return new NetworkStream(socket, true);
  90. }
  91. catch {
  92. socket.Dispose();
  93. throw;
  94. }
  95. }
  96. };
  97. // The host part is ignored for a unix socket but HttpClient still requires one.
  98. return new HttpClient(handler) { BaseAddress = new Uri("http://localhost") };
  99. }
  100. }