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:
@@ -281,6 +281,36 @@ Denylist требует, чтобы автор каждого нового кл
|
||||
виде, поэтому запускается только через явное подтверждение с описанием риска.
|
||||
Такой файл следует хранить как пароль и удалять после завершения переноса.
|
||||
|
||||
#### Резервная копия либо полная, либо её нет
|
||||
|
||||
Для `includeSecrets=true` правило строгое: если секрет хотя бы одного пира
|
||||
получить не удалось — расшифровка не прошла или шифртекста нет вовсе — **весь**
|
||||
запрос завершается ошибкой, называющей проблемного пира, и файл не создаётся.
|
||||
|
||||
Раньше оба этих случая обрабатывались молча: пир уезжал в файл с пустым полем
|
||||
`secret`, а запрос отвечал успехом. Оператор получал файл, выглядящий полным:
|
||||
|
||||
```json
|
||||
[{"name":"A","secret":"..."},
|
||||
{"name":"B","secret":""},
|
||||
{"name":"C","secret":"..."}]
|
||||
```
|
||||
|
||||
Обнаруживалось это уже после импорта на новом сервере: B получал новый
|
||||
сгенерированный секрет, а его клиент — отказ авторизации. Смысл режима ровно в
|
||||
том, что пользователь СПЕЦИАЛЬНО выбрал «копия с действующими credentials»;
|
||||
частичный результат под этим именем — худший из возможных ответов.
|
||||
|
||||
Безопасная выгрузка (`includeSecrets=false`) шифртекст не трогает вовсе и
|
||||
повреждённых данных не замечает: пустой `secret` там — не потеря, а весь смысл
|
||||
режима.
|
||||
|
||||
Секреты пиров хранятся только зашифрованными, в единственном формате `v1:` +
|
||||
AES-GCM. Значение без этого префикса — не «формат предыдущего поколения», а
|
||||
повреждённые данные, и расшифровка на них отказывает. Прежняя реализация
|
||||
возвращала такое содержимое как якобы успешно расшифрованный секрет, то есть
|
||||
мусор из колонки уходил и в клиентскую ссылку, и в резервную копию.
|
||||
|
||||
### Импорт пиров
|
||||
|
||||
Импорт проверяется так же строго, как обычное создание пира: те же правила для
|
||||
@@ -478,6 +508,23 @@ upstream выберет для нового секрета. Список мар
|
||||
запись, и только потом требуется переменная. Перезапуск уже установленного
|
||||
сервиса без неё работает штатно.
|
||||
|
||||
Тот же принцип распространён на machine token `HYSTERIA2_TRAFFIC_STATS_SECRET`.
|
||||
Раньше при пустом env и пустой базе админка генерировала его сама, и это было
|
||||
хуже, чем отказ: записать значение в `/etc/hysteria/config.yaml` она не может —
|
||||
файл принадлежит оркестратору и доступен ей только на чтение, что проверяет
|
||||
smoke. Результат — сервис объявлял себя здоровым, а machine auth переставал
|
||||
совпадать, потому что Hysteria продолжала слать прежний токен. Допустимых
|
||||
состояний три:
|
||||
|
||||
| env | база | поведение |
|
||||
| --- | --- | --- |
|
||||
| задан | любое | база синхронизируется с env: владелец значения — оркестратор |
|
||||
| пуст | токен есть | рабочее состояние, ничего не меняется |
|
||||
| пуст | пусто | **отказ старта** |
|
||||
|
||||
Вторая строка нужна для ручного `systemctl start` без `EnvironmentFile`: она не
|
||||
изобретает контракт, а использует уже согласованный.
|
||||
|
||||
### Пир установщика защищён во всех путях записи
|
||||
|
||||
`bootstrap-admin-peer` нельзя переименовать, переподписать или занять его имя
|
||||
@@ -490,6 +537,56 @@ upstream выберет для нового секрета. Список мар
|
||||
обязан иметь возможность его отозвать. В отличие от смены секрета, удаление не
|
||||
создаёт расхождения между базой и файлом — пира просто нет, и это видно в списке.
|
||||
|
||||
### Отзыв пира установщика необратим
|
||||
|
||||
Разрешать удаление имеет смысл только вместе с этим свойством, иначе панель
|
||||
предлагает операцию, которой не выполняет.
|
||||
|
||||
Признаком «создавать пир или нет» служит отметка `BOOTSTRAP_PEER_SEEDED` в
|
||||
таблице `config`. Она отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не
|
||||
«существует сейчас», и выставляется той же транзакцией, которой создаётся сам
|
||||
пир.
|
||||
|
||||
Раньше признаком было наличие строки в таблице пиров, и отзыв доступа не
|
||||
переживал перезапуск сервиса:
|
||||
|
||||
```text
|
||||
оператор удаляет bootstrap-admin-peer
|
||||
↓
|
||||
доступ действительно исчезает
|
||||
|
||||
systemctl restart hy2xs-admin (или reboot)
|
||||
↓
|
||||
InitSql → ensureSecureBootstrapPeer
|
||||
↓
|
||||
строки нет → прочитать HY2XS_ADMIN_CON_PASS из /etc/hy2xs/hy2xs.env
|
||||
↓
|
||||
создать пира заново → ТОТ ЖЕ секрет снова действует
|
||||
```
|
||||
|
||||
Переменная никуда не девается из `hy2xs.env` — её читает systemd-юнит, — поэтому
|
||||
восстановление происходило **молча**: ни строки в журнале, а в списке пиров
|
||||
запись просто снова есть. Отзыв учётных данных, который не переживает restart,
|
||||
отзывом не является.
|
||||
|
||||
Транзакционность здесь не формальность: раздельная запись вернула бы прежнее
|
||||
поведение в новой форме, потому что падение процесса между созданием пира и
|
||||
записью отметки снова дало бы следующему старту «ещё не создавался».
|
||||
|
||||
Что при этом происходит с файлом на диске: `/etc/hy2xs/bootstrap-admin.secret`
|
||||
принадлежит оркестратору, админка его не трогает, и после отзыва он содержит уже
|
||||
недействующее значение. Это ожидаемо — файл является копией того, что установка
|
||||
записала в базу, а не источником истины для рантайма.
|
||||
|
||||
Отключение (`Disabled = 1`) остаётся вторым, обратимым способом: `Hysteria2Auth`
|
||||
выбирает пира с условием `disabled = 0`, поэтому доступ закрывается сразу, а
|
||||
запись сохраняется.
|
||||
|
||||
Жизненный цикл закреплён тестами в `apps/dao/bootstrap_peer_test.go`: создание,
|
||||
перезапуск без изменений, удаление с последующими перезапусками, отключение,
|
||||
отказ старта без `HY2XS_ADMIN_CON_PASS` на чистой базе и успешный перезапуск без
|
||||
неё на установленной.
|
||||
|
||||
### Токены и пароли
|
||||
|
||||
Токены выписываются и проверяются `golang-jwt/jwt/v5`. Переход с v3 —
|
||||
|
||||
Reference in New Issue
Block a user