Многие компании используют сертификаты, подписанные внутренними удостоверяющими центрами (Certificate Authority) для ресурсов в приватных сетях. Поскольку такие сертификаты по умолчанию не могут быть доверенными, почти на каждом этапе вокруг пайплайна могут возникать ошибки такого рода: x509 certificate signed by unknown authority. Из-за этого до каждого компонента необходимо доставлять корневые сертификаты, используемые в компании. В статье расскажу, как это можно сделать.


Статья подготовлена на основе моего материала в курсе «CI/CD на примере Gitlab CI».


Обратите внимание: настройка инфраструктуры выходит за рамки этой статьи, поэтому в примерах, где не идёт речи о динамическом получении сертификата, я ограничился предположением, что на серверах компании внутренний корневой сертификат всегда расположен по пути /usr/local/share/ca-certificates/RootCA.crt.


Поскольку курс в основе этого материала строится вокруг Gitlab-CI, то и нижеследующие разделы рассматривают доставку сертификатов до тех или иных этапов или компонентов, входящих в пайплайн, построенный в гитлабе с раннерами, запущенными внутри корпоративной инфраструктуры. Поехали!


gitlab-runner


Передача корневого сертификата в управляющий компонент gitlab-runner — наиболее простая задача. gitlab-runner открывает HTTPS-соединения только с самим сервером GitLab и, возможно, с кластером Kubernetes. Однако последний явным образом требует указания своего корневого сертификата. Для верификации подлинности сервера GitLab используется сертификат, указанный в config.toml:


[[runners]]
  executor = "kubernetes"
  name = "Kubernetes Runner"
  tls-ca-file = "/usr/local/share/ca-certificates/RootCA.crt" # <===

Если gitlab-runner запущен непосредственно на хосте, этого достаточно. Если он запускается в докер-контейнере, в контейнер нужно смонтировать сертификат с хоста:


docker run   ...
  -v /usr/local/share/ca-certificates:/usr/local/share/ca-certificates:ro   ...
  gitlab/gitlab-runner

Наконец, если он запускается в Kubernetes, один из вариантов — монтировать директорию с хоста


В спецификации контейнера:


volumeMounts:
- mountPath: /usr/local/share/ca-certificates
  name: certs

В спецификации пода:


volumes:
- hostPath:
    path: /usr/local/share/ca-certificates
  name: certs

Если нет прав на монтирование hostPath, сертификат придётся положить в Kubernetes как секрет и монтировать его оттуда, поменяв содержимое ключа volumes. Например, если секрет создан командой


 kubectl create secret generic -n runner-namespace certs --from-file=/usr/local/share/ca-certificates

ключ volumes будет выглядеть так:


volumes:
- secret:
    secretName: certs
  name: certs

gitlab-runner helper image


gitlab-runner-helper — вспомогательная утилита, которая используется в пайплайнах для клонирования репозитория и работы с кэшем и артефактами. Обычно проблемы с сертификатами возникают тогда, когда используется собственное S3-хранилище (например, на базе ceph или minio). Как правило, происходит обращение к S3-хранилищу по HTTPS, и сервер S3 представляется корпоративным сертификатом. Единственный способ доставить туда сертификаты — пересобрать образ.


Helper image используется в тандеме с docker или kubernetes экзекьюторами. Чтобы настроить использование кастомного образа, укажите следующие строки в config.toml:


[[runners]]
  (...)
  executor = "docker" # (или kubernetes)
  [runners.docker] # (или kubernetes)
    (...)
    helper_image = "my.registry.local/gitlab/gitlab-runner-helper:tag"

Пайплайны


Действия выше выполняются относительно редко, однако если используется Docker или Kubernetes экзекьютор, то каждый джоб будет выполняться в свежесозданном контейнере, в котором снова нет нужных сертификатов. Поэтому необходим способ динамически доставлять сертификаты в контейнеры. Есть несколько таких способов:


  • Монтировать с хостов.
  • Получать из источника, их создающего.
  • Создать сервер, предоставляющий сертификаты.
  • Использовать образы со вшитыми сертификатами.

Монтирование с хостов


Если прописать в config.toml


[[runners]]
  executor = "kubernetes"
  (...)
  [runners.kubernetes]
    (...)
    [runners.kubernetes.volumes]
      [[runners.kubernetes.volumes.host_path]]
        host_path = "/usr/local/share/ca-certificates"
        mount_path = "/usr/local/share/ca-certificates"
        name = "certs-volume"
        read_only = true

то сертификаты будут монтироваться в основные и сервисные контейнеры запускаемых джобов. В случае с докер-экзекьютором аналогичный результат можно получить так:


[runners.docker]
  (...)
  volumes = ["/usr/local/share/ca-certificates:/usr/local/share/ca-certificates:ro"]

Минус подхода в том, что ответственность за актуальность сертификатов сдвигается на конфигурацию инфраструктуры, поэтому подход удобен, если вы этой инфраструктурой управляете. Если же вы, например, работаете в отдельной команде, которая в многопользовательском кластере Kubernetes сама подняла свой раннер, но не имеет админского доступа туда, вы не можете быть уверены в актуальности сертификатов на хосте, да и можете вовсе не иметь прав на монтирование hostPath. Тогда можно аналогично примеру для gitlab-runner создать в кубе секрет, содержащий ключи (имена файлов сертификатов) и значения (их содержимое):


 kubectl create secret generic -n runner-namespace certs --from-file=/usr/local/share/ca-certificates

а config.toml исправить следующим образом:


[[runners]]
  executor = "kubernetes"
  (...)
  [runners.kubernetes]
    (...)
    [runners.kubernetes.volumes]
      [[runners.kubernetes.volumes.secret]]
        name = "certs"
        mount_path = "/usr/local/share/ca-certificates"
        read_only = true

Если используется докер-экзекьютор на недоступном вам build-сервере, из этого тут же следует, что у вас нет доступа к конфигурации раннера, если только не используется экзотичный вариант подключения к сокету докер-демона по tcp (подобный доступ почти всегда означает, что и доступ к хосту получить тривиально). Лучше всего договориться с администраторами build-сервера о гарантированном наличии свежих сертификатов в определённой директории, которая будет монтироваться в контейнеры пайплайна.


Один из плюсов такого подхода в том, что можно делать следующее:


build:
  stage: build
  image: docker:stable
  services:
    - name: docker:18.09.6-dind
      entrypoint:
        - /bin/sh
      command:
        - -c
        - update-ca-certificates && dockerd-entrypoint.sh

Ключевой шаг, естественно, update-ca-certificates.


Поскольку для сервисных контейнеров нельзя прописать последовательность команд аналогично script, а писать длинные скрипты для установки сертификатов под ключом command не очень удобно, меньшим из зол является подобное монтирование сертификатов и сравнительно короткая последовательность команд.


Отступление про сборку docker-образов


Если вы в пайплайнах собираете докер-образы, то, независимо от того, используется docker-in-docker, kaniko или другой сборщик в пайплайне, в контексте сборки сертификаты снова отсутствуют, если их не вшили в базовый образ. Предположим, что каким-либо образом в контейнер пайплайна сертификаты были доставлены (либо примонтированы с хоста, либо получены из другого источника командами в before_script). Тогда конфигурация пайплайнов должна будет выглядеть так:


build:
  stage: build
  # допустим, сборка докером
  image: docker:stable
  services:
    - name: docker:18.09.6-dind
      entrypoint:
        - /bin/sh
      command:
        - -c
        - update-ca-certificates && dockerd-entrypoint.sh
  # before_script:
  #   - script_to_get_certs.sh
  script:
    - mkdir -p certs
    - cp /usr/local/share/ca-certificates/* certs/
    - docker build (...) # или канико или что угодно ещё
    - (...)

а в докер-файлах придётся всегда указывать что-нибудь вроде


FROM base-image

COPY certs/* /usr/local/share/ca-certificates/

RUN update-ca-certificates

RUN wget https://artifacts.company.ru/some/artifact # команда, которой требуется проверить сертификат

RUN go get github.com/golang/package # команда, которой может потребоваться доверять сертификату MITM-proxy
(...)

Эти добавления нужны, если во время сборки происходят обращения к внутренним сервисам, которые представляются корпоративными сертификатами. Кроме того, бывает, что выход в интернет осуществляется через MITM-proxy, который для инспекции трафика расшифровывает передаваемые данные и, соответственно, подменяет сертификат. Тогда, если во время сборки нужно обращаться в интернет, например, для доставки каких-либо пакетов, нужно доверять сертификату, который используется MITM-proxy. Поэтому для сборок в начале каждого докер-файла может потребоваться добавлять эти строки:


COPY certs/* /usr/local/share/ca-certificates/

RUN update-ca-certificates

Получать сертификаты из первоисточника


Если источник корневых сертификатов предоставляет удобный API для их получения, можно не утруждать себя хранением сертификатов на своих серверах с последующим монтированием в пайплайны. Допустим, корневой сертификат можно получить командой


curl http://pki.company.ru/api?name=RootCA -O /usr/local/share/ca-certificates/RootCA.crt

Пример со сборкой докер-образа примет такой вид:


build:
  stage: build
  # допустим, сборка докером
  image: docker:stable
  services:
    - name: docker:18.09.6-dind
      entrypoint:
        - /bin/sh
      command:
        - -c
        - curl http://pki.company.ru/api?name=RootCA -O /usr/local/share/ca-certificates/RootCA.crt && update-ca-certificates && dockerd-entrypoint.sh
  # before_script:
  #   - script_to_get_certs.sh
  script:
    - mkdir -p certs
    - curl http://pki.company.ru/api?name=RootCA -O /usr/local/share/ca-certificates/RootCA.crt
    - cp /usr/local/share/ca-certificates/* certs/
    - docker build (...) # или канико или что угодно ещё
    - (...)

То есть добавился шаг скачивания сертификатов.


Получать сертификаты из самописного сервиса


Если API центра сертификации недостаточно функционален или требует авторизации, которая в пайплайнах слишком громоздкая, можно запустить простейший веб-сервер, откуда сертификаты можно будет скачать почти таким же образом, как в предыдущем примере. Принципиального отличия от получения их из первоисточника нет, поэтому рассмотрим последний, наиболее эффективный вариант.


Использовать образы со вшитыми сертификатами


Рано или поздно многие компании обнаруживают потребность контролировать свою инфраструктуру. Админам надоедает отвечать на одинаковые вопросы разработчиков («почему у меня не качает с гитхаба?, «что значит x509?»), безопасникам надоедает наблюдать за тем, как все докер-файлы начинаются со строчки FROM vasyapupkin/zvercd, а разработчикам надоедает решать головоломки с изменчивыми промежуточными сертификатами и прочими вещами вне их области экспертизы. Тогда ответственные за платформу идут и пишут много-много докер-файлов наподобие такого:


FROM python:3.7

RUN curl http://pki.company.ru/api?name=RootCA -O /usr/local/share/ca-certificates/RootCA.crt     && update-ca-certificates

Эти образы сохраняются в корпоративный репозиторий, а разработчики компании начинают пользоваться только этими образами в качестве базовых. Дополнительным бонусом идёт возможность преднастроить во внутренних образах всевозможные кэши и проксирующие репозитории (nexus, artifactory и прочие).


Наличие базовых образов не отменяет необходимость получать сертификаты динамически, любым из остальных перечисленных способов. Это нужно инженерам, поддерживающим актуальность корпоративных базовых образов, чтобы своевременно обновлять в них сертификаты или создавать новые базовые образы.


Совместив два-три описанных подхода вы, наконец, сможете спать спокойно и не объяснять всем в сотый раз, что означает x509: certificate signed by unknown authority.