feat(ci): деплой проверяет, к тому ли хосту подключился
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
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 / openapi-codegen-check (pull_request) Successful in 2m10s
CI / backend-tests (pull_request) Successful in 17m38s
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
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 / openapi-codegen-check (pull_request) Successful in 2m10s
CI / backend-tests (pull_request) Successful in 17m38s
Сегодня подлинность хоста не проверяется нигде: deploy-tradein.yml явно ставил StrictHostKeyChecking=no, а во всех пяти вызовах appleboy/ssh-action не задан fingerprint. Пока Forgejo, раннеры и прод жили на одной машине, это было почти бесплатно. После переезда 30.08 тот же канал идёт через интернет и уносит GHCR_PAT, OPENAI_API_KEY, OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN и сам DEPLOY_SSH_KEY. Вызовов оказалось пять, а не четыре: deploy-obsidian.yml тоже ходит на DEPLOY_HOST с DEPLOY_SSH_KEY. Отпечаток добавлен во все. Механизм инертен по конструкции самого действия, а не нашей ветвлёнкой: appleboy/ssh-action@v1.0.3 -> drone-ssh 1.7.3 -> easyssh-proxy v1.5.0, где hostKeyCallback = ssh.InsecureIgnoreHostKey() и сверка включается только при непустом Fingerprint. Незаданный секрет = сегодняшнее поведение бит в бит. Но инертность, безопасная для мержа, опасна для самого окна: забытый секрет означал бы зелёный деплой через интернет без всякой проверки. Поэтому перед каждым ssh-шагом стоит преамбула, которая при пустом секрете печатает ::warning с перечислением того, что именно едет по этому каналу. Warning, а не отказ: секрета сегодня нет ни у кого, отказ сломал бы деплой в момент мержа. Ручной ssh в "Resolve deployed base SHA" отпечаток принять не может, ему отдан отдельный секрет с known_hosts по той же схеме. Заодно расхождение ключа перестало быть неотличимым от недоступного хоста: раньше 2>/dev/null глотал "Host key verification failed" и печаталось обычное сообщение про фолбэк на полную пересборку. Свойство fail-safe сохранено - при любой ошибке RAW_SHA пустеет и включается build-all. ops/selectel-ci-access.sh - аддитивная выдача CI-доступа на Selectel по решению владельца "сейчас только добавляем, удаляем после переезда": ставит отдельный ключ робота с from="адрес раннера", добавляет узкое правило ufw, не трогая общее, и печатает план сужения, который сам не выполняет. Ни одной команды, способной отрезать текущую сессию: sshd не перезапускается, default policy ufw не трогается, в authorized_keys только дозапись. Отзыв старого ключа, сужение правил и уборка секретов - в #3075, после подтверждённого переезда. Refs #3029, #3075, #3057, #2989
This commit is contained in:
parent
9810ae350f
commit
1a37c7e0f9
6 changed files with 802 additions and 4 deletions
|
|
@ -30,6 +30,22 @@ name: Deploy Infra Host
|
|||
# DEPLOY_HOST — второй проход по тому же хосту был бы лишним и мог бы состязаться
|
||||
# с основным деплоем за докер-демон (#2950). Поэтому job не делает ничего, пока
|
||||
# секрет пуст: включается ОДНОЙ настройкой в момент, когда хосты разъедутся.
|
||||
|
||||
# ── ПОДЛИННОСТЬ ХОСТА (#3029) ────────────────────────────────────────────────
|
||||
# Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а раннеры оставляет на
|
||||
# Beget — SSH становится междоузловым, через интернет. Поэтому у вызова
|
||||
# appleboy/ssh-action ниже появился вход `fingerprint`.
|
||||
# ЧТО ЗАДАТЬ: секрет INFRA_DEPLOY_SSH_FINGERPRINT =
|
||||
# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}'
|
||||
# (значение с префиксом `SHA256:`; именно ecdsa — см. разбор в deploy.yml).
|
||||
# ПОБАЙТОВО: значение сравнивается как есть, без trim — лишний пробел/перевод
|
||||
# строки при копипасте включает проверку и роняет ssh-шаг с `host key
|
||||
# fingerprint mismatch`.
|
||||
# ПОКА СЕКРЕТ НЕ ЗАДАН — поведение прежнее: пустой fingerprint у easyssh-proxy
|
||||
# v1.5.0 означает ssh.InsecureIgnoreHostKey(), то есть ровно как до этого PR.
|
||||
# Включается одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у
|
||||
# TRADEIN_INTERNAL_AUTH_SECRET (#2989).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
|
@ -63,6 +79,30 @@ jobs:
|
|||
echo "INFRA_DEPLOY_HOST задан — синхронизирую остающийся хост."
|
||||
fi
|
||||
|
||||
# #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть
|
||||
# громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет
|
||||
# ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как
|
||||
# защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у
|
||||
# кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь —
|
||||
# «инертно по умолчанию, включается одной настройкой». Заведут секрет —
|
||||
# предупреждение исчезнет само.
|
||||
- name: Подлинность хоста — статус проверки (#3029)
|
||||
if: steps.gate.outputs.enabled == 'true'
|
||||
env:
|
||||
HOST_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${HOST_FINGERPRINT:-}" ]; then
|
||||
echo "Подлинность хоста: сверяется по INFRA_DEPLOY_SSH_FINGERPRINT."
|
||||
else
|
||||
echo '::warning title=SSH без проверки подлинности хоста::INFRA_DEPLOY_SSH_FINGERPRINT не задан — ключ остающегося хоста НЕ проверяется (#3029). Фолбэка на DEPLOY_SSH_FINGERPRINT здесь нет и быть не должно: это другая машина. По каналу едет INFRA_DEPLOY_SSH_KEY и выполняется git reset на /opt/gendesign. Как снять отпечаток — см. шапку этого файла.'
|
||||
echo '###############################################################'
|
||||
echo '# ВНИМАНИЕ (#3029): INFRA_DEPLOY_SSH_FINGERPRINT не задан.'
|
||||
echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.'
|
||||
echo '# Как снять отпечаток — см. шапку этого файла.'
|
||||
echo '###############################################################'
|
||||
fi
|
||||
|
||||
- name: Синхронизировать /opt/gendesign на остающемся хосте
|
||||
if: steps.gate.outputs.enabled == 'true'
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
|
|
@ -71,6 +111,13 @@ jobs:
|
|||
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
||||
port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT }}
|
||||
# #3029: БЕЗ фолбэка на DEPLOY_SSH_FINGERPRINT — в отличие от user/key/port
|
||||
# выше. Те у двух хостов совпадают, а отпечаток — это идентичность
|
||||
# КОНКРЕТНОЙ машины: после переезда INFRA_DEPLOY_HOST=Beget, а
|
||||
# DEPLOY_HOST=Selectel, и фолбэк означал бы сверку ключа Beget'а с
|
||||
# отпечатком Selectel'а — гарантированный отказ ровно у того workflow,
|
||||
# который чинит остающийся хост. Пусто → проверка пропускается.
|
||||
fingerprint: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/gendesign
|
||||
|
|
|
|||
|
|
@ -18,6 +18,28 @@ name: Deploy Obsidian
|
|||
# единственная директория, которую реально исполняет этот инстанс.
|
||||
# См. issue #2416.
|
||||
|
||||
# ── ПОДЛИННОСТЬ ХОСТА (#3029) ────────────────────────────────────────────────
|
||||
# Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а раннеры оставляет на
|
||||
# Beget — SSH становится междоузловым, через интернет. Поэтому у вызова
|
||||
# appleboy/ssh-action ниже появился вход `fingerprint`.
|
||||
# ЧТО ЗАДАТЬ: секрет DEPLOY_SSH_FINGERPRINT =
|
||||
# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}'
|
||||
# (значение с префиксом `SHA256:`; именно ecdsa — см. разбор в deploy.yml).
|
||||
# ПОБАЙТОВО: значение сравнивается как есть, без trim — лишний пробел/перевод
|
||||
# строки при копипасте включает проверку и роняет ssh-шаг с `host key
|
||||
# fingerprint mismatch`.
|
||||
# ПОКА СЕКРЕТ НЕ ЗАДАН — поведение прежнее: пустой fingerprint у easyssh-proxy
|
||||
# v1.5.0 означает ssh.InsecureIgnoreHostKey(), то есть ровно как до этого PR.
|
||||
# Включается одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у
|
||||
# TRADEIN_INTERNAL_AUTH_SECRET (#2989).
|
||||
# NB: этот workflow ходит на secrets.DEPLOY_HOST, поэтому и отпечаток берётся
|
||||
# DEPLOY_SSH_FINGERPRINT — пара «хост+отпечаток» обязана меняться вместе. Но
|
||||
# CouchDB по плану ОСТАЁТСЯ на Beget вместе с Forgejo/GlitchTip (#3062), а
|
||||
# DEPLOY_HOST после 30.08 будет указывать на Selectel. Значит у этого файла есть
|
||||
# отдельная, ещё не закрытая проблема адресата — тот же класс, что #3059. Здесь
|
||||
# намеренно НЕ трогаем: правка про подлинность хоста, а не про маршрутизацию.
|
||||
# При переводе на INFRA_DEPLOY_HOST не забыть переключить и отпечаток.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
|
@ -39,6 +61,29 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть
|
||||
# громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет
|
||||
# ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как
|
||||
# защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у
|
||||
# кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь —
|
||||
# «инертно по умолчанию, включается одной настройкой». Заведут секрет —
|
||||
# предупреждение исчезнет само.
|
||||
- name: Подлинность хоста — статус проверки (#3029)
|
||||
env:
|
||||
HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${HOST_FINGERPRINT:-}" ]; then
|
||||
echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT."
|
||||
else
|
||||
echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029). По каналу едет DEPLOY_SSH_KEY и разворачивается стек CouchDB/Obsidian. После переезда на Selectel (#3057) соединение идёт через интернет. Как снять отпечаток — см. шапку этого файла.'
|
||||
echo '###############################################################'
|
||||
echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.'
|
||||
echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.'
|
||||
echo '# Как снять отпечаток — см. шапку этого файла.'
|
||||
echo '###############################################################'
|
||||
fi
|
||||
|
||||
- name: Deploy obsidian stack via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
|
|
@ -46,6 +91,9 @@ jobs:
|
|||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
port: ${{ secrets.DEPLOY_PORT || 22 }}
|
||||
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
|
||||
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
|
||||
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/gendesign
|
||||
|
|
|
|||
|
|
@ -3,6 +3,30 @@ name: Deploy Trade-In
|
|||
# Forgejo Actions — отдельный pipeline для подпроекта tradein-mvp/.
|
||||
# Триггерится только на изменения внутри tradein-mvp/ (или этого workflow),
|
||||
# не пересекается с основным deploy.yml.
|
||||
|
||||
# ── ПОДЛИННОСТЬ ХОСТА (#3029) ────────────────────────────────────────────────
|
||||
# Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а Forgejo и раннеры
|
||||
# оставляет на Beget — SSH перестаёт быть петлёй и идёт через интернет. В этом
|
||||
# workflow ДВА разных SSH-канала, и закрываются они по-разному:
|
||||
# 1) шаг "Deploy via SSH" (appleboy/ssh-action) → вход `fingerprint`,
|
||||
# секрет DEPLOY_SSH_FINGERPRINT;
|
||||
# 2) шаг "Resolve deployed base SHA" — обычный openssh-клиент, ему нужен
|
||||
# known_hosts, а не SHA256-строка → секрет DEPLOY_KNOWN_HOSTS.
|
||||
# Как снять значения:
|
||||
# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}'
|
||||
# → DEPLOY_SSH_FINGERPRINT (с префиксом `SHA256:`; почему именно ecdsa —
|
||||
# см. разбор в deploy.yml: дефолт x/crypto ставит ecdsa выше ed25519)
|
||||
# ssh-keyscan -p <порт> <хост>
|
||||
# → DEPLOY_KNOWN_HOSTS (все типы ключей сразу, без -t)
|
||||
# ПОБАЙТОВО: fingerprint сравнивается как есть, без trim — лишний пробел или
|
||||
# перевод строки при копипасте включает проверку и роняет ssh-шаг с `host key
|
||||
# fingerprint mismatch`.
|
||||
# ПОКА СЕКРЕТЫ НЕ ЗАДАНЫ — поведение прежнее в обоих каналах: пустой fingerprint
|
||||
# у easyssh-proxy v1.5.0 это ssh.InsecureIgnoreHostKey(), а второй шаг остаётся
|
||||
# на StrictHostKeyChecking=no, но печатает громкое предупреждение. Включается
|
||||
# одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у
|
||||
# TRADEIN_INTERNAL_AUTH_SECRET (#2989). Ничего не удаляем — только добавляем.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
|
@ -76,24 +100,72 @@ jobs:
|
|||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
# #3029: строки known_hosts прод-хоста. DEPLOY_SSH_FINGERPRINT здесь НЕ
|
||||
# подходит: ниже обычный openssh-клиент, а не Go-клиент ssh-action'а, и
|
||||
# SHA256-отпечаток он на вход не принимает — ему нужен known_hosts.
|
||||
# Получить: ssh-keyscan -p <порт> <хост> (без -t: пусть в секрете лежат
|
||||
# все типы ключей сразу, тогда выбор алгоритма клиентом ничего не ломает).
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
run: |
|
||||
# Write SSH key to a temp file
|
||||
SSH_KEY_FILE=$(mktemp)
|
||||
echo "$DEPLOY_SSH_KEY" > "$SSH_KEY_FILE"
|
||||
chmod 600 "$SSH_KEY_FILE"
|
||||
|
||||
# #3029: подлинность хоста для ЭТОГО канала. Раньше здесь стояло
|
||||
# безусловное -o StrictHostKeyChecking=no, то есть ключ хоста не
|
||||
# проверялся никогда. После переезда (#3057) соединение идёт через
|
||||
# интернет, поэтому: секрет задан → пишем known_hosts и требуем
|
||||
# StrictHostKeyChecking=yes; не задан → оставляем ровно сегодняшнее
|
||||
# поведение, но ГРОМКО об этом сообщаем. Инертно по умолчанию: пустой
|
||||
# секрет = поведение до этого PR бит в бит.
|
||||
KNOWN_HOSTS_FILE=$(mktemp)
|
||||
if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$KNOWN_HOSTS_FILE"
|
||||
chmod 600 "$KNOWN_HOSTS_FILE"
|
||||
SSH_HOST_OPTS=(-o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$KNOWN_HOSTS_FILE")
|
||||
echo "Подлинность хоста: сверяется по DEPLOY_KNOWN_HOSTS."
|
||||
else
|
||||
SSH_HOST_OPTS=(-o StrictHostKeyChecking=no)
|
||||
echo "::warning title=SSH без проверки подлинности хоста::DEPLOY_KNOWN_HOSTS не задан — ключ прод-хоста НЕ проверяется (#3029). После переезда на Selectel (#3057) этот SSH идёт через интернет: задайте секрет через ssh-keyscan -p <порт> <хост>."
|
||||
echo "################################################################"
|
||||
echo "# ВНИМАНИЕ (#3029): DEPLOY_KNOWN_HOSTS не задан. #"
|
||||
echo "# Подлинность прод-хоста НЕ проверяется — канал уязвим к MITM. #"
|
||||
echo "# Задать секрет: ssh-keyscan -p <порт> <хост> #"
|
||||
echo "################################################################"
|
||||
fi
|
||||
|
||||
# Try to read the marker file from the VPS. Suppress errors — if host is
|
||||
# unreachable or file missing, RAW_SHA will be empty.
|
||||
# #3029: сюда же попадает и расхождение ключа хоста. Шаг fail-safe по
|
||||
# построению — пустой RAW_SHA уводит в build-all ниже, — поэтому цена
|
||||
# ошибки в known_hosts здесь максимум лишняя полная пересборка, а не
|
||||
# сорванный деплой. Это и делает включение проверки безопасным.
|
||||
SSH_ERR_FILE=$(mktemp)
|
||||
RAW_SHA=$(ssh -i "$SSH_KEY_FILE" \
|
||||
-o StrictHostKeyChecking=no \
|
||||
"${SSH_HOST_OPTS[@]}" \
|
||||
-o ConnectTimeout=10 \
|
||||
-p "${DEPLOY_PORT:-22}" \
|
||||
"${DEPLOY_USER}@${DEPLOY_HOST}" \
|
||||
"cat /opt/gendesign/.tradein-deployed-sha 2>/dev/null || true" \
|
||||
2>/dev/null || true)
|
||||
2>"$SSH_ERR_FILE" || true)
|
||||
RAW_SHA=$(echo "$RAW_SHA" | tr -d '[:space:]')
|
||||
|
||||
rm -f "$SSH_KEY_FILE"
|
||||
# #3029: раньше stderr уходил в /dev/null, и «Host key verification
|
||||
# failed» был неотличим от недоступного хоста — неверный known_hosts
|
||||
# молча читался как штатный фолбэк на полную пересборку. Теперь эта
|
||||
# причина называется отдельно. Fail-safe шага не меняется: RAW_SHA всё
|
||||
# равно пуст, ветка build-all включается ровно как прежде.
|
||||
if grep -qiE 'host key verification failed|remote host identification has changed|no matching host key' "$SSH_ERR_FILE"; then
|
||||
echo '::warning title=Ключ хоста не сошёлся с DEPLOY_KNOWN_HOSTS::Проверка подлинности хоста НЕ прошла (#3029) — это не «хост недоступен», а расхождение known_hosts: сменился ключ хоста либо переехал адрес (#3057). Обновите секрет DEPLOY_KNOWN_HOSTS через ssh-keyscan -p <порт> <хост>. Шаг fail-safe: сейчас включится полная пересборка.'
|
||||
echo "Причина пустого RAW_SHA: проверка ключа хоста, а не недоступность."
|
||||
sed 's/^/ ssh: /' "$SSH_ERR_FILE"
|
||||
elif [ -s "$SSH_ERR_FILE" ]; then
|
||||
echo "ssh stderr (не про ключ хоста — хост недоступен либо иная ошибка):"
|
||||
sed 's/^/ ssh: /' "$SSH_ERR_FILE"
|
||||
fi
|
||||
|
||||
rm -f "$SSH_KEY_FILE" "$KNOWN_HOSTS_FILE" "$SSH_ERR_FILE"
|
||||
|
||||
# Validate: non-empty, looks like a git SHA, and is an ancestor of HEAD.
|
||||
DEPLOYED_SHA=""
|
||||
|
|
@ -632,6 +704,29 @@ jobs:
|
|||
scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- tradein-mvp/backend tradein-mvp/packages/scraper-kit tradein-mvp/VERSION $INFRA
|
||||
scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- tradein-mvp/frontend tradein-mvp/VERSION tradein-mvp/CHANGELOG.md $INFRA
|
||||
scripts/check-latest-image-revision.sh "$IMAGE_BROWSER" 900 -- tradein-mvp/browser $INFRA
|
||||
# #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть
|
||||
# громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет
|
||||
# ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как
|
||||
# защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у
|
||||
# кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь —
|
||||
# «инертно по умолчанию, включается одной настройкой». Заведут секрет —
|
||||
# предупреждение исчезнет само.
|
||||
- name: Подлинность хоста — статус проверки (#3029)
|
||||
env:
|
||||
HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${HOST_FINGERPRINT:-}" ]; then
|
||||
echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT."
|
||||
else
|
||||
echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029): при пустом отпечатке easyssh-proxy молча оставляет InsecureIgnoreHostKey. По этой же SSH-сессии едут GHCR_PAT и секреты Trade-In вместе с DEPLOY_SSH_KEY. После переезда на Selectel (#3057) канал идёт через интернет. Как снять отпечаток — см. шапку этого файла.'
|
||||
echo '###############################################################'
|
||||
echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.'
|
||||
echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.'
|
||||
echo '# Как снять отпечаток — см. шапку этого файла.'
|
||||
echo '###############################################################'
|
||||
fi
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
env:
|
||||
|
|
@ -661,6 +756,9 @@ jobs:
|
|||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
|
||||
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
|
||||
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||
envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA
|
||||
script: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
|
|
@ -4,6 +4,49 @@ name: Deploy
|
|||
# Migration 2026-05-16: GitHub → Forgejo (git.gendsgn.ru)
|
||||
# Builds images on Forgejo runner, pushes to ghcr.io (GitHub Container Registry),
|
||||
# SSH-deploys to Beget VPS (46.173.16.127).
|
||||
|
||||
# ── ПОДЛИННОСТЬ ХОСТА В ДЕПЛОЕ (#3029) ───────────────────────────────────────
|
||||
#
|
||||
# ЗАЧЕМ ИМЕННО СЕЙЧАС. Пока раннер и цель деплоя — одна и та же машина (Beget,
|
||||
# 46.173.16.127), SSH фактически не покидает петлю, и цена непроверенного ключа
|
||||
# хоста была низкой. После переезда 30.08 (#3057) цель уезжает на Selectel
|
||||
# (188.246.224.93), а Forgejo и раннеры ОСТАЮТСЯ на Beget — тот же самый SSH
|
||||
# становится междоузловым и идёт через интернет. По этой сессии через `envs:`
|
||||
# едут GHCR_PAT, OPENAI_API_KEY, OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN и сам
|
||||
# DEPLOY_SSH_KEY: без проверки ключа хоста MITM на маршруте забирает их разом,
|
||||
# причём молча — деплой при этом выглядит зелёным.
|
||||
#
|
||||
# ЧТО ЗАДАТЬ: секрет DEPLOY_SSH_FINGERPRINT — SHA256-отпечаток ХОСТОВОГО ключа
|
||||
# (не деплой-ключа!). Снять с любой машины:
|
||||
# ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}'
|
||||
# Значение кладётся ЦЕЛИКОМ, вместе с префиксом: `SHA256:xxxxxxxx…`.
|
||||
# СРАВНЕНИЕ ПОБАЙТОВОЕ и без trim. Лишний перевод строки или пробел, прилипший
|
||||
# при копипасте в UI секретов, делает значение непустым — проверка ВКЛЮЧАЕТСЯ и
|
||||
# все ssh-шаги падают с `host key fingerprint mismatch`. Вставлять без хвостов.
|
||||
#
|
||||
# ПОЧЕМУ ecdsa, А НЕ ed25519 — это грабли, на которые легко наступить.
|
||||
# appleboy/ssh-action@v1.0.3 = drone-ssh 1.7.3 на easyssh-proxy v1.5.0 поверх
|
||||
# golang.org/x/crypto v0.17.0. HostKeyAlgorithms клиент не задаёт, значит берётся
|
||||
# дефолт x/crypto, а там (ssh/common.go, supportedHostKeyAlgos) ecdsa-sha2-nistp256
|
||||
# стоит ВЫШЕ ssh-ed25519 и rsa. Со стоковым OpenSSH согласуется ECDSA — отпечаток
|
||||
# ed25519 просто не совпадёт, и деплой встанет с `host key fingerprint mismatch`.
|
||||
#
|
||||
# ПОЧЕМУ ЭТО НЕ ЛОМАЕТ СЕГОДНЯШНИЙ ДЕПЛОЙ. Незаданный секрет разворачивается в
|
||||
# пустую строку, а easyssh-proxy v1.5.0 (easyssh.go:178) делает буквально:
|
||||
# hostKeyCallback := ssh.InsecureIgnoreHostKey()
|
||||
# if config.Fingerprint != "" { …сверять отпечаток… }
|
||||
# То есть пустой fingerprint = поведение до этого PR бит в бит; сам drone-ssh
|
||||
# описывает флаг как "default is to skip verification". Проверка включается ОДНОЙ
|
||||
# настройкой — заведением секрета. Тот же приём, что уже применён в репо:
|
||||
# deploy-infra.yml инертен, пока пуст INFRA_DEPLOY_HOST (#3059); CADDY_SITES;
|
||||
# fail-open у TRADEIN_INTERNAL_AUTH_SECRET (#2989). Ничего не удаляем и не
|
||||
# срезаем — только добавляем, пока конвейер не проехал на новый хост.
|
||||
#
|
||||
# ВНИМАНИЕ ПРИ ПЕРЕЕЗДЕ: сменится хост — сменится и отпечаток. Секрет надо
|
||||
# обновить В ТОТ ЖЕ МОМЕНТ, когда DEPLOY_HOST начнёт указывать на Selectel,
|
||||
# иначе деплой встанет. Это осознанный размен: лучше громкий отказ, чем тихий
|
||||
# коннект не туда.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
|
@ -499,6 +542,29 @@ jobs:
|
|||
scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- backend data/sql $INFRA
|
||||
scripts/check-latest-image-revision.sh "$IMAGE_WORKER" 900 -- backend data/sql $INFRA
|
||||
scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- frontend $INFRA
|
||||
# #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть
|
||||
# громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет
|
||||
# ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как
|
||||
# защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у
|
||||
# кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь —
|
||||
# «инертно по умолчанию, включается одной настройкой». Заведут секрет —
|
||||
# предупреждение исчезнет само.
|
||||
- name: Подлинность хоста — статус проверки (#3029)
|
||||
env:
|
||||
HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${HOST_FINGERPRINT:-}" ]; then
|
||||
echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT."
|
||||
else
|
||||
echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029): при пустом отпечатке easyssh-proxy молча оставляет InsecureIgnoreHostKey. По этой же SSH-сессии едут GHCR_PAT, OPENAI_API_KEY, OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN и сам DEPLOY_SSH_KEY. После переезда на Selectel (#3057) канал идёт через интернет — MITM забирает их разом, а деплой остаётся зелёным. Как снять отпечаток — см. шапку deploy.yml.'
|
||||
echo '###############################################################'
|
||||
echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.'
|
||||
echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.'
|
||||
echo '# Как снять отпечаток — см. шапку этого файла.'
|
||||
echo '###############################################################'
|
||||
fi
|
||||
|
||||
- name: Deploy to VM via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
env:
|
||||
|
|
@ -522,6 +588,9 @@ jobs:
|
|||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
|
||||
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
|
||||
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||
envs: IMAGE_TAG,SENTRY_RELEASE_VAL,GHCR_PAT,GLITCHTIP_BACKEND_DSN,OBJECTIVE_API_KEY,OPENAI_API_KEY,LLM_ENABLED,OWN_DEVELOPER_IDS
|
||||
script: |
|
||||
set -euo pipefail
|
||||
|
|
@ -902,6 +971,29 @@ jobs:
|
|||
# подменять его перезагрузкой конфига нельзя.
|
||||
if: github.event_name == 'push' && needs.changes.outputs.caddy_only == 'true'
|
||||
steps:
|
||||
# #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть
|
||||
# громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет
|
||||
# ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как
|
||||
# защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у
|
||||
# кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь —
|
||||
# «инертно по умолчанию, включается одной настройкой». Заведут секрет —
|
||||
# предупреждение исчезнет само.
|
||||
- name: Подлинность хоста — статус проверки (#3029)
|
||||
env:
|
||||
HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${HOST_FINGERPRINT:-}" ]; then
|
||||
echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT."
|
||||
else
|
||||
echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029). По этому каналу едет DEPLOY_SSH_KEY и выполняется git reset + перезагрузка Caddy на проде: MITM здесь переписывает конфиг прокси всех доменов. После переезда на Selectel (#3057) соединение идёт через интернет. Как снять отпечаток — см. шапку deploy.yml.'
|
||||
echo '###############################################################'
|
||||
echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.'
|
||||
echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.'
|
||||
echo '# Как снять отпечаток — см. шапку этого файла.'
|
||||
echo '###############################################################'
|
||||
fi
|
||||
|
||||
- name: Синхронизировать конфиг и перезагрузить прокси
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
|
|
@ -909,6 +1001,9 @@ jobs:
|
|||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
# #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy
|
||||
# оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение.
|
||||
fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/gendesign
|
||||
|
|
|
|||
|
|
@ -60,11 +60,38 @@ echo "--- sshd (значимые директивы) ---"
|
|||
sshd -T 2>/dev/null | grep -E '^(permitrootlogin|passwordauthentication|pubkeyauthentication|port|permitemptypasswords)' \
|
||||
|| grep -E '^\s*(PermitRootLogin|PasswordAuthentication|Port)' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/* 2>/dev/null
|
||||
echo "--- файрвол ---"
|
||||
ufw status 2>/dev/null || echo "ufw не установлен"
|
||||
# verbose + numbered вместо голого `ufw status`: по verbose видно политику по
|
||||
# умолчанию, по numbered — порядок правил и их номера. И то и другое нужно,
|
||||
# чтобы после ops/selectel-ci-access.sh проверить две вещи сразу: что узкое
|
||||
# правило для адреса раннера (#3029) добавилось и что общее правило для ssh
|
||||
# пока живо (до переезда оно снимается ТОЛЬКО вручную и осознанно).
|
||||
ufw status verbose 2>/dev/null || echo "ufw не установлен"
|
||||
ufw status numbered 2>/dev/null
|
||||
nft list ruleset 2>/dev/null | head -20 || iptables -S 2>/dev/null | head -20
|
||||
echo "--- пользователи с shell ---"
|
||||
awk -F: '$7 !~ /(nologin|false)$/ {print $1" "$7}' /etc/passwd
|
||||
|
||||
echo "--- ключи доступа (тела ключей НЕ печатаются) ---"
|
||||
# Чем проверяется результат ops/selectel-ci-access.sh (#3029): видно, сколько
|
||||
# ключей у учётки, чем они ограничены и который из них CI-шный. Печатаются
|
||||
# только отпечаток, комментарий и опции — по ним ключ опознаётся, но
|
||||
# воспроизвести его нельзя, поэтому вывод аудита остаётся безопасным для
|
||||
# пересылки. Отпечаток тут тот же, что пишет sshd в auth.log при входе, так
|
||||
# что строки отсюда и из журнала сопоставляются напрямую.
|
||||
for ak in /root/.ssh/authorized_keys /home/*/.ssh/authorized_keys; do
|
||||
[ -f "$ak" ] || continue
|
||||
echo " $ak:"
|
||||
n=0
|
||||
while IFS= read -r akline; do
|
||||
case "$akline" in ''|'#'*) continue ;; esac
|
||||
n=$((n+1))
|
||||
akfp="$(printf '%s\n' "$akline" | ssh-keygen -lf - 2>/dev/null | awk '{$1=""; sub(/^ /,""); print}')"
|
||||
akopt="$(printf '%s\n' "$akline" | awk '{o="";for(i=1;i<=NF;i++){if($i ~ /^(ssh-|ecdsa-sha2-|sk-)/)break; o=o (o==""?"":" ") $i} print (o==""?"(без ограничений)":o)}')"
|
||||
printf ' %d) %s\n опции: %s\n' "$n" "${akfp:-<не разобран>}" "$akopt"
|
||||
done < "$ak"
|
||||
if [ "$n" -eq 0 ]; then echo " (пусто)"; fi
|
||||
done
|
||||
|
||||
section "Swap / sysctl"
|
||||
swapon --show || echo "swap отсутствует"
|
||||
sysctl vm.swappiness vm.overcommit_memory 2>/dev/null
|
||||
|
|
|
|||
483
ops/selectel-ci-access.sh
Normal file
483
ops/selectel-ci-access.sh
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
#!/usr/bin/env bash
|
||||
# Доступ CI-раннера на новый выделенный сервер (Selectel Poincare, 188.246.224.93).
|
||||
#
|
||||
# ── ЗАЧЕМ ЭТОТ СКРИПТ ВООБЩЕ ПОЯВИЛСЯ (#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.246.224.93 '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.246.224.93}"
|
||||
|
||||
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
|
||||
Loading…
Add table
Reference in a new issue