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
+2 -1
View File
@@ -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 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 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 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]");
+24 -1
View File
@@ -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> {
setOperationContext(`doctor-${Date.now().toString(36)}`);
const configRaw = await readText(options.sourceConfigPath);
@@ -64,7 +87,7 @@ export async function doctor(options: ReconfigureOptions): Promise<void> {
const context: ReconfigureContext = {
mode: "reconfigure",
options: { ...options, dryRun: true, apply: false },
options: { ...options, dryRun: true, apply: false, skipServiceStart: true },
config,
packageVersion: await readPackageValue(options.packageDir, "package.version", "unknown"),
packageBuildId: await readPackageValue(options.packageDir, "package.build_id", "unknown"),
+66
View File
@@ -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)");
});
});