feat(ci): деплой проверяет, к тому ли хосту подключился #3079

Merged
lekss361 merged 1 commit from chore/3029-ci-access-perimeter into main 2026-08-24 16:36:51 +00:00
Owner

Закрывает часть приёмки #3029. Решение владельца 24.08: сейчас только добавляем, удаляем после переезда — отзыв старого ключа и сужение правил вынесены в #3075.

Дыра, которая закрывается

Подлинность хоста не проверяется нигде:

  • deploy-tradein.yml явно ставил -o StrictHostKeyChecking=no;
  • во всех вызовах appleboy/ssh-action не задан вход fingerprint.

Пока Forgejo, раннеры и прод жили на одной машине, цена была почти нулевой. После 30.08 DEPLOY_HOST указывает на Selectel, и тот же канал идёт через интернет — а по нему через envs: едут GHCR_PAT, OPENAI_API_KEY, OBJECTIVE_API_KEY, GLITCHTIP_BACKEND_DSN и сам DEPLOY_SSH_KEY.

Вызовов оказалось пять, а не четыре — в исходном разборе был пропущен deploy-obsidian.yml, который тоже ходит на DEPLOY_HOST с DEPLOY_SSH_KEY. Отпечаток добавлен во все пять.

Почему мерж безопасен — проверено по исходникам, а не по README

Цепочка пиннинга прослежена до кода:

appleboy/ssh-action@v1.0.3Dockerfile: FROM ghcr.io/appleboy/drone-ssh:1.7.3go.mod: easyssh-proxy v1.5.0easyssh.go:

hostKeyCallback := ssh.InsecureIgnoreHostKey()
if config.Fingerprint != "" {
    hostKeyCallback = func(hostname string, remote net.Addr, publicKey ssh.PublicKey) error {
        if ssh.FingerprintSHA256(publicKey) != config.Fingerprint {
            return fmt.Errorf("ssh: host key fingerprint mismatch")
        }
        return nil
    }
}

Незаданный секрет в Actions разворачивается в пустую строку → InsecureIgnoreHostKey() → поведение до PR бит в бит. Условная конструкция не нужна: механизм инертен по устройству самого действия. Неверное значение падает громко, а не тихо.

Инертность безопасна для мержа и ОПАСНА для окна

Это главная правка по итогам ревью. Если 30.08 секрет забудут завести — или заведут пустым, сняв ssh-keyscan -t ecdsa с хоста без ECDSA-ключа — деплой проедет зелёным через интернет вообще без проверки. Защита молча не применилась бы ровно тогда, когда впервые нужна.

Поэтому перед каждым из пяти ssh-шагов стоит преамбула: при пустом секрете печатает ::warning с перечислением того, что именно едет по этому каналу; при заданном — одну строку подтверждения. Warning, а не отказ — секрета сегодня нет ни у кого, отказ сломал бы деплой в момент мержа, что прямо запрещено требованием стадийности.

Грабля: нужен ECDSA-отпечаток, а не ed25519

Клиент не задаёт HostKeyAlgorithms, берётся дефолт x/crypto v0.17.0, где supportedHostKeyAlgos ставит ecdsa-sha2-nistp256 выше ssh-ed25519. Со стоковым OpenSSH согласуется ECDSA — привычный ed25519-отпечаток просто остановит деплой.

ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}'
#   -> SHA256:...   (префикс обязателен)

Сравнение идёт побайтово и без trim — прилипший при копипасте перевод строки делает значение непустым, проверка включается, и все пять шагов падают с host key fingerprint mismatch. Предупреждение об этом добавлено в шапку каждого файла.

Новые секреты (задавать НЕ сейчас, а в окно)

секрет хост кто использует
DEPLOY_SSH_FINGERPRINT Selectel deploy.yml ×2, deploy-tradein.yml, deploy-obsidian.yml
INFRA_DEPLOY_SSH_FINGERPRINT Beget deploy-infra.yml
DEPLOY_KNOWN_HOSTS Selectel шаг Resolve deployed base SHA

В deploy-infra.yml намеренно нет фолбэка на DEPLOY_SSH_FINGERPRINT, хотя у соседних username/key/port он есть: отпечаток — идентичность конкретной машины, и фолбэк означал бы сверку ключа Beget с отпечатком Selectel, то есть гарантированный отказ ровно у того workflow, который чинит остающийся хост.

Ручной ssh — отдельный случай

deploy-tradein.yml, шаг Resolve deployed base SHA читает маркер с прода обычным openssh-клиентом, которому отпечаток не скормить. Схема: секрет задан → StrictHostKeyChecking=yes + UserKnownHostsFile; не задан → прежнее поведение плюс баннер.

Заодно расхождение ключа перестало быть неотличимым от недоступного хоста: раньше 2>/dev/null глотал Host key verification failed, и печаталось обычное «фолбэк на полную пересборку». Теперь причина называется отдельно. Fail-safe сохранён: при любой ошибке RAW_SHA пустеет и включается build-all, так что цена ошибки в known_hosts — максимум лишняя полная пересборка, а не сорванный деплой.

ops/selectel-ci-access.sh

Аддитивная выдача CI-доступа, 483 строки. Ставит отдельный ключ робота (не переиспользуя админский — отзыв одного не должен бить по другому) с ограничением from="<адрес раннера>", добавляет узкое правило ufw, не трогая общее, и печатает план сужения, который сам не выполняет.

Ревью подтвердило мандат построчно: sshd не перезапускается, default policy ufw не трогается, ufw delete встречается только внутри печатаемого плана, в authorized_keys идёт исключительно >> — включая дозапись недостающего финального перевода строки, иначе склеились бы две строки. Порт берётся из живого sshd -T, а не предполагается 22. Пустой адрес без явного CI_ALLOW_ANY_SOURCE=yes — отказ, чтобы не получить from="".

Пользователь намеренно не создаётся: отдельная учётка тянет docker-группу, права на /opt/gendesign и смену DEPLOY_USER, то есть перенастройку живого конвейера.

Замечание вне scope

deploy-obsidian.yml ходит на DEPLOY_HOST, но CouchDB по плану остаётся на Beget вместе с Forgejo и GlitchTip. После 30.08 DEPLOY_HOST будет указывать на Selectel — тот же класс, что #3059: молчаливо не тот адресат. Не трогал (правка про подлинность хоста, не про маршрутизацию), но пометил комментарием в файле.

Test plan

  • YAML всех 8 workflow парсится, ключ on: на месте у каждого, число jobs не изменилось
  • Семантический diff распарсенного YAML: изменились только with.fingerprint ×5, новые шаги ×5 и один run; триггеры, paths, docker-команды и healthcheck'и идентичны; ci*.yml, perimeter-smoke, stale-claims — без единого изменения
  • Тела всех пяти преамбул прогнаны через bash с пустым и заполненным значением: под set -euo pipefail не падают, exit 0 в обеих ветках
  • Resolve deployed base SHA прогнан с подставным ssh в обоих режимах: без секрета аргументы побайтово как сегодня, с секретом — StrictHostKeyChecking=yes; временные файлы вычищаются
  • bash -n на ops/selectel-ci-access.sh и ops/selectel-audit.sh
  • После мержа: убедиться, что ближайший деплой зелёный и в логе появилось предупреждение об отсутствующем отпечатке
  • В окно: завести три секрета, прогнать ops/selectel-ci-access.sh, проверить деплой end-to-end Beget → Selectel

Refs #3029, #3075, #3057, #2989

Закрывает часть приёмки #3029. Решение владельца 24.08: **сейчас только добавляем, удаляем после переезда** — отзыв старого ключа и сужение правил вынесены в #3075. ## Дыра, которая закрывается Подлинность хоста не проверяется **нигде**: - `deploy-tradein.yml` явно ставил `-o StrictHostKeyChecking=no`; - во всех вызовах `appleboy/ssh-action` не задан вход `fingerprint`. Пока Forgejo, раннеры и прод жили на одной машине, цена была почти нулевой. После 30.08 `DEPLOY_HOST` указывает на Selectel, и тот же канал идёт через интернет — а по нему через `envs:` едут `GHCR_PAT`, `OPENAI_API_KEY`, `OBJECTIVE_API_KEY`, `GLITCHTIP_BACKEND_DSN` и сам `DEPLOY_SSH_KEY`. **Вызовов оказалось пять, а не четыре** — в исходном разборе был пропущен `deploy-obsidian.yml`, который тоже ходит на `DEPLOY_HOST` с `DEPLOY_SSH_KEY`. Отпечаток добавлен во все пять. ## Почему мерж безопасен — проверено по исходникам, а не по README Цепочка пиннинга прослежена до кода: `appleboy/ssh-action@v1.0.3` → `Dockerfile: FROM ghcr.io/appleboy/drone-ssh:1.7.3` → `go.mod: easyssh-proxy v1.5.0` → `easyssh.go`: ```go hostKeyCallback := ssh.InsecureIgnoreHostKey() if config.Fingerprint != "" { hostKeyCallback = func(hostname string, remote net.Addr, publicKey ssh.PublicKey) error { if ssh.FingerprintSHA256(publicKey) != config.Fingerprint { return fmt.Errorf("ssh: host key fingerprint mismatch") } return nil } } ``` Незаданный секрет в Actions разворачивается в пустую строку → `InsecureIgnoreHostKey()` → поведение до PR бит в бит. Условная конструкция не нужна: механизм инертен по устройству самого действия. Неверное значение падает громко, а не тихо. ## Инертность безопасна для мержа и ОПАСНА для окна Это главная правка по итогам ревью. Если 30.08 секрет забудут завести — или заведут пустым, сняв `ssh-keyscan -t ecdsa` с хоста без ECDSA-ключа — деплой проедет **зелёным** через интернет вообще без проверки. Защита молча не применилась бы ровно тогда, когда впервые нужна. Поэтому перед каждым из пяти ssh-шагов стоит преамбула: при пустом секрете печатает `::warning` с перечислением того, что именно едет по этому каналу; при заданном — одну строку подтверждения. **Warning, а не отказ** — секрета сегодня нет ни у кого, отказ сломал бы деплой в момент мержа, что прямо запрещено требованием стадийности. ## Грабля: нужен ECDSA-отпечаток, а не ed25519 Клиент не задаёт `HostKeyAlgorithms`, берётся дефолт `x/crypto v0.17.0`, где `supportedHostKeyAlgos` ставит `ecdsa-sha2-nistp256` **выше** `ssh-ed25519`. Со стоковым OpenSSH согласуется ECDSA — привычный ed25519-отпечаток просто остановит деплой. ```bash ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}' # -> SHA256:... (префикс обязателен) ``` Сравнение идёт **побайтово и без trim** — прилипший при копипасте перевод строки делает значение непустым, проверка включается, и все пять шагов падают с `host key fingerprint mismatch`. Предупреждение об этом добавлено в шапку каждого файла. ## Новые секреты (задавать НЕ сейчас, а в окно) | секрет | хост | кто использует | |---|---|---| | `DEPLOY_SSH_FINGERPRINT` | Selectel | `deploy.yml` ×2, `deploy-tradein.yml`, `deploy-obsidian.yml` | | `INFRA_DEPLOY_SSH_FINGERPRINT` | Beget | `deploy-infra.yml` | | `DEPLOY_KNOWN_HOSTS` | Selectel | шаг `Resolve deployed base SHA` | В `deploy-infra.yml` **намеренно нет фолбэка** на `DEPLOY_SSH_FINGERPRINT`, хотя у соседних `username`/`key`/`port` он есть: отпечаток — идентичность конкретной машины, и фолбэк означал бы сверку ключа Beget с отпечатком Selectel, то есть гарантированный отказ ровно у того workflow, который чинит остающийся хост. ## Ручной ssh — отдельный случай `deploy-tradein.yml`, шаг `Resolve deployed base SHA` читает маркер с прода обычным openssh-клиентом, которому отпечаток не скормить. Схема: секрет задан → `StrictHostKeyChecking=yes` + `UserKnownHostsFile`; не задан → прежнее поведение плюс баннер. Заодно расхождение ключа перестало быть неотличимым от недоступного хоста: раньше `2>/dev/null` глотал `Host key verification failed`, и печаталось обычное «фолбэк на полную пересборку». Теперь причина называется отдельно. **Fail-safe сохранён**: при любой ошибке `RAW_SHA` пустеет и включается build-all, так что цена ошибки в `known_hosts` — максимум лишняя полная пересборка, а не сорванный деплой. ## ops/selectel-ci-access.sh Аддитивная выдача CI-доступа, 483 строки. Ставит **отдельный** ключ робота (не переиспользуя админский — отзыв одного не должен бить по другому) с ограничением `from="<адрес раннера>"`, добавляет узкое правило ufw, **не трогая общее**, и печатает план сужения, который сам не выполняет. Ревью подтвердило мандат построчно: sshd не перезапускается, default policy ufw не трогается, `ufw delete` встречается только внутри печатаемого плана, в `authorized_keys` идёт исключительно `>>` — включая дозапись недостающего финального перевода строки, иначе склеились бы две строки. Порт берётся из живого `sshd -T`, а не предполагается 22. Пустой адрес без явного `CI_ALLOW_ANY_SOURCE=yes` — отказ, чтобы не получить `from=""`. Пользователь намеренно не создаётся: отдельная учётка тянет docker-группу, права на `/opt/gendesign` и смену `DEPLOY_USER`, то есть перенастройку живого конвейера. ## Замечание вне scope `deploy-obsidian.yml` ходит на `DEPLOY_HOST`, но CouchDB по плану остаётся на Beget вместе с Forgejo и GlitchTip. После 30.08 `DEPLOY_HOST` будет указывать на Selectel — тот же класс, что #3059: молчаливо не тот адресат. Не трогал (правка про подлинность хоста, не про маршрутизацию), но пометил комментарием в файле. ## Test plan - [x] YAML всех 8 workflow парсится, ключ `on:` на месте у каждого, число jobs не изменилось - [x] Семантический diff распарсенного YAML: изменились только `with.fingerprint` ×5, новые шаги ×5 и один `run`; триггеры, `paths`, docker-команды и healthcheck'и идентичны; `ci*.yml`, `perimeter-smoke`, `stale-claims` — без единого изменения - [x] Тела всех пяти преамбул прогнаны через bash с пустым и заполненным значением: под `set -euo pipefail` не падают, `exit 0` в обеих ветках - [x] `Resolve deployed base SHA` прогнан с подставным `ssh` в обоих режимах: без секрета аргументы побайтово как сегодня, с секретом — `StrictHostKeyChecking=yes`; временные файлы вычищаются - [x] `bash -n` на `ops/selectel-ci-access.sh` и `ops/selectel-audit.sh` - [ ] После мержа: убедиться, что ближайший деплой зелёный и в логе появилось предупреждение об отсутствующем отпечатке - [ ] В окно: завести три секрета, прогнать `ops/selectel-ci-access.sh`, проверить деплой end-to-end Beget → Selectel Refs #3029, #3075, #3057, #2989
lekss361 added 1 commit 2026-08-24 16:18:10 +00:00
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
1a37c7e0f9
Сегодня подлинность хоста не проверяется нигде: 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
lekss361 merged commit 536137d460 into main 2026-08-24 16:36:51 +00:00
lekss361 deleted branch chore/3029-ci-access-perimeter 2026-08-24 16:36:51 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3079
No description provided.