Перейти к содержанию
Публикация AiManual

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

Разбираем схему, в которой rootless-контейнер пользуется SSH, но не получает приватный ключ: внутрь передаётся только сокет ssh-agent. Показываем, почему обычно

Коротко

Что будет в материале

  1. 01

    Зачем вообще прятать приватный ключ от контейнера

  2. 02

    Почему в rootless Docker обычное монтирование SSH_AUTH_SOCK не работает

  3. 03

    Схема: отдельный ssh-agent с mapped UID через setpriv и systemd

  4. 04

    Как проверить, что схема работает

Если приватный ключ SSH лежит внутри контейнера, компрометация процесса означает кражу ключа: злоумышленник получает доступ ко всем серверам и репозиториям, куда этот ключ пускает. Схема на ssh-agent и rootless Docker разрывает эту связь. Приложение в контейнере полноценно пользуется SSH-доступом, но самого приватного ключа не получает: внутрь передаётся только Unix-сокет агента, а ключ остаётся на хосте. В исходном разборе этой архитектуры ключевая мысль сформулирована прямо: ограничение обеспечивается самой схемой доступа и сохраняется даже при компрометации ПО внутри контейнера.

Второй момент, который часто упускают: замена SSH на токен или короткоживущий сертификат проблему не решает. Это будет просто другой секрет в той же среде и доступный тому же процессу. Разница принципиальная: агент не отдаёт ключевой материал, он выполняет криптографические операции по запросу клиента.

Дальше по порядку: почему привычные способы проброса ломаются в rootless Docker, как собрать отдельный агент с mapped UID через setpriv и systemd, как проверить работу схемы и где заканчивается её защита. Сразу оговорюсь: это снижение поверхности атаки, а не абсолютная безопасность.

Зачем вообще прятать приватный ключ от контейнера

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

Почему монтирование ~/.ssh и переменные окружения - плохая идея

Два распространённых подхода выглядят так:

  • bind mount каталога ~/.ssh внутрь контейнера, чтобы ssh и git увидели ключ по стандартному пути;
  • передача ключа через переменные окружения, secrets или отдельный файл, скопированный в образ.

В обоих случаях ключ оказывается внутри контейнера и доступен процессу. Любой код в этой среде, будь то ваш собственный модуль, зависимость из реестра или сгенерированный ИИ-агентом скрипт, может прочитать файл и отправить содержимое наружу. Достаточно одной уязвимости где-то в цепочке зависимостей. Монтирование каталога ~/.ssh вдобавок отдаёт вместе с ключом known_hosts, config и всё остальное, что вы туда положили.

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

Что даёт ssh-agent: ключ снаружи, доступ внутри

ssh-agent держит приватный ключ в своей памяти и подписывает данные по запросу. Клиенту не нужно читать ключ с диска: он подключается к Unix-сокету агента и просит подписать вызов. Контейнеру передаётся только путь к сокету через переменную SSH_AUTH_SOCK. Приватный ключ не покидает хост, и в файловой системе контейнера его просто нет.

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

Почему в rootless Docker обычное монтирование SSH_AUTH_SOCK не работает

Самая частая точка отказа. Стандартный рецепт выглядит безобидно: смонтировать сокет агента внутрь и указать путь в SSH_AUTH_SOCK. В rootless Docker это не срабатывает для процесса с non-root UID: сокет на месте, путей не перепутано, а соединение не проходит. Причина в UID/GID-маппинге, и она описана в том же материале про SSH-доступ для rootless-контейнера как ключевая сложность схемы.

Как UID/GID-маппинг ломает доступ к сокету

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

  1. ssh-agent на хосте создаёт сокет, владелец - ваш пользователь на хосте, права ограничены владельцем.
  2. rootless Docker запускает контейнеры в отдельном user namespace. UID 0 внутри контейнера соответствует вашему пользователю на хосте, а остальные UID сдвинуты в диапазон subordinate UID из /etc/subuid.
  3. Процесс внутри контейнера с UID 1000 на хосте работает под другим номером, обычно это 100000 + 1000.

Права на Unix-сокет ядро проверяет по хостовому UID. Файл, который внутри контейнера выглядит принадлежащим UID 1000, на хостовой стороне принадлежит номеру вида 101000. Если сокет создан пользователем хоста с UID 1000, а процесс из контейнера виден ядру как 101000, соединение отклоняется. Внутри контейнера это выглядит как недоступный агент, хотя сокет смонтирован и файл на месте. Именно поэтому простое -v /run/user/1000/ssh-agent.sock:/ssh-agent.sock не решает задачу для non-root пользователя.

Конкретные номера зависят от /etc/subuid, /etc/subgid и настроек демона, поэтому их нужно смотреть в своём окружении, а не копировать из примеров в статьях.

Почему запуск от root внутри контейнера - не решение

Соблазн очевидный: запустить процесс от root внутри контейнера. Root в контейнере мапится в вашего пользователя на хосте, и сокет, созданный этим пользователем, окажется доступен. Формально работает, но ценой отказа от самой идеи ограничения: приложение получает максимальные привилегии внутри контейнера, а ошибка в конфигурации или выход за его пределы бьёт заметно больнее. Смысл схемы в том, чтобы согласовать UID/GID, а не поднять привилегии до root.

Схема: отдельный ssh-agent с mapped UID через setpriv и systemd

Идея: поднять ssh-agent, работающий с тем хостовым UID, в который мапится нужный контейнерный UID, и держать его под управлением systemd с урезанными привилегиями. Тогда внутри контейнера сокет выглядит принадлежащим вашему пользователю, и права совпадают без chmod 777 и прочих костылей.

Минимальный пример конфигурации ssh-agent

[Unit]
Description=ssh-agent for rootless container (mapped UID)
After=default.target

[Service]
Type=simple
# Каталог под сокет: владелец и права должны совпасть с mapped UID
ExecStartPre=+/usr/bin/install -d -o 101000 -g 101000 -m 0750 /run/ssh-agent-rootless
ExecStart=/usr/bin/setpriv --reuid=101000 --regid=101000 --clear-groups /usr/bin/ssh-agent -D -a /run/ssh-agent-rootless/agent.sock
Restart=on-failure
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=read-only
ProtectSystem=strict
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX
MemoryDenyWriteExecute=yes

[Install]
WantedBy=default.target

Что здесь важно:

  • setpriv --reuid --regid --clear-groups запускает ssh-agent с нужными числовыми UID/GID. Запись в /etc/passwd не требуется, но mapping должен быть описан в /etc/subuid и /etc/subgid.
  • ssh-agent -D -a путь работает в foreground и слушает конкретный сокет. В связке с systemd это удобнее форкающегося режима: unit остаётся живым, а Restart=on-failure перезапускает агент.
  • Каталог под сокет нужно создавать с владельцем, равным mapped UID. Если оставить его root-овым, доступ у процесса в контейнере не появится.
  • Ограничения systemd (NoNewPrivileges, ProtectSystem, PrivateTmp, RestrictAddressFamilies=AF_UNIX) сокращают ущерб при компрометации агента. Набор стоит подбирать под свою систему, но AF_UNIX достаточно: агенту нужен только Unix-сокет.

Ключи в агент добавляются отдельно, и здесь есть нюанс: ssh-agent создаёт сокет с правами, доступными владельцу, а владелец - mapped UID. Значит, ssh-add нужно запускать от того же UID, а файл ключа должен быть этому UID доступен на чтение. Второй вариант: загрузить ключ до понижения привилегий. Точную комбинацию прав придётся подобрать в своём окружении; универсальной команды тут нет. Загруженный ключ живёт столько же, сколько процесс агента, поэтому перезапуск юнита потребует повторной загрузки.

Минимальный пример конфигурации целевого контейнера

В контейнер уходят только сокет и переменная окружения с путём к нему. Никакого ~/.ssh и файлов с ключами:

docker run --rm -it \
  -e SSH_AUTH_SOCK=/run/agent/agent.sock \
  -v /run/ssh-agent-rootless:/run/agent \
  --user 1000:1000 \
  your-image sh -lc 'ssh-add -l && ssh -T git@github.com'

Критичная деталь: контейнерный UID в --user должен соответствовать тому mapped UID, под которым запущен агент. Вы выбираете пару значений (например, контейнерный UID 1000 и хостовый UID 101000) и держите её согласованной в двух местах: в юните агента и в запуске контейнера. Каталог под сокет лучше монтировать целиком, а не отдельный файл: bind mount файла привязан к inode, о чём ниже.

Пример стоит читать как шаблон. Номера UID, пути и способ запуска rootless Docker (docker, podman, пользовательский systemd-юнит) отличаются, поэтому значения подставляйте свои.

Как проверить, что схема работает

Проверка внутри контейнера: SSH_AUTH_SOCK и ssh-add -l

  1. echo $SSH_AUTH_SOCK: путь должен совпадать с точкой монтирования, например /run/agent/agent.sock.
  2. ls -l $SSH_AUTH_SOCK: файл существует, а его владелец совпадает с вашим UID внутри контейнера.
  3. ssh-add -l: команда обращается к агенту и перечисляет загруженные идентичности. Отпечатки видны - агент доступен и не пуст. Пустой список означает, что соединение есть, но ключи не загружены. Сообщение о невозможности связаться с агентом указывает на права или на несовпадение UID/GID.
  4. ssh -T git@github.com или подключение к своему серверу: проверка реального доступа.

Отдельная проверка на отсутствие ключа: поиск файлов вида id_* внутри контейнера (кроме публичных) не должен находить ничего в стандартных местах. Запускать эти команды стоит самостоятельно: результат зависит от вашей конфигурации, и никакие чужие логи его не заменят.

Типичные ошибки при проверке

  • Неверный путь. SSH_AUTH_SOCK указывает не туда, куда смонтирован сокет.
  • Несовпадение UID/GID. Сокет на хосте принадлежит другому номеру, чем тот, под которым процесс виден ядру.
  • Права на сокет. Агент создаёт сокет с ограничительными правами; если владелец не mapped UID, соединения не будет.
  • Права на каталог. Каталог без права на проход (execute) блокирует доступ к сокету, даже когда сам сокет доступен.
  • Устаревший bind mount. Каталог с сокетом пересоздали, а монтирование осталось привязанным к старому inode.

Список не претендует на исчерпывающий, но эти пять причин закрывают большинство случаев.

Ограничения схемы и границы защиты

Честная картина: схема убирает приватный ключ из контейнера, но не превращает контейнер в безопасную среду.

Делегированный доступ к SSH-идентичности

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

Практические следствия простые. Ограничивайте, кто может подключаться к сокету: владелец и права на каталог, отдельный UID для агента. Держите в агенте минимум ключей: отдельный ключ под задачу вместо одного универсального. И добавляйте ограничения на стороне сервера, например command= или restrict в authorized_keys. Тогда даже полный контроль над сокетом не даст произвольный доступ ко всем хостам.

Особенности bind mount при пересоздании каталога с сокетом

Bind mount отдельного файла в Linux привязывается к inode, а не к пути. Если агент перезапустился и создал сокет заново, старый inode исчез, а монтирование в контейнере продолжает указывать на него: снаружи конфигурация выглядит рабочей, внутри соединение не проходит. То же случается, когда systemd пересоздаёт RuntimeDirectory или правило tmpfiles чистит /run.

Отсюда практика: монтировать каталог целиком, а не файл, и предусмотреть перезапуск потребителя при перезапуске агента. Помогают зависимости в systemd (Requires и After от юнита агента) либо явный перезапуск контейнера после обновления сокета.

Где это особенно нужно: ИИ-агенты и MCP-серверы

Почему ИИ-агенты и MCP-серверы - особый случай

ИИ-агенты и промежуточные API-слои вроде MCP-серверов выполняют динамические задачи: обращаются к внешним API, клонируют репозитории, собирают команды на лету. Поведение такого процесса менее предсказуемо, чем у классического сервиса с фиксированным набором вызовов, а список подключённых инструментов меняется от задачи к задаче. Подход с ssh-agent особенно актуален именно для таких систем: компрометация процесса не должна приводить к утечке приватного ключа.

Рядом решают похожие задачи другими способами. Проект linux-mcp-daemon отдаёт агенту состояние Linux-системы через 38 инструментов MCP вместо прямого shell-доступа, а root-права выдаёт точечно по конфигу: разбор архитектуры mcpd и его граблей. Если речь про контроль над инструментами агента на уровне платформы, полезен материал о том, зачем проверять skills, plugins и MCP-серверы и почему статическая проверка даёт ограниченную гарантию. Про риски несанкционированных действий и изоляцию как базовую меру защиты есть отдельный технический разбор: анализ инцидентов и рекомендации по изоляции сред.

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

Типовые задачи, ради которых контейнеру вообще нужен агент: git clone и git push по SSH в приватные репозитории, деплой на серверы (rsync, scp, запуск команд), автоматизация на удалённых хостах, работа с внутренними git-серверами. Во всех случаях нужен доступ, а не ключ как файл. Если сценарий сводится к одному git pull в пайплайне, схема может оказаться избыточной: иногда проще выдать ограниченный deploy key. Когда модель получает shell внутри контейнера, та же логика изоляции обсуждается в материале про Open Terminal для Open WebUI.

Кому схема не подходит и что учесть перед внедрением

Когда схема избыточна

  • rootless Docker не используется: задачи с UID/GID-маппингом просто не возникает, и обычные механизмы Docker справляются с пробросом сокета.
  • Контейнеру SSH не нужен: всё общение идёт по HTTP API.
  • Доступ нужен однократно в CI: там уместнее короткоживущие credentials и отдельный раннер.
  • Код полностью доверенный, цена ошибки низкая, а время на настройку юнита и прав на сокет тратить не хочется.

Что подготовить перед настройкой

  • Настроенный rootless Docker и известный пользователь, от имени которого он работает.
  • Понимание маппинга UID/GID: значения в /etc/subuid и /etc/subgid и нужный контейнерный UID.
  • Доступ к systemd для юнита агента, пользовательского или системного.
  • Понимание прав на Unix-сокеты: владелец, режим, права на каталог.
  • Отдельный ключ под задачу с ограничениями на стороне сервера.

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

Схема снижает риск утечки ключа, но не устраняет все риски и требует аккуратной работы с правами. Начните с проверки маппинга UID (cat /etc/subuid), затем поднимите агент отдельным юнитом с setpriv, смонтируйте каталог с сокетом и добейтесь того, чтобы ssh-add -l внутри контейнера показывал идентичности, а приватного ключа в файловой системе контейнера не было.

Подписаться на канал