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:
@@ -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)");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user