diff --git a/.forgejo/workflows/deploy-infra.yml b/.forgejo/workflows/deploy-infra.yml index 13c7a665..2322ad7f 100644 --- a/.forgejo/workflows/deploy-infra.yml +++ b/.forgejo/workflows/deploy-infra.yml @@ -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 diff --git a/.forgejo/workflows/deploy-obsidian.yml b/.forgejo/workflows/deploy-obsidian.yml index 1209e8b2..9dfdbd2f 100644 --- a/.forgejo/workflows/deploy-obsidian.yml +++ b/.forgejo/workflows/deploy-obsidian.yml @@ -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 diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index d0332980..f9980961 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -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 diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 12dcdf0e..e1d314bf 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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 diff --git a/ops/selectel-audit.sh b/ops/selectel-audit.sh index da5bd651..5049f9db 100644 --- a/ops/selectel-audit.sh +++ b/ops/selectel-audit.sh @@ -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 diff --git a/ops/selectel-ci-access.sh b/ops/selectel-ci-access.sh new file mode 100644 index 00000000..0277f590 --- /dev/null +++ b/ops/selectel-ci-access.sh @@ -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 < -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