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-учётных данных; возраст
|
состояние в таблице `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` детектировал сам себя и падал шестым шагом из четырнадцати.**
|
- **`build.sh` детектировал сам себя и падал шестым шагом из четырнадцати.**
|
||||||
|
|||||||
@@ -701,7 +701,7 @@ hy2xs-orchestrator reconfigure \
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `hy2xs-orchestrator preflight-install` | Read‑only проверка чистоты хоста; ничего не меняет |
|
| `hy2xs-orchestrator preflight-install` | Read‑only проверка чистоты хоста; ничего не меняет |
|
||||||
| `hy2xs-orchestrator status` | Показать состояние платформы, сервисов, firewall и install marker |
|
| `hy2xs-orchestrator status` | Показать состояние платформы, сервисов, firewall и install marker |
|
||||||
| `hy2xs-orchestrator doctor` | Выполнить preflight и smoke‑checks текущей установки |
|
| `hy2xs-orchestrator doctor` | Выполнить preflight и smoke‑checks текущей установки; сервисы **не перезапускает** |
|
||||||
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
|
| `hy2xs-orchestrator reconfigure --dry-run` | Проверить конфиг без применения |
|
||||||
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
|
| `hy2xs-orchestrator reconfigure --apply` | Применить runtime‑конфигурацию |
|
||||||
| `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** |
|
| `hy2xs-orchestrator repair --allow-partial-state` | Довести до конца незавершённую установку **текущего поколения** |
|
||||||
@@ -872,6 +872,43 @@ hy2xs-orchestrator redact-config \
|
|||||||
|
|
||||||
Обычному пользователю не нужно собирать проект из исходников. Этот раздел нужен maintainer’у, который готовит release‑архив.
|
Обычному пользователю не нужно собирать проект из исходников. Этот раздел нужен maintainer’у, который готовит release‑архив.
|
||||||
|
|
||||||
|
### Перед работой: сверьте среду с контрактом
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./tools/dev/doctor.sh # Linux/macOS
|
||||||
|
.\tools\dev\doctor.ps1 # Windows (PowerShell 7+)
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
HY2XS development environment
|
||||||
|
contract: versions.env (HY2XS 1.0.0, release line 1)
|
||||||
|
|
||||||
|
Go:
|
||||||
|
required: 1.26.7
|
||||||
|
found: 1.25.6
|
||||||
|
FAIL — локальный Go собирает не ту stdlib, что уедет в релиз; поставьте 1.26.7
|
||||||
|
|
||||||
|
Node:
|
||||||
|
required: 24.20.0
|
||||||
|
found: 24.20.0
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт ничего не устанавливает и не меняет — он отвечает на один вопрос:
|
||||||
|
совпадает ли эта машина с контрактом сборки.
|
||||||
|
|
||||||
|
Раньше `versions.env` был контрактом только для сборки: она скачивает Go, Node и
|
||||||
|
Bun ровно тех версий, что там записаны, сверяя контрольные суммы, а машина
|
||||||
|
разработчика не проверялась никак. Расхождение обнаруживалось на Debian, внутри
|
||||||
|
release‑сборки, и выглядело как «у меня работало».
|
||||||
|
|
||||||
|
Расхождение не гипотетическое. Директива `go` в `apps/go.mod` — это языковой
|
||||||
|
baseline модуля, а не выбор компилятора, поэтому локальный Go другой минорной
|
||||||
|
линии собирал проект успешно, пока релизный бинарь компилировался на 1.26.7 и
|
||||||
|
наследовал **её** stdlib: проверялся не тот код, который уезжает в production.
|
||||||
|
Поэтому `go.mod` теперь объявляет `toolchain` явно, а `doctor` показывает
|
||||||
|
расхождение до сборки, а не после.
|
||||||
|
|
||||||
Сборка поддерживается на Debian 13 amd64 из чистого git work tree.
|
Сборка поддерживается на Debian 13 amd64 из чистого git work tree.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -935,12 +972,21 @@ export GITHUB_TOKEN=<token>
|
|||||||
| `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break‑glass |
|
| `HYSTERIA_VERIFY_UPSTREAM_HASHES` | `true` | Сверять артефакт с upstream `hashes.txt`; отключение — только break‑glass |
|
||||||
| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в lock‑файл |
|
| `HYSTERIA_WRITE_LOCK` | `false` | Записать разрешённые значения обратно в lock‑файл |
|
||||||
|
|
||||||
Переменные проверки зависимостей:
|
Проверка зависимостей переменными не управляется: у неё **нет аварийного
|
||||||
|
выхода**. Релизный артефакт HY2XS невозможно собрать с непройденным гейтом, и
|
||||||
|
поле `dependency_security_gate` в `metadata/package.env` принимает единственное
|
||||||
|
значение `true`.
|
||||||
|
|
||||||
| Переменная | По умолчанию | Назначение |
|
Раньше здесь были описаны два способа выпустить релиз, зная об уязвимости. Ими
|
||||||
| --- | --- | --- |
|
они не являлись: финальная приёмка архива требует буквально
|
||||||
| `ALLOW_VULNERABLE_DEPENDENCIES` | `false` | Выпустить релиз, зная о найденной уязвимости. Решение человека, а не поведение по умолчанию; попадает в metadata как `dependency_security_gate=accepted-risk` |
|
`dependency_security_gate=true`, поэтому сборка с любым из них проходила весь
|
||||||
| `SKIP_SECURITY_SCAN` | `false` | Полностью пропустить шаг; в metadata пишется `skipped` |
|
цикл и падала на последнем шаге. Документированная операция, которую продукт сам
|
||||||
|
же запрещает, — хуже отсутствующей.
|
||||||
|
|
||||||
|
Если advisory вышло в неудачный момент, чинится это обновлением графа
|
||||||
|
(`apps/go.sum`, `apps/frontend/pnpm-lock.yaml`) или версии toolchain в
|
||||||
|
`versions.env`. Для локальной работы обходить нечего: `go test ./...`,
|
||||||
|
`govulncheck ./...` и `pnpm audit` запускаются напрямую и tarball не создают.
|
||||||
|
|
||||||
Проверка versions‑контракта и проверка зависимостей отвечают на разные вопросы.
|
Проверка versions‑контракта и проверка зависимостей отвечают на разные вопросы.
|
||||||
Первая следит, что зафиксированные версии **согласованы между собой**; вторая —
|
Первая следит, что зафиксированные версии **согласованы между собой**; вторая —
|
||||||
@@ -981,6 +1027,7 @@ tar -tzf dist/hy2xs-install-1.0.0.tar.gz | grep -E \
|
|||||||
├── orchestrator/ # install-only orchestrator на Bun + TypeScript
|
├── orchestrator/ # install-only orchestrator на Bun + TypeScript
|
||||||
├── package/ # skeleton будущего install package
|
├── package/ # skeleton будущего install package
|
||||||
├── tools/build/ # production builder и packaging pipeline
|
├── tools/build/ # production builder и packaging pipeline
|
||||||
|
├── tools/dev/ # doctor: сверка среды разработки с versions.env
|
||||||
├── tools/test/ # end-to-end проверки с реальным клиентом Hysteria
|
├── tools/test/ # end-to-end проверки с реальным клиентом Hysteria
|
||||||
├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения
|
├── tools/legacy/ # purge-v0.sh: очистка сервера от предыдущего поколения
|
||||||
├── docs/ # спецификации baseline, тестов и эксплуатации
|
├── docs/ # спецификации baseline, тестов и эксплуатации
|
||||||
|
|||||||
@@ -93,10 +93,17 @@ func UpdatePeer(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
peerUpdateDto.Id = &id
|
peerUpdateDto.Id = &id
|
||||||
if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" && service.ExistPeerName(*peerUpdateDto.Name, id) {
|
if peerUpdateDto.Name != nil && *peerUpdateDto.Name != "" {
|
||||||
|
taken, nameErr := service.ExistPeerName(*peerUpdateDto.Name, id)
|
||||||
|
if nameErr != nil {
|
||||||
|
vo.Fail(nameErr.Error(), c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c)
|
vo.Fail(fmt.Sprintf("name %s already exists", *peerUpdateDto.Name), c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
|
if err = service.UpdatePeer(id, peerUpdateDto); err != nil {
|
||||||
vo.Fail(err.Error(), c)
|
vo.Fail(err.Error(), c)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"hy2xs-admin/model/constant"
|
|
||||||
"hy2xs-admin/model/entity"
|
"hy2xs-admin/model/entity"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -12,11 +11,11 @@ import (
|
|||||||
func GetAdminUser(query interface{}, args ...interface{}) (entity.AdminUser, error) {
|
func GetAdminUser(query interface{}, args ...interface{}) (entity.AdminUser, error) {
|
||||||
var admin entity.AdminUser
|
var admin entity.AdminUser
|
||||||
if tx := sqliteDB.Model(&entity.AdminUser{}).Where(query, args...).First(&admin); tx.Error != nil {
|
if tx := sqliteDB.Model(&entity.AdminUser{}).Where(query, args...).First(&admin); tx.Error != nil {
|
||||||
if tx.Error == gorm.ErrRecordNotFound {
|
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||||
return admin, errors.New(constant.WrongPassword)
|
return admin, ErrAdminUserNotFound
|
||||||
}
|
}
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return admin, errors.New(constant.SysError)
|
return admin, ErrStorage
|
||||||
}
|
}
|
||||||
return admin, nil
|
return admin, nil
|
||||||
}
|
}
|
||||||
@@ -24,7 +23,7 @@ func GetAdminUser(query interface{}, args ...interface{}) (entity.AdminUser, err
|
|||||||
func SaveAdminUser(admin entity.AdminUser) (int64, error) {
|
func SaveAdminUser(admin entity.AdminUser) (int64, error) {
|
||||||
if tx := sqliteDB.Save(&admin); tx.Error != nil {
|
if tx := sqliteDB.Save(&admin); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return 0, errors.New(constant.SysError)
|
return 0, ErrStorage
|
||||||
}
|
}
|
||||||
return *admin.Id, nil
|
return *admin.Id, nil
|
||||||
}
|
}
|
||||||
@@ -36,7 +35,7 @@ func UpdateAdminUser(ids []int64, updates map[string]interface{}) error {
|
|||||||
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
||||||
if tx := sqliteDB.Model(&entity.AdminUser{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
|
if tx := sqliteDB.Model(&entity.AdminUser{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hy2xs-admin/model/constant"
|
||||||
|
"hy2xs-admin/model/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testAdminPassword = "bootstrap-admin-password"
|
||||||
|
testBootstrapSecret = "bootstrap-peer-secret-value"
|
||||||
|
testTrafficStatsToken = "traffic-stats-machine-token"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startService воспроизводит запуск сервиса на указанной базе.
|
||||||
|
//
|
||||||
|
// Именно последовательность целиком, а не отдельная функция: дефект, ради
|
||||||
|
// которого написаны эти тесты, проявлялся только на ВТОРОМ запуске.
|
||||||
|
func startService(t *testing.T, dbPath string) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
|
||||||
|
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
|
||||||
|
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
|
||||||
|
|
||||||
|
err := InitSqlAt(dbPath)
|
||||||
|
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInstalledService(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||||
|
if err := startService(t, dbPath); err != nil {
|
||||||
|
t.Fatalf("первый запуск сервиса не удался: %v", err)
|
||||||
|
}
|
||||||
|
return dbPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPeer(name string, authID string, digest string) entity.Peer {
|
||||||
|
remark := ""
|
||||||
|
cipher := ""
|
||||||
|
quota := int64(-1)
|
||||||
|
zero := int64(0)
|
||||||
|
maxDevices := int64(3)
|
||||||
|
|
||||||
|
return entity.Peer{
|
||||||
|
Name: &name,
|
||||||
|
Remark: &remark,
|
||||||
|
AuthId: &authID,
|
||||||
|
SecretDigest: &digest,
|
||||||
|
SecretEncrypted: &cipher,
|
||||||
|
QuotaBytes: "a,
|
||||||
|
DownloadBytes: &zero,
|
||||||
|
UploadBytes: &zero,
|
||||||
|
ExpiresAt: &zero,
|
||||||
|
MaxDevices: &maxDevices,
|
||||||
|
Disabled: &zero,
|
||||||
|
BannedUntil: &zero,
|
||||||
|
LastConnectionAt: &zero,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootstrapPeerExists(t *testing.T) bool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := GetPeer("name = ?", BootstrapPeerName)
|
||||||
|
if err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !IsNotFound(err) {
|
||||||
|
t.Fatalf("неожиданная ошибка при чтении пира установщика: %v", err)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Установка создаёт пира установщика и отмечает это в базе.
|
||||||
|
func TestBootstrapPeerIsSeededOnFirstStart(t *testing.T) {
|
||||||
|
newInstalledService(t)
|
||||||
|
|
||||||
|
if !bootstrapPeerExists(t) {
|
||||||
|
t.Fatal("пир установщика не создан при первом запуске")
|
||||||
|
}
|
||||||
|
|
||||||
|
seeded, err := bootstrapPeerSeeded()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение отметки: %v", err)
|
||||||
|
}
|
||||||
|
if !seeded {
|
||||||
|
t.Fatal("отметка о создании пира установщика не выставлена")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычный перезапуск сервиса ничего не меняет: пир на месте, дублей нет.
|
||||||
|
func TestBootstrapPeerSurvivesRestartUntouched(t *testing.T) {
|
||||||
|
dbPath := newInstalledService(t)
|
||||||
|
before, err := GetPeer("name = ?", BootstrapPeerName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("пир установщика не найден после установки: %v", err)
|
||||||
|
}
|
||||||
|
_ = CloseSqliteDB()
|
||||||
|
|
||||||
|
if err := startService(t, dbPath); err != nil {
|
||||||
|
t.Fatalf("перезапуск сервиса не удался: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := GetPeer("name = ?", BootstrapPeerName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("пир установщика пропал после перезапуска: %v", err)
|
||||||
|
}
|
||||||
|
if *after.Id != *before.Id {
|
||||||
|
t.Fatalf("пир установщика пересоздан: id %d -> %d", *before.Id, *after.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
peers, err := ListPeer("name = ?", BootstrapPeerName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("список пиров: %v", err)
|
||||||
|
}
|
||||||
|
if len(peers) != 1 {
|
||||||
|
t.Fatalf("после перезапуска пиров установщика %d, ожидался ровно один", len(peers))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Главный инвариант: удаление пира установщика — необратимый отзыв доступа.
|
||||||
|
//
|
||||||
|
// Раньше HY2XS_ADMIN_CON_PASS оставался в /etc/hy2xs/hy2xs.env, и признаком
|
||||||
|
// «создавать или нет» служило наличие строки в таблице. Поэтому
|
||||||
|
// `systemctl restart hy2xs-admin` после удаления возвращал того же пира с тем
|
||||||
|
// же секретом, молча, и отозванные учётные данные снова становились
|
||||||
|
// действующими.
|
||||||
|
func TestDeletedBootstrapPeerIsNotRecreatedOnRestart(t *testing.T) {
|
||||||
|
dbPath := newInstalledService(t)
|
||||||
|
|
||||||
|
peer, err := GetPeer("name = ?", BootstrapPeerName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("пир установщика не найден после установки: %v", err)
|
||||||
|
}
|
||||||
|
if err := DeletePeer([]int64{*peer.Id}); err != nil {
|
||||||
|
t.Fatalf("удаление пира установщика отклонено: %v", err)
|
||||||
|
}
|
||||||
|
_ = CloseSqliteDB()
|
||||||
|
|
||||||
|
// Переменная окружения на месте — ровно то состояние, в котором сервис и
|
||||||
|
// живёт после установки.
|
||||||
|
if err := startService(t, dbPath); err != nil {
|
||||||
|
t.Fatalf("перезапуск сервиса после отзыва не удался: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if bootstrapPeerExists(t) {
|
||||||
|
t.Fatal("пир установщика воскрес после перезапуска: отзыв доступа не переживает restart")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отзыв обязан пережить не один перезапуск, а любое их число.
|
||||||
|
func TestDeletedBootstrapPeerStaysGoneAcrossRepeatedRestarts(t *testing.T) {
|
||||||
|
dbPath := newInstalledService(t)
|
||||||
|
|
||||||
|
peer, err := GetPeer("name = ?", BootstrapPeerName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("пир установщика не найден: %v", err)
|
||||||
|
}
|
||||||
|
if err := DeletePeer([]int64{*peer.Id}); err != nil {
|
||||||
|
t.Fatalf("удаление: %v", err)
|
||||||
|
}
|
||||||
|
_ = CloseSqliteDB()
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := startService(t, dbPath); err != nil {
|
||||||
|
t.Fatalf("перезапуск #%d не удался: %v", i+1, err)
|
||||||
|
}
|
||||||
|
if bootstrapPeerExists(t) {
|
||||||
|
t.Fatalf("пир установщика воскрес на перезапуске #%d", i+1)
|
||||||
|
}
|
||||||
|
_ = CloseSqliteDB()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отключённый пир остаётся в базе и не переподписывается сам собой.
|
||||||
|
func TestDisabledBootstrapPeerKeepsItsStateAcrossRestart(t *testing.T) {
|
||||||
|
dbPath := newInstalledService(t)
|
||||||
|
|
||||||
|
peer, err := GetPeer("name = ?", BootstrapPeerName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("пир установщика не найден: %v", err)
|
||||||
|
}
|
||||||
|
if err := UpdatePeer([]int64{*peer.Id}, map[string]interface{}{"disabled": 1}); err != nil {
|
||||||
|
t.Fatalf("отключение пира установщика: %v", err)
|
||||||
|
}
|
||||||
|
_ = CloseSqliteDB()
|
||||||
|
|
||||||
|
if err := startService(t, dbPath); err != nil {
|
||||||
|
t.Fatalf("перезапуск: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after, err := GetPeer("name = ?", BootstrapPeerName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("пир установщика пропал: %v", err)
|
||||||
|
}
|
||||||
|
if after.Disabled == nil || *after.Disabled != 1 {
|
||||||
|
t.Fatal("перезапуск снял отключение с пира установщика")
|
||||||
|
}
|
||||||
|
if *after.SecretDigest != *peer.SecretDigest {
|
||||||
|
t.Fatal("перезапуск переподписал пира установщика")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отсутствие HY2XS_ADMIN_CON_PASS на чистой базе — отказ старта, а не повод
|
||||||
|
// придумать секрет: он продублирован в /etc/hy2xs/bootstrap-admin.secret.
|
||||||
|
func TestFirstStartWithoutBootstrapSecretFails(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||||
|
|
||||||
|
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
|
||||||
|
t.Setenv("HY2XS_ADMIN_CON_PASS", "")
|
||||||
|
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
|
||||||
|
|
||||||
|
err := InitSqlAt(dbPath)
|
||||||
|
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("запуск без HY2XS_ADMIN_CON_PASS не отклонён")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "HY2XS_ADMIN_CON_PASS") {
|
||||||
|
t.Errorf("отказ не называет переменную: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перезапуск уже установленного сервиса без переменной обязан работать: пир
|
||||||
|
// создан однажды, и требовать секрет второй раз незачем.
|
||||||
|
func TestRestartWithoutBootstrapSecretSucceeds(t *testing.T) {
|
||||||
|
dbPath := newInstalledService(t)
|
||||||
|
_ = CloseSqliteDB()
|
||||||
|
|
||||||
|
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
|
||||||
|
t.Setenv("HY2XS_ADMIN_CON_PASS", "")
|
||||||
|
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
|
||||||
|
|
||||||
|
if err := InitSqlAt(dbPath); err != nil {
|
||||||
|
t.Fatalf("перезапуск установленного сервиса без HY2XS_ADMIN_CON_PASS отклонён: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||||
|
|
||||||
|
if !bootstrapPeerExists(t) {
|
||||||
|
t.Fatal("пир установщика пропал при перезапуске без переменной")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отметка и сам пир пишутся неделимо: иначе падение между двумя операциями
|
||||||
|
// вернуло бы прежнее поведение на следующем старте.
|
||||||
|
func TestBootstrapSeedIsAtomic(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||||
|
if err := InitSqliteDBAt(dbPath); err != nil {
|
||||||
|
t.Fatalf("открытие базы: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||||
|
if err := RunMigrations(); err != nil {
|
||||||
|
t.Fatalf("миграции: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Занимаем имя обычной записью: следующая вставка упрётся в UNIQUE(name)
|
||||||
|
// уже ВНУТРИ транзакции. Отметка не имеет права остаться выставленной.
|
||||||
|
if _, err := SavePeer(testPeer(BootstrapPeerName, "some-auth-id", "some-digest")); err != nil {
|
||||||
|
t.Fatalf("подготовка конфликтующей записи: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := seedBootstrapPeerOnce(testPeer(BootstrapPeerName, BootstrapPeerName, "bootstrap-digest")); err == nil {
|
||||||
|
t.Fatal("вставка с конфликтом UNIQUE(name) не отклонена")
|
||||||
|
}
|
||||||
|
|
||||||
|
seeded, err := bootstrapPeerSeeded()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение отметки: %v", err)
|
||||||
|
}
|
||||||
|
if seeded {
|
||||||
|
t.Fatal("отметка выставлена, хотя пир не записан: транзакции нет")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Токен trafficStats — межкомпонентный контракт: при пустом env и пустой базе
|
||||||
|
// админка обязана отказать, а не сгенерировать значение, которого не знает
|
||||||
|
// Hysteria.
|
||||||
|
func TestTrafficStatsSecretIsNotInvented(t *testing.T) {
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||||
|
|
||||||
|
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
|
||||||
|
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
|
||||||
|
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", "")
|
||||||
|
|
||||||
|
err := InitSqlAt(dbPath)
|
||||||
|
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("запуск без machine token не отклонён: админка придумала его сама")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET") {
|
||||||
|
t.Errorf("отказ не называет переменную: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, cfgErr := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||||
|
if cfgErr == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
|
||||||
|
t.Fatal("в базе появился сгенерированный machine token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уже согласованный токен в базе позволяет запуститься без переменной: это не
|
||||||
|
// изобретение контракта, а использование существующего.
|
||||||
|
func TestTrafficStatsSecretFromDatabaseIsAccepted(t *testing.T) {
|
||||||
|
dbPath := newInstalledService(t)
|
||||||
|
_ = CloseSqliteDB()
|
||||||
|
|
||||||
|
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
|
||||||
|
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
|
||||||
|
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", "")
|
||||||
|
|
||||||
|
if err := InitSqlAt(dbPath); err != nil {
|
||||||
|
t.Fatalf("перезапуск с токеном в базе отклонён: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||||
|
|
||||||
|
cfg, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("токен пропал из базы: %v", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*cfg.Value) != testTrafficStatsToken {
|
||||||
|
t.Fatalf("токен подменён: %q", *cfg.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
-14
@@ -2,17 +2,23 @@ package dao
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"hy2xs-admin/model/constant"
|
|
||||||
"hy2xs-admin/model/entity"
|
"hy2xs-admin/model/entity"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func SaveConfig(config entity.Config) (int64, error) {
|
func SaveConfig(config entity.Config) (int64, error) {
|
||||||
if tx := sqliteDB.Save(&config); tx.Error != nil {
|
return saveConfigOn(sqliteDB, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveConfigOn(db *gorm.DB, config entity.Config) (int64, error) {
|
||||||
|
if tx := db.Save(&config); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return 0, errors.New(constant.SysError)
|
return 0, ErrStorage
|
||||||
}
|
}
|
||||||
return *config.Id, nil
|
return *config.Id, nil
|
||||||
}
|
}
|
||||||
@@ -44,8 +50,15 @@ func UpsertConfigValue(key string, value string) error {
|
|||||||
if sqliteDB == nil {
|
if sqliteDB == nil {
|
||||||
return errors.New("sqlite is not initialized")
|
return errors.New("sqlite is not initialized")
|
||||||
}
|
}
|
||||||
|
return upsertConfigValueOn(sqliteDB, key, value)
|
||||||
|
}
|
||||||
|
|
||||||
tx := sqliteDB.Model(&entity.Config{}).
|
// upsertConfigValueOn — та же запись, но по переданному соединению.
|
||||||
|
//
|
||||||
|
// Нужна для операций, которые обязаны быть неделимыми вместе с записью в другую
|
||||||
|
// таблицу: пир установщика и отметка о его создании (см. SeedBootstrapPeerOnce).
|
||||||
|
func upsertConfigValueOn(db *gorm.DB, key string, value string) error {
|
||||||
|
tx := db.Model(&entity.Config{}).
|
||||||
Where("key = ?", key).
|
Where("key = ?", key).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"value": value,
|
"value": value,
|
||||||
@@ -53,14 +66,14 @@ func UpsertConfigValue(key string, value string) error {
|
|||||||
})
|
})
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
if tx.RowsAffected > 0 {
|
if tx.RowsAffected > 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
remark := key
|
remark := key
|
||||||
if _, err := SaveConfig(entity.Config{Key: &key, Value: &value, Remark: &remark}); err != nil {
|
if _, err := saveConfigOn(db, entity.Config{Key: &key, Value: &value, Remark: &remark}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -102,16 +115,43 @@ func (c configTx) UpdateConfig(keys []string, updates map[string]interface{}) er
|
|||||||
return updateConfigOn(c.db, keys, updates)
|
return updateConfigOn(c.db, keys, updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateConfigOn обновляет существующие строки и отказывает, если обновлять
|
||||||
|
// было нечего.
|
||||||
|
//
|
||||||
|
// UPDATE без совпавших строк — не ошибка SQL: gorm возвращает nil и
|
||||||
|
// RowsAffected = 0. Пока результат определялся только по tx.Error, отсутствие
|
||||||
|
// строки выглядело успешной записью, и это не теоретическая аккуратность.
|
||||||
|
// Путь оператора выглядит так:
|
||||||
|
//
|
||||||
|
// controller.UpdateConfigs → service.UpdateConfigs (транзакция)
|
||||||
|
// → applyRuntimeConfigUpdates → RescheduleResetTraffic
|
||||||
|
//
|
||||||
|
// Применение к рантайму идёт ПОСЛЕ записи и полагается на её успех. При
|
||||||
|
// отсутствующей строке RESET_TRAFFIC_CRON планировщик получал новое расписание,
|
||||||
|
// API отвечал успехом, а база сохраняла старое значение — и ближайший
|
||||||
|
// перезапуск сервиса молча возвращал прежний cron. Оператор при этом дважды
|
||||||
|
// видел подтверждение изменения, которого не произошло.
|
||||||
|
//
|
||||||
|
// Здесь решение принимается по RowsAffected — ровно так же, как в
|
||||||
|
// UpsertConfigValue ниже, где эта же ошибка уже была разобрана и исправлена.
|
||||||
|
// Разница между двумя функциями одного файла и была настоящим дефектом.
|
||||||
func updateConfigOn(db *gorm.DB, keys []string, updates map[string]interface{}) error {
|
func updateConfigOn(db *gorm.DB, keys []string, updates map[string]interface{}) error {
|
||||||
if len(updates) == 0 {
|
if len(updates) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
||||||
if tx := db.Model(&entity.Config{}).
|
tx := db.Model(&entity.Config{}).
|
||||||
Where("key in ?", keys).
|
Where("key in ?", keys).
|
||||||
Updates(updates); tx.Error != nil {
|
Updates(updates)
|
||||||
|
if tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
|
}
|
||||||
|
if tx.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"config: не найдено ни одной строки для ключей %s; настройка не сохранена",
|
||||||
|
strings.Join(keys, ", "),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -120,11 +160,11 @@ func GetConfig(query interface{}, args ...interface{}) (entity.Config, error) {
|
|||||||
var config entity.Config
|
var config entity.Config
|
||||||
if tx := sqliteDB.Model(&entity.Config{}).
|
if tx := sqliteDB.Model(&entity.Config{}).
|
||||||
Where(query, args...).First(&config); tx.Error != nil {
|
Where(query, args...).First(&config); tx.Error != nil {
|
||||||
if tx.Error == gorm.ErrRecordNotFound {
|
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||||
return config, errors.New(constant.ConfigNotExist)
|
return config, ErrConfigNotFound
|
||||||
}
|
}
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return config, errors.New(constant.SysError)
|
return config, ErrStorage
|
||||||
}
|
}
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
@@ -134,7 +174,7 @@ func ListConfig(query interface{}, args ...interface{}) ([]entity.Config, error)
|
|||||||
if tx := sqliteDB.Model(&entity.Config{}).
|
if tx := sqliteDB.Model(&entity.Config{}).
|
||||||
Where(query, args...).Order("create_time desc").Find(&configs); tx.Error != nil {
|
Where(query, args...).Order("create_time desc").Find(&configs); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return configs, errors.New(constant.SysError)
|
return configs, ErrStorage
|
||||||
}
|
}
|
||||||
return configs, nil
|
return configs, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hy2xs-admin/model/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newConfigDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||||
|
if err := InitSqliteDBAt(dbPath); err != nil {
|
||||||
|
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||||
|
}
|
||||||
|
if err := RunMigrations(); err != nil {
|
||||||
|
t.Fatalf("не удалось применить миграции: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = CloseSqliteDB() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запись в отсутствующую строку — отказ, а не тихий успех.
|
||||||
|
//
|
||||||
|
// UPDATE без совпавших строк не является ошибкой SQL: gorm возвращает nil и
|
||||||
|
// RowsAffected = 0. Пока результат определялся только по tx.Error, оператор
|
||||||
|
// получал подтверждение изменения, которого не произошло, а
|
||||||
|
// applyRuntimeConfigUpdates следом применял новое значение к планировщику —
|
||||||
|
// то есть рантайм и база расходились до ближайшего перезапуска сервиса.
|
||||||
|
func TestUpdateConfigFailsWhenRowIsMissing(t *testing.T) {
|
||||||
|
newConfigDB(t)
|
||||||
|
|
||||||
|
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil {
|
||||||
|
t.Fatalf("подготовка повреждённой базы: %v", tx.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("запись в отсутствующую строку выдана за успех")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), constant.ResetTrafficCron) {
|
||||||
|
t.Errorf("отказ не называет ключ: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateConfig обновляет существующее и не имеет права заводить ключи:
|
||||||
|
// иначе он молча создавал бы строки, которых нет в модели продукта.
|
||||||
|
if _, err := GetConfig("key = ?", constant.ResetTrafficCron); !errors.Is(err, ErrConfigNotFound) {
|
||||||
|
t.Fatalf("строка появилась после отказа: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычная запись в существующую строку продолжает работать.
|
||||||
|
func TestUpdateConfigWritesExistingRow(t *testing.T) {
|
||||||
|
newConfigDB(t)
|
||||||
|
|
||||||
|
if err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil {
|
||||||
|
t.Fatalf("запись существующего ключа отклонена: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := GetConfig("key = ?", constant.ResetTrafficCron)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("чтение: %v", err)
|
||||||
|
}
|
||||||
|
if *cfg.Value != "@weekly" {
|
||||||
|
t.Fatalf("значение не сохранено: %q", *cfg.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Транзакционная запись обязана вести себя так же: отказ на отсутствующей
|
||||||
|
// строке откатывает всю партию, а не оставляет её наполовину применённой.
|
||||||
|
func TestConfigTxRollsBackWhenRowIsMissing(t *testing.T) {
|
||||||
|
newConfigDB(t)
|
||||||
|
|
||||||
|
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.JwtSecret); tx.Error != nil {
|
||||||
|
t.Fatalf("подготовка: %v", tx.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := WithConfigTx(func(tx ConfigTx) error {
|
||||||
|
if err := tx.UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": "x"})
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("партия с отсутствующей строкой не отклонена")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, getErr := GetConfig("key = ?", constant.ResetTrafficCron)
|
||||||
|
if getErr != nil {
|
||||||
|
t.Fatalf("чтение: %v", getErr)
|
||||||
|
}
|
||||||
|
if *cfg.Value == "@weekly" {
|
||||||
|
t.Fatal("первый ключ партии остался применённым после отказа на втором")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отсутствие записи и отказ хранилища — разные ответы.
|
||||||
|
func TestGetConfigReportsNotFoundDistinctly(t *testing.T) {
|
||||||
|
newConfigDB(t)
|
||||||
|
|
||||||
|
_, err := GetConfig("key = ?", "NO_SUCH_KEY")
|
||||||
|
if !errors.Is(err, ErrConfigNotFound) {
|
||||||
|
t.Fatalf("отсутствие ключа не распознано как ErrConfigNotFound: %v", err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrStorage) {
|
||||||
|
t.Fatal("отсутствие ключа выдано за отказ хранилища")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPeerReportsNotFoundDistinctly(t *testing.T) {
|
||||||
|
newConfigDB(t)
|
||||||
|
|
||||||
|
_, err := GetPeer("name = ?", "no-such-peer")
|
||||||
|
if !errors.Is(err, ErrPeerNotFound) {
|
||||||
|
t.Fatalf("отсутствие пира не распознано как ErrPeerNotFound: %v", err)
|
||||||
|
}
|
||||||
|
if !IsNotFound(err) {
|
||||||
|
t.Fatal("IsNotFound не распознаёт ErrPeerNotFound")
|
||||||
|
}
|
||||||
|
if IsNotFound(ErrStorage) {
|
||||||
|
t.Fatal("IsNotFound принимает отказ хранилища за отсутствие записи")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertConfigValue, в отличие от UpdateConfig, строку заводит: это её работа.
|
||||||
|
func TestUpsertConfigValueCreatesMissingRow(t *testing.T) {
|
||||||
|
newConfigDB(t)
|
||||||
|
|
||||||
|
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil {
|
||||||
|
t.Fatalf("подготовка: %v", tx.Error)
|
||||||
|
}
|
||||||
|
if err := UpsertConfigValue(constant.ResetTrafficCron, "@daily"); err != nil {
|
||||||
|
t.Fatalf("upsert отклонён: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := GetConfig("key = ?", constant.ResetTrafficCron)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("строка не создана: %v", err)
|
||||||
|
}
|
||||||
|
if *cfg.Value != "@daily" {
|
||||||
|
t.Fatalf("значение не сохранено: %q", *cfg.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-10
@@ -1,9 +1,7 @@
|
|||||||
package dao
|
package dao
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"hy2xs-admin/model/constant"
|
|
||||||
"hy2xs-admin/model/entity"
|
"hy2xs-admin/model/entity"
|
||||||
"hy2xs-admin/model/vo"
|
"hy2xs-admin/model/vo"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,7 +11,7 @@ import (
|
|||||||
func SaveMetricSample(sample entity.MetricSample) error {
|
func SaveMetricSample(sample entity.MetricSample) error {
|
||||||
if tx := sqliteDB.Save(&sample); tx.Error != nil {
|
if tx := sqliteDB.Save(&sample); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -22,7 +20,7 @@ func LastMetricSample() (entity.MetricSample, error) {
|
|||||||
var sample entity.MetricSample
|
var sample entity.MetricSample
|
||||||
if tx := sqliteDB.Model(&entity.MetricSample{}).Order("sampled_at desc").Limit(1).Find(&sample); tx.Error != nil {
|
if tx := sqliteDB.Model(&entity.MetricSample{}).Order("sampled_at desc").Limit(1).Find(&sample); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return sample, errors.New(constant.SysError)
|
return sample, ErrStorage
|
||||||
}
|
}
|
||||||
return sample, nil
|
return sample, nil
|
||||||
}
|
}
|
||||||
@@ -33,7 +31,7 @@ func CleanupMetricSample(olderThanMs int64) error {
|
|||||||
}
|
}
|
||||||
if tx := sqliteDB.Exec("DELETE FROM metric_sample WHERE sampled_at < ?", olderThanMs); tx.Error != nil {
|
if tx := sqliteDB.Exec("DELETE FROM metric_sample WHERE sampled_at < ?", olderThanMs); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -54,7 +52,7 @@ func DashboardPeerSummary(nowMs int64) (vo.DashboardPeerVo, error) {
|
|||||||
COALESCE(SUM(CASE WHEN expires_at > 0 AND expires_at < ? THEN 1 ELSE 0 END),0) AS expired
|
COALESCE(SUM(CASE WHEN expires_at > 0 AND expires_at < ? THEN 1 ELSE 0 END),0) AS expired
|
||||||
FROM peer`, nowMs).Scan(&r); tx.Error != nil {
|
FROM peer`, nowMs).Scan(&r); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return result, errors.New(constant.SysError)
|
return result, ErrStorage
|
||||||
}
|
}
|
||||||
result.Total = r.Total
|
result.Total = r.Total
|
||||||
result.Enabled = r.Enabled
|
result.Enabled = r.Enabled
|
||||||
@@ -75,7 +73,7 @@ func DashboardTrafficSummary() (vo.DashboardTrafficVo, error) {
|
|||||||
COALESCE(SUM(upload_bytes),0) AS upload
|
COALESCE(SUM(upload_bytes),0) AS upload
|
||||||
FROM peer`).Scan(&r); tx.Error != nil {
|
FROM peer`).Scan(&r); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return result, errors.New(constant.SysError)
|
return result, ErrStorage
|
||||||
}
|
}
|
||||||
result.DownloadBytes = r.Download
|
result.DownloadBytes = r.Download
|
||||||
result.UploadBytes = r.Upload
|
result.UploadBytes = r.Upload
|
||||||
@@ -125,7 +123,7 @@ func DashboardTopPeers(fromMs int64, toMs int64, limit int, source string) ([]vo
|
|||||||
ORDER BY total DESC
|
ORDER BY total DESC
|
||||||
LIMIT ?`, fromMs, toMs, limit).Scan(&rows); tx.Error != nil {
|
LIMIT ?`, fromMs, toMs, limit).Scan(&rows); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return rows, errors.New(constant.SysError)
|
return rows, ErrStorage
|
||||||
}
|
}
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
@@ -164,7 +162,7 @@ func DashboardTrafficTimeseries(fromMs int64, toMs int64, bucketMs int64, source
|
|||||||
GROUP BY ts
|
GROUP BY ts
|
||||||
ORDER BY ts ASC`, alignedFrom, alignedFrom, bucketMs, bucketMs, queryFrom, toMs).Scan(&rows); tx.Error != nil {
|
ORDER BY ts ASC`, alignedFrom, alignedFrom, bucketMs, bucketMs, queryFrom, toMs).Scan(&rows); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return rows, errors.New(constant.SysError)
|
return rows, ErrStorage
|
||||||
}
|
}
|
||||||
|
|
||||||
rowMap := make(map[int64]vo.DashboardSeriesPointVo, len(rows))
|
rowMap := make(map[int64]vo.DashboardSeriesPointVo, len(rows))
|
||||||
@@ -203,7 +201,7 @@ func DashboardSystemTimeseries(fromMs int64, toMs int64, bucketMs int64) ([]vo.D
|
|||||||
GROUP BY ts
|
GROUP BY ts
|
||||||
ORDER BY ts ASC`, alignedFrom, alignedFrom, bucketMs, bucketMs, fromMs, toMs).Scan(&rows); tx.Error != nil {
|
ORDER BY ts ASC`, alignedFrom, alignedFrom, bucketMs, bucketMs, fromMs, toMs).Scan(&rows); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return rows, errors.New(constant.SysError)
|
return rows, ErrStorage
|
||||||
}
|
}
|
||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"hy2xs-admin/model/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Отличать «строки нет» от «база не ответила» обязан слой данных.
|
||||||
|
//
|
||||||
|
// Что было. Каждый Get* возвращал `errors.New(constant.WrongPassword)` для
|
||||||
|
// gorm.ErrRecordNotFound и `errors.New(constant.SysError)` для всего
|
||||||
|
// остального. Обе — свежие значения ошибки со строкой внутри, поэтому
|
||||||
|
// вызывающий мог отличить одно от другого только сравнением текста, чего никто
|
||||||
|
// не делал: везде проверялось `err != nil` или `err == nil`.
|
||||||
|
//
|
||||||
|
// Из-за этого сбой SQLite неотличим от отсутствия записи, а решения на этом
|
||||||
|
// принимаются серьёзные:
|
||||||
|
//
|
||||||
|
// ensureSecureBootstrapPeer — «пира нет» означает «создать пир заново»;
|
||||||
|
// applyPeerImportEntry — «по auth_id не нашли» означает «искать по имени»
|
||||||
|
// и, не найдя, СОЗДАТЬ запись;
|
||||||
|
// ExistPeerName — «ошибка» означает «имя свободно».
|
||||||
|
//
|
||||||
|
// То есть при недоступной базе продукт не отказывал, а трактовал отказ как
|
||||||
|
// разрешение действовать. Транзакция спасает целостность, но не смысл: решение
|
||||||
|
// уже принято неверно к моменту записи.
|
||||||
|
//
|
||||||
|
// Значения объявлены один раз и возвращаются как есть, поэтому работает
|
||||||
|
// errors.Is. Тексты сохранены прежними: WrongPassword уезжает в ответ Hysteria
|
||||||
|
// при неудачной machine-auth, и менять его здесь было бы изменением внешнего
|
||||||
|
// контракта ради внутренней аккуратности.
|
||||||
|
var (
|
||||||
|
// ErrPeerNotFound — пира с такими условиями в базе нет.
|
||||||
|
ErrPeerNotFound = errors.New(constant.WrongPassword)
|
||||||
|
|
||||||
|
// ErrAdminUserNotFound — учётной записи администратора нет.
|
||||||
|
ErrAdminUserNotFound = errors.New(constant.WrongPassword)
|
||||||
|
|
||||||
|
// ErrConfigNotFound — строки с таким ключом в таблице `config` нет.
|
||||||
|
ErrConfigNotFound = errors.New(constant.ConfigNotExist)
|
||||||
|
|
||||||
|
// ErrStorage — база не смогла ответить. Не «нет данных», а «неизвестно».
|
||||||
|
ErrStorage = errors.New(constant.SysError)
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsNotFound — истина только для «записи нет», и никогда для отказа хранилища.
|
||||||
|
//
|
||||||
|
// Помощник существует, чтобы у вызывающих был ровно один способ задать этот
|
||||||
|
// вопрос: три разных sentinel'а для трёх таблиц иначе неизбежно проверялись бы
|
||||||
|
// по-разному в разных местах.
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
return errors.Is(err, ErrPeerNotFound) ||
|
||||||
|
errors.Is(err, ErrAdminUserNotFound) ||
|
||||||
|
errors.Is(err, ErrConfigNotFound)
|
||||||
|
}
|
||||||
+13
-9
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"hy2xs-admin/model/constant"
|
|
||||||
"hy2xs-admin/model/dto"
|
"hy2xs-admin/model/dto"
|
||||||
"hy2xs-admin/model/entity"
|
"hy2xs-admin/model/entity"
|
||||||
"time"
|
"time"
|
||||||
@@ -50,7 +49,7 @@ func WithPeerTx(fn func(tx PeerTx) error) error {
|
|||||||
func (p peerTx) SavePeer(peer entity.Peer) (int64, error) {
|
func (p peerTx) SavePeer(peer entity.Peer) (int64, error) {
|
||||||
if tx := p.db.Save(&peer); tx.Error != nil {
|
if tx := p.db.Save(&peer); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return 0, errors.New(constant.SysError)
|
return 0, ErrStorage
|
||||||
}
|
}
|
||||||
return *peer.Id, nil
|
return *peer.Id, nil
|
||||||
}
|
}
|
||||||
@@ -62,19 +61,24 @@ func (p peerTx) UpdatePeer(ids []int64, updates map[string]interface{}) error {
|
|||||||
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
updates["update_time"] = time.Now().Format("2006-01-02 15:04:05")
|
||||||
if tx := p.db.Model(&entity.Peer{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
|
if tx := p.db.Model(&entity.Peer{}).Where("id in ?", ids).Updates(updates); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPeer различает «пира нет» и «база не ответила» разными значениями ошибки.
|
||||||
|
//
|
||||||
|
// Раньше оба случая возвращались как свежие errors.New, и вызывающий не имел
|
||||||
|
// способа их разделить иначе как сравнением текста. Разница принципиальна:
|
||||||
|
// «нет» разрешает создать запись, «неизвестно» — не разрешает ничего.
|
||||||
func (p peerTx) GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
|
func (p peerTx) GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
|
||||||
var peer entity.Peer
|
var peer entity.Peer
|
||||||
if tx := p.db.Model(&entity.Peer{}).Where(query, args...).First(&peer); tx.Error != nil {
|
if tx := p.db.Model(&entity.Peer{}).Where(query, args...).First(&peer); tx.Error != nil {
|
||||||
if tx.Error == gorm.ErrRecordNotFound {
|
if errors.Is(tx.Error, gorm.ErrRecordNotFound) {
|
||||||
return peer, errors.New(constant.WrongPassword)
|
return peer, ErrPeerNotFound
|
||||||
}
|
}
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return peer, errors.New(constant.SysError)
|
return peer, ErrStorage
|
||||||
}
|
}
|
||||||
return peer, nil
|
return peer, nil
|
||||||
}
|
}
|
||||||
@@ -99,7 +103,7 @@ func GetPeer(query interface{}, args ...interface{}) (entity.Peer, error) {
|
|||||||
func DeletePeer(ids []int64) error {
|
func DeletePeer(ids []int64) error {
|
||||||
if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Peer{}); tx.Error != nil {
|
if tx := sqliteDB.Where("id in ?", ids).Delete(&entity.Peer{}); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -108,7 +112,7 @@ func ListPeer(query interface{}, args ...interface{}) ([]entity.Peer, error) {
|
|||||||
var peers []entity.Peer
|
var peers []entity.Peer
|
||||||
if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).Order("create_time desc").Find(&peers); tx.Error != nil {
|
if tx := sqliteDB.Model(&entity.Peer{}).Where(query, args...).Order("create_time desc").Find(&peers); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return peers, errors.New(constant.SysError)
|
return peers, ErrStorage
|
||||||
}
|
}
|
||||||
return peers, nil
|
return peers, nil
|
||||||
}
|
}
|
||||||
@@ -129,7 +133,7 @@ func PagePeer(peerPageDto dto.PeerPageDto) ([]entity.Peer, int64, error) {
|
|||||||
tx.Count(&total)
|
tx.Count(&total)
|
||||||
if tx.Scopes(Paginate(peerPageDto.PageNum, peerPageDto.PageSize)).Order("create_time desc").Find(&peers); tx.Error != nil {
|
if tx.Scopes(Paginate(peerPageDto.PageNum, peerPageDto.PageSize)).Order("create_time desc").Find(&peers); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return peers, 0, errors.New(constant.SysError)
|
return peers, 0, ErrStorage
|
||||||
}
|
}
|
||||||
return peers, total, nil
|
return peers, total, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+179
-36
@@ -59,7 +59,19 @@ func InitSqliteDBAt(path string) error {
|
|||||||
// порт, который получил аргументом из ExecStart, а startup тут же читал его
|
// порт, который получил аргументом из ExecStart, а startup тут же читал его
|
||||||
// обратно. База не была источником этого факта ни на одном шаге.
|
// обратно. База не была источником этого факта ни на одном шаге.
|
||||||
func InitSql() error {
|
func InitSql() error {
|
||||||
if err := InitSqliteDB(); err != nil {
|
return InitSqlAt(constant.SqliteDBPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitSqlAt поднимает базу по указанному пути.
|
||||||
|
//
|
||||||
|
// Путь параметризован по той же причине, что и у InitSqliteDBAt: жизненный
|
||||||
|
// цикл пира установщика — единственное, что доказывает необратимость отзыва
|
||||||
|
// доступа, и проверить его можно только прогнав ПОЛНУЮ последовательность
|
||||||
|
// запуска (миграции → seeder → повторный запуск) дважды на одной базе.
|
||||||
|
// Отдельная проверка ensureSecureBootstrapPeer в отрыве от InitSql доказывала
|
||||||
|
// бы поведение функции, а не поведение сервиса при перезапуске.
|
||||||
|
func InitSqlAt(path string) error {
|
||||||
|
if err := InitSqliteDBAt(path); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := runMigrations(); err != nil {
|
if err := runMigrations(); err != nil {
|
||||||
@@ -156,30 +168,78 @@ func ensureSecureBootstrapAdmin() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureSecureBootstrapPeer создаёт пир установщика.
|
// BootstrapPeerName — имя и authId пира, который создаёт установка.
|
||||||
//
|
//
|
||||||
// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS по той же причине, что и
|
// Объявлено здесь, а не литералом в четырёх местах: то же значение проверяет
|
||||||
// пароль администратора, и с дополнительным следствием: этот секрет лежит ещё
|
// service.ReservedBootstrapPeerName, и расхождение между ними означало бы, что
|
||||||
// и в /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора
|
// guard защищает не тот пир, который создал seeder.
|
||||||
// для проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с
|
const BootstrapPeerName = "bootstrap-admin-peer"
|
||||||
// файлом, и первая же проверка подключения после установки провалилась бы —
|
|
||||||
// на корректном во всём остальном сервере.
|
// bootstrapPeerSeededValue — значение маркера constant.BootstrapPeerSeeded.
|
||||||
|
const bootstrapPeerSeededValue = "true"
|
||||||
|
|
||||||
|
// ensureSecureBootstrapPeer создаёт пир установщика РОВНО ОДИН РАЗ за жизнь
|
||||||
|
// базы.
|
||||||
//
|
//
|
||||||
// Порядок проверок здесь важен: сначала выясняем, нужно ли вообще создавать
|
// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS: он лежит ещё и в
|
||||||
// пир, и только потом требуем env. Иначе перезапуск уже установленного сервиса
|
// /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора для
|
||||||
// без переменной падал бы на ровном месте.
|
// проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с файлом, и
|
||||||
|
// первая же проверка подключения после установки провалилась бы — на
|
||||||
|
// корректном во всём остальном сервере.
|
||||||
|
//
|
||||||
|
// Что было и почему это была дыра в отзыве доступа.
|
||||||
|
//
|
||||||
|
// Признаком «создавать или нет» служило наличие строки в таблице пиров. Панель
|
||||||
|
// при этом разрешает удалить bootstrap-admin-peer, и это осознанное решение:
|
||||||
|
// после установки он остаётся обычным действующим доступом, а запрет удаления
|
||||||
|
// означал бы вечный неотзываемый вход. Но HY2XS_ADMIN_CON_PASS продолжает жить
|
||||||
|
// в /etc/hy2xs/hy2xs.env, который читает systemd-юнит, поэтому:
|
||||||
|
//
|
||||||
|
// оператор удаляет bootstrap-admin-peer → доступ действительно исчезает
|
||||||
|
// systemctl restart hy2xs-admin / reboot → InitSql → ensureSecureBootstrapPeer
|
||||||
|
// → строки нет → создать заново
|
||||||
|
// → ТОТ ЖЕ секрет снова действует
|
||||||
|
//
|
||||||
|
// То есть отзыв учётных данных не переживал перезапуск, и восстановление
|
||||||
|
// происходило молча: в журнале ничего, в списке пиров запись просто снова есть.
|
||||||
|
// Отзыв, который не переживает restart, отзывом не является.
|
||||||
|
//
|
||||||
|
// Теперь решение принимается по отдельной отметке в таблице `config`. Она
|
||||||
|
// отвечает на вопрос «пир КОГДА-ЛИБО создавался», а не «существует сейчас»,
|
||||||
|
// поэтому удаление становится окончательным. Отметка и сам пир пишутся одной
|
||||||
|
// транзакцией: иначе падение между двумя операциями вернуло бы прежнее
|
||||||
|
// поведение на следующем старте.
|
||||||
|
//
|
||||||
|
// Порядок проверок сохранён: сначала выясняем, нужно ли вообще создавать пир, и
|
||||||
|
// только потом требуем env. Перезапуск установленного сервиса без переменной
|
||||||
|
// обязан работать штатно.
|
||||||
func ensureSecureBootstrapPeer() error {
|
func ensureSecureBootstrapPeer() error {
|
||||||
if _, err := GetPeer("auth_id = ?", "bootstrap-admin-peer"); err == nil {
|
seeded, err := bootstrapPeerSeeded()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if seeded {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if _, err := GetPeer("name = ?", "bootstrap-admin-peer"); err == nil {
|
|
||||||
return nil
|
// Пир есть, а отметки нет. На чистой установке это недостижимо: имя
|
||||||
|
// зарезервировано во всех путях записи панели, поэтому единственный
|
||||||
|
// создатель такой строки — этот же seeder. Ветка закрывает базу, созданную
|
||||||
|
// сборкой до появления отметки, и делает функцию идемпотентной, не
|
||||||
|
// возвращая при этом прежнюю семантику: она срабатывает только при
|
||||||
|
// отсутствующем маркере.
|
||||||
|
switch _, err := GetPeer("name = ?", BootstrapPeerName); {
|
||||||
|
case err == nil:
|
||||||
|
return UpsertConfigValue(constant.BootstrapPeerSeeded, bootstrapPeerSeededValue)
|
||||||
|
case !errors.Is(err, ErrPeerNotFound):
|
||||||
|
// База не ответила. «Неизвестно» не даёт права создавать учётные данные.
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS"))
|
bootstrapSecret := strings.TrimSpace(os.Getenv("HY2XS_ADMIN_CON_PASS"))
|
||||||
if bootstrapSecret == "" {
|
if bootstrapSecret == "" {
|
||||||
return errors.New(
|
return errors.New(
|
||||||
"HY2XS_ADMIN_CON_PASS не задан, а пира установщика bootstrap-admin-peer в базе нет.\n" +
|
"HY2XS_ADMIN_CON_PASS не задан, а пир установщика bootstrap-admin-peer ещё ни разу не создавался.\n" +
|
||||||
"Секрет этого пира задаёт install-оркестратор и кладёт копию в\n" +
|
"Секрет этого пира задаёт install-оркестратор и кладёт копию в\n" +
|
||||||
"/etc/hy2xs/bootstrap-admin.secret, откуда его читает проверка machine-auth.\n" +
|
"/etc/hy2xs/bootstrap-admin.secret, откуда его читает проверка machine-auth.\n" +
|
||||||
"Секрет, придуманный админкой, разошёлся бы с этим файлом, и проверка\n" +
|
"Секрет, придуманный админкой, разошёлся бы с этим файлом, и проверка\n" +
|
||||||
@@ -194,8 +254,18 @@ func ensureSecureBootstrapPeer() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := GetPeer("secret_digest = ?", secretDigest); err == nil {
|
// Чужой пир с тем же секретом. Создавать нельзя — UNIQUE(secret_digest), —
|
||||||
return nil
|
// но и молчать нельзя: секрет установщика уже выдан кому-то ещё.
|
||||||
|
switch _, err := GetPeer("secret_digest = ?", secretDigest); {
|
||||||
|
case err == nil:
|
||||||
|
return errors.New(
|
||||||
|
"секрет из HY2XS_ADMIN_CON_PASS уже принадлежит другому пиру.\n" +
|
||||||
|
"Пир установщика не создан: два пира не могут делить один секрет.\n" +
|
||||||
|
"Переименуйте или удалите этот пир, либо смените HY2XS_ADMIN_CON_PASS\n" +
|
||||||
|
"через `hy2xs-orchestrator reconfigure`.",
|
||||||
|
)
|
||||||
|
case !errors.Is(err, ErrPeerNotFound):
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
secretEncrypted, err := encryptBootstrapPeerSecret(bootstrapSecret)
|
secretEncrypted, err := encryptBootstrapPeerSecret(bootstrapSecret)
|
||||||
@@ -203,8 +273,8 @@ func ensureSecureBootstrapPeer() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
name := "bootstrap-admin-peer"
|
name := BootstrapPeerName
|
||||||
authID := "bootstrap-admin-peer"
|
authID := BootstrapPeerName
|
||||||
remark := "bootstrap peer seeded from HY2XS_ADMIN_CON_PASS"
|
remark := "bootstrap peer seeded from HY2XS_ADMIN_CON_PASS"
|
||||||
quota := int64(-1)
|
quota := int64(-1)
|
||||||
download := int64(0)
|
download := int64(0)
|
||||||
@@ -230,8 +300,44 @@ func ensureSecureBootstrapPeer() error {
|
|||||||
BannedUntil: &bannedUntil,
|
BannedUntil: &bannedUntil,
|
||||||
LastConnectionAt: &lastConnection,
|
LastConnectionAt: &lastConnection,
|
||||||
}
|
}
|
||||||
_, saveErr := SavePeer(peer)
|
return seedBootstrapPeerOnce(peer)
|
||||||
return saveErr
|
}
|
||||||
|
|
||||||
|
// bootstrapPeerSeeded читает отметку о том, что пир установщика уже создавался.
|
||||||
|
//
|
||||||
|
// Отсутствие строки — это «ещё не создавался», а не отказ: миграция 000
|
||||||
|
// заводит ключ пустым, но база могла быть создана и до его появления. Любая
|
||||||
|
// ДРУГАЯ ошибка — отказ хранилища, и трактовать её как «не создавался» нельзя:
|
||||||
|
// именно так и появлялся бы пир заново при недоступной базе.
|
||||||
|
func bootstrapPeerSeeded() (bool, error) {
|
||||||
|
cfg, err := GetConfig("key = ?", constant.BootstrapPeerSeeded)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrConfigNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if cfg.Value == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(*cfg.Value) == bootstrapPeerSeededValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedBootstrapPeerOnce пишет пир и отметку о его создании неделимо.
|
||||||
|
//
|
||||||
|
// Раздельная запись вернула бы исходный дефект в новой форме: упади процесс
|
||||||
|
// между SavePeer и записью отметки, следующий старт снова увидел бы
|
||||||
|
// «не создавался» и — после удаления пира оператором — создал бы его заново.
|
||||||
|
func seedBootstrapPeerOnce(peer entity.Peer) error {
|
||||||
|
if sqliteDB == nil {
|
||||||
|
return errors.New("sqlite is not initialized")
|
||||||
|
}
|
||||||
|
return sqliteDB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if _, err := (peerTx{db: tx}).SavePeer(peer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return upsertConfigValueOn(tx, constant.BootstrapPeerSeeded, bootstrapPeerSeededValue)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPeerSecretDigest(rawSecret string) (string, error) {
|
func buildPeerSecretDigest(rawSecret string) (string, error) {
|
||||||
@@ -370,12 +476,18 @@ func seedBaseConfig() error {
|
|||||||
//
|
//
|
||||||
// Оставшееся — четыре внутренних секрета и единственная настройка, которой
|
// Оставшееся — четыре внутренних секрета и единственная настройка, которой
|
||||||
// действительно владеет панель.
|
// действительно владеет панель.
|
||||||
|
//
|
||||||
|
// BOOTSTRAP_PEER_SEEDED заводится пустым: пустое значение означает «пир
|
||||||
|
// установщика ещё ни разу не создавался», и это верное состояние для
|
||||||
|
// чистой базы. Отметку выставляет ensureSecureBootstrapPeer той же
|
||||||
|
// транзакцией, которой создаёт пир.
|
||||||
defaults := map[string]string{
|
defaults := map[string]string{
|
||||||
constant.JwtSecret: "",
|
constant.JwtSecret: "",
|
||||||
constant.ResetTrafficCron: "0 0 * * *",
|
constant.ResetTrafficCron: "0 0 * * *",
|
||||||
constant.Hysteria2TrafficStatsSecret: "",
|
constant.Hysteria2TrafficStatsSecret: "",
|
||||||
constant.PeerSecretKey: "",
|
constant.PeerSecretKey: "",
|
||||||
constant.PeerSecretEncryptionKey: "",
|
constant.PeerSecretEncryptionKey: "",
|
||||||
|
constant.BootstrapPeerSeeded: "",
|
||||||
}
|
}
|
||||||
for k, v := range defaults {
|
for k, v := range defaults {
|
||||||
if tx := sqliteDB.Exec("INSERT OR IGNORE INTO config(key, value, remark) VALUES(?, ?, ?)", k, v, k); tx.Error != nil {
|
if tx := sqliteDB.Exec("INSERT OR IGNORE INTO config(key, value, remark) VALUES(?, ?, ?)", k, v, k); tx.Error != nil {
|
||||||
@@ -597,33 +709,64 @@ func tableExists(tableName string) bool {
|
|||||||
}
|
}
|
||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
// ensureTrafficStatsSecret синхронизирует machine token с окружением.
|
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
|
||||||
|
// придумывает его сам.
|
||||||
//
|
//
|
||||||
// Владелец значения — оркестратор: он же кладёт этот секрет в
|
// Владелец значения — оркестратор. Он же кладёт этот секрет в
|
||||||
// /etc/hysteria/config.yaml, откуда Hysteria берёт его для обращения к
|
// /etc/hysteria/config.yaml, откуда Hysteria берёт его для обращения к
|
||||||
// /internal/hysteria/auth. Поэтому env всегда побеждает содержимое базы, а
|
// /internal/hysteria/auth, и в /etc/hy2xs/hy2xs.env, откуда его читает
|
||||||
// собственная генерация — только для случая, когда переменной нет вовсе.
|
// systemd-юнит админки. Токен — межкомпонентный контракт, а не внутреннее
|
||||||
|
// состояние панели.
|
||||||
//
|
//
|
||||||
// Четыре почти одинаковые ветки INSERT/UPDATE свёрнуты в UpsertConfigValue:
|
// Что было. При пустом env и пустой базе админка генерировала случайный токен
|
||||||
// каждая из них по-своему решала «есть строка или нет», и две из четырёх
|
// сама. Записать его в /etc/hysteria/config.yaml она не может — файл
|
||||||
// решали неверно (см. комментарий к UpsertConfigValue).
|
// принадлежит оркестратору и для пользователя hy2xs-admin доступен только на
|
||||||
|
// чтение (это проверяет smoke). Результат:
|
||||||
|
//
|
||||||
|
// admin стартовал успешно
|
||||||
|
// Hysteria продолжала слать старый токен
|
||||||
|
// machine auth переставал совпадать
|
||||||
|
//
|
||||||
|
// То есть повреждённый контракт запуска не диагностировался, а маскировался
|
||||||
|
// «починкой» неправильным способом: сервис объявлял себя здоровым, а
|
||||||
|
// авторизация клиентов молча ломалась.
|
||||||
|
//
|
||||||
|
// Тот же принцип, что уже применён к HY2XS_ADMIN_INITIAL_PASSWORD и
|
||||||
|
// HY2XS_ADMIN_CON_PASS: значение, которого не знает никто, кроме сгенерировавшего
|
||||||
|
// его процесса, бесполезно, и правильная реакция на его отсутствие громкая.
|
||||||
|
//
|
||||||
|
// Три допустимых состояния:
|
||||||
|
//
|
||||||
|
// env задан → синхронизировать базу с ним (env главнее);
|
||||||
|
// env пуст, в базе токен есть → рабочее состояние, ничего не делаем;
|
||||||
|
// env пуст, в базе пусто → отказ старта.
|
||||||
|
//
|
||||||
|
// Вторая ветка нужна для ручного `systemctl start` без EnvironmentFile: она не
|
||||||
|
// изобретает контракт, а использует уже согласованный.
|
||||||
func ensureTrafficStatsSecret() error {
|
func ensureTrafficStatsSecret() error {
|
||||||
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
|
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
|
||||||
if envSecret != "" {
|
if envSecret != "" {
|
||||||
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, envSecret)
|
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, envSecret)
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
|
existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
|
||||||
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
if err != nil && !errors.Is(err, ErrConfigNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err == nil && existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
secret, secErr := util.RandomString(32)
|
return errors.New(
|
||||||
if secErr != nil {
|
"HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET не задан, и в базе этого токена тоже нет.\n" +
|
||||||
return secErr
|
"Это межкомпонентный контракт, а не настройка панели: тем же значением\n" +
|
||||||
}
|
"Hysteria обращается к /internal/hysteria/auth, и владеет им\n" +
|
||||||
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, secret)
|
"install-оркестратор через /etc/hysteria/config.yaml и /etc/hy2xs/hy2xs.env.\n" +
|
||||||
|
"Сгенерированный админкой токен не попал бы в конфиг Hysteria: этот файл\n" +
|
||||||
|
"доступен ей только на чтение, — и machine auth перестал бы совпадать.\n" +
|
||||||
|
"Проверьте EnvironmentFile в юните hy2xs-admin и запустите\n" +
|
||||||
|
"`hy2xs-orchestrator repair --allow-partial-state`.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CloseSqliteDB() error {
|
func CloseSqliteDB() error {
|
||||||
|
|||||||
+6
-8
@@ -1,19 +1,17 @@
|
|||||||
package dao
|
package dao
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
"hy2xs-admin/model/constant"
|
|
||||||
"hy2xs-admin/model/entity"
|
"hy2xs-admin/model/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SaveTrafficSample(sample entity.TrafficSample) error {
|
func SaveTrafficSample(sample entity.TrafficSample) error {
|
||||||
if tx := sqliteDB.Save(&sample); tx.Error != nil {
|
if tx := sqliteDB.Save(&sample); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -36,7 +34,7 @@ func UpsertTrafficAggregateHourly(peerId int64, hourStart int64, rxBytes int64,
|
|||||||
}),
|
}),
|
||||||
}).Create(&agg); tx.Error != nil {
|
}).Create(&agg); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -59,7 +57,7 @@ func UpsertTrafficAggregateDaily(peerId int64, dayStart int64, rxBytes int64, tx
|
|||||||
}),
|
}),
|
||||||
}).Create(&agg); tx.Error != nil {
|
}).Create(&agg); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -74,7 +72,7 @@ func CleanupTrafficSample(olderThanMs int64) error {
|
|||||||
}
|
}
|
||||||
if tx := sqliteDB.Exec("DELETE FROM traffic_sample WHERE sampled_at < ?", olderThanMs); tx.Error != nil {
|
if tx := sqliteDB.Exec("DELETE FROM traffic_sample WHERE sampled_at < ?", olderThanMs); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -85,7 +83,7 @@ func CleanupTrafficAggregateHourly(olderThanMs int64) error {
|
|||||||
}
|
}
|
||||||
if tx := sqliteDB.Exec("DELETE FROM traffic_aggregate_hourly WHERE hour_start < ?", olderThanMs); tx.Error != nil {
|
if tx := sqliteDB.Exec("DELETE FROM traffic_aggregate_hourly WHERE hour_start < ?", olderThanMs); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -96,7 +94,7 @@ func CleanupTrafficAggregateDaily(olderThanMs int64) error {
|
|||||||
}
|
}
|
||||||
if tx := sqliteDB.Exec("DELETE FROM traffic_aggregate_daily WHERE day_start < ?", olderThanMs); tx.Error != nil {
|
if tx := sqliteDB.Exec("DELETE FROM traffic_aggregate_daily WHERE day_start < ?", olderThanMs); tx.Error != nil {
|
||||||
logrus.Errorf("%v", tx.Error)
|
logrus.Errorf("%v", tx.Error)
|
||||||
return errors.New(constant.SysError)
|
return ErrStorage
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+13
@@ -6,6 +6,19 @@ module hy2xs-admin
|
|||||||
// с ней в бинарь попадала соответствующая stdlib.
|
// с ней в бинарь попадала соответствующая stdlib.
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
|
// toolchain объявляет, каким компилятором проект собирается на самом деле.
|
||||||
|
//
|
||||||
|
// Директива `go` выше — это языковой baseline модуля, и она НЕ выбирает
|
||||||
|
// компилятор: с ней одной локальный `go build` на 1.25 проходил успешно, хотя
|
||||||
|
// релизный бинарь собирается на 1.26.7 и наследует её stdlib. То есть
|
||||||
|
// разработчик и сборка проверяли разный код, а расхождение не было видно ни в
|
||||||
|
// одном из выводов.
|
||||||
|
//
|
||||||
|
// Значение обязано совпадать с GO_VERSION из versions.env; это проверяет
|
||||||
|
// verify_go_toolchain_contract, а `tools/dev/doctor` показывает то же
|
||||||
|
// расхождение локально, до сборки.
|
||||||
|
toolchain go1.26.7
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/didip/tollbooth v4.0.2+incompatible
|
github.com/didip/tollbooth v4.0.2+incompatible
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
|||||||
@@ -30,6 +30,21 @@ const (
|
|||||||
PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY"
|
PeerSecretEncryptionKey = "PEER_SECRET_ENCRYPTION_KEY"
|
||||||
Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET"
|
Hysteria2TrafficStatsSecret = "HYSTERIA2_TRAFFIC_STATS_SECRET"
|
||||||
ResetTrafficCron = "RESET_TRAFFIC_CRON"
|
ResetTrafficCron = "RESET_TRAFFIC_CRON"
|
||||||
|
|
||||||
|
// BootstrapPeerSeeded — отметка о том, что пир установщика УЖЕ был создан
|
||||||
|
// однажды. Не «существует сейчас», а именно «был создан».
|
||||||
|
//
|
||||||
|
// Различие и есть суть ключа. Раньше единственным признаком служило наличие
|
||||||
|
// строки в таблице пиров, поэтому удаление bootstrap-admin-peer выглядело
|
||||||
|
// отзывом доступа, но им не было: HY2XS_ADMIN_CON_PASS продолжает жить в
|
||||||
|
// /etc/hy2xs/hy2xs.env, и ближайший `systemctl restart hy2xs-admin` или
|
||||||
|
// перезагрузка сервера возвращали тот же самый секрет в базу. Молча.
|
||||||
|
//
|
||||||
|
// Отзыв учётных данных, который не переживает перезапуск, — не отзыв.
|
||||||
|
// Маркер делает удаление окончательным: seeder читает его, а не таблицу
|
||||||
|
// пиров, поэтому после первого успешного bootstrap он не создаёт запись
|
||||||
|
// больше никогда.
|
||||||
|
BootstrapPeerSeeded = "BOOTSTRAP_PEER_SEEDED"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Что операторский API имеет право прочитать и записать в таблице `config`.
|
// Что операторский API имеет право прочитать и записать в таблице `config`.
|
||||||
|
|||||||
+10
-3
@@ -19,9 +19,16 @@ func initPeerRouter(peerApi *gin.RouterGroup) {
|
|||||||
peers.PATCH("/:id/kick", controller.KickPeer)
|
peers.PATCH("/:id/kick", controller.KickPeer)
|
||||||
peers.GET("/:id/client-config", controller.PeerClientConfig)
|
peers.GET("/:id/client-config", controller.PeerClientConfig)
|
||||||
|
|
||||||
// aliases на один миграционный релиз
|
// Алиасов /:id/client-url и /:id/qr здесь больше нет.
|
||||||
peers.GET("/:id/client-url", controller.PeerClientConfig)
|
//
|
||||||
peers.GET("/:id/qr", controller.PeerClientConfig)
|
// Они были оставлены «на один миграционный релиз», но миграционного
|
||||||
|
// релиза у продукта не существует: HY2XS v1 ставится только на чистый
|
||||||
|
// хост, база предыдущего поколения не переносится, а клиентов у этих
|
||||||
|
// путей нет — панель ходит в /:id/client-config. Дожив до 1.0.0, они
|
||||||
|
// стали бы частью публичного API v1, и убирать их пришлось бы уже со
|
||||||
|
// сломом совместимости.
|
||||||
|
//
|
||||||
|
// Отсутствие закреплено TestRouterHasNoRemovedRoutes.
|
||||||
}
|
}
|
||||||
|
|
||||||
peerApi.POST("/peer-import", controller.ImportPeer)
|
peerApi.POST("/peer-import", controller.ImportPeer)
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ func TestRouterHasNoRemovedRoutes(t *testing.T) {
|
|||||||
// входом к JWT_SECRET, PEER_SECRET_KEY и PEER_SECRET_ENCRYPTION_KEY.
|
// входом к JWT_SECRET, PEER_SECRET_KEY и PEER_SECRET_ENCRYPTION_KEY.
|
||||||
// Потребителей у него не было ни одного.
|
// Потребителей у него не было ни одного.
|
||||||
"getConfig",
|
"getConfig",
|
||||||
|
// Алиасы клиентской конфигурации, оставленные «на один миграционный
|
||||||
|
// релиз». Миграционного релиза у clean-install-only продукта нет, а в
|
||||||
|
// 1.0.0 они стали бы частью публичного API.
|
||||||
|
"client-url",
|
||||||
|
"/qr",
|
||||||
}
|
}
|
||||||
for _, route := range buildRoutes(t) {
|
for _, route := range buildRoutes(t) {
|
||||||
for _, dead := range removed {
|
for _, dead := range removed {
|
||||||
|
|||||||
+144
-18
@@ -80,8 +80,12 @@ func CreatePeer(peerDto dto.PeerSaveDto) (vo.PeerVo, error) {
|
|||||||
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
|
if strings.TrimSpace(*peerDto.Name) == ReservedBootstrapPeerName {
|
||||||
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
|
return vo.PeerVo{}, fmt.Errorf("имя %q зарезервировано за пиром установщика", ReservedBootstrapPeerName)
|
||||||
}
|
}
|
||||||
if ExistPeerName(*peerDto.Name, 0) {
|
taken, err := ExistPeerName(*peerDto.Name, 0)
|
||||||
return vo.PeerVo{}, errors.New(fmt.Sprintf("name %s already exists", *peerDto.Name))
|
if err != nil {
|
||||||
|
return vo.PeerVo{}, err
|
||||||
|
}
|
||||||
|
if taken {
|
||||||
|
return vo.PeerVo{}, fmt.Errorf("name %s already exists", *peerDto.Name)
|
||||||
}
|
}
|
||||||
secret := ""
|
secret := ""
|
||||||
if peerDto.Secret != nil && *peerDto.Secret != "" {
|
if peerDto.Secret != nil && *peerDto.Secret != "" {
|
||||||
@@ -203,6 +207,18 @@ func assertBootstrapPeerIdentityUnchanged(id int64, peerDto dto.PeerUpdateDto) e
|
|||||||
// обязан иметь возможность его отозвать. В отличие от смены секрета, удаление
|
// обязан иметь возможность его отозвать. В отличие от смены секрета, удаление
|
||||||
// не создаёт расхождения между базой и файлом: пира просто нет, и это видно в
|
// не создаёт расхождения между базой и файлом: пира просто нет, и это видно в
|
||||||
// списке.
|
// списке.
|
||||||
|
//
|
||||||
|
// Отзыв при этом ОКОНЧАТЕЛЬНЫЙ, и это свойство обеспечивается не здесь, а
|
||||||
|
// отметкой constant.BootstrapPeerSeeded в dao.ensureSecureBootstrapPeer. Пока
|
||||||
|
// признаком «создавать или нет» служило наличие строки в таблице, удаление
|
||||||
|
// откатывалось первым же перезапуском сервиса: HY2XS_ADMIN_CON_PASS никуда не
|
||||||
|
// девается из /etc/hy2xs/hy2xs.env, и seeder создавал пира с тем же секретом
|
||||||
|
// заново. Разрешать удаление имеет смысл только вместе с этим свойством, иначе
|
||||||
|
// панель предлагает операции, которых не выполняет.
|
||||||
|
//
|
||||||
|
// Секрет остаётся в /etc/hy2xs/bootstrap-admin.secret и после удаления. Файлом
|
||||||
|
// владеет оркестратор, админка его не трогает; после отзыва он содержит уже
|
||||||
|
// недействующее значение (см. docs/04-admin-panel.md).
|
||||||
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
|
func DeletePeer(id int64) error { return dao.DeletePeer([]int64{id}) }
|
||||||
|
|
||||||
func GetPeerVo(id int64) (vo.PeerVo, error) {
|
func GetPeerVo(id int64) (vo.PeerVo, error) {
|
||||||
@@ -248,10 +264,42 @@ func BuildPeerClientConfig(id int64) (vo.PeerClientConfigVo, error) {
|
|||||||
return vo.PeerClientConfigVo{Url: url}, nil
|
return vo.PeerClientConfigVo{Url: url}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListExportPeer собирает выгрузку пиров в одном из двух режимов.
|
||||||
|
//
|
||||||
|
// includeSecrets=false — безопасная выгрузка: шифртекст не трогается вовсе,
|
||||||
|
// поле secret остаётся пустым, и это ожидаемое содержимое файла.
|
||||||
|
//
|
||||||
|
// includeSecrets=true — резервная копия с ДЕЙСТВУЮЩИМИ учётными данными,
|
||||||
|
// которую оператор запрашивает отдельной кнопкой с подтверждением риска.
|
||||||
|
// Здесь правило строгое: либо в файл попадают все секреты, либо файла нет.
|
||||||
|
//
|
||||||
|
// Что было:
|
||||||
|
//
|
||||||
|
// if includeSecrets && item.SecretEncrypted != nil {
|
||||||
|
// if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil {
|
||||||
|
// ex.Secret = dec
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Два молчаливых канала потери. Ошибка расшифровки проглатывалась, и пир
|
||||||
|
// уезжал в файл с пустым secret; пир без шифртекста — тоже. Запрос при этом
|
||||||
|
// завершался успехом, и оператор получал файл, выглядящий полным:
|
||||||
|
//
|
||||||
|
// [{"name":"A","secret":"..."},
|
||||||
|
// {"name":"B","secret":""}, <- потерян молча
|
||||||
|
// {"name":"C","secret":"..."}]
|
||||||
|
//
|
||||||
|
// Обнаруживалось это уже после импорта на новом сервере: B получал новый
|
||||||
|
// сгенерированный секрет, а его клиент — отказ авторизации. Смысл режима в
|
||||||
|
// том, что пользователь СПЕЦИАЛЬНО выбрал «копия с действующими credentials»;
|
||||||
|
// частичный результат под этим именем — худший из возможных ответов.
|
||||||
|
//
|
||||||
|
// Сообщение называет пира по имени: имена не секретны и уже видны в списке, а
|
||||||
|
// без имени оператору некуда идти с этой ошибкой.
|
||||||
func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
|
func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
|
||||||
peers, err := dao.ListPeer("1=1")
|
peers, err := dao.ListPeer("1=1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New(constant.SysError)
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make([]bo.PeerExport, 0, len(peers))
|
out := make([]bo.PeerExport, 0, len(peers))
|
||||||
for _, item := range peers {
|
for _, item := range peers {
|
||||||
@@ -269,16 +317,47 @@ func ListExportPeer(includeSecrets bool) ([]bo.PeerExport, error) {
|
|||||||
BannedUntil: int64Val(item.BannedUntil),
|
BannedUntil: int64Val(item.BannedUntil),
|
||||||
LastConnectionAt: int64Val(item.LastConnectionAt),
|
LastConnectionAt: int64Val(item.LastConnectionAt),
|
||||||
}
|
}
|
||||||
if includeSecrets && item.SecretEncrypted != nil {
|
if includeSecrets {
|
||||||
if dec, derr := DecryptPeerSecret(*item.SecretEncrypted); derr == nil {
|
secret, secretErr := exportPeerSecret(item)
|
||||||
ex.Secret = dec
|
if secretErr != nil {
|
||||||
|
return nil, secretErr
|
||||||
}
|
}
|
||||||
|
ex.Secret = secret
|
||||||
}
|
}
|
||||||
out = append(out, ex)
|
out = append(out, ex)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exportPeerSecret возвращает действующий секрет пира или объясняет, почему
|
||||||
|
// резервная копия невозможна.
|
||||||
|
func exportPeerSecret(peer entity.Peer) (string, error) {
|
||||||
|
name := strVal(peer.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("id=%d", int64Val(peer.Id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if peer.SecretEncrypted == nil || strings.TrimSpace(*peer.SecretEncrypted) == "" {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"резервная копия с учётными данными не создана: у пира %q нет сохранённого секрета. "+
|
||||||
|
"Файл не сформирован целиком, чтобы копия не выглядела полной. "+
|
||||||
|
"Пересоздайте секрет этого пира или выгрузите список без учётных данных",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
decrypted, err := DecryptPeerSecret(*peer.SecretEncrypted)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"резервная копия с учётными данными не создана: не удалось расшифровать секрет пира %q. "+
|
||||||
|
"Обычная причина — подменённый или потерянный PEER_SECRET_ENCRYPTION_KEY. "+
|
||||||
|
"Файл не сформирован целиком, чтобы копия не выглядела полной",
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return decrypted, nil
|
||||||
|
}
|
||||||
|
|
||||||
// preparedPeerImport — запись импорта со всем криптоматериалом, посчитанным
|
// preparedPeerImport — запись импорта со всем криптоматериалом, посчитанным
|
||||||
// заранее.
|
// заранее.
|
||||||
//
|
//
|
||||||
@@ -416,26 +495,21 @@ func UpsertPeerExport(items []bo.PeerExport) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
|
func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
|
||||||
var existing entity.Peer
|
existing, found, err := findPeerForImport(tx, entry)
|
||||||
var err error
|
if err != nil {
|
||||||
|
return err
|
||||||
if entry.authID != "" {
|
|
||||||
existing, err = tx.GetPeer("auth_id = ?", entry.authID)
|
|
||||||
}
|
|
||||||
if err != nil || existing.Id == nil {
|
|
||||||
existing, err = tx.GetPeer("name = ?", entry.name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пир установщика не переопределяется импортом ни при каком совпадении:
|
// Пир установщика не переопределяется импортом ни при каком совпадении:
|
||||||
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
|
// его секрет живёт ещё и в /etc/hy2xs/bootstrap-admin.secret.
|
||||||
if err == nil && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
|
if found && existing.Name != nil && *existing.Name == ReservedBootstrapPeerName {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"peer import: пир %q принадлежит установщику и не может быть изменён импортом",
|
"peer import: пир %q принадлежит установщику и не может быть изменён импортом",
|
||||||
ReservedBootstrapPeerName,
|
ReservedBootstrapPeerName,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil && existing.Id != nil {
|
if found {
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"name": entry.name,
|
"name": entry.name,
|
||||||
"remark": entry.remark,
|
"remark": entry.remark,
|
||||||
@@ -491,14 +565,66 @@ func applyPeerImportEntry(tx dao.PeerTx, entry preparedPeerImport) error {
|
|||||||
return saveErr
|
return saveErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func ExistPeerName(name string, id int64) bool {
|
// findPeerForImport ищет запись, которую импорт должен обновить.
|
||||||
|
//
|
||||||
|
// Возвращает три состояния, а не два: найдено, не найдено, неизвестно. Раньше
|
||||||
|
// последних двух не существовало — код выглядел так:
|
||||||
|
//
|
||||||
|
// existing, err = tx.GetPeer("auth_id = ?", entry.authID)
|
||||||
|
// if err != nil || existing.Id == nil {
|
||||||
|
// existing, err = tx.GetPeer("name = ?", entry.name)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// то есть отказ SQLite на первом запросе трактовался как «по auth_id ничего
|
||||||
|
// нет» и запускал поиск по имени; отказ на втором — как «такого пира нет
|
||||||
|
// вовсе», и запись СОЗДАВАЛАСЬ. При недоступной базе импорт не отказывал, а
|
||||||
|
// пытался наплодить дубликаты; целостность спасала транзакция, но решение
|
||||||
|
// принималось на несуществующих данных.
|
||||||
|
//
|
||||||
|
// Порядок поиска сохранён: authId — устойчивый идентификатор, имя — запасной
|
||||||
|
// ключ для файлов, выгруженных без него.
|
||||||
|
func findPeerForImport(tx dao.PeerTx, entry preparedPeerImport) (entity.Peer, bool, error) {
|
||||||
|
if entry.authID != "" {
|
||||||
|
peer, err := tx.GetPeer("auth_id = ?", entry.authID)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return peer, true, nil
|
||||||
|
case !errors.Is(err, dao.ErrPeerNotFound):
|
||||||
|
return entity.Peer{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peer, err := tx.GetPeer("name = ?", entry.name)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return peer, true, nil
|
||||||
|
case errors.Is(err, dao.ErrPeerNotFound):
|
||||||
|
return entity.Peer{}, false, nil
|
||||||
|
default:
|
||||||
|
return entity.Peer{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExistPeerName отвечает «имя занято» и отказывает, когда ответить не может.
|
||||||
|
//
|
||||||
|
// Прежняя реализация возвращала `err == nil`, то есть любой отказ хранилища
|
||||||
|
// означал «имя свободно». Проверка уникальности, которая при недоступной базе
|
||||||
|
// отвечает «можно», ничего не проверяет.
|
||||||
|
func ExistPeerName(name string, id int64) (bool, error) {
|
||||||
var err error
|
var err error
|
||||||
if id != 0 {
|
if id != 0 {
|
||||||
_, err = dao.GetPeer("name = ? and id != ?", name, id)
|
_, err = dao.GetPeer("name = ? and id != ?", name, id)
|
||||||
} else {
|
} else {
|
||||||
_, err = dao.GetPeer("name = ?", name)
|
_, err = dao.GetPeer("name = ?", name)
|
||||||
}
|
}
|
||||||
return err == nil
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return true, nil
|
||||||
|
case errors.Is(err, dao.ErrPeerNotFound):
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdatePeerLastConnectionAt(id int64, conAt int64) error {
|
func UpdatePeerLastConnectionAt(id int64, conAt int64) error {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"hy2xs-admin/dao"
|
||||||
|
"hy2xs-admin/model/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Резервная копия с учётными данными обязана быть полной или отсутствовать.
|
||||||
|
//
|
||||||
|
// Раньше ListExportPeer(true) молча пропускала пиров, чей секрет не удалось
|
||||||
|
// получить, и отдавала успешный ответ с пустым полем secret. Оператор,
|
||||||
|
// специально выбравший режим «копия с действующими credentials», узнавал о
|
||||||
|
// потере только после импорта на новом сервере — по отвалившимся клиентам.
|
||||||
|
|
||||||
|
func seedPeerWithCipher(t *testing.T, name string, authID string, cipher string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
digest, err := PeerSecretDigest(name + "-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("digest: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remark := ""
|
||||||
|
quota := int64(-1)
|
||||||
|
zero := int64(0)
|
||||||
|
maxDevices := int64(3)
|
||||||
|
|
||||||
|
id, err := dao.SavePeer(entity.Peer{
|
||||||
|
Name: &name,
|
||||||
|
Remark: &remark,
|
||||||
|
AuthId: &authID,
|
||||||
|
SecretDigest: &digest,
|
||||||
|
SecretEncrypted: &cipher,
|
||||||
|
QuotaBytes: "a,
|
||||||
|
DownloadBytes: &zero,
|
||||||
|
UploadBytes: &zero,
|
||||||
|
ExpiresAt: &zero,
|
||||||
|
MaxDevices: &maxDevices,
|
||||||
|
Disabled: &zero,
|
||||||
|
BannedUntil: &zero,
|
||||||
|
LastConnectionAt: &zero,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("не удалось создать пира %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupExportReturnsEverySecret(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
seedPeer(t, "alpha1", "alpha-auth")
|
||||||
|
seedPeer(t, "bravo2", "bravo-auth")
|
||||||
|
|
||||||
|
items, err := ListExportPeer(true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("резервная копия отклонена на исправных данных: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("ожидалось 2 записи, получено %d", len(items))
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Secret == "" {
|
||||||
|
t.Errorf("пир %s уехал в копию без секрета", item.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Повреждённый шифртекст: расшифровка падает, и весь бэкап обязан упасть
|
||||||
|
// вместе с ней, назвав пира по имени.
|
||||||
|
func TestBackupExportFailsWhenAnySecretCannotBeDecrypted(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
seedPeer(t, "alpha1", "alpha-auth")
|
||||||
|
seedPeerWithCipher(t, "bravo2", "bravo-auth", "not-a-valid-ciphertext")
|
||||||
|
seedPeer(t, "charl3", "charlie-auth")
|
||||||
|
|
||||||
|
items, err := ListExportPeer(true)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("резервная копия с нерасшифровываемым секретом не отклонена")
|
||||||
|
}
|
||||||
|
if items != nil {
|
||||||
|
t.Error("при отказе не должно возвращаться частичное содержимое файла")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "bravo2") {
|
||||||
|
t.Errorf("отказ не называет проблемного пира: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пир вовсе без сохранённого шифртекста — второй молчаливый канал потери.
|
||||||
|
func TestBackupExportFailsWhenSecretIsMissing(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
seedPeer(t, "alpha1", "alpha-auth")
|
||||||
|
seedPeerWithCipher(t, "bravo2", "bravo-auth", "")
|
||||||
|
|
||||||
|
_, err := ListExportPeer(true)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("резервная копия для пира без секрета не отклонена")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "bravo2") {
|
||||||
|
t.Errorf("отказ не называет проблемного пира: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Безопасная выгрузка шифртекст не трогает вовсе: пустой secret там — не
|
||||||
|
// потеря, а весь смысл режима. Повреждённые данные не должны ей мешать.
|
||||||
|
func TestSafeExportIgnoresBrokenSecrets(t *testing.T) {
|
||||||
|
newTestDB(t)
|
||||||
|
seedPeer(t, "alpha1", "alpha-auth")
|
||||||
|
seedPeerWithCipher(t, "bravo2", "bravo-auth", "not-a-valid-ciphertext")
|
||||||
|
|
||||||
|
items, err := ListExportPeer(false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("безопасная выгрузка отклонена: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("ожидалось 2 записи, получено %d", len(items))
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
if item.Secret != "" {
|
||||||
|
t.Errorf("безопасная выгрузка содержит секрет пира %s", item.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hy2xs-admin/dao"
|
||||||
"hy2xs-admin/model/bo"
|
"hy2xs-admin/model/bo"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +31,11 @@ const MaxPeerImportItems = 5000
|
|||||||
// HY2XS_ADMIN_CON_PASS. Его секрет продублирован в
|
// HY2XS_ADMIN_CON_PASS. Его секрет продублирован в
|
||||||
// /etc/hy2xs/bootstrap-admin.secret, и подмена секрета импортом молча
|
// /etc/hy2xs/bootstrap-admin.secret, и подмена секрета импортом молча
|
||||||
// рассинхронизировала бы файл на диске с базой.
|
// рассинхронизировала бы файл на диске с базой.
|
||||||
const ReservedBootstrapPeerName = "bootstrap-admin-peer"
|
//
|
||||||
|
// Значение берётся из dao, а не пишется здесь второй раз: guard обязан
|
||||||
|
// защищать ровно тот пир, который создаёт seeder, и две независимые строковые
|
||||||
|
// константы рано или поздно разошлись бы.
|
||||||
|
const ReservedBootstrapPeerName = dao.BootstrapPeerName
|
||||||
|
|
||||||
// Тот же набор символов, что и у validateStr в слое контроллеров.
|
// Тот же набор символов, что и у validateStr в слое контроллеров.
|
||||||
var peerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$`)
|
var peerNamePattern = regexp.MustCompile(`^[a-zA-Z0-9!@#$%^&*()_+\-=]{6,32}$`)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hy2xs-admin/dao"
|
"hy2xs-admin/dao"
|
||||||
@@ -42,9 +43,37 @@ func EncryptPeerSecret(rawSecret string) (string, error) {
|
|||||||
return util.EncryptAESGCM(rawSecret, key)
|
return util.EncryptAESGCM(rawSecret, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DecryptPeerSecret расшифровывает сохранённый секрет пира.
|
||||||
|
//
|
||||||
|
// Формат хранения ровно один: `v1:` + AES-GCM. Значение без этого префикса —
|
||||||
|
// не «секрет в старом формате», а повреждённые данные, и ответом на них
|
||||||
|
// является ошибка.
|
||||||
|
//
|
||||||
|
// Что было:
|
||||||
|
//
|
||||||
|
// if !strings.HasPrefix(stored, "v1:") {
|
||||||
|
// return stored, nil
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// то есть содержимое колонки возвращалось как якобы успешно расшифрованный
|
||||||
|
// секрет. Ветка досталась от поколения, в котором секреты пиров лежали в базе
|
||||||
|
// открытым текстом; при clean-install-only политике такой строки не может
|
||||||
|
// существовать — EncryptPeerSecret всегда пишет префикс, — а вред остаётся:
|
||||||
|
//
|
||||||
|
// повреждённая колонка → мусор уходит в клиентскую ссылку как секрет;
|
||||||
|
// резервная копия с секретами → мусор попадает в файл вместо credentials;
|
||||||
|
// значение, записанное в обход → принимается без единой проверки.
|
||||||
|
//
|
||||||
|
// Это тот же класс, что и удалённый SHA-224 fallback при входе: молчаливое
|
||||||
|
// «понимаем формат предыдущего поколения» превращается в молчаливое «понимаем
|
||||||
|
// что угодно».
|
||||||
func DecryptPeerSecret(stored string) (string, error) {
|
func DecryptPeerSecret(stored string) (string, error) {
|
||||||
if !strings.HasPrefix(stored, "v1:") {
|
if !strings.HasPrefix(stored, peerSecretCipherPrefix) {
|
||||||
return stored, nil
|
return "", fmt.Errorf(
|
||||||
|
"секрет пира хранится в неизвестном формате: ожидался префикс %q. "+
|
||||||
|
"HY2XS хранит секреты пиров только зашифрованными",
|
||||||
|
peerSecretCipherPrefix,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
|
key, err := dao.GetOrCreatePeerSecretEncryptionKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -52,3 +81,8 @@ func DecryptPeerSecret(stored string) (string, error) {
|
|||||||
}
|
}
|
||||||
return util.DecryptAESGCM(stored, key)
|
return util.DecryptAESGCM(stored, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// peerSecretCipherPrefix — единственный поддерживаемый формат хранения.
|
||||||
|
// Значение задаёт util.EncryptAESGCM; здесь оно объявлено, чтобы проверка и
|
||||||
|
// сообщение об ошибке не расходились с ним по разным файлам молча.
|
||||||
|
const peerSecretCipherPrefix = "v1:"
|
||||||
|
|||||||
@@ -147,11 +147,28 @@ Hysteria в шаблоне) плюс несовместимость `DefaultRow`
|
|||||||
Версия `govulncheck` пиньтся в `versions.env`, а база уязвимостей подтягивается
|
Версия `govulncheck` пиньтся в `versions.env`, а база уязвимостей подтягивается
|
||||||
на каждом запуске: пин инструмента не должен превращаться в пин знаний о мире.
|
на каждом запуске: пин инструмента не должен превращаться в пин знаний о мире.
|
||||||
|
|
||||||
Аварийный выход — `ALLOW_VULNERABLE_DEPENDENCIES=true`, по той же логике, что и
|
Аварийного выхода у шага **нет**, и это отличает его от `ALLOW_DIRTY_BUILD`.
|
||||||
`ALLOW_DIRTY_BUILD`: выпустить релиз, зная об уязвимости, можно, но это решение
|
Результат уезжает в `metadata/package.env` полем `dependency_security_gate`,
|
||||||
человека, а не поведение по умолчанию. Результат шага уезжает в
|
которое принимает единственное значение `true`: по готовому tarball видно, что
|
||||||
`metadata/package.env` полем `dependency_security_gate`, так что по готовому
|
он проверялся, потому что непроверенного 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` нельзя переименовать, переподписать или занять его имя
|
`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 —
|
Токены выписываются и проверяются `golang-jwt/jwt/v5`. Переход с v3 —
|
||||||
|
|||||||
@@ -369,6 +369,52 @@ wildcard-маршрутом фронтенда или дублирующая р
|
|||||||
- удаление **разрешено**: это осознанное действие оператора, и расхождения
|
- удаление **разрешено**: это осознанное действие оператора, и расхождения
|
||||||
между базой и `bootstrap-admin.secret` оно не создаёт.
|
между базой и `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)
|
## A10. Импорт пиров (unit)
|
||||||
|
|
||||||
`apps/service/peer_import_test.go`:
|
`apps/service/peer_import_test.go`:
|
||||||
@@ -811,3 +857,12 @@ hy2xs-orchestrator doctor
|
|||||||
32. документация нигде не утверждает, что Salamander — фиксированный инвариант
|
32. документация нигде не утверждает, что Salamander — фиксированный инвариант
|
||||||
33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
|
33. документация не фиксирует конкретный номер версии как «текущую версию», а объясняет latest-stable build policy
|
||||||
34. форма создания пира содержит примеры значений и пояснения для полей «Пир», «Комментарий» и «Секрет»
|
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;
|
3. дождаться истечения TTL;
|
||||||
4. повторить `hy2xs-orchestrator doctor`.
|
4. повторить `hy2xs-orchestrator doctor`.
|
||||||
|
|
||||||
|
`doctor` безопасно запускать на работающем сервере: он не перезапускает
|
||||||
|
сервисы и живые соединения не рвёт. Раньше это было не так — команда звала
|
||||||
|
общий smoke, который начинается с `systemctl restart hysteria-server
|
||||||
|
hy2xs-admin`, и диагностика подозрения на проблему сама создавала обрыв у всех
|
||||||
|
подключённых клиентов.
|
||||||
|
|
||||||
Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного
|
Вариант `server public IPv4:` пустой означает, что на интерфейсах нет ни одного
|
||||||
публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline;
|
публичного маршрутизируемого IPv4 — сервер за NAT. Это топология вне baseline;
|
||||||
осознанное решение оформляется через `HY2XS_PUBLIC_ENDPOINT_POLICY=warn`.
|
осознанное решение оформляется через `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.
|
Команда выполняет 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
|
## 13. Admin UI access via SSH tunnel
|
||||||
|
|
||||||
Production policy: UI остаётся loopback-only (`HY2XS_UI_BIND_HOST=127.0.0.1`), внешний доступ к `8080/tcp` не открывается.
|
Production policy: UI остаётся loopback-only (`HY2XS_UI_BIND_HOST=127.0.0.1`), внешний доступ к `8080/tcp` не открывается.
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ function usage(): never {
|
|||||||
console.error(" hy2xs-orchestrator install --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke] [--non-interactive]");
|
console.error(" hy2xs-orchestrator install --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke] [--non-interactive]");
|
||||||
console.error(" hy2xs-orchestrator reconfigure --package-dir <path> [--config <path>] [--dry-run|--apply] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
|
console.error(" hy2xs-orchestrator reconfigure --package-dir <path> [--config <path>] [--dry-run|--apply] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
|
||||||
console.error(" hy2xs-orchestrator repair --package-dir <path> [--config <path>] [--allow-partial-state] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
|
console.error(" hy2xs-orchestrator repair --package-dir <path> [--config <path>] [--allow-partial-state] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
|
||||||
console.error(" hy2xs-orchestrator doctor --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
|
console.error(" hy2xs-orchestrator doctor --package-dir <path> [--config <path>] [--skip-firewall] [--skip-smoke]");
|
||||||
|
console.error(" note: doctor never restarts services — it diagnoses the running installation");
|
||||||
console.error(" hy2xs-orchestrator status --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
|
console.error(" hy2xs-orchestrator status --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
|
||||||
console.error(" hy2xs-orchestrator diagnostics collect --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
|
console.error(" hy2xs-orchestrator diagnostics collect --package-dir <path> [--config <path>] [--skip-firewall] [--skip-service-start] [--skip-smoke]");
|
||||||
console.error(" hy2xs-orchestrator redact-config --config <path> [--in-place | --out <path>] [--format auto|env|yaml]");
|
console.error(" hy2xs-orchestrator redact-config --config <path> [--in-place | --out <path>] [--format auto|env|yaml]");
|
||||||
|
|||||||
@@ -57,6 +57,29 @@ async function checkSshForwardingForLocalUi(uiBindHost: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* doctor — диагностика работающей установки, а не её починка.
|
||||||
|
*
|
||||||
|
* skipServiceStart здесь выставляется ПРИНУДИТЕЛЬНО, независимо от того, что
|
||||||
|
* пришло из командной строки.
|
||||||
|
*
|
||||||
|
* Что было. doctor собирал контекст с параметрами по умолчанию
|
||||||
|
* (`skipServiceStart: false`) и звал общий smoke, а тот первым же действием
|
||||||
|
* выполняет `systemctl restart hysteria-server hy2xs-admin`. То есть команда,
|
||||||
|
* которую docs/12 и docs/13 предлагают запускать ПРИ ПОДОЗРЕНИИ на проблему,
|
||||||
|
* гарантированно обрывала все живые VPN-соединения на сервере — включая
|
||||||
|
* ситуацию, когда с сервисом всё в порядке и оператор просто проверял состояние.
|
||||||
|
*
|
||||||
|
* Диагностика, меняющая то, что диагностирует, отвечает не на заданный вопрос:
|
||||||
|
* после рестарта проверяется уже другое состояние системы. Флаги
|
||||||
|
* --skip-service-start/--skip-smoke оставлены разбираемыми, чтобы вызов
|
||||||
|
* `doctor --skip-service-start` не падал на неизвестном аргументе, но повлиять
|
||||||
|
* они могут только в сторону сужения проверок.
|
||||||
|
*
|
||||||
|
* Остальная часть smoke — слушатели, права на файлы, machine auth, семантика
|
||||||
|
* /etc/hysteria/config.yaml, версия бинаря — состояние сервера не меняет и
|
||||||
|
* выполняется полностью.
|
||||||
|
*/
|
||||||
export async function doctor(options: ReconfigureOptions): Promise<void> {
|
export async function doctor(options: ReconfigureOptions): Promise<void> {
|
||||||
setOperationContext(`doctor-${Date.now().toString(36)}`);
|
setOperationContext(`doctor-${Date.now().toString(36)}`);
|
||||||
const configRaw = await readText(options.sourceConfigPath);
|
const configRaw = await readText(options.sourceConfigPath);
|
||||||
@@ -64,7 +87,7 @@ export async function doctor(options: ReconfigureOptions): Promise<void> {
|
|||||||
|
|
||||||
const context: ReconfigureContext = {
|
const context: ReconfigureContext = {
|
||||||
mode: "reconfigure",
|
mode: "reconfigure",
|
||||||
options: { ...options, dryRun: true, apply: false },
|
options: { ...options, dryRun: true, apply: false, skipServiceStart: true },
|
||||||
config,
|
config,
|
||||||
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
|
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
|
||||||
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
|
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* doctor обязан диагностировать установку, не изменяя её.
|
||||||
|
*
|
||||||
|
* Регрессия, которую закрепляет этот файл. doctor собирал контекст с
|
||||||
|
* `skipServiceStart` по умолчанию (false) и звал общий smoke, а тот первым же
|
||||||
|
* действием выполняет `systemctl restart hysteria-server hy2xs-admin`. Команда,
|
||||||
|
* которую runbook предлагает запускать при подозрении на проблему, обрывала все
|
||||||
|
* живые VPN-соединения на сервере — в том числе когда с сервисом всё в порядке.
|
||||||
|
*
|
||||||
|
* Проверяется исходник, а не поведение: единственная альтернатива — поднять
|
||||||
|
* настоящие systemd-юниты, чего в этой среде не существует. Утверждение при
|
||||||
|
* этом точное: рестарт в smoke стоит ровно под одним условием, и тест
|
||||||
|
* фиксирует, что doctor это условие закрывает безусловно.
|
||||||
|
*/
|
||||||
|
// Пути считаются от самого файла теста, а не от cwd: `bun test` запускается и
|
||||||
|
// из корня репозитория, и из orchestrator/ — сборка делает первое, разработчик
|
||||||
|
// обычно второе.
|
||||||
|
function source(relativeToSrc: string): string {
|
||||||
|
return readFileSync(join(import.meta.dir, "..", "src", relativeToSrc), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("doctor не перезапускает сервисы", () => {
|
||||||
|
test("контекст doctor принудительно выставляет skipServiceStart", () => {
|
||||||
|
const doctorSource = source("commands/doctor.ts");
|
||||||
|
expect(doctorSource).toContain("skipServiceStart: true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skipServiceStart выставляется ПОСЛЕ раскрытия options и не может быть перекрыт", () => {
|
||||||
|
const doctorSource = source("commands/doctor.ts");
|
||||||
|
const spread = doctorSource.indexOf("...options");
|
||||||
|
const forced = doctorSource.indexOf("skipServiceStart: true");
|
||||||
|
|
||||||
|
expect(spread).toBeGreaterThan(-1);
|
||||||
|
expect(forced).toBeGreaterThan(-1);
|
||||||
|
// Порядок в объектном литерале решает: `{ ...options, skipServiceStart: true }`
|
||||||
|
// игнорирует значение из командной строки, обратный порядок — нет.
|
||||||
|
expect(forced).toBeGreaterThan(spread);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("smoke по-прежнему рестартует сервисы только под флагом", () => {
|
||||||
|
const smokeSource = source("steps/smoke.ts");
|
||||||
|
const restart = "systemctl restart hysteria-server hy2xs-admin";
|
||||||
|
|
||||||
|
expect(smokeSource).toContain(restart);
|
||||||
|
// Ровно одно место рестарта, и оно под проверкой флага: иначе выставление
|
||||||
|
// skipServiceStart в doctor ничего бы не гарантировало.
|
||||||
|
expect(smokeSource.split(restart).length - 1).toBe(1);
|
||||||
|
|
||||||
|
const guard = smokeSource.indexOf("if (!context.options.skipServiceStart)");
|
||||||
|
const restartAt = smokeSource.indexOf(restart);
|
||||||
|
expect(guard).toBeGreaterThan(-1);
|
||||||
|
expect(guard).toBeLessThan(restartAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doctor не глушит остальные проверки smoke", () => {
|
||||||
|
const doctorSource = source("commands/doctor.ts");
|
||||||
|
// skipSmoke приходит из options и по умолчанию false: doctor сужает только
|
||||||
|
// рестарт, а не диагностику целиком.
|
||||||
|
expect(doctorSource).not.toContain("skipSmoke: true");
|
||||||
|
expect(doctorSource).toContain("await smoke(context)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,8 +12,14 @@ set -euo pipefail
|
|||||||
# строке '/hui', а dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ
|
# строке '/hui', а dead-route скан падал на router_test.go, который ПЕРЕЧИСЛЯЕТ
|
||||||
# удалённые маршруты, чтобы доказать их отсутствие. Помощник существует, чтобы
|
# удалённые маршруты, чтобы доказать их отсутствие. Помощник существует, чтобы
|
||||||
# третьего раза не было.
|
# третьего раза не было.
|
||||||
|
#
|
||||||
|
# Отбрасываются оба вида строк-комментариев: `//` для Go и TypeScript, `#` для
|
||||||
|
# shell. Раньше стриплось только `//`, поэтому сканы по tools/build/*.sh не
|
||||||
|
# работали вовсе — там комментарии начинаются с решётки, и объяснение «почему
|
||||||
|
# этого больше нет» считалось за код. Ни один из языков не использует префикс
|
||||||
|
# другого для чего-то иного, так что общий фильтр безопасен для обоих.
|
||||||
code_without_comments() {
|
code_without_comments() {
|
||||||
grep -v '^[[:space:]]*//' "$1" 2>/dev/null || true
|
grep -vE '^[[:space:]]*(//|#)' "$1" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
# code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ.
|
# code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ.
|
||||||
@@ -551,11 +557,67 @@ run_dependency_hygiene_acceptance() {
|
|||||||
grep -q '^GOVULNCHECK_VERSION=' versions.env \
|
grep -q '^GOVULNCHECK_VERSION=' versions.env \
|
||||||
|| fail "acceptance: версия govulncheck должна быть частью контракта сборки"
|
|| fail "acceptance: версия govulncheck должна быть частью контракта сборки"
|
||||||
|
|
||||||
# По готовому tarball должно быть видно, проверялся он или собран с
|
# По готовому tarball должно быть видно, что он проверялся — ровно как
|
||||||
# пропущенной проверкой — ровно как hysteria_compat_gate.
|
# hysteria_compat_gate.
|
||||||
grep -q '^dependency_security_gate=true$' "$package_dir/metadata/package.env" \
|
grep -q '^dependency_security_gate=true$' "$package_dir/metadata/package.env" \
|
||||||
|| fail "acceptance: релизный пакет обязан собираться с включённой проверкой зависимостей"
|
|| fail "acceptance: релизный пакет обязан собираться с включённой проверкой зависимостей"
|
||||||
|
|
||||||
|
# У шага не должно быть обходов — ни объявленных, ни забытых.
|
||||||
|
#
|
||||||
|
# Раньше их было два: ALLOW_VULNERABLE_DEPENDENCIES=true писал в metadata
|
||||||
|
# `accepted-risk`, SKIP_SECURITY_SCAN=true — `skipped`. Оба документировались
|
||||||
|
# README и docs/02 как способ выпустить релиз, зная об уязвимости, и оба
|
||||||
|
# гарантированно падали здесь же, строкой выше: приёмка требует буквально
|
||||||
|
# `true`. Сборка проходила весь цикл и умирала на последнем шаге ради
|
||||||
|
# операции, которую продукт в итоге запрещает.
|
||||||
|
#
|
||||||
|
# Противоречие закрыто в пользу строгой политики, и теперь это проверяется, а
|
||||||
|
# не только описано. Ищется КОД: комментарий выше объясняет, почему обходов
|
||||||
|
# нет, и обязан называть их по имени.
|
||||||
|
local bypass_var bypass_hits
|
||||||
|
for bypass_var in ALLOW_VULNERABLE_DEPENDENCIES SKIP_SECURITY_SCAN; do
|
||||||
|
bypass_hits="$(code_mentions_in "$bypass_var" tools/build)"
|
||||||
|
[ -z "$bypass_hits" ] \
|
||||||
|
|| fail "acceptance: обход проверки зависимостей $bypass_var вернулся в: $bypass_hits"
|
||||||
|
done
|
||||||
|
|
||||||
|
# И в документации тоже: описанный, но нерабочий аварийный выход — хуже, чем
|
||||||
|
# его отсутствие. Именно из-за такого описания противоречие и прожило до
|
||||||
|
# приёмки.
|
||||||
|
#
|
||||||
|
# У markdown нет строк-комментариев, поэтому code_without_comments здесь не
|
||||||
|
# применим и правило простое: место для истории — CHANGELOG.md, он под скан не
|
||||||
|
# попадает. README и docs описывают текущую политику, а не отменённую.
|
||||||
|
! grep -rq 'ALLOW_VULNERABLE_DEPENDENCIES\|SKIP_SECURITY_SCAN' README.md docs \
|
||||||
|
|| fail "acceptance: README/docs снова описывают обход проверки зависимостей, которого нет в сборке; место для истории — CHANGELOG.md"
|
||||||
|
|
||||||
|
# Единственное значение поля — true. `accepted-risk`/`skipped` не должны
|
||||||
|
# вернуться даже как строковые литералы: их некому произвести.
|
||||||
|
local dead_gate_value
|
||||||
|
for dead_gate_value in accepted-risk skipped; do
|
||||||
|
! grep -rqF "DEPENDENCY_SECURITY_GATE=\"${dead_gate_value}\"" tools/build \
|
||||||
|
|| fail "acceptance: у dependency_security_gate снова появилось значение ${dead_gate_value}"
|
||||||
|
done
|
||||||
|
|
||||||
|
log_step "Acceptance: versions.env is a developer contract, not only a build contract"
|
||||||
|
# Сборка соблюдала versions.env, а машина разработчика не проверялась никак:
|
||||||
|
# локальный Go 1.25 собирал проект успешно, пока релизный бинарь собирался на
|
||||||
|
# 1.26.7 и наследовал ЕЁ stdlib. Проверялся не тот код, который уезжает в
|
||||||
|
# production.
|
||||||
|
local doctor_script
|
||||||
|
for doctor_script in tools/dev/doctor.sh tools/dev/doctor.ps1; do
|
||||||
|
[ -f "$doctor_script" ] \
|
||||||
|
|| fail "acceptance: проверка среды разработки $doctor_script отсутствует"
|
||||||
|
grep -q 'versions.env' "$doctor_script" \
|
||||||
|
|| fail "acceptance: $doctor_script обязан читать контракт из versions.env, а не носить свои значения"
|
||||||
|
done
|
||||||
|
[ -x tools/dev/doctor.sh ] \
|
||||||
|
|| fail "acceptance: tools/dev/doctor.sh не исполняемый"
|
||||||
|
# Собственных версий у doctor быть не должно: второй список версий неизбежно
|
||||||
|
# разъедется с versions.env.
|
||||||
|
! grep -qE '(GO|NODE|PNPM|BUN)_VERSION[[:space:]]*=[[:space:]]*[0-9]' tools/dev/doctor.sh \
|
||||||
|
|| fail "acceptance: tools/dev/doctor.sh завёл собственные значения версий"
|
||||||
|
|
||||||
log_step "Acceptance: the Go toolchain is on a supported line"
|
log_step "Acceptance: the Go toolchain is on a supported line"
|
||||||
# Go компилирует hy2xs-admin, поэтому его stdlib уезжает в production-бинарь.
|
# Go компилирует hy2xs-admin, поэтому его stdlib уезжает в production-бинарь.
|
||||||
# На 1.21 govulncheck находил 17 вызываемых уязвимостей в одной только stdlib.
|
# На 1.21 govulncheck находил 17 вызываемых уязвимостей в одной только stdlib.
|
||||||
|
|||||||
+41
-26
@@ -27,24 +27,40 @@ set -euo pipefail
|
|||||||
# production-зависимостями и порогом PNPM_AUDIT_LEVEL:
|
# production-зависимостями и порогом PNPM_AUDIT_LEVEL:
|
||||||
# devDependencies в артефакт не попадают.
|
# devDependencies в артефакт не попадают.
|
||||||
|
|
||||||
# Аварийный выход существует по той же причине, что и ALLOW_DIRTY_BUILD: новое
|
# Аварийного выхода у этого шага НЕТ, и это осознанное решение.
|
||||||
# advisory может выйти в неудачный момент, и решение «выпускаем, зная о нём»
|
#
|
||||||
# должно приниматься человеком ЯВНО, а не пропуском шага по умолчанию.
|
# Раньше существовали два: ALLOW_VULNERABLE_DEPENDENCIES=true записывал в
|
||||||
ALLOW_VULNERABLE_DEPENDENCIES="${ALLOW_VULNERABLE_DEPENDENCIES:-false}"
|
# metadata `dependency_security_gate=accepted-risk`, SKIP_SECURITY_SCAN=true —
|
||||||
|
# `skipped`. Оба были описаны в README и docs/02 как способ выпустить релиз,
|
||||||
|
# зная об уязвимости.
|
||||||
|
#
|
||||||
|
# Способом они не были. Финальная приёмка архива требует буквально
|
||||||
|
#
|
||||||
|
# grep -q '^dependency_security_gate=true$' metadata/package.env
|
||||||
|
#
|
||||||
|
# то есть сборка с любым из этих значений доходила до самого конца — компиляция,
|
||||||
|
# бандл, тесты, метаданные, tar — и падала на последнем шаге. Продукт
|
||||||
|
# документировал операцию, которую сам же запрещал, а обнаруживалось это через
|
||||||
|
# полный цикл сборки.
|
||||||
|
#
|
||||||
|
# Из двух непротиворечивых вариантов выбран строгий: гейт обязателен, значение
|
||||||
|
# в metadata ровно одно. Контракт при этом читается однозначно:
|
||||||
|
#
|
||||||
|
# релизный артефакт HY2XS невозможно собрать с непройденной проверкой
|
||||||
|
# зависимостей.
|
||||||
|
#
|
||||||
|
# Для локальной работы обходить нечего: `go test ./...`, `govulncheck ./...` и
|
||||||
|
# `pnpm audit` запускаются напрямую и к созданию tarball отношения не имеют.
|
||||||
security_gate_failed() {
|
security_gate_failed() {
|
||||||
local scanner="$1"
|
local scanner="$1"
|
||||||
local details="$2"
|
local details="$2"
|
||||||
|
|
||||||
if [ "$ALLOW_VULNERABLE_DEPENDENCIES" = "true" ]; then
|
fail "$scanner: найдены уязвимости в зависимостях.
|
||||||
log_info "WARNING: $scanner нашёл уязвимости, но сборка продолжена по ALLOW_VULNERABLE_DEPENDENCIES=true"
|
|
||||||
log_info "$details"
|
Обойти этот шаг нельзя: релизный пакет HY2XS собирается только с пройденной
|
||||||
DEPENDENCY_SECURITY_GATE="accepted-risk"
|
проверкой. Обновите граф зависимостей (apps/go.sum, apps/frontend/pnpm-lock.yaml)
|
||||||
export DEPENDENCY_SECURITY_GATE
|
или версию toolchain в versions.env.
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
fail "$scanner: найдены уязвимости в зависимостях. Обновите граф или, осознанно принимая риск, выставьте ALLOW_VULNERABLE_DEPENDENCIES=true.
|
|
||||||
$details"
|
$details"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,25 +122,24 @@ $report"
|
|||||||
security_gate_failed "pnpm audit" "$report"
|
security_gate_failed "pnpm audit" "$report"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Результат шага уезжает в metadata/package.env: по готовому tarball должно
|
# Результат шага уезжает в metadata/package.env — так же, как hysteria_compat_gate.
|
||||||
# быть видно, проверялся он или собран с пропущенной проверкой. Ровно так же
|
#
|
||||||
# устроен hysteria_compat_gate.
|
# Значение у поля теперь ровно одно: `true`. Пропущенного состояния не бывает,
|
||||||
|
# потому что не бывает пакета, собранного с пропущенной проверкой; поле остаётся
|
||||||
|
# в metadata как утверждение о готовом артефакте, а не как переключатель.
|
||||||
run_dependency_security_gate() {
|
run_dependency_security_gate() {
|
||||||
if [ "${SKIP_SECURITY_SCAN:-false}" = "true" ]; then
|
|
||||||
log_info "WARNING: проверка зависимостей на уязвимости пропущена (SKIP_SECURITY_SCAN=true)"
|
|
||||||
DEPENDENCY_SECURITY_GATE="skipped"
|
|
||||||
export DEPENDENCY_SECURITY_GATE
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
[ -n "${GOVULNCHECK_VERSION:-}" ] \
|
[ -n "${GOVULNCHECK_VERSION:-}" ] \
|
||||||
|| fail "security: GOVULNCHECK_VERSION не задан; load_versions_contract должен выполниться первым"
|
|| fail "security: GOVULNCHECK_VERSION не задан; load_versions_contract должен выполниться первым"
|
||||||
[ -n "${PNPM_AUDIT_LEVEL:-}" ] \
|
[ -n "${PNPM_AUDIT_LEVEL:-}" ] \
|
||||||
|| fail "security: PNPM_AUDIT_LEVEL не задан; load_versions_contract должен выполниться первым"
|
|| fail "security: PNPM_AUDIT_LEVEL не задан; load_versions_contract должен выполниться первым"
|
||||||
|
|
||||||
DEPENDENCY_SECURITY_GATE="true"
|
|
||||||
export DEPENDENCY_SECURITY_GATE
|
|
||||||
|
|
||||||
run_go_vulnerability_gate
|
run_go_vulnerability_gate
|
||||||
run_frontend_vulnerability_gate
|
run_frontend_vulnerability_gate
|
||||||
|
|
||||||
|
# Флаг выставляется ПОСЛЕ обеих проверок, а не до них. Разницы в поведении
|
||||||
|
# сейчас нет — обе ветки отказа завершают сборку, — но «утверждение о
|
||||||
|
# результате», записанное перед получением результата, рано или поздно
|
||||||
|
# переживает свою причину.
|
||||||
|
DEPENDENCY_SECURITY_GATE="true"
|
||||||
|
export DEPENDENCY_SECURITY_GATE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ verify_api_namespace_contract() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
verify_go_toolchain_contract() {
|
verify_go_toolchain_contract() {
|
||||||
local declared_go_line contract_minor go_minor
|
local declared_go_line contract_minor go_minor declared_toolchain
|
||||||
declared_go_line="$(grep -E '^go[[:space:]]+[0-9]' apps/go.mod | head -n1 | awk '{print $2}')"
|
declared_go_line="$(grep -E '^go[[:space:]]+[0-9]' apps/go.mod | head -n1 | awk '{print $2}')"
|
||||||
[ -n "$declared_go_line" ] || fail "versions contract: apps/go.mod has no 'go' directive"
|
[ -n "$declared_go_line" ] || fail "versions contract: apps/go.mod has no 'go' directive"
|
||||||
|
|
||||||
@@ -259,6 +259,16 @@ verify_go_toolchain_contract() {
|
|||||||
|| fail "versions contract: apps/go.mod major version $declared_go_line is incompatible with GO_VERSION $GO_VERSION"
|
|| fail "versions contract: apps/go.mod major version $declared_go_line is incompatible with GO_VERSION $GO_VERSION"
|
||||||
[ "$go_minor" -le "$contract_minor" ] \
|
[ "$go_minor" -le "$contract_minor" ] \
|
||||||
|| fail "versions contract: apps/go.mod requires go $declared_go_line, but GO_VERSION is $GO_VERSION"
|
|| fail "versions contract: apps/go.mod requires go $declared_go_line, but GO_VERSION is $GO_VERSION"
|
||||||
|
|
||||||
|
# Директива `go` задаёт языковой baseline и НЕ выбирает компилятор: с ней
|
||||||
|
# одной локальная сборка на другой минорной линии проходит успешно, и
|
||||||
|
# разработчик проверяет не ту stdlib, которая уедет в production-бинарь.
|
||||||
|
# `toolchain` называет компилятор явно и обязан совпадать с versions.env
|
||||||
|
# точно, а не «быть не выше».
|
||||||
|
declared_toolchain="$(grep -E '^toolchain[[:space:]]+go[0-9]' apps/go.mod | head -n1 | awk '{print $2}')"
|
||||||
|
[ -n "$declared_toolchain" ] \
|
||||||
|
|| fail "versions contract: apps/go.mod has no 'toolchain' directive; add 'toolchain go${GO_VERSION}'"
|
||||||
|
expect_equal "apps/go.mod toolchain" "$declared_toolchain" "go${GO_VERSION}"
|
||||||
}
|
}
|
||||||
|
|
||||||
verify_versions_contract() {
|
verify_versions_contract() {
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#Requires -Version 7.0
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Проверка среды разработки против versions.env (Windows).
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Точный аналог tools/dev/doctor.sh: сборка релиза скачивает Go, Node и Bun
|
||||||
|
ровно тех версий, что записаны в versions.env, а машина разработчика не
|
||||||
|
проверялась никак — расхождение обнаруживалось только на Debian, внутри
|
||||||
|
release-сборки.
|
||||||
|
|
||||||
|
Расхождение не гипотетическое: локальный Go 1.25 собирал проект успешно
|
||||||
|
(директива `go` в go.mod — языковой baseline, а не выбор компилятора), а
|
||||||
|
релизный бинарь компилировался на 1.26.7 и наследовал ЕЁ stdlib. То есть
|
||||||
|
проверялся не тот код, который уезжает в production.
|
||||||
|
|
||||||
|
Скрипт ничего не устанавливает и не меняет.
|
||||||
|
|
||||||
|
.PARAMETER Quiet
|
||||||
|
Печатать только итог.
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[switch]$Quiet
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$rootDir = Resolve-Path (Join-Path $PSScriptRoot '..' '..')
|
||||||
|
Set-Location $rootDir
|
||||||
|
|
||||||
|
$versionsPath = Join-Path $rootDir 'versions.env'
|
||||||
|
if (-not (Test-Path $versionsPath)) {
|
||||||
|
Write-Error 'versions.env не найден: doctor запускается из репозитория HY2XS'
|
||||||
|
}
|
||||||
|
|
||||||
|
# versions.env — POSIX shell, но по построению содержит только присваивания
|
||||||
|
# KEY=value и комментарии: это его объявленный контракт (см. шапку файла).
|
||||||
|
$contract = @{}
|
||||||
|
foreach ($line in Get-Content $versionsPath) {
|
||||||
|
$trimmed = $line.Trim()
|
||||||
|
if (-not $trimmed -or $trimmed.StartsWith('#')) { continue }
|
||||||
|
$separator = $trimmed.IndexOf('=')
|
||||||
|
if ($separator -lt 1) { continue }
|
||||||
|
$contract[$trimmed.Substring(0, $separator).Trim()] = $trimmed.Substring($separator + 1).Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
$failures = 0
|
||||||
|
|
||||||
|
function Say([string]$Text) {
|
||||||
|
if (-not $Quiet) { Write-Host $Text }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Версия спрашивается у самого бинаря: что записано в PATH — не тот вопрос,
|
||||||
|
# отвечать должен тот инструмент, который реально запустится.
|
||||||
|
function Get-ToolVersion {
|
||||||
|
param([string]$Command, [string[]]$Arguments)
|
||||||
|
|
||||||
|
$resolved = Get-Command $Command -ErrorAction SilentlyContinue
|
||||||
|
if (-not $resolved) { return 'not found' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$output = & $resolved.Source @Arguments 2>$null | Select-Object -First 1
|
||||||
|
} catch {
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
$match = [regex]::Match([string]$output, '\d+\.\d+\.\d+')
|
||||||
|
if (-not $match.Success) { return 'unknown' }
|
||||||
|
return $match.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-Contract {
|
||||||
|
param([string]$Label, [string]$Required, [string]$Found, [string]$Hint)
|
||||||
|
|
||||||
|
if (-not $Found) { $Found = 'unknown' }
|
||||||
|
|
||||||
|
Say ''
|
||||||
|
Say "${Label}:"
|
||||||
|
Say " required: $Required"
|
||||||
|
Say " found: $Found"
|
||||||
|
|
||||||
|
if ($Found -eq $Required) {
|
||||||
|
Say ' OK'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Say " FAIL — $Hint"
|
||||||
|
$script:failures++
|
||||||
|
}
|
||||||
|
|
||||||
|
Say 'HY2XS development environment'
|
||||||
|
Say "contract: versions.env (HY2XS $($contract.HY2XS_VERSION), release line $($contract.HY2XS_RELEASE_LINE))"
|
||||||
|
|
||||||
|
# Go попадает в production-артефакт целиком: его stdlib компилируется в
|
||||||
|
# hy2xs-admin. Несовпадение здесь — не удобство, а разная поверхность атаки у
|
||||||
|
# разработчика и у релиза.
|
||||||
|
Test-Contract 'Go' $contract.GO_VERSION (Get-ToolVersion 'go' @('version')) `
|
||||||
|
"локальный Go собирает не ту stdlib, что уедет в релиз; поставьте $($contract.GO_VERSION)"
|
||||||
|
|
||||||
|
# Node живёт только на build-хосте, но собирает код, который уезжает в production.
|
||||||
|
Test-Contract 'Node' $contract.NODE_VERSION (Get-ToolVersion 'node' @('--version')) `
|
||||||
|
"frontend-ассеты собираются другой версией Node; поставьте $($contract.NODE_VERSION)"
|
||||||
|
|
||||||
|
Test-Contract 'pnpm' $contract.PNPM_VERSION (Get-ToolVersion 'pnpm' @('--version')) `
|
||||||
|
"разрешение зависимостей frontend может разойтись с lock-файлом; поставьте $($contract.PNPM_VERSION)"
|
||||||
|
|
||||||
|
# Bun физически входит в скомпилированный оркестратор (`bun build --compile`),
|
||||||
|
# поэтому его версия — часть артефакта, а не инструмента.
|
||||||
|
Test-Contract 'Bun' $contract.BUN_VERSION (Get-ToolVersion 'bun' @('--version')) `
|
||||||
|
"runtime Bun входит в бинарь оркестратора; поставьте $($contract.BUN_VERSION)"
|
||||||
|
|
||||||
|
# go.mod обязан называть компилятор явно: директива `go` этого не делает.
|
||||||
|
$declaredToolchain = (Select-String -Path (Join-Path $rootDir 'apps' 'go.mod') -Pattern '^toolchain\s+(go[0-9][^\s]*)' |
|
||||||
|
Select-Object -First 1).Matches.Groups[1].Value
|
||||||
|
if (-not $declaredToolchain) { $declaredToolchain = 'отсутствует' }
|
||||||
|
|
||||||
|
Test-Contract 'apps/go.mod toolchain' "go$($contract.GO_VERSION)" $declaredToolchain `
|
||||||
|
'директива toolchain разошлась с versions.env; сборка отвергнет это в verify_go_toolchain_contract'
|
||||||
|
|
||||||
|
Say ''
|
||||||
|
if ($failures -eq 0) {
|
||||||
|
Say 'Итог: среда разработки совпадает с контрактом сборки.'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Say "Итог: расхождений — $failures. Локальные проверки идут не по тому toolchain,"
|
||||||
|
Say 'которым собирается релиз.'
|
||||||
|
exit 1
|
||||||
Executable
+127
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Проверка среды разработки против versions.env.
|
||||||
|
#
|
||||||
|
# Зачем.
|
||||||
|
#
|
||||||
|
# versions.env — единый контракт «продукт / платформа / toolchain», и сборка
|
||||||
|
# релиза его соблюдает: она скачивает Go, Node и Bun ровно тех версий, что
|
||||||
|
# записаны здесь, сверяя контрольные суммы. Машина разработчика при этом не
|
||||||
|
# проверялась НИКАК, поэтому расхождение обнаруживалось только на Debian, внутри
|
||||||
|
# release-сборки, и выглядело как «у меня работало».
|
||||||
|
#
|
||||||
|
# Расхождение не гипотетическое: локальный Go 1.25 собирал проект успешно (`go`
|
||||||
|
# в go.mod — языковой baseline, а не выбор компилятора), а релизный бинарь
|
||||||
|
# компилировался на 1.26.7 и наследовал ЕЁ stdlib. То есть проверялся не тот
|
||||||
|
# код, который уезжает в production.
|
||||||
|
#
|
||||||
|
# Скрипт ничего не устанавливает и не меняет: он отвечает на один вопрос —
|
||||||
|
# совпадает ли эта машина с контрактом сборки.
|
||||||
|
#
|
||||||
|
# Использование:
|
||||||
|
# ./tools/dev/doctor.sh проверить и вернуть код выхода
|
||||||
|
# ./tools/dev/doctor.sh --quiet только итог
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
QUIET=false
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--quiet) QUIET=true ;;
|
||||||
|
-h|--help)
|
||||||
|
printf 'usage: %s [--quiet]\n' "$0"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'unknown argument: %s\n' "$arg" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -f versions.env ] || {
|
||||||
|
printf 'versions.env не найден: doctor запускается из репозитория HY2XS\n' >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./versions.env
|
||||||
|
|
||||||
|
FAILURES=0
|
||||||
|
|
||||||
|
say() {
|
||||||
|
[ "$QUIET" = true ] || printf '%s\n' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Версия извлекается из вывода самого инструмента: спрашивать `которая версия
|
||||||
|
# записана в PATH` бессмысленно, отвечать должен тот бинарь, который реально
|
||||||
|
# запустится.
|
||||||
|
tool_version() {
|
||||||
|
local command="$1"
|
||||||
|
shift
|
||||||
|
command -v "$command" >/dev/null 2>&1 || {
|
||||||
|
printf 'not found'
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
"$command" "$@" 2>/dev/null | head -n1 | grep -Eo '[0-9]+\.[0-9]+\.[0-9]+' | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local label="$1" required="$2" found="$3" hint="$4"
|
||||||
|
|
||||||
|
if [ -z "$found" ]; then
|
||||||
|
found="unknown"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say ""
|
||||||
|
say "$label:"
|
||||||
|
say " required: $required"
|
||||||
|
say " found: $found"
|
||||||
|
|
||||||
|
if [ "$found" = "$required" ]; then
|
||||||
|
say " OK"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
say " FAIL — $hint"
|
||||||
|
FAILURES=$((FAILURES + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
say "HY2XS development environment"
|
||||||
|
say "contract: versions.env (HY2XS $HY2XS_VERSION, release line $HY2XS_RELEASE_LINE)"
|
||||||
|
|
||||||
|
# Go попадает в production-артефакт целиком: его stdlib компилируется в
|
||||||
|
# hy2xs-admin. Несовпадение здесь — не удобство, а разная поверхность атаки у
|
||||||
|
# разработчика и у релиза.
|
||||||
|
check "Go" "$GO_VERSION" "$(tool_version go version)" \
|
||||||
|
"локальный Go собирает не ту stdlib, что уедет в релиз; поставьте $GO_VERSION"
|
||||||
|
|
||||||
|
# Node живёт только на build-хосте (сборка frontend-ассетов), но собирает код,
|
||||||
|
# который уезжает в production.
|
||||||
|
check "Node" "$NODE_VERSION" "$(tool_version node --version)" \
|
||||||
|
"frontend-ассеты собираются другой версией Node; поставьте $NODE_VERSION"
|
||||||
|
|
||||||
|
check "pnpm" "$PNPM_VERSION" "$(tool_version pnpm --version)" \
|
||||||
|
"разрешение зависимостей frontend может разойтись с lock-файлом; поставьте $PNPM_VERSION"
|
||||||
|
|
||||||
|
# Bun физически входит в скомпилированный оркестратор (`bun build --compile`),
|
||||||
|
# поэтому его версия — часть артефакта, а не инструмента.
|
||||||
|
check "Bun" "$BUN_VERSION" "$(tool_version bun --version)" \
|
||||||
|
"runtime Bun входит в бинарь оркестратора; поставьте $BUN_VERSION"
|
||||||
|
|
||||||
|
# go.mod обязан называть компилятор явно: директива `go` этого не делает.
|
||||||
|
DECLARED_TOOLCHAIN="$(grep -E '^toolchain[[:space:]]+go[0-9]' apps/go.mod | head -n1 | awk '{print $2}' || true)"
|
||||||
|
check "apps/go.mod toolchain" "go$GO_VERSION" "${DECLARED_TOOLCHAIN:-отсутствует}" \
|
||||||
|
"директива toolchain разошлась с versions.env; сборка отвергнет это в verify_go_toolchain_contract"
|
||||||
|
|
||||||
|
say ""
|
||||||
|
if [ "$FAILURES" -eq 0 ]; then
|
||||||
|
say "Итог: среда разработки совпадает с контрактом сборки."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "Итог: расхождений — $FAILURES. Локальные проверки идут не по тому toolchain,"
|
||||||
|
say "которым собирается релиз."
|
||||||
|
exit 1
|
||||||
Reference in New Issue
Block a user