DeepSeek Harness (dsh) — открытый каркас для агентов, построенный на Cordis по принципу «всё есть плагин»: модели, инструменты, навыки, сессии, песочницы, файловые системы и сам интерфейс собираются как подключаемые слои. Лицензия MIT, состояние — предварительный выпуск для разработчиков с заявленными ломающими изменениями.
Локальный запуск сводится к одной команде npx @deepseek-ai/dsh web. Задача этой статьи другая: постоянно работающий узел на удалённой машине, доступный по домену с сертификатом и паролем, с моделями через OpenRouter и службой systemd.
Каркас собирается из исходников. При предварительном выпуске это единственный способ получить состояние ветки main раньше публикации в реестре npm, закрепиться на конкретной ревизии и при необходимости внести собственные правки. Готовый пакет из npm отстаёт и правок не допускает.
Наружу узел публикуется через Nginx Proxy Manager: сертификат, принудительный HTTPS, поддержка веб-сокетов и список доступа с паролем настраиваются в веб-интерфейсе, а не в конфигурационных файлах.
Конфигурация проверена на Ubuntu 26.04 LTS. Команды рассчитаны на root-сессию, поэтому sudo в них не используется.
Предварительный выпуск не предназначен для промышленной эксплуатации. Развёртывание оправдано там, где поломка после обновления остаётся неприятностью, а не происшествием.
- Что определяет схему установки
- Сервер слушает только петлю
- Токен входа не заменяет авторизацию
- Режим workspace-write ограничивает только запись
- Каталог запуска считается рабочим корнем
- Шаг 1. Node.js и подготовка машины
- Память под сборку
- Если сборка падает по куче
- Шаг 2. Отдельный пользователь и каталоги
- Пользовательский префикс npm
- Шаг 3. Сборка из исходников
- Установочные сценарии зависимостей
- Закрепление ревизии
- Чем это отличается от npx и глобального пакета
- Шаг 4. Модели через OpenRouter
- Конфигурация через файл настроек
- Конфигурация через интерфейс
- Что недоступно без ключа DeepSeek
- Шаг 5. Служба systemd
- Разбор ключевых директив
- Шаг 6. Полные права агенту
- Зачем это нужно
- Защитные ключи юнита
- Проверка на Playwright
- Песочница: почему bash вообще отказывается выполняться
- Доступ к Docker
- Шаг 7. Публикация через Nginx Proxy Manager
- 7.1. Переброска порта на машине с dsh
- 7.2. Межсетевой экран и Fail2Ban
- 7.3. Узел в Nginx Proxy Manager
- 7.4. Если заслон доверия всё равно отклоняет запросы
- 7.5. Список доступа — тот самый пароль перед портом
- Когда домен не требуется
- Шаг 8. Вход по токену и диагностика по кодам ответа
- Коды ответа указывают на слой обрыва
- Шаг 9. Первая настройка интерфейса
- Дополнения: как выбирать и в каком порядке ставить
- Механика установки
- Минимальный набор для серверной установки
- Почему dsh-passwords здесь не нужен
- Что на сервере не нужно
- Рекомендуемый порядок
- Обновление без сюрпризов
- Когда достаточно пакета из npm
- Сводная таблица ошибок
- Безопасность и телеметрия
- Заключение
Что определяет схему установки
Четыре особенности каркаса задают всю дальнейшую конфигурацию. Пропуск любой из них приводит к типовым ошибкам, разобранным ниже.
Сервер слушает только петлю
Веб-приложение принимает --host, --port и повторяемый --trusted-host, но значение --host 0.0.0.0 умышленно не поддерживается и завершается ошибкой использования. По умолчанию адрес прослушивания — http://127.0.0.1:3080.
Отсюда главное следствие для публикации: Nginx Proxy Manager, работающий на другой машине, до этой петли не дотянется. На машине с dsh обязано слушать внешний адрес что-то своё — об этом шаг 7.
Токен входа не заменяет авторизацию
Интерфейс закрыт токеном, который печатается в журнал при запуске и меняется при каждом перезапуске. Без токена сервер отвечает 401 на любой запрос, включая curl с самой машины.
Однако это одноразовый пропуск, а не учётные записи и не разграничение прав: обладатель ссылки получает полный доступ и расходует средства на моделях. Список доступа в Nginx Proxy Manager обязателен.
Режим workspace-write ограничивает только запись
Новые сессии по умолчанию работают в режиме workspace-write: изменения через bash и файловую систему ограничены рабочим каталогом сессии и временными каталогами. Чтение, сетевой доступ и видимость процессов не ограничены.
Агент способен прочитать файл с учётными данными и отправить его во внешнюю сеть. Это заявленное поведение, а не изъян, — и именно оно определяет требования к изоляции машины.
Каталог запуска считается рабочим корнем
Во всех режимах рабочим корнем по умолчанию становится каталог запуска. Отсюда требование отдельного пустого каталога вместо домашнего каталога пользователя.
Итоговый план: отдельный системный пользователь с собственным префиксом npm, сборка из репозитория, служба systemd с защитными ключами, осознанно выданные права на Docker, переброска порта под межсетевым экраном, узел в Nginx Proxy Manager и только после этого — дополнения.
Шаг 1. Node.js и подготовка машины
Базовая установка среды выполнения из репозитория NodeSource:
apt update && apt install -y curl git build-essential
curl -fsSL https://deb.nodesource.com/setup_24.x | bash -
apt install -y nodejs
npm install -g pnpm@10
node -v && pnpm -v Сразу после установки проверяется, какие именно исполняемые файлы оказались в PATH:
which -a node
command -v pnpm Проверка не избыточна. Если на машине уже присутствовал Node, установленный не через apt (типичный случай — /usr/local/bin), после NodeSource в системе окажутся две версии: /usr/bin/node из пакета и другая, стоящая раньше в PATH. Команда node -v покажет вторую, сборка пойдёт на ней же, а pnpm установится рядом с ней — не в /usr/bin.
Записанный по привычке путь /usr/bin/pnpm в юните systemd даст ошибку запуска:
dsh.service: Unable to locate executable '/usr/bin/pnpm': No such file or directory Оба выведенных пути понадобятся при составлении юнита systemd на шаге 5. Их удобно сохранить сразу.
Пакет build-essential нужен зависимостям с нативной сборкой — они есть и у самого каркаса, и у части плагинов.
Память под сборку
Сборка дерева TypeScript — самая требовательная к памяти часть установки. Порядок такой: сначала посмотреть, что есть, и только потом что-то добавлять.
free -h
swapon --show Если свободно от 4 ГБ, делать нечего — сборка пройдёт как есть. Подкачка нужна только на небольших машинах, где памяти меньше и раздел подкачки отсутствует:
fallocate -l 4G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
free -h Если сборка падает по куче
Отдельный случай — ошибка JavaScript heap out of memory при формально свободной памяти. Node сам выбирает потолок кучи, исходя из объёма машины, и на скромной конфигурации выбирает скромно: процесс упирается в предел раньше, чем в физическую память.
Внешний флаг для этого не нужен: сборочный сценарий репозитория сам запускает компилятор строкой node --max-old-space-size=4096, и переменная NODE_OPTIONS снаружи лишь продублирует уже заданное значение. Если четырёх гигабайт потолка не хватает физически, помогает подкачка, а не флаг. Текущий потолок при необходимости смотрится так:
node -e 'console.log(require("v8").getHeapStatistics().heap_size_limit / 1048576 + " MB")' Шаг 2. Отдельный пользователь и каталоги
Служба работает не от root, хотя на шаге 6 этому пользователю выдаются полные права. Смысл здесь не в изоляции, а в порядке: у агента собственные каталоги, собственный префикс npm и понятная принадлежность файлов — в журналах и в ps сразу видно, где работает он, а где вы.
adduser --system --group --home /opt/dsh --shell /bin/bash dsh
mkdir -p /opt/dsh/work /opt/dsh/.dsh
chown -R dsh:dsh /opt/dsh Оба каталога обязательны: work — рабочий корень сессий, .dsh — будущий $DSH_HOME для настроек, учётных данных и профилей. При создании только первого запись settings.yaml на шаге 4 завершится ошибкой No such file or directory.
Пользовательский префикс npm
Агенту требуется ставить пакеты. Установка в проект (npm install, pnpm add) прав root не требует вообще: зависимости попадают в node_modules рабочего каталога. Для глобальных установок задаётся пользовательский префикс, иначе они растекутся по системным каталогам и перемешаются с пакетами дистрибутива:
sudo -u dsh -H bash -lc '
mkdir -p /opt/dsh/.npm-global
npm config set prefix /opt/dsh/.npm-global
echo "export PATH=/opt/dsh/.npm-global/bin:\$PATH" >> /opt/dsh/.profile
npm config get prefix
' Строка дописывается именно в .profile, а не в .bashrc. Пользователь создан ключом --system, поэтому образцы из /etc/skel в его домашний каталог не скопированы и .bashrc ничем не подключается. Все команды в статье идут через bash -lc, то есть через оболочку входа, а она читает .profile. Ошибка проявляется позже и обманчиво: пакет установлен, а программа отвечает command not found.
Каталог /opt/dsh/.npm-global/bin обязательно попадает в PATH юнита на шаге 5, иначе служба не увидит программ, установленных агентом. Пакеты Python подключаются тем же способом — pip install --user или uv в домашнем каталоге.
Шаг 3. Сборка из исходников
Репозиторий клонируется в домашний каталог пользователя dsh, а не в рабочий корень сессий: смешивать исходники каркаса с каталогом, куда пишет агент, не стоит.
sudo -u dsh -H bash -lc '
cd /opt/dsh
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
' Клон весит около 135 МБ, установка тянет примерно тысячу пакетов, среди которых есть увесистые вложенные программы: сборки Codex и Claude Agent SDK занимают по сотне мегабайт каждая. Установка укладывается в минуту, сборка — в несколько.
В выводе появляются предупреждения, на результат не влияющие: циклические зависимости рабочего пространства, неподдерживаемая платформа linux-arm64 у одного из вложенных пакетов и пара строк вида Failed to create bin … apps/cli/lib/bin.js. Последние выглядят пугающе, но объясняются просто: ссылка на исполняемый файл создаётся до сборки, а сам файл появляется после неё.
Сборка обязательна: рабочий запуск веб-приложения требует собранных пакетов и артефактов интерфейса, без них загрузка профиля падает на ошибках разрешения модулей. Проверка результата:
sudo -u dsh -H bash -lc 'cd /opt/dsh/deepseek-harness && pnpm dsh --version' Установочные сценарии зависимостей
pnpm 10 и новее по умолчанию не выполняет сценарии install и postinstall у зависимостей — разрешение объявляется списком onlyBuiltDependencies в pnpm-workspace.yaml. В репозитории каркаса этот список уже заполнен, поэтому вмешательства не требуется: в выводе видны строки Running install script для node-pty и koffi и Running postinstall script для subprocess-local. Именно они собирают нативный модуль терминала, обвязку к системным библиотекам и вспомогательную программу запуска дочерних процессов.
Проверять нужно обратное — отсутствие этих строк и предупреждение об игнорированных сборках. Тогда инструменты, работающие через оболочку, откажут уже в сессии при формально успешной сборке. Разрешение выдаётся интерактивно и установка повторяется:
sudo -u dsh -H bash -lc '
cd /opt/dsh/deepseek-harness
pnpm approve-builds
pnpm install
' Это заметное отличие от установки готовым пакетом: там разрешение приходится выдавать самому — npm блокирует те же сценарии и печатает предупреждение install-scripts с готовой строкой --allow-scripts. В репозитории решение уже принято за вас.
Закрепление ревизии
При предварительном выпуске main меняется ежедневно, и обновление вслепую однажды приходится на ломающее изменение. Рабочий узел закрепляется на конкретной ревизии:
sudo -u dsh -H bash -lc '
cd /opt/dsh/deepseek-harness
git log --oneline -5
git rev-parse HEAD
' Выведенный хеш записывается рядом с остальными сведениями об узле: по нему выполняется откат, если очередное обновление окажется неудачным. Ветка в качестве ориентира не годится — она движется.
Команда apt install dsh ставит посторонний пакет — распределённую оболочку для параллельного выполнения команд на группе машин. Совпадает только имя.
Чем это отличается от npx и глобального пакета
README предлагает два пути: npx @deepseek-ai/dsh web и запуск из клона. Первый годится для разовой пробы на своей машине — версия не закреплена, кеш подтягивается из реестра, для постоянной службы это лишняя зависимость от доступности npm. Есть и третий, в README не описанный: npm install -g @deepseek-ai/dsh, поскольку в манифесте объявлено поле bin. Он проще сборки и обновляется одной командой, но даёт только опубликованную версию — на предварительном выпуске она заметно отстаёт от ветки и не допускает собственных правок.
Разрыв виден прямо в выводе сборки: пакеты рабочего пространства собираются под версией 0.1.2-alpha.2, тогда как в реестре на тот же день лежит 0.1.1-rc.2.
Сборка выбрана именно поэтому: свежий код, возможность закрепиться на ревизии, возможность править каркас. Плата — минуты ожидания и обязательный шаг сборки после каждого обновления.
Шаг 4. Модели через OpenRouter
OpenRouter не входит в готовый набор поставщиков. Штатно вводом ключа настраиваются DeepSeek, Anthropic и OpenAI; Bedrock, Vertex, Azure и Codex требуют родных учётных данных, и одного поля ключа для них недостаточно.
OpenRouter добавляется как пользовательский поставщик. Минимальный набор: идентификатор строчными буквами, базовый адрес, протокол, учётные данные и хотя бы одна модель.
Конфигурация через файл настроек
sudo -u dsh -H bash -c 'umask 077; cat > /opt/dsh/.dsh/settings.yaml <<EOF
llm-pi-ai:
providers:
openrouter:
apiKeyEnv: OPENROUTER_API_KEY
api: openai-completions
baseURL: https://openrouter.ai/api/v1
compat:
supportsDeveloperRole: false
maxTokensField: max_tokens
models:
- id: z-ai/glm-5.3-flash
- id: anthropic/claude-sonnet-4-5
- id: google/gemini-2.5-pro
input: [text, image]
EOF' sudo -u dsh -H bash -c 'umask 077; cat > /opt/dsh/.dsh-env <<EOF
OPENROUTER_API_KEY=sk-or-v1-ключ
DSH_HOME=/opt/dsh/.dsh
DSH_TELEMETRY_DISABLED=1
EOF' Идентификаторы моделей указываются ровно как у OpenRouter, без приставки openrouter/. Приставка требуется там, где запрос идёт через LiteLLM; здесь обращение к совместимому с OpenAI адресу прямое, и лишний префикс даст UNKNOWN_MODEL.
Ключи compat. Именно с них рекомендуется начинать, если шлюз отвергает запросы при верных ключе и адресе. У модели, объявленной рассуждающей, системная подсказка уходит в роли developer, а ограничение вывода передаётся как max_completion_tokens, и не всякий совместимый шлюз это принимает.
Пустое значение после двоеточия в compat недопустимо: такой ключ отвергается, а не игнорируется, поскольку пустота стёрла бы сведения из каталога, ничего не предложив взамен.
Параметр input. Модель, введённая вручную, считается только текстовой, пока не указано иное: опросить адрес о поддерживаемых видах содержимого невозможно. Попытка приложить изображение к такой модели отклоняется до отправки запроса.
Порядок поиска учётных данных: унаследованное окружение → $DSH_HOME/.credentials.yaml → файл .env каталога запуска → $DSH_HOME/.env.
Конфигурация через интерфейс
Тот же результат достигается позже через Settings → Models → Add a custom provider: Provider ID openrouter, базовый адрес https://openrouter.ai/api/v1, протокол openai-completions, ключ и Fetch available models. Опрос идёт по совместимому с OpenAI GET /models, у OpenRouter такой метод присутствует.
Ключ записывается только в одну сторону: после сохранения страница получает замаскированное описание, а не исходное значение.
Идентификатор поставщика постоянен — на него ссылаются запросы, сохранённые сессии, модели по умолчанию и ссылки на учётные данные. Переименование не поддерживается, доступно только создание нового и удаление старого.
Интерфейс пишет в тот же settings.yaml. Рекомендуется выбрать один способ конфигурации, а вручную дописывать только то, чего нет в форме: compat и input.
Что недоступно без ключа DeepSeek
Встроенный поиск использует DEEPSEEK_API_KEY и принимает DEEPSEEK_SEARCH_BASE_URL. Модели через OpenRouter работают, а инструмент web_search — нет. Инструмент web_fetch отключён в любом случае, пока слой правок не добавит поставщика и не включит его.
Если поиск нужен, отдельный ключ DeepSeek только под эту функцию обходится дешевле полного перехода на родного поставщика.
Шаг 5. Служба systemd
Сначала фиксируются фактические пути, определённые на шаге 1 — их подставит heredoc без кавычек. Домен в --trusted-host указывается тот же, что будет заведён в Nginx Proxy Manager на шаге 7:
PNPM_BIN="$(command -v pnpm)"
NODE_DIR="$(dirname "$(command -v node)")"
echo "pnpm=$PNPM_BIN node_dir=$NODE_DIR" cat > /etc/systemd/system/dsh.service <<EOF
[Unit]
Description=DeepSeek Harness web
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dsh
Group=dsh
WorkingDirectory=/opt/dsh/work
Environment=PATH=/opt/dsh/.npm-global/bin:$NODE_DIR:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EnvironmentFile=/opt/dsh/.dsh-env
ExecStart=$PNPM_BIN --dir /opt/dsh/deepseek-harness dsh web --port 3080 --no-open --trusted-host dsh.example.com
Restart=on-failure
RestartSec=5
TimeoutStopSec=15
KillSignal=SIGTERM
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
ReadWritePaths=/opt/dsh
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now dsh
sleep 25
systemctl status dsh --no-pager
curl -sI http://127.0.0.1:3080 | head -1 Ожидаемый ответ — HTTP/1.1 401: без токена каркас отказывает, и это признак работающей службы, а не ошибки.
Пауза перед проверкой не запас вежливости, а необходимость. Запускающий сценарий поднимает исходники через tsx, и от старта до готовности проходит порядка пятнадцати секунд. В этом окне порт уже слушает, но маршруты ещё не зарегистрированы, и curl возвращает 404 Not Found — состояние, которое легко принять за поломку настроек. Через двадцать-тридцать секунд ответ меняется на 401. Ссылка с токеном появляется в журнале примерно тогда же.
Разбор ключевых директив
ExecStart через pnpm. Ключ --dir указывает на клон репозитория, поэтому рабочим каталогом службы остаётся /opt/dsh/work, а не каталог с исходниками. Путь к самому pnpm берётся из command -v, а не пишется по памяти: при двух установленных версиях Node он оказывается не в /usr/bin.
Environment=PATH — не косметика. У systemd собственный PATH, и без каталога Node запускающий сценарий pnpm со строкой #!/usr/bin/env node подхватит не ту версию, на которой выполнялась сборка. Первым в списке стоит /opt/dsh/.npm-global/bin из шага 2, иначе служба не найдёт программы, установленные агентом.
Защитные ключи (NoNewPrivileges, ProtectSystem, ProtectHome, ReadWritePaths) удерживают процесс службы в её каталоге. Первые два будут сняты на шаге 6 — без этого агент не сможет ставить системные пакеты; ReadWritePaths=/opt/dsh и остальные остаются, под ними находятся рабочий каталог сессий, исходники и префикс npm.
--trusted-host добавляет имена, принимаемые заслоном доверия браузера на /api. Без него интерфейс по домену открывается, но запросы к API отклоняются, и причина ошибочно ищется в настройках узла прокси. Флаг повторяемый: несколько доменов — несколько флагов.
Остановка. Дереву плагинов даётся до пяти секунд на разбор: первый SIGTERM начинает штатное завершение и выходит с кодом 0, второй сигнал завершает процесс немедленно. Значение TimeoutStopSec=15 не даёт systemd прервать завершение на середине.
Юнит вставляется исключительно конструкцией cat > файл <<'EOF'. Вставка содержимого секций напрямую в оболочку даёт серию ошибок вида [Unit]: command not found.
Шаг 6. Полные права агенту
Этот шаг выполняется после создания службы: команды ниже перезапускают юнит и проверяют доступ уже от работающего процесса.
Зачем это нужно
Части задач одних npm-пакетов мало. Показательный пример — Playwright: сам пакет и браузеры ставятся в домашний каталог и проблем не вызывают, а вот playwright install-deps тянет через apt системные библиотеки, без которых Chromium не запускается. То же касается сборки нативных модулей и любых утилит из репозиториев.
Узкий список путей в sudoers для этого не годится. Установщик вызывает не apt-get напрямую, а оболочку с командной строкой внутри, и sudo отклоняет её сообщением sudo: I'm sorry dsh. I'm afraid I can't do that. Добавлять в список /bin/sh бессмысленно — это тот же полный доступ, только менее очевидный. Да и сам apt-get с ключом -o позволяет задать настройку вида APT::Update::Pre-Invoke с произвольной командой от root. Поэтому права выдаются прямо:
cat > /etc/sudoers.d/dsh <<'EOF'
dsh ALL=(ALL) NOPASSWD: ALL
EOF
chmod 440 /etc/sudoers.d/dsh
visudo -c
sudo -u dsh sudo -n id Последняя команда должна вывести uid=0(root).
Защитные ключи юнита
Одного правила недостаточно: ключи юнита блокируют повышение прав раньше, чем sudo дойдёт до проверки. NoNewPrivileges=true завершает sudo сообщением The 'no new privileges' flag is set, а ProtectSystem=full оставляет /usr доступным только для чтения даже для root внутри службы. Оба удаляются:
sed -i '/^NoNewPrivileges=true$/d; /^ProtectSystem=full$/d' /etc/systemd/system/dsh.service
systemctl daemon-reload
systemctl restart dsh
systemctl show dsh -p NoNewPrivileges -p ProtectSystem
sudo -u dsh sudo -n apt-get update Ожидаются NoNewPrivileges=no, пустой ProtectSystem и успешное обновление списка пакетов. Ключи PrivateTmp, ProtectHome и ReadWritePaths остаются: работе apt они не мешают, а порядок в каталогах поддерживают.
После этого шага у агента root на машине. Отдельный пользователь, режим workspace-write и политика подтверждений держатся только на добросовестности того, что попадёт в контекст: одной вредоносной подсказки на открытой странице или одного плагина сомнительного происхождения достаточно, и команда выполнится с правами root.
Отсюда требование к остальному периметру: раз внутренние границы сняты, значение приобретают внешние — список доступа перед портом, межсетевой экран, пропускающий только адрес прокси, и осознанный отбор дополнений из шага «Дополнения». Машину с такой настройкой следует считать выделенной под агента: соседство с чужими рабочими средами, ключами поставщиков моделей и токенами хостинга репозиториев означает, что цена одной неудачной подсказки — все они разом.
Проверка на Playwright
sudo -u dsh -H bash -lc '
cd /opt/dsh/work/проект
npm i -D @playwright/test
npx playwright install-deps chromium
npx playwright install chromium
npx playwright screenshot --browser chromium https://example.com /tmp/t.png
' Браузер ложится в /opt/dsh/.cache/ms-playwright и занимает около двухсот мегабайт. Полученный снимок означает, что цепочка рабочая: агент поставил системные зависимости сам и запустил браузер.
Песочница: почему bash вообще отказывается выполняться
Режим workspace-write не декларация, а работающее ограничение: каждая команда bash запускается внутри песочницы файловой системы. Строится она средствами операционной системы — bubblewrap или Landlock на Linux, sandbox-exec на macOS, ограниченный маркер доступа на Windows. Если ни один механизм не доступен, каркас отказывается выполнять команду:
sandbox mode "workspace-write" is requested but no sandbox backend
is usable on this host; refusing to run the command unconfined Отказ обманчив тем, что выглядит как проблема прав или настроек сессии, хотя относится к ядру. Инструменты чтения при этом продолжают работать — они идут другим слоем, — и агент честно сообщает, что читать может, а выполнять нет.
Проверка, чего именно не хватает:
sysctl kernel.unprivileged_userns_clone user.max_user_namespaces kernel.apparmor_restrict_unprivileged_userns
grep -i landlock /boot/config-$(uname -r) Типовая причина на Ubuntu — единица в kernel.apparmor_restrict_unprivileged_userns. Landlock при этом собран в ядре и стоит первым в CONFIG_LSM, пространства имён не урезаны, но создать их непривилегированному процессу AppArmor не даёт, и bubblewrap не поднимается. Лечится так:
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
echo 'kernel.apparmor_restrict_unprivileged_userns=0' > /etc/sysctl.d/99-userns.conf
apt install -y bubblewrap
sudo -u dsh bwrap --ro-bind / / --dev /dev true && echo 'песочница работает' Файл в /etc/sysctl.d закрепляет настройку после перезагрузки. Затем служба перезапускается, чтобы каркас заново определил доступные механизмы.
Проверяется песочница на живой сессии, а не по выводу команд: агенту даётся задание записать файл за пределами рабочего каталога, например touch /etc/proba. В режиме workspace-write он должен получить отказ. Созданный файл означает, что ограничение не действует.
Обратная сторона понятна из шага целиком: политика danger-full-access и подтверждения в положении never снимают песочницу вовсе. Вместе с полным sudo это означает, что любая команда из контекста выполняется немедленно и от root. Для узла, работающего без присмотра, песочницу стоит вернуть — она остаётся последним ограничителем.
Доступ к Docker
Отдельной настройки больше не требуется — с полным sudo агент добавит себя в группу сам. Если членство нужно сразу:
usermod -aG docker dsh
systemctl restart dsh
sudo -u dsh docker ps В юните задан PrivateTmp=true, поэтому у службы собственный /tmp. Файл, положенный агентом в /tmp, демон Docker при монтировании не увидит. Каталоги сборки размещаются в /opt/dsh/work.
Шаг 7. Публикация через Nginx Proxy Manager
Nginx Proxy Manager закрывает разом четыре задачи, которых у dsh нет: имя, сертификат Let’s Encrypt с автоматическим продлением, принудительный HTTPS и пароль перед портом через список доступа. Настройка выполняется в веб-интерфейсе, файлы конфигурации nginx править не требуется.
Цепочка выглядит так: браузер → домен → узел в Nginx Proxy Manager → внешний адрес машины с dsh → переброска на 127.0.0.1:3080.
Последнее звено обязательно. Nginx Proxy Manager на другой машине до петли не дотянется физически, а dsh web внешний адрес не слушает. Это первая типовая ошибка при разнесённой инфраструктуре: узел заведён, домен отвечает, а в браузере 502.
7.1. Переброска порта на машине с dsh
Роль переброски — отдать петлю наружу на одном высоком порту, закрытом межсетевым экраном для всех, кроме адреса прокси. Достаточно socat:
apt install -y socat
cat > /etc/systemd/system/dsh-forward.service <<'EOF'
[Unit]
Description=dsh 3081 -> 127.0.0.1:3080
After=dsh.service
Requires=dsh.service
[Service]
ExecStart=/usr/bin/socat TCP-LISTEN:3081,fork,reuseaddr TCP:127.0.0.1:3080
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now dsh-forward
ss -tlnp | grep 3081 Служба привязана к dsh.service через Requires: остановка каркаса закрывает и внешний порт.
Если Nginx Proxy Manager работает на той же машине в контейнере, переброска не нужна вовсе — узел направляется на адрес мостовой сети Docker, а dsh остаётся на петле:
ip -4 addr show docker0 Полученный адрес (обычно 172.17.0.1) указывается в поле Forward Hostname / IP, порт — 3080. Значение 127.0.0.1 в этом поле не работает: внутри контейнера это его собственная петля.
7.2. Межсетевой экран и Fail2Ban
ufw allow proto tcp from АДРЕС_ПРОКСИ to any port 3081 comment 'dsh via npm'
ufw status numbered Fail2Ban настраивается до того, как Nginx Proxy Manager начнёт обращаться к порту. Пока порт закрыт, повторяющиеся попытки прокси распознаются изолятором как сканирование, и его адрес попадает в бан. Запрещающее правило встаёт в ufw выше разрешающего — результатом становится 502 при полностью работоспособной службе.
sed -i 's|^ignoreip = .*|& АДРЕС_ПРОКСИ|' /etc/fail2ban/jail.local
fail2ban-client reload
grep ignoreip /etc/fail2ban/jail.local Ключевая деталь: ignoreip предотвращает новые баны, но не снимает уже наложенный. Попавший в список адрес выпускается вручную:
fail2ban-client status ufw-scan
fail2ban-client set ufw-scan unbanip АДРЕС_ПРОКСИ Ответ 1 означает снятый бан. Наличие десятков посторонних адресов в списке банов на машине хостера — норма, искать следует именно адрес прокси.
Правила ufw удаляются по содержимому, а не по номеру. Нумерация смещается по мере истечения банов Fail2Ban, и командой с устаревшим номером легко снести собственное разрешающее правило — с тем же результатом 502 и часом поисков.
7.3. Узел в Nginx Proxy Manager
Домен направляется A-записью на адрес машины с прокси. Далее Hosts → Proxy Hosts → Add Proxy Host.
Вкладка Details:
| Поле | Значение |
|---|---|
| Domain Names | dsh.example.com — ровно то имя, что задано в --trusted-host |
| Scheme | http — внутри цепочки шифрование не нужно |
| Forward Hostname / IP | внешний адрес машины с dsh (или адрес моста docker, если прокси на той же машине) |
| Forward Port | 3081 при переброске, 3080 при работе через мост |
| Cache Assets | выключено — интерфейс отдаёт собственные заголовки |
| Block Common Exploits | включено, но снимается первым при разборе отказов на /api |
| Websockets Support | включено обязательно |
| Access List | созданный список с паролем |
Без поддержки веб-сокетов интерфейс откроется и даже примет запрос, но ответ модели не появится: страница держит постоянное соединение, и без него сессия не обновляется. Симптом выглядит как зависание, а не как ошибка.
Вкладка SSL: Request a new SSL Certificate, включить Force SSL и HTTP/2 Support, указать почту, согласиться с условиями Let’s Encrypt. HSTS включается по желанию и только после того, как домен заработал.
Работа допустима только по HTTPS. Веб-интерфейс использует Web Crypto API, доступный исключительно в защищённом контексте, и по обычному http страница падает с crypto.randomUUID is not a function. Force SSL обязателен, а не желателен.
Вкладка Advanced. Ответы модели приходят потоком и держат соединение минутами, поэтому стандартных таймаутов nginx не хватает — длинный ответ обрывается на середине:
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
proxy_request_buffering off;
client_max_body_size 100m; Отключённая буферизация здесь не тонкая настройка, а условие работы: с ней nginx копит ответ целиком и отдаёт его одним куском, из-за чего постепенное появление текста в интерфейсе пропадает.
7.4. Если заслон доверия всё равно отклоняет запросы
Бывает, что домен указан в --trusted-host, узел настроен верно, а интерфейс по-прежнему открывается пустым или отвечает отказом на /api. Тогда помогает приём, который стоит держать про запас: подменить заголовки так, будто запрос пришёл с самой машины.
location / {
proxy_pass http://адрес_машины_dsh:3081;
proxy_http_version 1.1;
proxy_set_header Host 127.0.0.1:3080;
proxy_set_header Origin http://127.0.0.1:3080;
proxy_set_header Referer http://127.0.0.1:3080/;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
} Блок вставляется в то же поле Custom Nginx Configuration и заменяет набор директив из предыдущего подраздела: таймауты и отключённая буферизация уже внутри него, а заголовки веб-сокетов заданы вручную.
Собственный location / перекрывает блок, который Nginx Proxy Manager создаёт сам, а вместе с ним и проверку списка доступа. Список остаётся выбранным в интерфейсе, но пароль не спрашивается, и вход держится на одном токене. Это осознанный размен: приём применяется тогда, когда без него интерфейс не работает вовсе.
7.5. Список доступа — тот самый пароль перед портом
Access Lists → Add Access List: имя произвольное, на вкладке Authorization заводится пара имени и пароля, на вкладке Access — правило Allow для нужных адресов или 0.0.0.0/0, если вход должен работать отовсюду. Флажок Satisfy Any означает «достаточно любого из условий»; при пароле для всех адресов его следует снять.
Получается два независимых слоя: пароль списка доступа отсекает посторонних до того, как запрос дойдёт до каркаса, а токен dsh отсекает тех, кто пароль всё же узнал. Ни один из них не заменяет другой: список доступа не различает пользователей внутри интерфейса, а токен общий и живёт до перезапуска.
Когда домен не требуется
Для разовой работы с одной машины вся эта цепочка заменяется одной командой:
ssh -N -L 3080:127.0.0.1:3080 пользователь@сервер Интерфейс открывается по http://127.0.0.1:3080, петля считается защищённым контекстом, и Web Crypto API работает без сертификата. Отпадают узел, правило ufw и оговорки про Fail2Ban. Плата — доступ только оттуда, где есть ключ SSH: ни с телефона, ни из чужого браузера войти не получится. Именно поэтому основной схемой остаётся Nginx Proxy Manager.
Шаг 8. Вход по токену и диагностика по кодам ответа
Собственная проверка входа у веб-интерфейса есть: без токена сервер отвечает 401 Unauthorized на любой запрос, включая curl с самой машины. Ссылка с токеном печатается в журнал при запуске:
dsh web: http://127.0.0.1:3080/?token=<TOKEN> Открывается собственный домен с тем же параметром:
https://dsh.example.com/?token=<TOKEN> Токен меняется при каждом перезапуске службы и в $DSH_HOME не сохраняется. При проверке следует учитывать, что grep -r token /opt/dsh/.dsh даёт ложное совпадение с settings.yaml: слово встречается в строке maxTokensField: max_tokens.
Для повседневной работы удобен помощник:
cat >> /root/.bashrc <<'EOF'
dshurl() {
echo "https://dsh.example.com/?token=$(journalctl -u dsh -n 40 --no-pager | grep -o 'token=[A-Za-z0-9_-]*' | tail -1 | cut -d= -f2)"
}
EOF
source /root/.bashrc
dshurl Коды ответа указывают на слой обрыва
| Наблюдаемый результат | Место обрыва |
|---|---|
401 от curl на петле | штатное поведение: dsh запрашивает токен |
| Окно с запросом пароля браузера | список доступа работает, цепочка до nginx цела |
401 после ввода пароля | цепочка цела целиком: нужен параметр ?token= |
| 502 от Nginx Proxy Manager или Cloudflare | источник недостижим: не работает переброска, ufw или Fail2Ban |
| Страница открылась, ответ не приходит | не включена поддержка веб-сокетов |
| Ответ обрывается на середине | таймауты и буферизация на вкладке Advanced |
Запросы к /api отклонены | домен не указан в --trusted-host либо мешает Block Common Exploits |
Connection refused с машины прокси | не работает dsh-forward.service |
| Таймаут с машины прокси | межсетевой экран |
Проверка выполняется с машины прокси, а не с той, где установлен dsh:
curl -sv --max-time 10 http://адрес_машины_dsh:3081 2>&1 | tail -20 Ответ 401 на этом шаге — хороший знак: он означает, что до каркаса запрос дошёл и разбираться дальше нужно уже в настройках узла.
Шаг 9. Первая настройка интерфейса
- Settings → Models — поставщик
openrouterдолжен присутствовать в списке. Выбранная модель становится моделью по умолчанию для новых сессий. - Choose workspace — добавляется и выбирается
/opt/dsh/work. До выбора рабочего каталога поле ввода сессии недоступно. - Пробная задача для проверки цепочки целиком.
Изменения моделей применяются со следующего запроса, перезапуск сервера не требуется. Однако сессия, уже отправившая запрос, остаётся на модели, записанной в её собственном журнале: после смены настроек начинается новый разговор.
Дополнения: как выбирать и в каком порядке ставить
Экосистема выглядит внушительно: несколько сотен репозиториев по метке dsh-plugin, пять конкурирующих сводных списков, десятки новых проектов еженедельно. При этом большинство проектов моложе месяца, у значительной части менее сотни звёзд, а сам каркас меняется под ними.
Плагин здесь не просто зависимость. Это модуль, загружаемый в живой контекст, способный регистрировать инструменты, службы, элементы интерфейса и перехватчики в конвейере вызовов. Он работает внутри процесса, у которого есть оболочка и ключи поставщиков.
Один из сводных списков прямо указывает, что ежедневный отбор ограничивается статической проверкой сценариев, зависимостей, точек входа и чувствительных операций — это не полный аудит и не гарантия совместимости.
Механика установки
Плагины ставятся не в каталог с исходниками, а в профиль внутри $DSH_HOME. Команда передаёт аргументы в pnpm с каталогом профиля в качестве рабочего, поэтому add, remove и update работают как обычно:
sudo -u dsh -H bash -lc 'DSH_HOME=/opt/dsh/.dsh pnpm --dir /opt/dsh/deepseek-harness dsh plugin --profile web add ПАКЕТ'
systemctl restart dsh После каждого успешного запуска состав слоёв сверяется с установленным: зависимость, чей манифест объявляет ключ dsh.bundle.patch, входит в стек слоёв, зависимость без такого объявления остаётся обычной с однократным предупреждением, удалённая — покидает стек.
Две типовые ловушки:
- Сборка при установке. Плагины из git, собирающиеся собственным сценарием
prepare, блокируются pnpm 10. Первыйaddпадает с подсказкой проallowBuildsи указанием наpnpm-workspace.yamlпрофиля — напечатанный ключ копируется туда, после чего команда повторяется. - Конфликт слоёв. Команда
dsh plugin addдобавляет в слой пакетов все зависимости профиля, объявившиеdsh.bundle, и при пересечении с уже установленным каркас падает при запуске сduplicate loader entry id.
Отсюда рабочее правило: по одному пакету за раз, с перезапуском службы и проверкой журнала между установками.
Добавление и удаление плагинов работает только с самой машины, с 127.0.0.1:3080. Через домен интерфейс ведёт разговоры, но управлять плагинами не может: дополнения ставятся командой в консоли.
Минимальный набор для серверной установки
Проверка безопасности. dsh-security-audit работает только на чтение: настройки, происхождение плагинов, сессии, сетевая открытость. Ставится первым и запускается заново после каждого следующего дополнения.
Политика подтверждений. dsh-auto-mode (отказ по умолчанию, проверки защищённых путей и учётных данных) либо dsh-tool-approval (ручное подтверждение вызовов). Это прямое закрытие исходного ограничения: по умолчанию агент читает произвольные файлы и обращается в сеть без подтверждения.
Политика проверяется на живой сессии: агенту даётся задание прочитать файл с учётными данными. Молча выполненное чтение означает, что политика не работает.
Состав контекста. dsh-context показывает, из чего набрано контекстное окно и как оно меняется. При работе через OpenRouter это единственный вменяемый способ контролировать расход: счётчики баланса (dsh-cost-meter, dsh-billing, dsh-balance-meter) привязаны к счёту DeepSeek и покажут пустоту. Фактические расходы отслеживаются на странице активности OpenRouter.
Клиент MCP входит в поставку как зависимость для слоёв правок, но ни один сервер не включён по умолчанию: команда каждого сервера — доверенный исполняемый код за пределами песочницы агента. При наличии собственной автоматизации это самая ценная связка, но включается она осознанно, слоем правок.
Почему dsh-passwords здесь не нужен
Плагин dsh-passwords добавляет каркасу собственный заслон с входом, подучётными записями, ограничениями по токенам и времени и автоматическим сертификатом. При публикации через Nginx Proxy Manager большая часть этого дублируется, а сертификат прямо конфликтует со схемой: плагин занимает порты 80 и 443 и тянет собственный Let’s Encrypt, тогда как TLS уже завершается на прокси. Обход требует MCP_GATEWAY_AUTO_TLS=0 и высокого порта.
Плагин оправдан при нескольких пользователях с разными квотами — список доступа nginx такого не умеет. Для одного оператора пара «список доступа плюс токен» закрывает задачу меньшими средствами.
Плагины, правящие файлы каркаса накладкой, при сборке из исходников конфликтуют с git pull: изменённые файлы отслеживаются репозиторием, и обновление либо отказывается идти, либо затирает правки. Перед обновлением такие дополнения снимаются, после — ставятся заново.
Что на сервере не нужно
Терминальные интерфейсы, пусковые программы для Windows, настольные клиенты, управление браузером и компьютером (требуют рабочего стола), оформление и мини-игры. Плагин dsh-ssh (удалённое выполнение, SFTP, ProxyJump) выглядит привлекательно при парке машин, но фактически означает выдачу агенту ключей от остальных серверов — до настроенной и проверенной политики подтверждений его установка откладывается.
Рекомендуемый порядок
Проверка безопасности → политика подтверждений → проверка на живой сессии → панель контекста → MCP. Каждое дополнение закрепляется на версии, а не на ветке: при предварительном выпуске обновление плагина и обновление каркаса регулярно конфликтуют.
Обновление без сюрпризов
Обновление — это git, установка зависимостей и обязательная пересборка. Пропуск последнего шага не выдаёт ошибки: служба поднимется на прежних артефактах.
systemctl stop dsh
sudo -u dsh -H bash -lc '
cd /opt/dsh/deepseek-harness
git rev-parse HEAD
git pull
pnpm install
pnpm run build
'
systemctl start dsh
sleep 25
systemctl status dsh --no-pager
curl -sI http://127.0.0.1:3080 | head -1 Команда git rev-parse HEAD стоит перед git pull намеренно: работающая ревизия записывается до того, как её станет негде взять. Это единственный способ вернуться к рабочему состоянию при предварительном выпуске.
Откат выполняется тем же набором команд с переходом на записанный хеш:
systemctl stop dsh
sudo -u dsh -H bash -lc '
cd /opt/dsh/deepseek-harness
git checkout ЗАПИСАННЫЙ_ХЕШ
pnpm install
pnpm run build
'
systemctl start dsh Пусковой механизм не проверяет свежесть артефактов, поэтому устаревшая сборка интерфейса продолжает работать до пересборки. Ситуация «обновление выполнено, изменений нет» практически всегда означает пропущенный pnpm run build. Узел в Nginx Proxy Manager при этом трогать не нужно: адрес и порт не меняются, отдаётся лишь 502 на время остановки.
Когда достаточно пакета из npm
Если правки в каркас не вносятся и отставание опубликованной версии от ветки не мешает, сборку можно заменить установкой npm install -g @deepseek-ai/dsh в тот же префикс из шага 2. Тогда отпадают подкачка, минуты ожидания и риск забыть пересборку, а обновление сводится к одной команде с точным номером версии. Шаги 2 и 5–9 при этом не меняются — кроме строки ExecStart, где вместо pnpm указывается /opt/dsh/.npm-global/bin/dsh.
Сводная таблица ошибок
| Симптом | Причина |
|---|---|
| Установлена посторонняя программа | выполнено apt install dsh; каркас ставится только из репозитория или npm |
Unable to locate executable '/usr/bin/pnpm' | pnpm установлен рядом с другим Node; путь берётся из command -v pnpm |
JavaScript heap out of memory | памяти меньше, чем требует сборка: добавляется подкачка (потолок кучи сборочный сценарий задаёт сам) |
| «Обновление выполнено, изменений нет» | пропущен pnpm run build после git pull |
| Ошибки разрешения модулей при старте | артефакты не собраны: pnpm run build не выполнялся вовсе |
| Предупреждение об игнорированных сборках | сценарии зависимостей пропущены; нужен pnpm approve-builds и повторный pnpm install |
| Инструменты с оболочкой отказывают в сессии | не собраны node-pty и koffi: установка прошла без установочных сценариев |
| Пакеты встали не туда, куда ожидалось | вызов sudo -u dsh без ключа -H: прочитана настройка root |
EACCES при npm install -g от агента | не задан пользовательский prefix для npm |
| Программа, установленная агентом, не найдена | /opt/dsh/.npm-global/bin отсутствует в PATH юнита или PATH дописан в .bashrc вместо .profile |
| Не к чему откатиться после неудачного обновления | ревизия не записана до git pull; ветка ориентиром не служит |
[Unit]: command not found | содержимое юнита вставлено в оболочку; требуется cat > файл <<'EOF' |
settings.yaml: No such file or directory | не создан $DSH_HOME (/opt/dsh/.dsh) |
--host 0.0.0.0 завершается ошибкой | не поддерживается умышленно; нужна переброска порта |
| Узел заведён, но отдаёт 502 | на машине с dsh ничего не слушает внешний адрес: не запущен dsh-forward.service |
| 502 при прокси на той же машине | в поле Forward указан 127.0.0.1 вместо адреса моста docker |
| 502 при работающей службе и открытом порте | Fail2Ban забанил адрес прокси: нужны ignoreip и unbanip |
| Разрешающее правило ufw исчезло | удаление по номеру после смещения нумерации истёкшими банами |
| Страница открылась, ответ модели не приходит | в узле не включена Websockets Support |
| Длинный ответ обрывается | стандартные таймауты nginx; задаются на вкладке Advanced |
| Текст появляется одним куском в конце | включена буферизация; отключается proxy_buffering off |
Интерфейс открывается, запросы к /api отклоняются | домен не задан в --trusted-host либо мешает Block Common Exploits; при упорстве — подмена Host/Origin/Referer из 7.4 |
| Пароль списка доступа не спрашивается | собственный location / перекрывает блок, создаваемый Nginx Proxy Manager |
crypto.randomUUID is not a function | вход по http: не включён Force SSL |
UNKNOWN_MODEL на OpenRouter | лишняя приставка openrouter/ в идентификаторе модели |
| Шлюз отвергает все запросы при верном ключе | требуются compat.supportsDeveloperRole: false и maxTokensField: max_tokens |
| Правка модели не применилась | старая сессия держит модель из своего журнала; начинается новая |
401 на любой запрос, включая петлю | штатное поведение: требуется ?token= из журнала запуска |
| Токен не подходит после перезапуска | токен новый; извлекается из журнала заново |
dsh plugin add не выполняется | в PATH пользователя нет pnpm |
Первый add плагина из git падает | pnpm 10 блокирует сборку; ключ из подсказки вносится в pnpm-workspace.yaml профиля |
duplicate loader entry id при запуске | пересечение слоёв после dsh plugin add |
| Плагин не ставится через домен | управление плагинами работает только локально |
| Правки плагина пропали после обновления | накладка на файлы каркаса стёрта заменой каталога модуля |
sudo: The 'no new privileges' flag is set | из юнита не удалены NoNewPrivileges и ProtectSystem; см. шаг 6 |
no sandbox backend is usable on this host | единица в kernel.apparmor_restrict_unprivileged_userns; bubblewrap не поднимается |
sudo: I'm sorry … I can't do that при install-deps | установщик вызывает оболочку, а не apt-get; узкий список путей в sudoers для этого не годится |
Файл из /tmp не виден в контейнере | PrivateTmp=true: у службы собственный /tmp |
Безопасность и телеметрия
Перед запуском рекомендуется прочитать SAFETY.md в репозитории — на этом разработчики настаивают в описании проекта.
Телеметрия по умолчанию остаётся локальной. При её включении в выгрузку попадают тексты сообщений, аргументы и результаты вызовов инструментов и пути рабочего каталога; правил вычистки чувствительных данных в поставке нет. Любое непустое значение DSH_TELEMETRY_DISABLED означает окончательный отказ от выгрузки.
Итоговый периметр складывается из четырёх независимых слоёв: отдельный непривилегированный пользователь, защитные ключи systemd, список доступа на прокси и межсетевой экран, пропускающий только адрес прокси. Отключение любого из них обесценивает остальные три.
Заключение
Сборка из исходников плюс Nginx Proxy Manager дают узел, который работает на свежем коде и при этом остаётся управляемым: ревизия закреплена и записана, откат выполняется теми же четырьмя командами, а имя, сертификат, принудительный HTTPS и пароль перед портом настраиваются в браузере и переживают перезапуски без правки конфигурационных файлов.
Тонкие места сосредоточены в двух точках. Первая — последнее звено цепочки: прокси на соседней машине до петли не дотянется, поэтому переброска порта, правило ufw и адрес прокси в ignoreip обязательны, иначе 502 при полностью исправной службе. Вторая — три настройки узла, без которых интерфейс выглядит сломанным при рабочем соединении: поддержка веб-сокетов, Force SSL и таймауты с отключённой буферизацией.
Третья точка — дисциплина обновления: git pull без pnpm run build тихо оставляет старый интерфейс, а ревизия, не записанная заранее, лишает возможности вернуться. Всё остальное — разграничение прав. Агент по умолчанию читает произвольные файлы и обращается в сеть, поэтому отдельный системный пользователь, защитные ключи юнита и осознанный выбор схемы доступа к Docker дают больше, чем любой набор дополнений.









