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

    Описание проблемы


    Для нужд удаленного управления 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


    Сборка Docker образа


    Для сборки образа нам потребуется публичный сертификат центра сертификации (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 можно видеть кто что делал. Там же можно видеть попытки подключения, которые завершились неудачно.

    Поделиться публикацией

    Комментарии 6

      0
      Засунуть за какой-либо популярный веб-прокси типа nginx — не рассматривался такой вариант?
      Касательно клиентских сертификатов. Видится интеграция с чем-то типа www.keycloak.org
        0
        Возможно вы правы и можно как-то использовать проверенный nginx вместо моего костыля. Однако, я обнаружил что Docker слегка нарушает HTTP протокол. Поэтому я не искал путей «стандартного» проксирования, достал Visual Studio и начал писать.
          0
          Нарушает в чем? Upgrade connection? Это стандартная история для веб-сокетов, либо я Вас не понимаю

          Кстати, пять минут гугления и вот — github.com/srault95/docker-proxy-api

          Поясню, что Ваш велосипед не плохой, это очень круто в целях саморазвития. Может даже удастся какое-то готовое решение собрать (коробочное). И, например, продавать его.
          Я просто за kiss :-) и разные подходы
            0
            Посылая запрос с Connection: Upgrade, docker в довесок отправляет дополнительные данные, что насколько я понял делать нельзя. Обмениваться данными можно после рукопожатия.
              0
              Начальная цель была не в проксировании, а в проверке отзыва сертификата.
              Проксирование в моем случае просто было средством достижения цели.

              Как я писал в после, я использую Windows в качестве ОС.
              Можно ли заставить nginx в качестве upstream'а использовать не tcp-сокет, а npipe? Вводя новый элемент в систему (прокси), хочется убрать лишнее. А именно факт наличия незащищенного tcp-сокета.

              PS: не спора ради, а для поиска альтернативного решения.
            0
            -

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое