gendesign/ops/selectel-ci-access.sh
bot-backend efaab2efda
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
chore(ops): адрес Poincare — 188.124.37.140 вместо заменённого 188.246.224.93 (#3110)
Selectel заменил адрес сервера в ночь на 27.08 по нашей заявке: старый
188.246.224.93 фильтровался российскими операторами и не открывался ни с
домашнего интернета, ни с мобильного (#3110). Новый адрес фильтрации не имеет —
проверено с машины владельца после переключения DNS.

Замена уронила сервер на 10 часов: адрес на порте сменился, а в ОС остался
прежний (разбор и восстановление — #3119). Здесь только то, что осталось в
репозитории.

Единственное функциональное вхождение — дефолт HOST_IP в ops/selectel-ci-access.sh:
скрипт открывает раннеру доступ на прод-хост, и с прежним значением он молча
настроил бы доступ на адрес, которого у нас больше нет. Остальные семь — тексты
комментариев в Caddyfile-секциях, compose, bootstrap-скриптах и раннбуке крона;
они не исполняются, но именно по ним сверяются при переезде, и разошедшийся
адрес в них дороже, чем кажется.
2026-08-27 11:23:37 +03:00

483 lines
36 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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