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

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

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

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

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

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

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

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

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

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

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

Контракт разработки: apps/go.mod объявляет toolchain go1.26.7 (директива go —
языковой baseline, а не выбор компилятора), tools/dev/doctor.sh|.ps1 сверяют
среду с versions.env.
This commit is contained in:
2026-08-30 06:48:50 +05:00
parent e30fdaa004
commit cf094f6e6f
33 changed files with 1976 additions and 154 deletions
+180 -37
View File
@@ -59,7 +59,19 @@ func InitSqliteDBAt(path string) error {
// порт, который получил аргументом из ExecStart, а startup тут же читал его
// обратно. База не была источником этого факта ни на одном шаге.
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
}
if err := runMigrations(); err != nil {
@@ -156,30 +168,78 @@ func ensureSecureBootstrapAdmin() error {
return nil
}
// ensureSecureBootstrapPeer создаёт пир установщика.
// BootstrapPeerName — имя и authId пира, который создаёт установка.
//
// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS по той же причине, что и
// пароль администратора, и с дополнительным следствием: этот секрет лежит ещё
// и в /etc/hy2xs/bootstrap-admin.secret, откуда его читает smoke оркестратора
// для проверки machine-auth. Сгенерированный здесь секрет разошёлся бы с
// файлом, и первая же проверка подключения после установки провалилась бы —
// на корректном во всём остальном сервере.
// Объявлено здесь, а не литералом в четырёх местах: то же значение проверяет
// service.ReservedBootstrapPeerName, и расхождение между ними означало бы, что
// guard защищает не тот пир, который создал seeder.
const BootstrapPeerName = "bootstrap-admin-peer"
// bootstrapPeerSeededValue — значение маркера constant.BootstrapPeerSeeded.
const bootstrapPeerSeededValue = "true"
// ensureSecureBootstrapPeer создаёт пир установщика РОВНО ОДИН РАЗ за жизнь
// базы.
//
// Порядок проверок здесь важен: сначала выясняем, нужно ли вообще создавать
// пир, и только потом требуем env. Иначе перезапуск уже установленного сервиса
// без переменной падал бы на ровном месте.
// Секрет приходит ТОЛЬКО из HY2XS_ADMIN_CON_PASS: он лежит ещё и в
// /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 {
if _, err := GetPeer("auth_id = ?", "bootstrap-admin-peer"); err == nil {
seeded, err := bootstrapPeerSeeded()
if err != nil {
return err
}
if seeded {
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"))
if bootstrapSecret == "" {
return errors.New(
"HY2XS_ADMIN_CON_PASS не задан, а пира установщика bootstrap-admin-peer в базе нет.\n" +
"HY2XS_ADMIN_CON_PASS не задан, а пир установщика bootstrap-admin-peer ещё ни разу не создавался.\n" +
"Секрет этого пира задаёт install-оркестратор и кладёт копию в\n" +
"/etc/hy2xs/bootstrap-admin.secret, откуда его читает проверка machine-auth.\n" +
"Секрет, придуманный админкой, разошёлся бы с этим файлом, и проверка\n" +
@@ -194,8 +254,18 @@ func ensureSecureBootstrapPeer() error {
return err
}
if _, err := GetPeer("secret_digest = ?", secretDigest); err == nil {
return nil
// Чужой пир с тем же секретом. Создавать нельзя — UNIQUE(secret_digest), —
// но и молчать нельзя: секрет установщика уже выдан кому-то ещё.
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)
@@ -203,8 +273,8 @@ func ensureSecureBootstrapPeer() error {
return err
}
name := "bootstrap-admin-peer"
authID := "bootstrap-admin-peer"
name := BootstrapPeerName
authID := BootstrapPeerName
remark := "bootstrap peer seeded from HY2XS_ADMIN_CON_PASS"
quota := int64(-1)
download := int64(0)
@@ -230,8 +300,44 @@ func ensureSecureBootstrapPeer() error {
BannedUntil: &bannedUntil,
LastConnectionAt: &lastConnection,
}
_, saveErr := SavePeer(peer)
return saveErr
return seedBootstrapPeerOnce(peer)
}
// 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) {
@@ -370,12 +476,18 @@ func seedBaseConfig() error {
//
// Оставшееся — четыре внутренних секрета и единственная настройка, которой
// действительно владеет панель.
//
// BOOTSTRAP_PEER_SEEDED заводится пустым: пустое значение означает «пир
// установщика ещё ни разу не создавался», и это верное состояние для
// чистой базы. Отметку выставляет ensureSecureBootstrapPeer той же
// транзакцией, которой создаёт пир.
defaults := map[string]string{
constant.JwtSecret: "",
constant.ResetTrafficCron: "0 0 * * *",
constant.Hysteria2TrafficStatsSecret: "",
constant.PeerSecretKey: "",
constant.PeerSecretEncryptionKey: "",
constant.BootstrapPeerSeeded: "",
}
for k, v := range defaults {
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
}
// ensureTrafficStatsSecret синхронизирует machine token с окружением.
// ensureTrafficStatsSecret синхронизирует machine token с окружением и НЕ
// придумывает его сам.
//
// Владелец значения — оркестратор: он же кладёт этот секрет в
// Владелец значения — оркестратор. Он же кладёт этот секрет в
// /etc/hysteria/config.yaml, откуда Hysteria берёт его для обращения к
// /internal/hysteria/auth. Поэтому env всегда побеждает содержимое базы, а
// собственная генерация — только для случая, когда переменной нет вовсе.
// /internal/hysteria/auth, и в /etc/hy2xs/hy2xs.env, откуда его читает
// systemd-юнит админки. Токен — межкомпонентный контракт, а не внутреннее
// состояние панели.
//
// Четыре почти одинаковые ветки INSERT/UPDATE свёрнуты в UpsertConfigValue:
// каждая из них по-своему решала «есть строка или нет», и две из четырёх
// решали неверно (см. комментарий к UpsertConfigValue).
// Что было. При пустом env и пустой базе админка генерировала случайный токен
// сама. Записать его в /etc/hysteria/config.yaml она не может — файл
// принадлежит оркестратору и для пользователя 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 {
envSecret := strings.TrimSpace(os.Getenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET"))
if envSecret != "" {
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, envSecret)
}
if existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret); err == nil {
if existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
return nil
}
existing, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil && !errors.Is(err, ErrConfigNotFound) {
return err
}
if err == nil && existing.Value != nil && strings.TrimSpace(*existing.Value) != "" {
return nil
}
secret, secErr := util.RandomString(32)
if secErr != nil {
return secErr
}
return UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, secret)
return errors.New(
"HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET не задан, и в базе этого токена тоже нет.\n" +
"Это межкомпонентный контракт, а не настройка панели: тем же значением\n" +
"Hysteria обращается к /internal/hysteria/auth, и владеет им\n" +
"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 {