feat(ops): off-box копия рантайм-конфига — зашифрованной, иначе никак #3146

Merged
lekss361 merged 1 commit from feat/2203-env-offbox-copy into main 2026-08-27 14:00:46 +00:00
Owner

Закрывает четвёртый (последний) пункт приёмки #2203.

Зачем

В дампах есть колонки под pgp_sym_encrypt. Ключ к ним лежит на самой машине и никуда не уезжает. Дампы в S3 ежедневно, ключ — нигде: потеря машины означает «дампы есть, расшифровать нечем». Ради этой связки шифрование в базе и заводилось, и именно она сегодня не работает.

Куда кладём и почему именно туда

В тот же S3, отдельным префиксом env/, и только зашифрованным. Что рассматривал:

вариант почему нет
рядом с дампами, открытым ключ рядом с шифротекстом обнуляет шифрование — одна утечка доступа к бакету отдаёт и то и другое
на соседний хост по SSH доверия между машинами нет (проверил: Permission denied (publickey)) — пришлось бы расширить периметр ровно тогда, когда #3075 его сужает
отдельный бакет, отдельные ключи правильнее всего, но требует новых учётных данных

Выбран единственный исполнимый без расширения доступа: шифротекст в S3, парольная фраза — вне S3.

Fail-closed

Без парольной фразы скрипт не выгружает файл открытым — падает с явным сообщением и алертом в канал.

Это не перестраховка. Молчаливая выгрузка ключа в бакет, где лежат дампы, хуже отсутствия копии: отсутствие видно, а ложное чувство защищённости — нет.

Ещё два свойства, каждое из-за конкретной цены ошибки:

Фраза уходит через дескриптор, а не аргументом. --passphrase '<фраза>' в командной строке видна в ps любому пользователю машины.

После шифрования файл проверяется обратной расшифровкой. Без этого можно годами возить в S3 нечитаемый мусор и узнать об этом ровно в тот момент, когда он понадобился.

Прогон на хосте

Подставной исходник, боевой файл не трогался (его и не читал — на это в репозитории стоит хук, и правильно):

без фразы  → код возврата 1, файлов создано 0
с фразой   → «Зашифровано и проверено расшифровкой», 110 байт
своей фразой   → читается
чужой фразой   → не читается

Один шаг за владельцем, и он неустраним

Парольная фраза обязана жить там, где переживёт смерть машины — иначе копия бесполезна, расшифровать будет нечем. Сгенерировать её здесь и оставить на хосте нельзя по построению: погибнет вместе с тем, ради спасения от чего всё делается.

  1. Придумать фразу и положить в vault (meta/00_credentials.md) — это отдельная система на другой машине.
  2. Добавить строкой в /etc/default/gendesign-backup:
    ENV_OFFBOX_PASSPHRASE='<та же фраза>'
    

До этого шага скрипт будет ежедневно падать с понятным сообщением — намеренно громко, чтобы пункт не забылся.

Тесты

Пять, каждый сторожит свойство, ради которого всё сделано: скрипт вызывается из cron (страховка от мёртвого кода), отказ без фразы, шифрование до выгрузки, фраза не в аргументах, проверка обратным чтением между шифрованием и выгрузкой.

$ uv run python -m pytest tests/ops/ -q
85 passed

$ uv run ruff check …
All checks passed!

Refs #2203

Закрывает четвёртый (последний) пункт приёмки #2203. ## Зачем В дампах есть колонки под `pgp_sym_encrypt`. Ключ к ним лежит на самой машине и никуда не уезжает. Дампы в S3 ежедневно, ключ — нигде: **потеря машины означает «дампы есть, расшифровать нечем»**. Ради этой связки шифрование в базе и заводилось, и именно она сегодня не работает. ## Куда кладём и почему именно туда В тот же S3, отдельным префиксом `env/`, и **только зашифрованным**. Что рассматривал: | вариант | почему нет | |---|---| | рядом с дампами, открытым | ключ рядом с шифротекстом обнуляет шифрование — одна утечка доступа к бакету отдаёт и то и другое | | на соседний хост по SSH | доверия между машинами нет (проверил: `Permission denied (publickey)`) — пришлось бы **расширить** периметр ровно тогда, когда #3075 его сужает | | отдельный бакет, отдельные ключи | правильнее всего, но требует новых учётных данных | Выбран единственный исполнимый без расширения доступа: **шифротекст в S3, парольная фраза — вне S3**. ## Fail-closed Без парольной фразы скрипт **не выгружает файл открытым** — падает с явным сообщением и алертом в канал. Это не перестраховка. Молчаливая выгрузка ключа в бакет, где лежат дампы, хуже отсутствия копии: отсутствие видно, а ложное чувство защищённости — нет. Ещё два свойства, каждое из-за конкретной цены ошибки: **Фраза уходит через дескриптор, а не аргументом.** `--passphrase '<фраза>'` в командной строке видна в `ps` любому пользователю машины. **После шифрования файл проверяется обратной расшифровкой.** Без этого можно годами возить в S3 нечитаемый мусор и узнать об этом ровно в тот момент, когда он понадобился. ## Прогон на хосте Подставной исходник, боевой файл не трогался (его и не читал — на это в репозитории стоит хук, и правильно): ``` без фразы → код возврата 1, файлов создано 0 с фразой → «Зашифровано и проверено расшифровкой», 110 байт своей фразой → читается чужой фразой → не читается ``` ## Один шаг за владельцем, и он неустраним Парольная фраза обязана жить там, где переживёт смерть машины — иначе копия бесполезна, расшифровать будет нечем. Сгенерировать её здесь и оставить на хосте нельзя **по построению**: погибнет вместе с тем, ради спасения от чего всё делается. 1. Придумать фразу и положить в vault (`meta/00_credentials.md`) — это отдельная система на другой машине. 2. Добавить строкой в `/etc/default/gendesign-backup`: ``` ENV_OFFBOX_PASSPHRASE='<та же фраза>' ``` До этого шага скрипт будет ежедневно падать с понятным сообщением — намеренно громко, чтобы пункт не забылся. ## Тесты Пять, каждый сторожит свойство, ради которого всё сделано: скрипт вызывается из cron (страховка от мёртвого кода), отказ без фразы, шифрование до выгрузки, фраза не в аргументах, проверка обратным чтением между шифрованием и выгрузкой. ``` $ uv run python -m pytest tests/ops/ -q 85 passed $ uv run ruff check … All checks passed! ``` Refs #2203
lekss361 added 1 commit 2026-08-27 13:32:58 +00:00
feat(ops): off-box копия рантайм-конфига — зашифрованной, иначе никак
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
de940c7534
Четвёртый пункт приёмки #2203. В дампах есть колонки под pgp_sym_encrypt, ключ
к ним лежит на самой машине и никуда не уезжает: потеря машины означает «дампы
есть, расшифровать нечем». Ради этой связки шифрование в базе и заводилось.

КУДА И ПОЧЕМУ ИМЕННО ТУДА. В тот же S3, отдельным префиксом env/, и только
зашифрованным. Разобранные варианты:

  - рядом с дампами открытым — нельзя: ключ рядом с шифротекстом обнуляет
    шифрование, одна утечка доступа к бакету отдаёт и то и другое;
  - на соседний хост по SSH — потребовало бы завести доверие между машинами,
    которого нет (проверено: Permission denied (publickey)), то есть РАСШИРИТЬ
    периметр ровно тогда, когда #3075 его сужает;
  - отдельный бакет с отдельными ключами — правильнее всего, но требует новых
    учётных данных.

Выбран единственный исполнимый без расширения доступа: шифротекст в S3,
парольная фраза — вне S3.

FAIL-CLOSED. Без фразы скрипт не выгружает файл открытым, а падает с явным
сообщением и алертом. Молчаливая выгрузка ключа в бакет с дампами хуже
отсутствия копии: создаёт ложное чувство защищённости.

Фраза уходит через дескриптор, а не аргументом — иначе видна в ps любому
пользователю машины. После шифрования файл проверяется обратной расшифровкой:
без этого можно годами возить нечитаемый мусор и узнать в тот момент, когда он
понадобился.

Прогон на хосте 27.08 (подставной исходник, боевой не трогался):
  без фразы → код 1, файлов создано 0
  с фразой  → «Зашифровано и проверено расшифровкой», 110 байт
  своей фразой читается, чужой — нет

ОДИН ШАГ ЗА ВЛАДЕЛЬЦЕМ, и он неустраним: фраза обязана жить там, где переживёт
смерть машины, иначе копия бесполезна — расшифровать будет нечем. Сгенерировать
её здесь и оставить на хосте нельзя по построению. Инструкция — в шапке скрипта.

Пять тестов сторожат ровно те свойства, ради которых всё сделано.
Прогон: 85 ops-тестов зелёные, ruff чист.

Refs #2203
lekss361 merged commit 3297c1b683 into main 2026-08-27 14:00:46 +00:00
lekss361 deleted branch feat/2203-env-offbox-copy 2026-08-27 14:00:47 +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#3146
No description provided.