All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI / openapi-codegen-check (pull_request) Successful in 1m58s
CI / backend-tests (pull_request) Successful in 17m16s
Selectel заменил адрес сервера в ночь на 27.08 по нашей заявке: старый 188.246.224.93 фильтровался российскими операторами и не открывался ни с домашнего интернета, ни с мобильного (#3110). Новый адрес фильтрации не имеет — проверено с машины владельца после переключения DNS. Замена уронила сервер на 10 часов: адрес на порте сменился, а в ОС остался прежний (разбор и восстановление — #3119). Здесь только то, что осталось в репозитории. Единственное функциональное вхождение — дефолт HOST_IP в ops/selectel-ci-access.sh: скрипт открывает раннеру доступ на прод-хост, и с прежним значением он молча настроил бы доступ на адрес, которого у нас больше нет. Остальные семь — тексты комментариев в Caddyfile-секциях, compose, bootstrap-скриптах и раннбуке крона; они не исполняются, но именно по ним сверяются при переезде, и разошедшийся адрес в них дороже, чем кажется.
483 lines
36 KiB
Bash
483 lines
36 KiB
Bash
#!/usr/bin/env bash
|
||
# Доступ CI-раннера на новый выделенный сервер (Selectel Poincare, 188.124.37.140).
|
||
#
|
||
# ── ЗАЧЕМ ЭТОТ СКРИПТ ВООБЩЕ ПОЯВИЛСЯ (#3029) ─────────────────────────────────
|
||
#
|
||
# По переезду 30.08.2026 (#2989, #3057) Forgejo и его раннеры ОСТАЮТСЯ на Beget
|
||
# (46.173.16.127), а цель деплоя уезжает на Selectel. Сегодня раннер и прод —
|
||
# одна машина: ssh-сессия деплоя фактически локальная, и цена того, что
|
||
# подлинность хоста нигде не проверяется, близка к нулю.
|
||
#
|
||
# После переезда РОВНО ТЕ ЖЕ сессии пойдут через интернет, а по ним через `envs:`
|
||
# едут GHCR_PAT, OPENAI_API_KEY, OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN —
|
||
# см. .forgejo/workflows/deploy.yml (два вызова appleboy/ssh-action), deploy-
|
||
# tradein.yml (вызов + сырой `ssh` в шаге «Resolve deployed base SHA»),
|
||
# deploy-infra.yml и deploy-obsidian.yml. Плюс сам DEPLOY_SSH_KEY, который
|
||
# раннер держит у себя.
|
||
#
|
||
# Отсюда задача: у робота должен быть СВОЙ ключ, ограниченный по источнику и по
|
||
# возможностям. Это половина периметра; вторая половина — проверка подлинности
|
||
# хоста на стороне раннера (fingerprint / known_hosts вместо
|
||
# StrictHostKeyChecking=no) — живёт в .forgejo/workflows/ и этим скриптом НЕ
|
||
# трогается.
|
||
#
|
||
# ── МАНДАТ: ТОЛЬКО ДОБАВЛЯЕМ ──────────────────────────────────────────────────
|
||
#
|
||
# Скрипт не удаляет и не сужает НИЧЕГО: ни одной строки из authorized_keys, ни
|
||
# одного правила ufw. Это прямое решение владельца от 24.08.2026, а не
|
||
# перестраховка автора: пока конвейер не проехал end-to-end на новый хост, любое
|
||
# удаление — способ остаться без доступа ровно тогда, когда он нужен.
|
||
#
|
||
# Поэтому:
|
||
# • новый CI-ключ ДОБАВЛЯЕТСЯ, старый (человеческий, из selectel-bootstrap.sh)
|
||
# остаётся рабочим;
|
||
# • узкое правило файрвола ДОБАВЛЯЕТСЯ, общее `allow 22/tcp` остаётся;
|
||
# • что и как сузить ПОТОМ — печатается в конце прогона отдельным блоком,
|
||
# который скрипт сознательно НЕ выполняет.
|
||
#
|
||
# Идемпотентен: повторный запуск не дублирует ни ключ, ни правило.
|
||
#
|
||
# Запуск (от root): ssh root@188.124.37.140 'bash -s' < ops/selectel-ci-access.sh
|
||
# (переменные — через ssh ... 'CI_DEPLOY_PUBKEY="..." bash -s' < ... либо
|
||
# положить скрипт на хост и запустить с окружением локально)
|
||
#
|
||
# ── Переменные окружения ──────────────────────────────────────────────────────
|
||
# CI_DEPLOY_PUBKEY — ПУБЛИЧНАЯ часть отдельного CI-ключа (обязательна).
|
||
# Приватная часть в этом скрипте не фигурирует вообще и
|
||
# на сервер не попадает: она живёт только в секрете
|
||
# Forgejo DEPLOY_SSH_KEY.
|
||
# CI_RUNNER_FROM — адрес(а) раннера для from= и для ufw, через запятую.
|
||
# По умолчанию 46.173.16.127 (Beget — там остаются
|
||
# Forgejo и раннеры). ПРОВЕРЬ фактический исходящий адрес
|
||
# раннера перед прогоном, см. блок проверки ниже.
|
||
# CI_DEPLOY_USER — учётка, в которую логинится раннер (по умолчанию
|
||
# gendesign — то же, что ADMIN_USER в selectel-bootstrap.sh
|
||
# и что лежит в секрете DEPLOY_USER).
|
||
# SSH_PORT — порт sshd. По умолчанию берётся из живого `sshd -T`,
|
||
# а если его не спросить — 22 (как в bootstrap).
|
||
# CI_KEY_COMMENT — метка ключа в authorized_keys (по умолчанию
|
||
# ci-deploy@forgejo-runner (#3029)). По ней ключ потом
|
||
# находят глазами.
|
||
# CI_ALLOW_ANY_SOURCE=yes — осознанно поставить ключ БЕЗ from=. Нужен, только
|
||
# если исходящий адрес раннера плавает. Для прода это
|
||
# плохая идея: ключ становится пригоден откуда угодно.
|
||
|
||
set -euo pipefail
|
||
|
||
CI_DEPLOY_PUBKEY="${CI_DEPLOY_PUBKEY:-}"
|
||
# Намеренно ${VAR-default}, а НЕ ${VAR:-default}: подстановка только когда
|
||
# переменная не задана вовсе. Если её задали пустой — это осознанное «хочу без
|
||
# from=», и такое должно упереться в явный отказ ниже, а не молча превратиться
|
||
# в адрес по умолчанию.
|
||
CI_RUNNER_FROM="${CI_RUNNER_FROM-46.173.16.127}"
|
||
CI_DEPLOY_USER="${CI_DEPLOY_USER:-gendesign}"
|
||
CI_KEY_COMMENT="${CI_KEY_COMMENT:-ci-deploy@forgejo-runner (#3029)}"
|
||
CI_ALLOW_ANY_SOURCE="${CI_ALLOW_ANY_SOURCE:-no}"
|
||
|
||
log() { printf '\n\033[1m>>> %s\033[0m\n' "$1"; }
|
||
warn() { printf '\033[33m[!] %s\033[0m\n' "$1"; }
|
||
die() { printf '\033[31m[x] %s\033[0m\n' "$1" >&2; exit 1; }
|
||
|
||
[ "$(id -u)" -eq 0 ] || die "запускать от root"
|
||
|
||
# Порт спрашиваем у живого sshd, а не предполагаем: правило файрвола для не того
|
||
# порта — это правило, которое ничего не разрешает, и обнаружится оно только
|
||
# первым красным деплоем.
|
||
EFFECTIVE_PORT="$(sshd -T 2>/dev/null | awk '/^port /{print $2; exit}' || true)"
|
||
SSH_PORT="${SSH_PORT:-${EFFECTIVE_PORT:-22}}"
|
||
if [ -n "$EFFECTIVE_PORT" ] && [ "$SSH_PORT" != "$EFFECTIVE_PORT" ]; then
|
||
warn "SSH_PORT=$SSH_PORT, а sshd слушает $EFFECTIVE_PORT — правило уйдёт на $SSH_PORT."
|
||
fi
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
log "0. Проверка входных данных"
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
[ -n "$CI_DEPLOY_PUBKEY" ] || die "CI_DEPLOY_PUBKEY не задан.
|
||
Ключ генерируется НА МАШИНЕ, где он будет храниться, и сюда приезжает только
|
||
публичная половина:
|
||
ssh-keygen -t ed25519 -C 'ci-deploy@forgejo-runner' -f ci_deploy_ed25519
|
||
Приватную (ci_deploy_ed25519) — в секрет Forgejo DEPLOY_SSH_KEY.
|
||
Публичную (ci_deploy_ed25519.pub) — сюда, в CI_DEPLOY_PUBKEY."
|
||
|
||
# Отдельный ключ для робота — не формальность. Человек и робот компрометируются
|
||
# по-разному (украденный ноут против утёкшего секрета в CI) и отзываются
|
||
# независимо. Если ключ один на двоих, отзыв по любому поводу выбивает обоих —
|
||
# и чинить приходится в момент, когда и так что-то горит. Поэтому ADMIN_PUBKEY
|
||
# из selectel-bootstrap.sh здесь НЕ переиспользуется.
|
||
#
|
||
# Механически отличить «человеческий» ключ от «робота» скрипт не может — в
|
||
# authorized_keys такого признака нет. Но подсунутый повторно ключ он не
|
||
# перепишет: строка с этим телом уже лежит в файле, и дело уйдёт в ветку
|
||
# «конфликт» ниже, которая по мандату ничего не трогает и зовёт человека.
|
||
|
||
# Ключ приходит извне — проверяем, что это вообще ключ, а не обрезанная копипаста.
|
||
# Строка с опциями (from=..., command=... в начале) тоже отвергается: опции
|
||
# складывает этот скрипт, чужие пришли бы мимо всей логики ниже.
|
||
read -r KEY_TYPE KEY_BLOB _ <<<"$CI_DEPLOY_PUBKEY" || true
|
||
case "${KEY_TYPE:-}" in
|
||
ssh-ed25519|ssh-rsa|ssh-dss|ecdsa-sha2-*|sk-ssh-ed25519@openssh.com|sk-ecdsa-sha2-*) ;;
|
||
*) die "CI_DEPLOY_PUBKEY начинается с '$KEY_TYPE' — это не голый публичный ключ.
|
||
Ожидается ровно 'ssh-ed25519 AAAA... [comment]', без опций в начале строки." ;;
|
||
esac
|
||
[ -n "${KEY_BLOB:-}" ] || die "в CI_DEPLOY_PUBKEY нет тела ключа"
|
||
|
||
KEY_TMP="$(mktemp)"
|
||
trap 'rm -f "$KEY_TMP"' EXIT
|
||
printf '%s %s\n' "$KEY_TYPE" "$KEY_BLOB" > "$KEY_TMP"
|
||
KEY_FP_LINE="$(ssh-keygen -lf "$KEY_TMP" 2>/dev/null || true)"
|
||
[ -n "$KEY_FP_LINE" ] || die "ssh-keygen не признал CI_DEPLOY_PUBKEY ключом — проверь копипасту"
|
||
KEY_FP="$(printf '%s' "$KEY_FP_LINE" | awk '{print $2}')"
|
||
# Тело ключа не секрет, но в лог прогона его целиком тащить незачем — дальше
|
||
# везде показываем огрызок + отпечаток, по которому ключ и опознаётся.
|
||
KEY_MASK="${KEY_BLOB:0:16}…${KEY_BLOB: -8}"
|
||
echo "ключ: $KEY_TYPE $KEY_MASK"
|
||
echo "отпечаток: $KEY_FP"
|
||
|
||
# Источник. Пустой from= — это ключ, пригодный откуда угодно; молча так делать
|
||
# нельзя, но и запрещать совсем — значит толкнуть человека править
|
||
# authorized_keys руками, что хуже.
|
||
if [ "$CI_ALLOW_ANY_SOURCE" = "yes" ]; then
|
||
CI_RUNNER_FROM=""
|
||
warn "CI_ALLOW_ANY_SOURCE=yes — ключ ставится БЕЗ ограничения по источнику."
|
||
warn "Годится для разовой отладки; для прода вернись и задай CI_RUNNER_FROM."
|
||
elif [ -z "$CI_RUNNER_FROM" ]; then
|
||
die "CI_RUNNER_FROM пуст. Без from= ключ работает откуда угодно — это ровно та
|
||
цена, ради снижения которой затевался #3029. Если так и надо, скажи это явно:
|
||
CI_ALLOW_ANY_SOURCE=yes"
|
||
else
|
||
echo "источник: $CI_RUNNER_FROM"
|
||
warn "Это ДОЛЖЕН быть адрес, с которого раннер выходит наружу (после NAT), а не"
|
||
warn "адрес контейнера. Проверить — с самого раннера: curl -s ifconfig.me"
|
||
fi
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
log "1. Куда пишем: учётка $CI_DEPLOY_USER и её authorized_keys"
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
# Пользователя НЕ создаём. Отдельная учётка для робота была бы чище, но она
|
||
# тянет за собой членство в docker-группе, права на /opt/gendesign и смену
|
||
# секрета DEPLOY_USER — то есть перенастройку действующего конвейера в момент,
|
||
# когда мандат — «ничего не менять, только добавить». Разделение учёток —
|
||
# кандидат в пост-переездные работы, не в эту правку.
|
||
id "$CI_DEPLOY_USER" >/dev/null 2>&1 \
|
||
|| die "пользователя '$CI_DEPLOY_USER' нет. Сначала ops/selectel-bootstrap.sh
|
||
(он создаёт ADMIN_USER), либо задай CI_DEPLOY_USER под существующую учётку."
|
||
|
||
# Адрес хоста для примеров в финальном блоке. Спрашиваем у самого хоста, чтобы
|
||
# напечатанные команды копировались как есть, даже если адрес не тот, что записан
|
||
# в комментариях выше.
|
||
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
|
||
HOST_IP="${HOST_IP:-188.124.37.140}"
|
||
|
||
HOME_DIR="$(getent passwd "$CI_DEPLOY_USER" | cut -d: -f6)"
|
||
[ -n "$HOME_DIR" ] && [ -d "$HOME_DIR" ] || die "домашний каталог '$CI_DEPLOY_USER' не найден"
|
||
AK="$HOME_DIR/.ssh/authorized_keys"
|
||
|
||
# sshd может читать ключи не оттуда, куда мы пишем (AuthorizedKeysFile,
|
||
# AuthorizedKeysCommand). Тогда всё ниже отработает «успешно» и не даст ничего.
|
||
AK_CONF="$(sshd -T 2>/dev/null | awk '/^authorizedkeysfile /{$1=""; print substr($0,2); exit}' || true)"
|
||
case " ${AK_CONF:-.ssh/authorized_keys} " in
|
||
*" .ssh/authorized_keys "*|*" %h/.ssh/authorized_keys "*) ;;
|
||
*) warn "sshd читает ключи из '${AK_CONF}' — это НЕ ~/.ssh/authorized_keys." ;;
|
||
esac
|
||
if sshd -T 2>/dev/null | grep -qi '^authorizedkeyscommand /'; then
|
||
warn "включён AuthorizedKeysCommand — ключи могут браться из него, а не из файла."
|
||
fi
|
||
sshd -T 2>/dev/null | grep -qi '^pubkeyauthentication yes' \
|
||
|| warn "PubkeyAuthentication не 'yes' — вход по ключу может не работать вовсе."
|
||
# AllowUsers/AllowGroups, если заданы, режут вход раньше проверки ключа.
|
||
if sshd -T 2>/dev/null | grep -qiE '^(allowusers|allowgroups) '; then
|
||
sshd -T 2>/dev/null | grep -iE '^(allowusers|allowgroups) ' | sed 's/^/ /'
|
||
warn "заданы allowusers/allowgroups — убедись, что '$CI_DEPLOY_USER' туда попадает."
|
||
fi
|
||
|
||
if [ ! -f "$AK" ]; then
|
||
install -d -m 700 -o "$CI_DEPLOY_USER" -g "$CI_DEPLOY_USER" "$HOME_DIR/.ssh"
|
||
touch "$AK"
|
||
chmod 600 "$AK"
|
||
chown "$CI_DEPLOY_USER:$CI_DEPLOY_USER" "$AK"
|
||
echo "$AK создан (был пуст)"
|
||
fi
|
||
# Если у последней строки файла нет перевода строки, наш `>>` приклеится к ней и
|
||
# испортит СРАЗУ ДВЕ строки: чужую и свою. Дописать недостающий перевод — не
|
||
# правка содержимого (ни один ключ при этом не меняется), а условие того, что
|
||
# дальнейшее дописывание вообще безопасно.
|
||
if [ -s "$AK" ] && [ "$(tail -c1 "$AK" | wc -l)" -eq 0 ]; then
|
||
printf '\n' >> "$AK"
|
||
warn "в $AK не было завершающего перевода строки — дописал, иначе ключи склеятся"
|
||
fi
|
||
|
||
AK_LINES_BEFORE="$(wc -l < "$AK" | tr -d ' ')"
|
||
echo "$AK: строк до правки — $AK_LINES_BEFORE"
|
||
|
||
# Явная защита от «а поставлю-ка я тот же ключ, что у человека».
|
||
if [ -s "$AK" ] && grep -qF "$KEY_BLOB" "$AK"; then
|
||
EXISTING_LINE="$(grep -F "$KEY_BLOB" "$AK" | head -1)"
|
||
else
|
||
EXISTING_LINE=""
|
||
fi
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
log "2. CI-ключ в authorized_keys"
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
# ── Почему from= И правило файрвола, а не что-то одно ─────────────────────────
|
||
#
|
||
# Это два разных рубежа, и они ловят разное.
|
||
#
|
||
# ufw (пакетный фильтр) отбрасывает TCP-пакет ДО того, как sshd его увидит.
|
||
# Он защищает ПОРТ целиком — от всех, включая переборы чужих учёток и
|
||
# попытки достать до дыры в самом sshd. Но он один на весь хост и ничего не
|
||
# знает про конкретный ключ: разрешив адрес, разрешаешь с него всё.
|
||
#
|
||
# from= проверяет sshd УЖЕ ПОСЛЕ того, как ключ подошёл, и привязан именно к
|
||
# ЭТОЙ строке authorized_keys. Он отвечает на другой вопрос: «этим ключом —
|
||
# откуда можно?». Утёк DEPLOY_SSH_KEY из секретов Forgejo — с ноутбука
|
||
# злоумышленника он бесполезен, даже если порт 22 открыт всему интернету.
|
||
# Обратное тоже верно: from= ничего не сделает против перебора паролей или
|
||
# эксплойта в sshd — там работает только файрвол.
|
||
#
|
||
# То есть ни один из двух не заменяет другой: файрвол закрывает порт от всех,
|
||
# from= обесценивает украденный ключ. Нужны оба.
|
||
#
|
||
# Оговорка про NAT: sshd сравнивает from= с адресом, который видит он сам —
|
||
# то есть с исходящим адресом раннера после NAT, а не с адресом контейнера.
|
||
# Для голого IP обратный DNS не используется (UseDNS тут ни при чём), так что
|
||
# правило детерминированное. Плата — при смене адреса раннера деплой умрёт с
|
||
# внятной записью в auth.log: «not from a permitted host».
|
||
#
|
||
# ── Почему restrict, и что из-под него возвращается ───────────────────────────
|
||
#
|
||
# restrict — зонтик: выключает СРАЗУ ВСЁ необязательное (проброс портов,
|
||
# проброс агента, X11, выделение pty, ~/.ssh/rc) и, что важнее, автоматически
|
||
# подхватывает будущие ограничения новых версий OpenSSH. Перечислять no-*-
|
||
# опции руками — значит однажды не дописать новую.
|
||
#
|
||
# Что нужно вернуть, чтобы деплой работал? Разбор фактических вызовов
|
||
# (.forgejo/workflows/deploy.yml ×2, deploy-tradein.yml, deploy-infra.yml,
|
||
# deploy-obsidian.yml + сырой `ssh` в шаге «Resolve deployed base SHA»):
|
||
#
|
||
# pty — НЕ нужен. appleboy/ssh-action@v1.0.3 не запрашивает
|
||
# терминал (вход request_pty по умолчанию false и ни в
|
||
# одном из вызовов не задан), сырой `ssh` идёт с командой
|
||
# и без -t. Единственный кандидат на «требует tty» —
|
||
# `sudo chown 1000:1000 reports` в deploy.yml, но
|
||
# selectel-bootstrap.sh кладёт NOPASSWD:ALL, пароля никто
|
||
# не спрашивает, а requiretty в Ubuntu по умолчанию нет.
|
||
# port-forwarding — НЕ нужен: ни -L, ни -R, ни ProxyJump в workflow нет.
|
||
# И именно это ограничение здесь самое ценное: без него
|
||
# украденный CI-ключ превращается в вход во внутреннюю
|
||
# сеть нового хоста (тот же Postgres), а не только в
|
||
# право выполнить деплой.
|
||
# agent-forwarding — НЕ нужен и вреден: раннер держит боевой ключ, и проброс
|
||
# агента дал бы скомпрометированному прод-хосту ходить
|
||
# этим ключом дальше.
|
||
# X11 / user-rc — не нужны очевидно.
|
||
#
|
||
# То есть возвращать не надо ничего: голого restrict хватает. Если когда-нибудь
|
||
# в workflow появится request_pty: true — деплой упадёт на «PTY allocation
|
||
# request failed», и лечится это дописыванием ,pty в опции ЭТОЙ строки.
|
||
#
|
||
# Рассмотрено и отвергнуто: command="..." (форсированная команда). Она бы
|
||
# ограничила ключ одним сценарием, но appleboy/ssh-action шлёт произвольный
|
||
# скрипт целиком — форсированная команда сломала бы КАЖДЫЙ деплой. Правильная
|
||
# форма этого — скрипт-обёртка на хосте, читающая $SSH_ORIGINAL_COMMAND; это
|
||
# отдельная работа и делать её под мандатом «только добавляем» нельзя.
|
||
|
||
KEY_OPTIONS="restrict"
|
||
# restrict появился в OpenSSH 7.2 (2016). На Ubuntu 24.04 (9.6) это не вопрос,
|
||
# но скрипт может уехать на хост постарше, а строка с неизвестной опцией просто
|
||
# перестаёт авторизовать — молча для всех, кроме auth.log.
|
||
SSH_VER="$(ssh -V 2>&1 | sed -n 's/^OpenSSH_\([0-9]*\)\.\([0-9]*\).*/\1 \2/p')"
|
||
if [ -n "$SSH_VER" ]; then
|
||
# shellcheck disable=SC2086
|
||
set -- $SSH_VER
|
||
if [ "$1" -lt 7 ] || { [ "$1" -eq 7 ] && [ "$2" -lt 2 ]; }; then
|
||
KEY_OPTIONS="no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty,no-user-rc"
|
||
warn "OpenSSH $1.$2 старше 7.2 — вместо restrict перечисляю опции явно."
|
||
warn "Минус: новые ограничения будущих версий сами не подхватятся."
|
||
fi
|
||
fi
|
||
if [ -n "$CI_RUNNER_FROM" ]; then
|
||
KEY_OPTIONS="from=\"$CI_RUNNER_FROM\",$KEY_OPTIONS"
|
||
fi
|
||
|
||
DESIRED_LINE="$KEY_OPTIONS $KEY_TYPE $KEY_BLOB $CI_KEY_COMMENT"
|
||
KEY_ACTION=""
|
||
|
||
if [ -z "$EXISTING_LINE" ]; then
|
||
# Единственная запись в файл во всём скрипте — и она строго дописывающая.
|
||
printf '%s\n' "$DESIRED_LINE" >> "$AK"
|
||
KEY_ACTION="добавлен"
|
||
echo "ключ добавлен"
|
||
elif [ "$EXISTING_LINE" = "$DESIRED_LINE" ]; then
|
||
KEY_ACTION="уже был, идентичен"
|
||
echo "ключ уже стоит ровно в таком виде — ничего не делаю (идемпотентность)"
|
||
else
|
||
# Ключ уже есть, но с другими опциями. Переписать строку — значит удалить
|
||
# существующую, а это прямо запрещено мандатом. Плюс перезапись здесь опаснее,
|
||
# чем кажется: если старая строка ШИРЕ (например, без from=), то она и будет
|
||
# авторизовать — sshd, наткнувшись на строку с несовпавшим from=, идёт дальше
|
||
# по файлу и находит следующую подходящую. Так что «дописать построже рядом»
|
||
# тоже не сработает: узкая копия не отменяет широкую.
|
||
KEY_ACTION="КОНФЛИКТ — есть с другими опциями"
|
||
warn "этот ключ уже в $AK, но с другими опциями. Строку НЕ трогаю (мандат)."
|
||
echo " сейчас: $(printf '%s' "$EXISTING_LINE" | sed "s|$KEY_BLOB|<$KEY_MASK>|")"
|
||
echo " надо бы: $(printf '%s' "$DESIRED_LINE" | sed "s|$KEY_BLOB|<$KEY_MASK>|")"
|
||
echo " Правится вручную и осознанно, после бэкапа:"
|
||
echo " cp $AK $AK.bak.\$(date +%F-%H%M)"
|
||
echo " \${EDITOR:-nano} $AK"
|
||
fi
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
log "3. Правило файрвола для адреса раннера"
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
# ЧЕСТНО И СРАЗУ: пока живо общее правило `allow $SSH_PORT/tcp` (его ставит
|
||
# selectel-bootstrap.sh, и оно ОСТАЁТСЯ), это узкое правило не защищает ничего.
|
||
# ufw разрешает по первому совпадению, общее правило совпадает для всех.
|
||
#
|
||
# Оно добавляется намеренно, как задел: чтобы в момент пост-переездного сужения
|
||
# оставалось РОВНО ОДНО действие — удалить общее правило, — а не «придумать и
|
||
# добавить узкое, ничего не забыв, на живом проде». Момент сужения — худшее
|
||
# время для сочинения правил; сейчас же его можно добавить без последствий и
|
||
# спокойно убедиться, что оно верное.
|
||
#
|
||
# Проверить, что правило верное, до сужения можно так: смотреть, что деплой
|
||
# по-прежнему зелёный, и что в auth.log вход CI-ключа приходит именно с
|
||
# указанного адреса (рецепт — в разделе 4).
|
||
|
||
UFW_TOUCHED="нет"
|
||
if ! command -v ufw >/dev/null 2>&1; then
|
||
warn "ufw не установлен — правило не добавляю. Сначала ops/selectel-bootstrap.sh."
|
||
elif [ -z "$CI_RUNNER_FROM" ]; then
|
||
warn "CI_RUNNER_FROM пуст (CI_ALLOW_ANY_SOURCE) — узкое правило добавлять не из чего."
|
||
else
|
||
ufw status 2>/dev/null | grep -q '^Status: active' \
|
||
|| warn "ufw неактивен: правило запишется, но начнёт действовать только после включения."
|
||
|
||
IFS=',' read -r -a FROM_LIST <<<"$CI_RUNNER_FROM"
|
||
for addr in "${FROM_LIST[@]}"; do
|
||
addr="$(printf '%s' "$addr" | tr -d '[:space:]')"
|
||
[ -n "$addr" ] || continue
|
||
# from= в authorized_keys принимает и шаблоны с именами хостов, ufw — только
|
||
# адреса и подсети. Несовпадение форматов не ошибка: ключ ограничим, правило
|
||
# для такой записи просто пропустим.
|
||
case "$addr" in
|
||
*[!0-9./:abcdefABCDEF]*) warn "'$addr' не похож на IP/подсеть — правило ufw пропускаю"; continue ;;
|
||
esac
|
||
addr_re="${addr//./\\.}"
|
||
if ufw status 2>/dev/null | grep -qE "^${SSH_PORT}/tcp[[:space:]].*ALLOW[[:space:]]+${addr_re}([[:space:]]|$)"; then
|
||
echo "правило для $addr уже есть — не дублирую"
|
||
else
|
||
if ufw allow from "$addr" to any port "$SSH_PORT" proto tcp \
|
||
comment "ci runner ssh (#3029)" >/dev/null; then
|
||
echo "добавлено: allow from $addr to any port $SSH_PORT/tcp"
|
||
UFW_TOUCHED="да"
|
||
else
|
||
# Прогон не роняем: ключ уже стоит, а это правило сегодня всё равно
|
||
# ничего не решает (общее allow живо). Но сказать надо громко.
|
||
warn "ufw отказался добавить правило для '$addr' — проверь формат адреса"
|
||
fi
|
||
fi
|
||
done
|
||
fi
|
||
|
||
GENERAL_RULE="нет"
|
||
if command -v ufw >/dev/null 2>&1 &&
|
||
ufw status 2>/dev/null | grep -qE "^${SSH_PORT}/tcp[[:space:]]+ALLOW[[:space:]]+Anywhere"; then
|
||
GENERAL_RULE="да"
|
||
fi
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
log "4. Проверка результата"
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
AK_LINES_AFTER="$(wc -l < "$AK" | tr -d ' ')"
|
||
echo "--- $AK ---"
|
||
echo "строк: было $AK_LINES_BEFORE → стало $AK_LINES_AFTER (уменьшиться не может: только дописываем)"
|
||
echo "строка CI-ключа (тело ключа скрыто):"
|
||
grep -F "$KEY_BLOB" "$AK" | sed "s|$KEY_BLOB|<$KEY_MASK>|" | sed 's/^/ /' \
|
||
|| warn "строка не найдена — что-то пошло не так"
|
||
echo "отпечаток CI-ключа: $KEY_FP"
|
||
echo
|
||
echo "все ключи учётки $CI_DEPLOY_USER (отпечатки, без тел):"
|
||
ssh-keygen -lf "$AK" 2>/dev/null | sed 's/^/ /' || echo " (ssh-keygen не разобрал файл)"
|
||
|
||
echo
|
||
echo "--- файрвол ---"
|
||
if command -v ufw >/dev/null 2>&1; then
|
||
ufw status verbose 2>/dev/null | sed 's/^/ /' || true
|
||
else
|
||
echo " ufw не установлен"
|
||
fi
|
||
|
||
echo
|
||
echo "--- что проверить ПОСЛЕ первого прогона деплоя на этот хост ---"
|
||
echo "Вход именно CI-ключом и именно с ожидаемого адреса виден так:"
|
||
echo " journalctl -u ssh --since '-2h' | grep -F '$KEY_FP'"
|
||
echo " grep -F '$KEY_FP' /var/log/auth.log"
|
||
echo "Строка 'Accepted publickey for $CI_DEPLOY_USER from <АДРЕС>' — этот <АДРЕС>"
|
||
echo "и есть настоящий исходящий адрес раннера. Если он не совпадает с"
|
||
echo "'${CI_RUNNER_FROM:-<не задан>}', ключ откажет с 'not from a permitted host'."
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
log "Итог"
|
||
# ═══════════════════════════════════════════════════════════════════════════════
|
||
cat <<SUMMARY
|
||
учётка: $CI_DEPLOY_USER
|
||
CI-ключ: $KEY_ACTION
|
||
опции ключа: $KEY_OPTIONS
|
||
правило ufw: ${UFW_TOUCHED} (добавлялось в этот прогон)
|
||
общее allow $SSH_PORT/tcp: $GENERAL_RULE (остаётся намеренно)
|
||
удалено/сужено: ничего
|
||
SUMMARY
|
||
|
||
cat <<PLAN
|
||
|
||
================================================================================
|
||
ЧТО СУЗИТЬ ПОСЛЕ ПЕРЕЕЗДА — НЕ ВЫПОЛНЯТЬ, ПОКА ПЕРЕЕЗД НЕ ПОДТВЕРЖДЁН
|
||
================================================================================
|
||
Скрипт эти команды НЕ выполняет и выполнять не будет. Условие «переезд
|
||
подтверждён» — это не дата 30.08 в календаре, а вот что одновременно:
|
||
• deploy.yml и deploy-tradein.yml проехали на этот хост зелёными;
|
||
• в auth.log видно, что заходят CI-ключом $KEY_FP;
|
||
• откат на Beget больше не рассматривается.
|
||
|
||
ПОРЯДОК ПЕРЕКЛЮЧЕНИЯ (до всякого сужения):
|
||
1. Проверить новый ключ С САМОГО РАННЕРА, не с ноутбука:
|
||
ssh -i <приватный CI-ключ> -o BatchMode=yes -p $SSH_PORT \\
|
||
$CI_DEPLOY_USER@$HOST_IP 'echo ok'
|
||
2. Только после «ok» — поменять в секретах Forgejo DEPLOY_SSH_KEY на новый
|
||
приватный ключ и DEPLOY_HOST на новый адрес. Старый ключ при этом ещё
|
||
лежит в authorized_keys и остаётся запасным входом.
|
||
3. Прогнать деплой. Зелёный — идти дальше; красный — вернуть секреты назад,
|
||
доступ никуда не делся.
|
||
|
||
ШАГ 1 СУЖЕНИЯ — убрать общее правило ssh.
|
||
СНАЧАЛА добавить правило для СВОЕГО адреса, иначе снимаешь себе доступ:
|
||
curl -s ifconfig.me # свой адрес, со своей машины
|
||
ufw allow from <свой-адрес> to any port $SSH_PORT proto tcp comment 'admin ssh'
|
||
Убедиться, что своё правило видно, и только потом:
|
||
ufw status numbered
|
||
ufw delete allow $SSH_PORT/tcp
|
||
Проверить, не разрывая текущую сессию, ВТОРЫМ окном:
|
||
ssh -p $SSH_PORT $CI_DEPLOY_USER@$HOST_IP 'echo ok'
|
||
У Selectel IP-KVM платный (см. ops/selectel-bootstrap.sh) — запереться здесь
|
||
стоит денег и времени.
|
||
|
||
ШАГ 2 СУЖЕНИЯ — отозвать старый (человеческий) ключ из-под деплоя.
|
||
Делать ТОЛЬКО когда конвейер уверенно ездит CI-ключом $KEY_FP.
|
||
cp $AK $AK.bak.\$(date +%F-%H%M)
|
||
ssh-keygen -lf $AK # найти отпечаток лишнего ключа
|
||
\${EDITOR:-nano} $AK # удалить его строку руками, по одной
|
||
Руками, а не sed'ом по шаблону: authorized_keys — это файл, в котором ошибка
|
||
стоит доступа к серверу, и лишняя удалённая строка обнаружится не сразу.
|
||
|
||
ШАГ 3 — секреты.
|
||
DEPLOY_SSH_KEY со старым ключом после шага 2 бесполезен — заменить/удалить.
|
||
Заодно пересмотреть (НЕ удаляя вслепую): GHCR_PAT, OPENAI_API_KEY,
|
||
OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN — всё это ездит по тем же сессиям.
|
||
================================================================================
|
||
PLAN
|