using System.Net.Sockets; namespace RackPeek.Domain.Discovery; /// /// Talks to the Docker Engine API over either a unix socket or TCP. Podman's socket /// speaks the same API, so --docker-host unix:///run/user/1000/podman/podman.sock /// works without anything extra. /// public sealed class DockerApiClient : IDockerClient, IDisposable { public const string DefaultSocketPath = "/var/run/docker.sock"; private readonly HttpClient _httpClient; public DockerApiClient(string? dockerHost = null) { var host = string.IsNullOrWhiteSpace(dockerHost) ? Environment.GetEnvironmentVariable("DOCKER_HOST") : dockerHost; (_httpClient, Endpoint) = Create(host); _httpClient.Timeout = TimeSpan.FromSeconds(30); } public string Endpoint { get; } /// /// Whether the endpoint is a socket on this machine. Over TCP the engine is some /// other machine, so facts probed locally must not be attributed to it. /// public bool IsLocal => Endpoint.StartsWith("unix://", StringComparison.OrdinalIgnoreCase); /// /// The host part of a TCP endpoint — where the containers actually live — or null /// for a local socket. A name or an address, exactly as the user dialled it. /// public string? RemoteHost => IsLocal ? null : new Uri(Endpoint.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase)).DnsSafeHost; public void Dispose() => _httpClient.Dispose(); public async Task> ListContainersAsync( CancellationToken cancellationToken = default) { using HttpResponseMessage response = await _httpClient.GetAsync("/containers/json", cancellationToken); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(cancellationToken); return DockerContainerParser.Parse(json); } public async Task GetInfoAsync(CancellationToken cancellationToken = default) { try { using HttpResponseMessage response = await _httpClient.GetAsync("/info", cancellationToken); if (!response.IsSuccessStatusCode) return null; return DockerEngineInfoParser.Parse(await response.Content.ReadAsStringAsync(cancellationToken)); } catch (Exception ex) when ( ex is HttpRequestException or IOException or TimeoutException || (ex is TaskCanceledException && !cancellationToken.IsCancellationRequested)) { // /info being unreadable never fails discovery; the caller degrades instead. return null; } } /// /// The address the user dialled, as IPv4: taken verbatim when it is a literal, /// resolved once when it is a name. The inventory schema holds IPv4 only, so an /// IPv6-only endpoint yields null and the caller falls back. /// public static async Task ResolveIpv4Async(string host, CancellationToken cancellationToken = default) { if (System.Net.IPAddress.TryParse(host, out System.Net.IPAddress? literal)) return literal.AddressFamily == AddressFamily.InterNetwork ? host : null; try { System.Net.IPAddress[] addresses = await System.Net.Dns.GetHostAddressesAsync(host, cancellationToken); return addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork)?.ToString(); } catch (Exception ex) when (ex is SocketException or ArgumentException or PlatformNotSupportedException) { // Unresolvable, malformed, or no DNS on this platform (the browser console): // the address is a nicety, never worth failing discovery over. return null; } } private static (HttpClient Client, string Endpoint) Create(string? dockerHost) { if (string.IsNullOrWhiteSpace(dockerHost)) return (UnixSocketClient(DefaultSocketPath), $"unix://{DefaultSocketPath}"); if (dockerHost.StartsWith("unix://", StringComparison.OrdinalIgnoreCase)) { var path = dockerHost["unix://".Length..]; return (UnixSocketClient(path), dockerHost); } // tcp:// is the scheme people have in DOCKER_HOST, but it is plain HTTP on the wire. var uri = new Uri(dockerHost.Replace("tcp://", "http://", StringComparison.OrdinalIgnoreCase)); return (new HttpClient { BaseAddress = uri }, dockerHost); } private static HttpClient UnixSocketClient(string socketPath) { var handler = new SocketsHttpHandler { ConnectCallback = async (_, cancellationToken) => { var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); try { await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), cancellationToken); return new NetworkStream(socket, true); } catch { socket.Dispose(); throw; } } }; // The host part is ignored for a unix socket but HttpClient still requires one. return new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }; } }