Docker Remote API с аутентификацией по сертификату с проверкой отзыва

habr.png

Для нужд удаленного управления Docker’ом, Docker умеет предоставлять веб-API.
Это API может как вовсе не требовать аутентификации (что крайне не рекомендуется), так и использовать аутентификация по сертификату.

Проблема заключается в том, что родная аутентификация по сертификату не предусматривает проверку отзыва сертификата. И это может иметь серьезные последствия.

Я хочу рассказать как я решил эту проблему.

Для начала следует сказать что говорить я буду про Docker для Windows. Возможно в Linux все не так плохо, но сейчас не об этом.

Что мы имеем? У нас есть Docker, с вот таким конфигом:

{
    "hosts": ["tcp://0.0.0.0:2376", "npipe://"],
    "tlsverify": true,
    "tlscacert": "C:\\ssl\\ca.cer",
    "tlscert": "C:\\ssl\\server.cer",
    "tlskey": "C:\\ssl\\server.key"
}

Клиенты могут подключаться со своими сертификатами, но эти сертификаты не проверяются на предмет отзыва.

Идея решения проблемы заключается в том, чтобы написать свой прокси-сервис, который выступал бы в качестве посредника. Наш сервис будет установлен на том же сервере что и Docker, заберет себе порт 2376, будет общаться с Docker по //./pipe/docker_engine.

Недолго думая я создал ASP.NET Core проект и сделал простейшее проксирование:


Код простейшего прокси
app.Run(async (context) =>
{
    var certificate = context.Connection.ClientCertificate;
    if (certificate != null)
    {
        logger.LogInformation($"Certificate subject: {certificate.Subject}, serial: {certificate.SerialNumber}");
    }

    var handler = new ManagedHandler(async (host, port, cancellationToken) =>
    {
        var stream = new NamedPipeClientStream(".", "docker_engine", PipeDirection.InOut, PipeOptions.Asynchronous);
        var dockerStream = new DockerPipeStream(stream);

        await stream.ConnectAsync(NamedPipeConnectTimeout.Milliseconds, cancellationToken);
        return dockerStream;
    });

    using (var client = new HttpClient(handler, true))
    {
        var method = new HttpMethod(context.Request.Method);
        var builder = new UriBuilder("http://dockerengine")
        {
            Path = context.Request.Path,
            Query = context.Request.QueryString.ToUriComponent()
        };
        using (var request = new HttpRequestMessage(method, builder.Uri))
        {
            request.Version = new Version(1, 11);
            request.Headers.Add("User-Agent", "proxy");
            if (method != HttpMethod.Get)
            {
                request.Content = new StreamContent(context.Request.Body);
                request.Content.Headers.ContentType = new MediaTypeHeaderValue(context.Request.ContentType);
            }

            using (var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, context.RequestAborted))
            {
                context.Response.ContentType = response.Content.Headers.ContentType.ToString();
                var output = await response.Content.ReadAsStreamAsync();
                await output.CopyToAsync(context.Response.Body, 4096, context.RequestAborted);
            }
        }
    }
});

Этого оказалось достаточно для простых запросов GET и POST из Docker API. Но этого мало, т.к. для более сложных операций (требующий пользовательской ввод) Docker использует что-то похожее на WebSocket. Засада была в том, что Kestrel наотрез отказывался принимать запросы, которые приходили от Docker Client, мотивируя это тем, что в запросе с заголовком Connection: Upgrade не может быть тела. А оно было.

Пришлось отказаться от Kestrel и написать чуть больше кода. По сути — свой web сервер. Самостоятельно открывать порт, создавать TLS соединение, парсить HTTP заголовки, устанавливать внутреннее соединение с Docker и обмениваться потоками ввода-вывода. И это сработало.

Исходники можно посмотреть здесь.

Итак, приложение написано и надо бы его как-то запускать. Идея заключается в том, чтобы создать контейнер с нашим приложением, прокинуть внутрь npine:// и опубликовать порт 2376

Для сборки образа нам потребуется публичный сертификат центра сертификации (ca.cer), который будет выдавать сертификаты пользователям.

Этот сертификат будет установлен в доверенные корневые центры сертификации контейнера, в котором будет запущен наш прокси.

Установка его необходима для процедуры проверки сертификата.

Я не заморачивался написанием такого Docker-файла, который сам бы собирал приложение.
Поэтому его надо собрать самостоятельно. Из папки с dockerfile запускаем:

dotnet publish -c Release -o ..\publish .\DockerTLS\DockerTLS.csproj

Сейчас у нас должны быть: Dockerfile, publish, ca.cer. Собираем образ:

docker build -t vitaliyorg.azurecr.io/docker/proxy:1809 .
docker push vitaliyorg.azurecr.io/docker/proxy:1809

Разумеется, имя образа может быть любое.

Для запуска контейнера нам понадобятся сертификат сервера certificate.pfx и файл с паролем password.txt. Все содержимое файла считается паролем. Поэтому лишних переводов строк быть не должно.

Пусть все это добро находится в папке: c:\data на сервере, где установлен Docker.

На этом же сервере запускаем:

docker run --name docker-proxy -d -v "c:/data:c:/data" -v \\.\pipe\docker_engine:\\.\pipe\docker_engine --restart always -p 2376:2376 vitaliyorg.azurecr.io/docker/proxy:1809

С помощью docker logs можно видеть кто что делал. Там же можно видеть попытки подключения, которые завершились неудачно.

© Habrahabr.ru