#!/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 < -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