Задача звучала безобидно: внешний верстальщик сдаёт статичную вёрстку в GitLab, и она сама должна появляться на тестовом поддомене. Пуш в ветку — и через минуту можно смотреть. Самое простое решение — подключить его проект к тому же раннеру, которым выкладываются сайты. И это ровно то решение, после которого человек с правом пуша владеет всем сервером.
Это третья часть цикла «Общий сервер: безопасность и восстановление».
Почему простое решение опасно
Раннер-доставщик на общем сервере обычно устроен одинаково: контейнер с GitLab Runner, в который смонтирована папка со всеми сайтами на запись, джобы выполняются от root внутри контейнера, раннер групповой и берёт задания любого проекта группы. Для своих проектов это терпимо: команды в CI пишем мы сами.
Но команды выполняет не раннер, а файл .gitlab-ci.yml из репозитория. Кто может изменить этот файл в любом проекте, где доступен такой раннер, тот выполняет произвольные команды с правами на все сайты: прочитать wp-config.php соседа, подменить его код, удалить всё. Право Developer в GitLab этого достаточно — ветку можно защитить, но CI запускается и на других ветках.
Раннер с общим доступом к файлам сервера = ключ от сервера у каждого, кто может пушить в проект с этим раннером. Проверьте, кому в группе доступны ваши групповые раннеры.
Как сделали
Три независимых барьера, каждый закрывает свою дыру.
1. Отдельный раннер с минимумом прав
- проектный, а не групповой: подключается вручную к проектам внешних исполнителей;
- отдельный тег, «запускать задания без тегов» выключено — чужие джобы он не берёт;
- внутри контейнера работает от пользователя веб-сервера, не от root;
- видит только папки, перечисленные в его compose, — по одной на внешний проект;
- без сокета Docker, без привилегий, с лимитом памяти 256 МБ.
services:
runner:
build: .
user: "3000:3000" # тот же uid, что у php-fpm и nginx сайта
read_only: true
mem_limit: 256m
volumes:
- ./config:/etc/gitlab-runner
- /server/sites/dev.example.com/src:/deploy/dev.example.com # и больше ничего
restart: unless-stopped
Новый раннер под каждый проект не нужен: одного хватает на всех внешних, им добавляется строка в volumes.
2. CI-файл не в репозитории исполнителя
Даже ограниченный раннер выполнит любую команду из CI-файла в пределах своей папки. Поэтому сам файл лежит в нашем отдельном репозитории, а в настройках проекта исполнителя указан путь к нему (Settings → CI/CD → General pipelines → CI/CD configuration file):
dev-example.yml@our-group/ci-configs
Исполнителю в репозитории с конфигами — роль Reporter: читать файл GitLab ему нужно, менять — нет. Свой .gitlab-ci.yml в его репозитории просто игнорируется.
Путь назначения в команде выкладки пишите литералом, а не переменной. Переменные CI можно переопределить при пуше (push-опции, переменные ручного запуска), и rsync dist/ $TARGET превратится в «разложи куда скажу».
3. Сборка — не на боевом сервере
npm ci и npm run build исполняют код из package.json и всех зависимостей — то есть опять произвольный код. Сборка идёт на другом сервере в контейнере node:20, а на боевой раннер приезжает только готовая папка dist/ артефактом:
build:
tags: [build] # раннер на сборочном сервере
image: node:20
script:
- if [ -f package-lock.json ]; then npm ci; else npm install; fi
- npm run build
artifacts: { paths: [dist/], expire_in: 1 day }
deploy:
tags: [designer-deploy] # ограниченный раннер на боевом сервере
needs: [build]
script:
- test -f dist/index.html || { echo "в сборке нет dist/index.html"; exit 1; }
- rsync -a --delete dist/ /deploy/dev.example.com/
Закрыть тестовый поддомен от поисковиков — не файлами исполнителя
Вёрстка на открытом поддомене — готовый дубль будущего сайта в индексе. Закрываем на уровне nginx, чтобы ни один файл из сборки это не отменил: на каждом ответе X-Robots-Tag: noindex, nofollow, noarchive, /robots.txt всегда отвечает Disallow: /.
add_header в nginx наследуется только пока в блоке нет своих add_header. Добавили в location для статики заголовок кэширования — и X-Robots-Tag с уровня server на эти ответы больше не приходит. Проверка: curl -sI https://dev.example.com/style.css | grep -i x-robots.
Мелочи, на которых споткнулись
- свежий аккаунт исполнителя без подтверждения личности в GitLab не запускает конвейеры вовсе — ошибка выглядит как «некорректный YAML» с подсказкой user not verified;
- если в проекте включены общие облачные раннеры GitLab, сборка может уехать на них и упасть — их выключаем, групповые оставляем (нужен сборочный);
- в бесплатном GitLab лимит участников на всю группу — пять человек; внешний исполнитель занимает место один раз, в скольких бы проектах ни был;
- папке сайта нужен тот же владелец, что у раннера, иначе
rsyncупадёт сPermission denied.
Как закрыть доступ, когда работа закончена
- убрать исполнителя из участников проекта и репозитория с конфигами;
- убрать папку его сайта из
volumesраннера и пересоздать контейнер; - отключить раннер в настройках проекта.
Подключаете внешних исполнителей к своему CI?
Покажите, как устроены раннеры, — найду, где у исполнителя больше прав, чем нужно для его задачи.
// загружаю комментарии…