The Jerusalem PostSwiss voters reject tighter neutrality rules that would have curbed NATO cooperationESPN DeportesGil Mora cumple el deseo de Javier Aguirre en debut de Rafa MárquezESPNCoughlin first in LPGA history to open round with 9 straight birdiesInquirerTyphoon Queenie exits PARPunchPolice arrest two over kidnapping of 38-year-old man in OyoRTP DesportoPiloto italiano Nicolò Bulega conquista título mundial de SuperbikesDaily MaverickOWN GOAL: Manchester City’s future in the air after reports of Premier League guilty verdictColliderGuy Ritchie's Fantasy Epic That Bombed at the Box Office Officially Finds Retribution on Free StreamingSRF NewsAbstimmung Kanton Graubünden – Nein zum Stimmrechtsalter 16: «Eine verpasste Chance» für Junge20 MinutenVerletzte sich Mbappé wegen On-Schuhen? Widerspruch grossThe Hollywood ReporterAnna Kendrick Is “Over the Moon” About Directing ‘The Seven Husbands of Evelyn Hugo’Observador DesportoAbusos sexuais na Igreja. Associações acompanham Papa
The Daily Newsstand · Free, Always
Sunday, September 27, 2026

Docker для параноика: организация SSH-доступа для rootless-контейнера без передачи приватного ключа

Translate

Предисловие

SSH-доступ используется повсеместно: для работы с репозиториями, серверами, автоматизации и множества других задач. Но как часто мы задумываемся о том, что доступный приложению приватный ключ может быть украден?

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

Даже если мы доверяем собственному ПО, в нём и его зависимостях могут появиться уязвимости. При компрометации процесса доступные ему секреты тоже оказываются под угрозой.

Особенно остро эта проблема стоит в системах с ИИ-агентами. Между агентом и инфраструктурой часто используется дополнительный API-слой — например, MCP-сервер, — но и он может содержать уязвимости.

В статье разберём архитектуру, в которой процесс может полноценно пользоваться SSH-доступом, но возможность получить сам приватный ключ исключается на уровне архитектуры.

Основная идея.

  • Процесс может использовать выданные ему SSH-права для доступа к разрешённым ресурсам.

  • При этом процесс не получает доступ к приватному SSH-ключу, который используется для аутентификации.

  • Даже при компрометации процесса внутри контейнера приватный SSH-ключ не должен становиться ему доступен.

Дополнительно контейнер запускается в rootless-режиме. В такой конфигурации схема становится сложнее, поэтому отдельно разберём возникающие при настройке проблемы и способы их решения.

Как построена статья.

Материал построен последовательно.

  • Сначала разберём, зачем контейнеру может понадобиться SSH.

  • Затем рассмотрим сопоставление UID/GID в rootless Docker и разберём, почему обычное монтирование SSH_AUTH_SOCK не работает для процесса с non-root UID.

  • После этого рассмотрим минимальные примеры конфигурации ssh-agent и целевого контейнера.

  • В конце проверим работу схемы, разберём особенности bind mount и границы защиты.

Подробное описание того, как устроены пользователи в Unix-подобных системах, как работают процессы, Unix-сокеты и изоляция в Docker, выходит за рамки этой статьи.

Эти темы здесь рассматриваются только в той мере, которая необходима для понимания и реализации описанной схемы. По ходу статьи будут даны краткие пояснения основных терминов и механизмов.

Настройка rootless Docker и дополнительные меры защиты.

Предполагается, что rootless Docker и пользователь, от имени которого он работает, уже настроены.

Статья не рассматривает дополнительные меры защиты, такие как ограничение исходящего сетевого трафика (egress), настройка firewall, proxy или network policy, а также изоляция через виртуальные машины и microVM. Эти меры могут дополнять описанную схему, но решают отдельные задачи.

Введение

Контейнеру может требоваться полноценный SSH-доступ: клонировать приватный Git-репозиторий, выполнить git fetch, подключиться к серверу или запустить другую программу, использующую OpenSSH.

Простой вариант — поместить приватный ключ внутрь контейнера или примонтировать ~/.ssh:

docker run \
    -v ~/.ssh:/home/app/.ssh:ro \
    image-name

Такой вариант работает, но нарушает изоляцию учётных данных:

Процесс внутри контейнера получает непосредственный доступ к приватному ключу. Монтирование только для чтения защищает файл от изменения, но не от чтения: приложение может прочитать ключ и передать его за пределы контейнера.

В этой статье рассматривается конфигурация со следующими требованиями и ограничениями:

  • Docker daemon должен работать в режиме rootless, без root-привилегий на хосте;

  • процесс внутри контейнера не работает от root;

  • у контейнера удалены Linux capabilities;

  • запрещено получение новых привилегий;

  • корневая файловая система контейнера доступна только для чтения;

  • приватный ключ отсутствует в пространстве имён монтирования контейнера;

  • при работе SSH запрещаем использовать StrictHostKeyChecking=no;

  • приватный ключ не должен передаваться контейнеру в доступном для чтения виде:

    • простая передача приватного ключа из хранилища секретов не подходит: материал ключа всё равно становится доступен процессу контейнера;

    • переменные окружения не подходят для передачи приватного ключа: значение становится частью окружения процесса и доступно этому процессу;

    • монтирование приватного ключа с хоста делает его доступным для чтения процессу контейнера.

Схема:

В контейнер передаётся не ключ, а Unix-сокет ssh-agent. Далее работа с SSH ведётся через SSH-агент без прямого доступа к материалу приватного ключа.

Для работы с сокетом OpenSSH использует переменную SSH_AUTH_SOCK.

SSH_AUTH_SOCK — стандартный механизм OpenSSH для доступа клиента к ssh-agent. Однако при работе с контейнерами в режиме rootless Docker возникают технические сложности, давайте их разберём.

Основная техническая проблема такой схемы связана с UID:

UID процесса внутри rootless-контейнера не совпадает с его UID на хосте.

Но сначала определим, для чего контейнеру нужен SSH, и рассмотрим альтернативное решение.

Зачем нужен SSH в контейнере

Типичные сценарии

Типичные случаи, когда контейнеру может потребоваться SSH-доступ:

  • работа с приватными Git-репозиториями по SSH;

  • выполнение команд на удалённых серверах;

  • передача файлов через scp или rsync;

  • автоматизированное развёртывание и обслуживание серверов;

  • доступ к зависимостям и Git-сабмодулям через SSH;

  • работа инструментов, которые используют OpenSSH и SSH_AUTH_SOCK как штатный механизм аутентификации.

Альтернативное решение

Альтернативная архитектура — вообще не предоставлять контейнеру SSH-доступ, а вынести работу с Git или другими внешними системами в отдельный API-сервис.

Например, для ИИ-Агентов промежуточным слоем может быть MCP-сервер.

Однако такой сервис не всегда является полной заменой локальному git.

Если приложение работает с собственной рабочей копией, например /container/workspace, то при переносе файловых и Git-операций во внешний сервис возникают две независимые файловые системы:

Необходимо отдельно синхронизировать состояние файлов. Один из вариантов — предоставить обоим компонентам общую рабочую копию через volume.

Однако у такого решения появляется дополнительная граница доверия.

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

Если контейнер и MCP-сервис используют одну доступную на запись рабочую копию, процесс внутри контейнера может изменить её содержимое. Если MCP-сервис запускает Git hooks из этой копии, изменённый hook может выполниться в окружении сервиса с его правами доступа, включая доступные ему секреты.

Этот сценарий применим только тогда, когда сервис использует общую рабочую копию, запускает Git hooks и не изолирует их выполнение. Если hooks отключены или выполняются в изолированной среде без секретов, такой сценарий не реализуется.

При использовании стороннего MCP-сервиса его реализация и модель доступа становятся дополнительной границей доверия, которую необходимо отдельно оценить.

В рассматриваемом сценарии контейнеру нужен именно локальный OpenSSH с доступом к собственной рабочей копии.

Далее возвращаемся к схеме с отдельным ssh-agent и разбираем её основную техническую проблему — сопоставление UID.

Как rootless Docker изменяет UID

Перед тем как приступить к настройке ssh-agent, давайте совсем немного разберёмся в теории.

Теория: пользовательские пространства имён и сопоставление UID/GID

В Linux идентификатор пользователя (UID) является числом, которое ядро использует для проверки прав доступа. В конфигурации без user namespace UID процесса внутри контейнера численно совпадает с его UID на хосте. В rootless Docker используется user namespace, поэтому один и тот же процесс имеет разные UID внутри контейнера и на хосте.

Например, процесс с UID 1000 внутри контейнера может быть представлен на хосте как UID 100999. Это не новый пользователь в /etc/passwd хоста, а результат преобразования пространства имён.

Важно различать:

  • UID внутри контейнера — идентификатор процесса в контейнерном окружении;

  • UID на хосте — идентификатор того же процесса, каким его видит хост;

  • /etc/subuid и /etc/subgid — диапазоны идентификаторов, которые Docker может использовать для такого преобразования.

Для ssh-agent важен именно UID на стороне хоста, потому что проверка клиента Unix-сокета выполняется ядром и процессом ssh-agent в окружении хоста.

В обычной конфигурации Docker daemon работает с root-привилегиями.

В режиме rootless Docker запускает daemon и контейнеры внутри пользовательского пространства имён от непривилегированного пользователя хоста. Docker рекомендует этот режим как один из способов уменьшить последствия компрометации daemon или среды выполнения.

Пользовательское пространство имён изменяет соответствие UID между контейнером и хостом.

  • В примерах rootless Docker будет запускаться из-под пользователя rootless-docker.

  • А контейнеры будут работать от пользователя app.

Вот так могут выглядеть UID/GID у пользователя на хосте и в контейнере:

host  UID 999 (rootless-docker) → container UID 0 (root)
host  GID 999 → container GID 0 (root)

Вот так могут выглядеть UID/GID у пользователя app на хосте и в контейнере:

container UID 1000 (app) → host UID 100999 (UNDEFINED)
container GID 1000 (app) → host GID 100999 (UNDEFINED)

Так можно посмотреть текущее сопоставление внутри контейнера:

cat /proc/self/uid_map

Предположим, у нас много пользователей. Вот такие UID они будут получать:

container UID 0     → host UID 999
container UID 1     → host UID 100000
container UID 2     → host UID 100001
...
container UID 1000  → host UID 100999

Проглядывается формула для UID N >= 1:

host UID = subuid_start + N - 1

Docker использует такую схему в rootless-режиме: UID 0 контейнера отображается на пользователя, запустившего rootless Docker, а остальные UID берутся из subordinate UID range. Для GID применяется аналогичный механизм.

Диапазоны определяются файлами /etc/subuid и /etc/subgid для пользователей и групп соответственно.

Пример файла /etc/subuid:

rootless-docker:100000:65536
john:165536:65536
ci-runner:231072:65536

Подробнее: https://docs.docker.com/engine/security/rootless/uid-gid-mapping/.

Как вычислить UID на хосте, зная его UID в контейнере

Чтобы правильно настроить сокет ssh-agent, нужно заранее определить, какому пользователю контейнера он долженсоответствовать, и вычислить соответствующие UID/GID на хосте.

ROOTLESS_USER=rootless-docker
ROOTLESS_UID=$(id -u "$ROOTLESS_USER")

# Получить UID/GID из контейнера через rootless Docker daemon нужного пользователя
CONTAINER_UID=$(sudo -H -u "$ROOTLESS_USER" env DOCKER_HOST="unix:///run/user/$ROOTLESS_UID/docker.sock" docker run --rm image-name id -u app)
CONTAINER_GID=$(sudo -H -u "$ROOTLESS_USER" env DOCKER_HOST="unix:///run/user/$ROOTLESS_UID/docker.sock" docker run --rm image-name id -g app)

# Получить первый UID/GID из настроек диапазонов для нужного пользователя
# Пример: возьмёт `100000` из строки `rootless-docker:100000:65536`
SUBUID_START=$(awk -F: -v user="$ROOTLESS_USER" '$1 == user { print $2; exit }' /etc/subuid)
SUBGID_START=$(awk -F: -v user="$ROOTLESS_USER" '$1 == user { print $2; exit }' /etc/subgid)

echo "host UID: $((SUBUID_START + CONTAINER_UID - 1))"
echo "host GID: $((SUBGID_START + CONTAINER_GID - 1))"

При SUBUID_START=100000 и SUBGID_START=100000 результат будет следующим:

UID: 1000 -> 100999
GID: 1000 -> 100999

В дальнейшем 100999 будет использоваться как UID и GID процесса ssh-agent на хосте.

В системе автоматизации эти UID/GID следует рассчитывать автоматически. Изменять уже используемые диапазоны /etc/subuid и /etc/subgid следует только с учётом существующих файлов и запущенных контейнеров.

Если изменить UID/GID пользователя внутри контейнера или диапазоны в /etc/subuid и/или /etc/subgid, числовые UID/GID уже созданных на хосте файлов не изменятся, но изменится их соответствие владельцу внутри контейнера. В результате контейнер может перестать воспринимать эти файлы как принадлежащие ожидаемому пользователю.

Теперь мы знаем, каким UID процесс контейнера представлен на хосте. Это позволяет понять, почему сокет обычного ssh-agent пользователя rootless-docker нельзя просто примонтировать в контейнер.

Почему прямое монтирование SSH_AUTH_SOCK не работает для non-root UID

Обычный пример передачи ssh-agent в Docker выглядит так:

docker run \
    -e SSH_AUTH_SOCK=/ssh-agent \
    -v "$SSH_AUTH_SOCK:/ssh-agent" \
    image-name

В конфигурациях без пользовательского пространства имён этого часто достаточно.

SSH-клиент внутри rootless-контейнера работает от UID 1000, который на хосте представлен UID 100999.

Предположим, что ssh-agent на хосте запущен пользователем rootless-docker с UID = 999, тогда:

С точки зрения хоста это разные пользователи. В результате ssh-agent отклонит соединение из-за несовпадения UID.

При этом недостаточно открыть файловый доступ к сокету всем пользователям, например:

# Небезопасный диагностический пример: не используйте такой режим в рабочей конфигурации
chmod 0666 agent.sock

После этого системный вызов connect() к Unix-сокету может пройти успешно, но ssh-add -l всё равно может завершиться ошибкой. ssh-agent проверяет UID подключившегося процесса на стороне хоста. Если UID не совпадает с UID процесса ssh-agent и клиент не имеет EUID 0, агент отклонит соединение.

Поэтому вместо расширения прав сокета необходимо согласовать UID клиента и ssh-agent:

Для согласования UID запустим отдельный ssh-agent с mapped UID контейнерного пользователя и предоставим контейнеру только каталог с его сокетом.

Настройка SSH-агента на хосте

Запуск ssh-agent с конкретным UID

systemd может запустить процесс с числовым UID 100999 без записи для него в /etc/passwd. Однако при использовании User=100999 все команды unit-файла, включая ExecStartPost, также будут выполняться от этого UID.

Поэтому unit остаётся root-службой, а только сам ssh-agent запускается с mapped UID с помощью setpriv. Это позволяет ExecStartPost прочитать приватный ключ, принадлежащий root.

setpriv \
    --reuid=100999 \
    --regid=100999 \
    --clear-groups --inh-caps=-all --no-new-privs \
    /usr/bin/ssh-agent ...

setpriv запускает процесс с заданными UID/GID без создания отдельного пользователя или пользовательской сессии.

Используем --clear-groups, --inh-caps=-all и --no-new-privs, чтобы убрать дополнительные группы и не передать процессу лишние привилегии.

Например, если запускающий пользователь состоит в группе docker, процесс формально сможет обращаться к /var/run/docker.sock, если права сокета это позволяют. ssh-agent этого штатно не делает, но при уязвимости или подмене процесса такие права уже становятся важны.

Цепочка запуска выглядит так:

Определим переменные

Определим переменные, которые будут использоваться в конфигурации и для наглядности в последующих примерах:

BASE=/opt/ssh-access
IDENTITY=git

# Основной пользователь для работы с rootless Docker
ROOTLESS_USER=rootless-docker
ROOTLESS_UID=$(id -u "$ROOTLESS_USER")
ROOTLESS_GID=$(id -g "$ROOTLESS_USER")

# Пользователь, под которым работает процесс внутри контейнера
CONTAINER_UID=1000
CONTAINER_GID=1000

# Вычисление см. выше
MAPPED_UID=100999
MAPPED_GID=100999

Структура файлов и каталогов

Для примера будем использовать следующую структуру на хосте:

/opt/ssh-access/
├── .ssh/
│   └── git/
│       ├── key
│       └── key.pub
├── sockets/
│   └── git/
│       └── agent.sock
└── ssh_known_hosts

Каталог .ssh в контейнер не монтируется.

Контейнер получает доступ только к sockets/git/ и файлу доверенных ключей SSH-серверов ssh_known_hosts.

Зачем нужен ssh_known_hosts?

При первом подключении SSH обычно требует подтвердить ключ сервера вручную. Для CI/CD и другой автоматизации это неприемлемо.

Часто проверку отключают через StrictHostKeyChecking=no, но тогда клиент перестаёт проверять подлинность сервера.

Поэтому ключ сервера заранее сохраняется в ssh_known_hosts, а проверка остаётся включённой.

Создадим корневой каталог для ssh-agent:

sudo install -d \
    -o "$ROOTLESS_USER" \
    -g "$ROOTLESS_GID" \
    -m 0711 \
    "$BASE"

Права 0711 позволяют процессу с mapped UID пройти по известному пути внутри каталога, но не позволяют получить список его содержимого.

Владельцем каталогов с сокетами и самих сокетов будет mapped UID, от которого работает ssh-agent.

Группа rootless-пользователя позволяет Docker использовать эти каталоги как источник bind mount.

Создадим каталог для сокетов (идентичностей):

sudo install -d \
    -o "$MAPPED_UID" \
    -g "$ROOTLESS_GID" \
    -m 0710 \
    "$BASE/sockets"

Этот каталог будет содержать отдельные каталоги для SSH-идентичностей.

Схема позволяет использовать несколько независимых ssh-agent и сокетов. В нашей конфигурации каждый сокет соответствует отдельной SSH-идентичности.

Например, можно создать идентичности git, gitlab или отдельные git-read и git-write с соответствующими SSH-ключами, имеющими разные уровни доступа.

Каждому контейнеру можно предоставить доступ только к каталогу нужной идентичности. Таким образом контейнер получает только тот SSH-доступ, который ему разрешён.

Затем создадим каталог конкретной идентичности (пример для git):

sudo install -d \
    -o "$MAPPED_UID" \
    -g "$ROOTLESS_GID" \
    -m 0710 \
    "$BASE/sockets/git"

Для каждой SSH-идентичности создаётся отдельный каталог, потому что в контейнер будет монтироваться именно каталог, а не сам файл agent.sock. Это позволяет ssh-agent пересоздавать сокет внутри уже примонтированного каталога.

Создание SSH-ключа

Каталог с ключом должен быть доступен только пользователю root:

sudo install -d \
    -o root \
    -g root \
    -m 0700 \
    "$BASE/.ssh/git"

Создадим ключ Ed25519:

sudo \
    ssh-keygen \
    -t ed25519 \
    -f "$BASE/.ssh/git/key" \
    -C 'container-git' \
    -N ''

Будет создана пара ключей:

.ssh/git/key
.ssh/git/key.pub

Добавьте key.pub в сервисы, к которым требуется предоставить доступ по этому ssh ключу.

Служебный сервис systemd для запуска ssh-agent

Для разделения SSH-идентичностей можно создать несколько сокетов с отдельными экземплярами ssh-agent.

В этом шаблоне %i будет заменяться на конкретное имя, например git. Конкретные примеры запуска сервиса будут ниже.

# /etc/systemd/system/container-ssh-agent@.service

[Unit]
Description = SSH agent for container identity %i

[Service]
Type = simple

Environment = SSH_AUTH_SOCK=/opt/ssh-access/sockets/%i/agent.sock MAPPED_UID=100999 MAPPED_GID=100999

# Удалить старый сокет, который мог остаться после нештатного выключения
ExecStartPre = /usr/bin/rm -f -- /opt/ssh-access/sockets/%i/agent.sock

# Вместо `100999` укажите рассчитанные значения `MAPPED_UID` и `MAPPED_GID`
ExecStart = /usr/bin/setpriv \
            --reuid=${MAPPED_UID} \
            --regid=${MAPPED_GID} \
            --clear-groups --inh-caps=-all --no-new-privs \
            /usr/bin/ssh-agent \
            -D \
            -a /opt/ssh-access/sockets/%i/agent.sock

# Дождаться создания сокета `ssh-agent`
# После старта сервиса добавить в него нужный ключ через `ssh-add`
ExecStartPost = /bin/sh -c 'while [ ! -S "$SSH_AUTH_SOCK" ]; do sleep 0.05; done; exec /usr/bin/ssh-add /opt/ssh-access/.ssh/%i/key'

Restart = on-failure

[Install]
# Запускать этот сервис автоматически при обычной загрузке системы.
WantedBy = multi-user.target

Последовательность:

Эта команда выполняется на хосте. Файл приватного ключа не монтируется в контейнер и недоступен его процессам.

Запуск службы

Перечитаем конфигурацию systemd и запустим службу:

sudo systemctl daemon-reload

# Запустить сервис с именем `git`: значение `git` будет подставлено в `%i` шаблона
sudo systemctl enable --now container-ssh-agent@git.service

# проверить успешность запуска
systemctl status container-ssh-agent@git.service --no-pager -l

# Проверить созданный сокет и его права: должны быть 100999:100999, то есть `$MAPPED_UID:$MAPPED_GID`
ls -ln /opt/ssh-access/sockets/git/agent.sock

# Проверить все запущенные службы `ssh-agent` и их UID/GID
ps -o pid,uid,gid,cmd -C ssh-agent

ssh-agent настроен и запущен. Теперь подготовим ssh_known_hosts, чтобы контейнер мог проверять подлинность удалённого SSH-сервера.

Проверка ключа удалённого SSH-сервера

Не следует отключать проверку ключа удалённого сервера через StrictHostKeyChecking=no.

При такой настройке SSH-клиент перестаёт проверять, действительно ли он подключается к ожидаемому серверу.

В рассматриваемой схеме используется отдельный файл /opt/ssh-access/ssh_known_hosts, в который заранее помещается проверенный ключ сервера.

Сначала создайте файл: sudo install -o root -g root -m 0644 /dev/null /opt/ssh-access/ssh_known_hosts. Затем добавьте ключи серверов:

  • получите публичный host key сервера: ssh-keyscan -p 22 -t ed25519 git.example.com

  • сверьте его fingerprint с ключом на самом сервере или с официально опубликованным fingerprint

  • добавьте полученную строку в конец файла /opt/ssh-access/ssh_known_hosts

В примере запрашивается host key Ed25519. Если сервер использует ключ с другим алгоритмом, укажите его явно.

Как убедиться, что ключ настоящий?

  • если сервер ваш, сравните результат с его публичным host key непосредственно на сервере

  • повторный ssh-keyscan с другого компьютера может быть только дополнительной, но не основной проверкой

После проверки файл можно монтировать в контейнер только для чтения.

Запуск и проверка контейнера

Запуск контейнера с ограниченными привилегиями

Если вы сами создаёте Dockerfile, для этой схемы необходимо создать пользователя без root-привилегий.

Пример Dockerfile с указанием USER 1000:1000:

FROM debian:bookworm-slim

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        ca-certificates \
        git \
        openssh-client \
        util-linux \
    && rm -rf /var/lib/apt/lists/*

# Распространённый UID/GID для непривилегированного пользователя: `1000:1000`
RUN groupadd -g 1000 app \
    && useradd -m -u 1000 -g 1000 app && install -d -o app -g app /workspace

USER 1000:1000

Соберите демонстрационный образ ssh-client:test:

sudo -H -u "$ROOTLESS_USER" env DOCKER_HOST="unix:///run/user/$ROOTLESS_UID/docker.sock" docker build -t ssh-client:test .

Если вы уже используете готовый образ, узнайте его UID/GID по умолчанию:

# Полученные UID/GID используйте вместо 1000:1000 в последующих примерах.
sudo -H -u "$ROOTLESS_USER" env DOCKER_HOST="unix:///run/user/$ROOTLESS_UID/docker.sock" docker run --rm image-name id

Пример запуска контейнера из образа ssh-client:test:

sudo -H -u "$ROOTLESS_USER" env DOCKER_HOST="unix:///run/user/$ROOTLESS_UID/docker.sock" docker run --rm -it \
    --user 1000:1000 \
    --read-only \
    --cap-drop ALL \
    --security-opt no-new-privileges \
    --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
    --tmpfs /workspace:rw,uid=1000,gid=1000 \
    --workdir /workspace \
    -e SSH_AUTH_SOCK=/ssh-agent/agent.sock \
    -v /opt/ssh-access/sockets/git:/ssh-agent:ro \
    -v /opt/ssh-access/ssh_known_hosts:/etc/ssh/ssh_known_hosts:ro \
    ssh-client:test \
    sh

Обратите внимание на --user, -e SSH_AUTH_SOCK и два bind mount: именно через них контейнер получает доступ к ssh-agent и проверенным SSH host keys.

Описание параметров:

Параметр

Назначение

--rm

Удалить контейнер после завершения работы.

-it

Запустить контейнер в интерактивном режиме с терминалом.

--user 1000:1000

Запустить процесс от непривилегированного пользователя с UID/GID 1000:1000.

--read-only

Сделать корневую файловую систему контейнера доступной только для чтения.

--cap-drop ALL

Удалить все Linux capabilities, предоставляемые контейнеру по умолчанию.

--security-opt no-new-privileges

Запретить процессам внутри контейнера получать дополнительные привилегии.

--tmpfs /tmp

Создать временную файловую систему /tmp. 

nosuid запрещает setuid/setgid, nodev — устройства, noexec — запуск файлов.

--tmpfs /workspace

Создать доступный для записи временный /workspace с владельцем 1000:1000.

--workdir /workspace

Использовать /workspace как рабочий каталог.

-e SSH_AUTH_SOCK

Указать OpenSSH путь к сокету ssh-agent внутри контейнера.

-v ssh-agent:ro

Примонтировать каталог с сокетом ssh-agent только для чтения.

-v ssh_known_hosts:ro

Примонтировать файл с доверенными SSH host keys только для чтения.

ssh-client:test

Docker-образ, из которого запускается контейнер.

sh

Команда, запускаемая внутри контейнера.

Применяемые ограничения:

  • Docker daemon работает в режиме rootless;

  • процесс работает от UID 1000:1000;

  • корневая файловая система доступна только для чтения;

  • Linux capabilities удалены;

  • включён no-new-privileges;

  • приватный SSH-ключ отсутствует в контейнере.

Каталог с сокетом также монтируется только для чтения: :ro.

Проверка сопоставления UID и работы ssh-agent

На хосте:

# Проверить rootless-режим: в разделе Security Options должен быть указан `rootless`.
sudo -H -u "$ROOTLESS_USER" env DOCKER_HOST="unix:///run/user/$ROOTLESS_UID/docker.sock" docker info

# Показать таблицу сопоставления UID внутри контейнера с UID на хосте.
# В выводе будет видно, какой диапазон UID внутри контейнера сопоставлен с subordinate UID-диапазоном на хосте.
# Эти значения должны соответствовать расчётам, выполненным выше.
sudo -H -u "$ROOTLESS_USER" env DOCKER_HOST="unix:///run/user/$ROOTLESS_UID/docker.sock" docker run --rm ssh-client:test cat /proc/self/uid_map

В контейнере:

# Узнать UID/GID
id
# Пример: `uid=1000(app) gid=1000(app)`

# Проверить права доступа к каталогу с сокетом
ls -ldn /ssh-agent
# Пример прав на хосте: `100999:<ROOTLESS_GID>`
# Пример прав внутри контейнера: `1000:0`

# Проверить права доступа к сокету
ls -ln /ssh-agent/agent.sock
# Пример: `1000:1000` — сокетом владеет внутренний пользователь контейнера

Далее, если проверки выше завершены успешно, переходим к проверке самого ssh-agent:

ssh-add -l

В выводе должен появиться fingerprint загруженного ключа.

Обычный OpenSSH после этого работает без дополнительных программ-посредников:

ssh git@git.example.com

или:

git clone git@git.example.com:group/project.git

После успешной проверки SSH-доступ готов к работе. Осталось учесть особенности эксплуатации сокета и границы защиты этой схемы.

Эксплуатация и ограничения

Возможная проблема с bind-монтированием

При работе с каталогом сокета необходимо учитывать поведение bind mount.

Представим, что контейнер уже запущен с монтированием:

/opt/ssh-access/sockets/git
        ↓
/ssh-agent

Затем каталог на хосте удаляется, а затем добавляется заново:

rm -rf /opt/ssh-access/sockets/git
mkdir /opt/ssh-access/sockets/git

Путь снова имеет то же имя, но новый каталог является другим объектом файловой системы.

Существующий bind mount продолжает ссылаться на старый объект.

Проверить источник монтирования можно внутри контейнера:

findmnt -T /ssh-agent -o TARGET,SOURCE,FSTYPE,OPTIONS -n

В такой ситуации источник может отображаться как:

/opt/ssh-access/sockets/git//deleted

Создание нового каталога с тем же путём не изменяет существующее bind-монтирование.

Если вы столкнётесь с такой проблемой, контейнер необходимо перезапустить или пересоздать.

Поэтому не следует удалять и пересоздавать сам каталог, используемый как источник bind mount. Файлы внутри него можно удалять и создавать заново.

Как работает перезапуск ssh-agent в нашей схеме

Удаление самого каталога и перезапуск ssh-agent — разные операции.

В выбранной нами схеме контейнер монтирует каталог, а не сам файл agent.sock. Поэтому новый сокет сразу становится доступен внутри контейнера.

При такой схеме перезапуск или повторное создание agent.sock внутри существующего каталога не требует пересоздания контейнера.

systemctl restart container-ssh-agent@git.service

При перезапуске каталог остаётся на месте, а сокет внутри него создаётся заново:

Граница защиты этой схемы

ssh-agent не запрещает контейнеру использовать загруженную SSH-идентичность. Он не передаёт клиенту материал приватного ключа.

Файл приватного ключа остаётся на хосте; после загрузки ключ также хранится в памяти ssh-agent:

В рамках этой схемы процесс внутри контейнера не получает файл приватного ключа и не может прочитать его через bind mount:

cat ~/.ssh/id_ed25519

При этом контейнер может использовать загруженную SSH-идентичность через ssh-agent, включая операции подписи.

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

Поэтому доступ к SSH_AUTH_SOCK следует рассматривать как делегированный доступ к загруженной SSH-идентичности.

Другой процесс, получивший доступ к сокету агента, может использовать загруженные в него ключи, поэтому надо внимательно относиться к разделению сервисов между пользователями.

Теперь сведём настроенные компоненты и их связи в общую схему.

Итоговая архитектура

Полная цепочка SSH-аутентификации:

Контейнер при этом может одновременно использовать следующие ограничения:

  • Docker daemon работает в режиме rootless;

  • процесс работает без root-привилегий;

  • корневая файловая система доступна только для чтения;

  • Linux capabilities удалены;

  • включён no-new-privileges;

  • приватный SSH-ключ отсутствует в контейнере;

  • из SSH-учётных данных контейнер получает доступ только к сокету требуемого ssh-agent.

Ключевой технический элемент этой схемы — совпадение UID процесса ssh-agent на хосте с UID, в который преобразуется пользователь контейнера через rootless user namespace.

Расширять права сокета для решения этой проблемы не требуется.

Необходимо запустить ssh-agent с subordinate UID, соответствующим UID процесса внутри контейнера:

В результате обычный OpenSSH продолжает работать через SSH_AUTH_SOCK, приватный ключ остаётся за пределами контейнера, а схема разграничения доступа опирается на стандартные механизмы Linux:

  • пользовательскими пространствами имён;

  • сопоставлением UID и GID;

  • Unix-сокетами;

  • правами файловой системы;

  • запуском процессов без дополнительных привилегий.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.