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:
@@ -147,11 +147,28 @@ Hysteria в шаблоне) плюс несовместимость `DefaultRow`
|
||||
Версия `govulncheck` пиньтся в `versions.env`, а база уязвимостей подтягивается
|
||||
на каждом запуске: пин инструмента не должен превращаться в пин знаний о мире.
|
||||
|
||||
Аварийный выход — `ALLOW_VULNERABLE_DEPENDENCIES=true`, по той же логике, что и
|
||||
`ALLOW_DIRTY_BUILD`: выпустить релиз, зная об уязвимости, можно, но это решение
|
||||
человека, а не поведение по умолчанию. Результат шага уезжает в
|
||||
`metadata/package.env` полем `dependency_security_gate`, так что по готовому
|
||||
tarball видно, проверялся он или собран с пропущенной проверкой.
|
||||
Аварийного выхода у шага **нет**, и это отличает его от `ALLOW_DIRTY_BUILD`.
|
||||
Результат уезжает в `metadata/package.env` полем `dependency_security_gate`,
|
||||
которое принимает единственное значение `true`: по готовому tarball видно, что
|
||||
он проверялся, потому что непроверенного tarball не бывает.
|
||||
|
||||
Две переменные обхода здесь существовали и были описаны как способ выпустить
|
||||
релиз, зная об уязвимости. Способом они не были: финальная приёмка архива
|
||||
требует буквально `dependency_security_gate=true`, поэтому сборка с любой из них
|
||||
доходила до конца — компиляция, бандл, тесты, метаданные, tar — и падала на
|
||||
последнем шаге. Продукт документировал операцию, которую сам же запрещал.
|
||||
Противоречие закрыто в пользу строгой политики; отсутствие обходов проверяется
|
||||
приёмкой, а не только описано здесь.
|
||||
|
||||
Контракт читается однозначно:
|
||||
|
||||
> релизный артефакт HY2XS невозможно собрать с непройденной проверкой
|
||||
> зависимостей.
|
||||
|
||||
Новое advisory чинится обновлением графа (`apps/go.sum`,
|
||||
`apps/frontend/pnpm-lock.yaml`) или версии toolchain в `versions.env`. Для
|
||||
локальной работы обходить нечего: `go test ./...`, `govulncheck ./...` и
|
||||
`pnpm audit` запускаются напрямую и tarball не создают.
|
||||
|
||||
### Проверка, а не генерация
|
||||
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -369,6 +369,52 @@ wildcard-маршрутом фронтенда или дублирующая р
|
||||
- удаление **разрешено**: это осознанное действие оператора, и расхождения
|
||||
между базой и `bootstrap-admin.secret` оно не создаёт.
|
||||
|
||||
## A9e. Жизненный цикл пира установщика (unit, настоящая SQLite)
|
||||
|
||||
`apps/dao/bootstrap_peer_test.go` — проверяется не функция, а поведение сервиса
|
||||
при перезапуске: дефект, ради которого написан этот файл, проявлялся только на
|
||||
ВТОРОМ запуске, поэтому каждый тест прогоняет полную последовательность
|
||||
`InitSqlAt` дважды на одной базе.
|
||||
|
||||
- первый запуск создаёт пира и выставляет отметку `BOOTSTRAP_PEER_SEEDED`;
|
||||
- обычный перезапуск не пересоздаёт пира и не плодит дублей (`id` тот же,
|
||||
запись ровно одна);
|
||||
- **удаление переживает перезапуск**: после `DELETE` и рестарта пир не
|
||||
возвращается, хотя `HY2XS_ADMIN_CON_PASS` остаётся в окружении;
|
||||
- то же после трёх перезапусков подряд;
|
||||
- отключённый пир сохраняет `disabled = 1` и свой `secret_digest`;
|
||||
- отметка и пир пишутся одной транзакцией: при конфликте `UNIQUE(name)` внутри
|
||||
транзакции отметка не остаётся выставленной;
|
||||
- отсутствие `HY2XS_ADMIN_CON_PASS` на чистой базе — отказ старта;
|
||||
- перезапуск установленного сервиса без этой переменной проходит штатно;
|
||||
- `HYSTERIA2_TRAFFIC_STATS_SECRET`: пустой env при пустой базе — отказ старта,
|
||||
сгенерированного токена в базе не появляется; токен, уже согласованный
|
||||
ранее, принимается без переменной.
|
||||
|
||||
## A9f. Резервная копия пиров (unit)
|
||||
|
||||
`apps/service/peer_export_backup_test.go`:
|
||||
|
||||
- `includeSecrets=true` на исправных данных отдаёт секрет каждого пира;
|
||||
- нерасшифровываемый секрет хотя бы одного пира отклоняет **весь** запрос,
|
||||
сообщение называет пира, частичное содержимое не возвращается;
|
||||
- пир вовсе без шифртекста — тот же отказ;
|
||||
- `includeSecrets=false` повреждённых данных не замечает и пустой `secret`
|
||||
отдаёт штатно: это и есть смысл безопасного режима.
|
||||
|
||||
## A9g. Слой данных: «нет записи» против «база не ответила» (unit)
|
||||
|
||||
`apps/dao/config_test.go`:
|
||||
|
||||
- `UpdateConfig` по отсутствующей строке — **отказ**, а не тихий успех: UPDATE
|
||||
без совпавших строк не является ошибкой SQL, и раньше оператор получал
|
||||
подтверждение изменения, которого не произошло, а планировщик тут же получал
|
||||
новое расписание;
|
||||
- `UpdateConfig` не создаёт строк: это работа `UpsertConfigValue`;
|
||||
- транзакционная партия откатывается целиком, если одна из строк отсутствует;
|
||||
- `GetConfig`/`GetPeer` возвращают `ErrConfigNotFound`/`ErrPeerNotFound`,
|
||||
отличимые через `errors.Is` от `ErrStorage`.
|
||||
|
||||
## A10. Импорт пиров (unit)
|
||||
|
||||
`apps/service/peer_import_test.go`:
|
||||
@@ -811,3 +857,12 @@ hy2xs-orchestrator doctor
|
||||
32. документация нигде не утверждает, что Salamander — фиксированный инвариант
|
||||
33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
|
||||
34. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
|
||||
35. `hy2xs-orchestrator doctor` не перезапускает сервисы и не рвёт живые соединения
|
||||
36. удаление `bootstrap-admin-peer` переживает `systemctl restart` и `reboot`: пир не воскресает
|
||||
37. отключённый `bootstrap-admin-peer` остаётся отключённым после перезапуска
|
||||
38. резервная копия с `includeSecrets=true` завершается ошибкой целиком, если секрет хотя бы одного пира недоступен
|
||||
39. админка не генерирует `HYSTERIA2_TRAFFIC_STATS_SECRET` сама: пустой env при пустой базе — отказ старта
|
||||
40. проверка зависимостей на уязвимости не имеет обходов ни в сборке, ни в документации
|
||||
41. `apps/go.mod` объявляет `toolchain`, совпадающий с `GO_VERSION` из `versions.env`
|
||||
42. `tools/dev/doctor.sh` / `doctor.ps1` показывают расхождение среды разработки с `versions.env`
|
||||
43. маршруты-алиасы `/:id/client-url` и `/:id/qr` удалены и не входят в публичный API v1
|
||||
|
||||
@@ -253,6 +253,12 @@ Update the DNS A record before using this server.
|
||||
3. дождаться истечения TTL;
|
||||
4. повторить `hy2xs-orchestrator doctor`.
|
||||
|
||||
`doctor` безопасно запускать на работающем сервере: он не перезапускает
|
||||
сервисы и живые соединения не рвёт. Раньше это было не так — команда звала
|
||||
общий smoke, который начинается с `systemctl restart hysteria-server
|
||||
hy2xs-admin`, и диагностика подозрения на проблему сама создавала обрыв у всех
|
||||
подключённых клиентов.
|
||||
|
||||
Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного
|
||||
публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline;
|
||||
осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`.
|
||||
|
||||
@@ -116,6 +116,22 @@ hy2xs-orchestrator doctor --package-dir /usr/local/lib/hy2xs/package --config /e
|
||||
|
||||
Команда выполняет preflight + smoke как post-install/post-reboot validation.
|
||||
|
||||
`doctor` **не перезапускает сервисы**: он диагностирует работающую установку.
|
||||
Раньше он собирал контекст с параметрами по умолчанию и звал общий smoke, а тот
|
||||
первым же действием выполняет `systemctl restart hysteria-server hy2xs-admin` —
|
||||
то есть команда, которую этот раздел предлагает запускать при подозрении на
|
||||
проблему, гарантированно обрывала все живые VPN-соединения, включая случай,
|
||||
когда с сервисом всё в порядке. Диагностика, меняющая то, что диагностирует,
|
||||
отвечает не на заданный вопрос: после рестарта проверяется уже другое состояние.
|
||||
|
||||
Остальные проверки smoke выполняются полностью — слушатели, права и владельцы
|
||||
файлов, machine auth (включая негативные случаи), семантика
|
||||
`/etc/hysteria/config.yaml` против production-профиля, версия установленного
|
||||
бинаря. Состояние сервера ни одна из них не меняет.
|
||||
|
||||
Перезапуск сервисов остаётся операцией `install`, `reconfigure --apply` и
|
||||
`repair` — там он является частью применения изменений, а не проверкой.
|
||||
|
||||
## 13. Admin UI access via SSH tunnel
|
||||
|
||||
Production policy: UI остаётся loopback-only (`HY2XS_UI_BIND_HOST=127.0.0.1`), внешний доступ к `8080/tcp` не открывается.
|
||||
|
||||
Reference in New Issue
Block a user