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:
+100
@@ -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` детектировал сам себя и падал шестым шагом из четырнадцати.**
|
||||
|
||||
Reference in New Issue
Block a user