fix(v1): сделать отзыв доступа, бэкап и диагностику соответствующими своим именам

Проход по операциям, которые делают не то, что обещает их имя.

P0. Удаление bootstrap-admin-peer не было отзывом доступа. Признаком «создавать
пир или нет» служило наличие строки в таблице, а HY2XS_ADMIN_CON_PASS
продолжает жить в /etc/hy2xs/hy2xs.env — его читает systemd-юнит. Оператор
удалял пира, доступ исчезал, и ближайший restart возвращал того же пира с тем же
секретом. Молча. Признаком стала отметка BOOTSTRAP_PEER_SEEDED в таблице config:
«создавался когда-либо», а не «существует сейчас». Отметка и пир пишутся одной
транзакцией.

P1. Резервная копия с includeSecrets=true проглатывала и ошибку расшифровки, и
отсутствие шифртекста, отдавая пира с пустым secret и успешный ответ. Теперь
недоступный секрет любого пира отклоняет весь запрос с указанием имени.

P1. DecryptPeerSecret возвращала содержимое колонки как расшифрованный секрет,
если оно не начиналось с v1: — остаток поколения с открытыми секретами.

P1. doctor перезапускал hysteria-server и hy2xs-admin: диагностика подозрения на
проблему обрывала все живые соединения.

P1. Админка сама генерировала HYSTERIA2_TRAFFIC_STATS_SECRET, записать который в
/etc/hysteria/config.yaml она не может. Сервис объявлял себя здоровым, а machine
auth переставал совпадать.

P1. Обходы проверки зависимостей (accepted-risk/skipped) не могли произвести
артефакт: приёмка требует dependency_security_gate=true. Удалены из сборки и
документации, отсутствие проверяется приёмкой.

P2. UPDATE по отсутствующей строке config считался успехом, и cron
перепланировался при несохранённом значении. Решение по RowsAffected.

P2. Слой данных не отличал «записи нет» от «база не ответила»: sentinel-значения
ErrPeerNotFound / ErrAdminUserNotFound / ErrConfigNotFound / ErrStorage.

P2. Удалены алиасы /:id/client-url и /:id/qr.

Контракт разработки: apps/go.mod объявляет toolchain go1.26.7 (директива go —
языковой baseline, а не выбор компилятора), tools/dev/doctor.sh|.ps1 сверяют
среду с versions.env.
This commit is contained in:
2026-08-30 06:48:50 +05:00
parent e30fdaa004
commit cf094f6e6f
33 changed files with 1976 additions and 154 deletions
+100
View File
@@ -17,6 +17,106 @@ Hardening-проход перед релизом `1.0.0`. Основная те
состояние в таблице `config`; каналы утечки bootstrap-учётных данных; возраст
графа зависимостей.
Третий проход — операции, которые делают не то, что обещает их имя: отзыв
доступа, не переживающий перезапуск; резервная копия, молча получающаяся
неполной; диагностика, обрывающая соединения; аварийный выход сборки, которым
невозможно воспользоваться.
### Исправлено — операции, не выполняющие обещанного
- **Удаление `bootstrap-admin-peer` не было отзывом доступа.** Признаком
«создавать пир или нет» служило наличие строки в таблице пиров, а
`HY2XS_ADMIN_CON_PASS` продолжает жить в `/etc/hy2xs/hy2xs.env` — его читает
systemd-юнит. Поэтому оператор удалял пира, доступ действительно исчезал, а
ближайший `systemctl restart hy2xs-admin` или перезагрузка сервера возвращали
того же пира с тем же секретом. Молча: ни строки в журнале, в списке пиров
запись просто снова есть.
Признаком стала отметка `BOOTSTRAP_PEER_SEEDED` в таблице `config`: она
отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не «существует сейчас».
Отметка и сам пир пишутся одной транзакцией — раздельная запись вернула бы
прежнее поведение при падении процесса между двумя операциями. Удаление
осталось разрешённым и стало необратимым; отключение (`Disabled = 1`)
остаётся вторым, обратимым способом.
- **Резервная копия с секретами могла молча оказаться неполной.**
`ListExportPeer(true)` проглатывала и ошибку расшифровки, и отсутствие
шифртекста, отдавая пира с пустым полем `secret` и успешный ответ. Оператор,
СПЕЦИАЛЬНО выбравший режим «копия с действующими credentials», получал файл,
выглядящий полным, и узнавал о потере после импорта на новом сервере — по
отвалившимся клиентам. Теперь недоступный секрет хотя бы одного пира
отклоняет весь запрос с указанием имени; файл не создаётся. Безопасная
выгрузка (`includeSecrets=false`) не изменилась.
- **`DecryptPeerSecret` возвращала содержимое колонки как расшифрованный
секрет,** если оно не начиналось с `v1:`. Ветка досталась от поколения, где
секреты пиров лежали открытым текстом; при clean-install-only политике такой
строки существовать не может, а вред оставался: повреждённая колонка уходила
в клиентскую ссылку и в резервную копию как учётные данные. Формат хранения
теперь ровно один, всё остальное — ошибка. Тот же класс, что и удалённый
SHA-224 fallback при входе.
- **`hy2xs-orchestrator doctor` перезапускал оба сервиса.** Команда собирала
контекст с параметрами по умолчанию и звала общий smoke, который начинается с
`systemctl restart hysteria-server hy2xs-admin`. То есть диагностика, которую
runbook предлагает запускать при подозрении на проблему, гарантированно
обрывала все живые VPN-соединения — включая случай, когда с сервисом всё в
порядке. Диагностика, меняющая то, что диагностирует, отвечает не на заданный
вопрос. `doctor` принудительно выставляет `skipServiceStart`; остальные
проверки smoke выполняются полностью.
- **Админка сама придумывала `HYSTERIA2_TRAFFIC_STATS_SECRET`.** При пустом env
и пустой базе она генерировала случайный токен, записать который в
`/etc/hysteria/config.yaml` не может — файл принадлежит оркестратору и
доступен ей только на чтение. Сервис объявлял себя здоровым, а machine auth
переставал совпадать, потому что Hysteria продолжала слать прежнее значение.
Тот же принцип, что уже действует для `HY2XS_ADMIN_INITIAL_PASSWORD`: пустой
env при пустой базе — отказ старта; уже согласованный токен в базе
принимается.
- **Обходы проверки зависимостей существовали только на бумаге.**
Документированные `dependency_security_gate=accepted-risk` и `skipped` не
могли произвести артефакт: финальная приёмка архива требует буквально `true`,
поэтому сборка с ними проходила весь цикл и падала на последнем шаге. Продукт
документировал операцию, которую сам же запрещал. Обе переменные удалены из
сборки и документации; их отсутствие проверяется приёмкой. Контракт стал
однозначным: релизный артефакт невозможно собрать с непройденной проверкой.
- **`UPDATE` по отсутствующей строке `config` считался успехом.**
`updateConfigOn` смотрел только на `tx.Error`, а UPDATE без совпавших строк
ошибкой SQL не является. Следом `applyRuntimeConfigUpdates` применял значение
к планировщику, поэтому оператор дважды получал подтверждение изменения,
которого не произошло, а перезапуск сервиса возвращал прежний cron. Решение
принимается по `RowsAffected` — как в соседней `UpsertConfigValue`, где эта же
ошибка уже была разобрана.
- **Слой данных не отличал «записи нет» от «база не ответила».** Каждый `Get*`
возвращал свежий `errors.New` со строкой, поэтому отказ SQLite был
неотличим от отсутствия записи, а решения на этом принимались серьёзные:
«пира нет» означало «создать заново», «по auth_id не нашли» — «искать по
имени и, не найдя, создать», «ошибка» в `ExistPeerName` — «имя свободно». При
недоступной базе продукт не отказывал, а трактовал отказ как разрешение
действовать. Введены sentinel-значения `ErrPeerNotFound`,
`ErrAdminUserNotFound`, `ErrConfigNotFound` и `ErrStorage`.
- **Алиасы `/:id/client-url` и `/:id/qr` удалены.** Они были оставлены «на один
миграционный релиз», которого у clean-install-only продукта не существует;
дожив до `1.0.0`, они стали бы частью публичного API v1.
### Добавлено — контракт разработки
- **`apps/go.mod` объявляет `toolchain go1.26.7`.** Директива `go` — языковой
baseline модуля, а не выбор компилятора: с ней одной локальный `go build` на
другой минорной линии проходил успешно, хотя релизный бинарь собирается на
1.26.7 и наследует **её** stdlib. Разработчик и сборка проверяли разный код.
Совпадение с `versions.env` проверяет `verify_go_toolchain_contract`.
- **`tools/dev/doctor.sh` и `tools/dev/doctor.ps1`** сверяют Go, Node, pnpm, Bun
и директиву `toolchain` с `versions.env`. Собственных значений версий у них
нет — второй список неизбежно разъехался бы с контрактом. Сборка соблюдала
`versions.env` и раньше; машина разработчика не проверялась никак, и
расхождение обнаруживалось на Debian, внутри release-сборки.
### Исправлено — сборка не собиралась
- **`build.sh` детектировал сам себя и падал шестым шагом из четырнадцати.**