import { randomBytes } from "node:crypto"; import type { RuntimeContext } from "../types/context"; import { info } from "../lib/log"; import { readText } from "../lib/fs"; import { parseEnvFile } from "../lib/envFile"; import { runReadOnly, runReadOnlySecret, runMutatingVisible } from "../lib/process"; import { ADMIN_LOGIN_PATH, HYSTERIA_MACHINE_AUTH_PATH, hysteriaMachineAuthUrl } from "../config/profile"; import { assertHysteriaConfigMatchesProfile } from "./configAssertions"; import { assertEffectiveFirewallIsOurs } from "./firewall"; function parseLocalAddress(line: string): string { const cols = line.trim().split(/\s+/); return cols[3] ?? ""; } function hasTcpListener(lines: string, host: string, port: number): boolean { return lines .split("\n") .filter(Boolean) .some((line) => { const local = parseLocalAddress(line); return local === `${host}:${port}`; }); } function hasUdpListener(lines: string, host: string, port: number): boolean { return lines .split("\n") .filter(Boolean) .some((line) => { const local = parseLocalAddress(line); return local === `${host}:${port}`; }); } async function retry( label: string, attempts: number, delayMs: number, action: () => Promise, validate: (value: T) => boolean, errorFactory: (value: T | undefined, error: unknown) => Error, ): Promise { let lastValue: T | undefined; let lastError: unknown; for (let i = 0; i < attempts; i += 1) { try { const value = await action(); lastValue = value; if (validate(value)) { return value; } } catch (error) { lastError = error; } if (i < attempts - 1) { info(`${label}: retry ${i + 1}/${attempts}`); // Ожидание — не операция над хостом. Здесь стоял `sleep` через мутирующий // раннер: подпроцесс ради таймера, который вдобавок классифицировал паузу // между двумя чтениями как изменение системы. await Bun.sleep(delayMs); } } throw errorFactory(lastValue, lastError); } export async function smoke(context: RuntimeContext): Promise { if (context.options.skipServiceStart && context.options.skipSmoke) { info("service start and smoke checks skipped by flags"); return; } if (!context.options.skipServiceStart) { await runMutatingVisible`systemctl restart hysteria-server hy2xs-admin`; } else { info("service restart skipped by flag"); } await retry( "systemd hysteria-server active", 15, 1000, async () => runReadOnlySecret`systemctl is-active hysteria-server || true`, (state) => state.trim() === "active", (state, error) => new Error(`hysteria-server is not active: ${state ?? String(error)}`), ); await retry( "systemd hy2xs-admin active", 15, 1000, async () => runReadOnlySecret`systemctl is-active hy2xs-admin || true`, (state) => state.trim() === "active", (state, error) => new Error(`hy2xs-admin is not active: ${state ?? String(error)}`), ); if (context.options.skipSmoke) { info("smoke checks skipped by flag"); return; } await retry( "ui tcp listener readiness", 15, 1000, async () => runReadOnlySecret`ss -H -ltn`, (lines) => hasTcpListener(lines, context.config.uiBindHost, context.config.uiPort), (lines, error) => new Error(`ui listener not ready on ${context.config.uiBindHost}:${context.config.uiPort}: ${lines ?? String(error)}`), ); await retry( "hysteria udp listener readiness", 15, 1000, async () => runReadOnlySecret`ss -H -lun`, (lines) => hasUdpListener(lines, context.config.hysteriaBindHost, context.config.hysteriaPort), (lines, error) => new Error(`hysteria udp listener not ready on 0.0.0.0:${context.config.hysteriaPort}: ${lines ?? String(error)}`), ); await retry( "admin healthz readiness", 15, 1000, async () => runReadOnlySecret`curl -sS --max-time 5 http://127.0.0.1:${context.config.uiPort}/healthz`, (response) => /"ok"\s*:\s*true/.test(response), (response, error) => new Error(`admin healthz is not ready: ${response ?? String(error)}`), ); // Всё, что ниже, — НАБЛЮДЕНИЕ, и оно выполняется read-only раннерами. // // Раньше эти проверки шли через runMutatingVisible: `test -s`, `grep -q`, // `stat`, `sudo -u ... test` и `nft -c` объявлялись мутациями. Пока смысл // выбора раннера держался на внимательности автора правки, свойство «doctor // не меняет диагностируемую систему» невозможно было включить guard'ом — он // отказал бы на первой же читающей команде. Классификация здесь — часть // контракта, а не стиль. info(`installed Hysteria: ${await runReadOnly`/usr/local/bin/hysteria version`}`); await runReadOnly`test -s /etc/hysteria/config.yaml`; await runReadOnly`test -s /etc/hy2xs/hy2xs.env`; await runReadOnly`test -s /etc/hysteria/post-install.env`; await runReadOnly`test -s ${context.config.bootstrapAdminSecretPath}`; await runReadOnly`grep -q '^ADMIN_USER=' ${context.config.bootstrapAdminSecretPath}`; await runReadOnly`grep -q '^ADMIN_INITIAL_PASSWORD=' ${context.config.bootstrapAdminSecretPath}`; await runReadOnly`grep -q '^ADMIN_CON_PASS=' ${context.config.bootstrapAdminSecretPath}`; await runReadOnly`test "$(stat -c '%a' /etc/hysteria/config.yaml)" = '640'`; await runReadOnly`test "$(stat -c '%U:%G' /etc/hysteria/config.yaml)" = 'hysteria:hy2xs-admin'`; await runReadOnly`test "$(stat -c '%a' /etc/hy2xs/hy2xs.env)" = '600'`; await runReadOnly`test "$(stat -c '%U:%G' /etc/hy2xs/hy2xs.env)" = 'root:root'`; await runReadOnly`test "$(stat -c '%a' /etc/hysteria/post-install.env)" = '600'`; await runReadOnly`test "$(stat -c '%U:%G' /etc/hysteria/post-install.env)" = 'root:root'`; await runReadOnly`test "$(stat -c '%a' ${context.config.bootstrapAdminSecretPath})" = '600'`; await runReadOnly`test "$(stat -c '%U:%G' ${context.config.bootstrapAdminSecretPath})" = 'root:root'`; await runReadOnly`sudo -u hysteria test -r /etc/hysteria/config.yaml`; await runReadOnly`sudo -u hy2xs-admin test -r /etc/hysteria/config.yaml`; await runReadOnly`sudo -u hy2xs-admin test ! -w /etc/hysteria/config.yaml`; await runReadOnly`sudo -u hy2xs-admin test ! -r /etc/hy2xs/hy2xs.env`; await runReadOnly`sudo -u hy2xs-admin test ! -r /etc/hy2xs/bootstrap-admin.secret`; await runReadOnly`sudo -u hysteria test ! -r /etc/hy2xs/bootstrap-admin.secret`; if (context.config.uiBindHost === "127.0.0.1") { const tcp = await runReadOnlySecret`ss -H -ltn`; if (hasTcpListener(tcp, "0.0.0.0", context.config.uiPort)) { throw new Error(`ui listener must not be public on 0.0.0.0:${context.config.uiPort}`); } } await runReadOnly`! ss -H -ltn | grep -q '\[::\]:${context.config.uiPort} '`; await runReadOnly`! ss -H -lun | grep -q '\[::\]:${context.config.hysteriaPort} '`; // Путь machine-auth берётся из профиля, а не пишется здесь литералом: это // тот же контракт, который уезжает в /etc/hysteria/config.yaml. const machineAuthUrlNoToken = `http://127.0.0.1:${context.config.uiPort}${HYSTERIA_MACHINE_AUTH_PATH}`; const machineAuthUrl = hysteriaMachineAuthUrl( context.config.uiPort, context.config.hysteriaTrafficStatsSecret ); const missingTokenAuthCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrlNoToken}`; if (missingTokenAuthCode.trim() !== "403") { throw new Error(`unexpected auth status without machine token: ${missingTokenAuthCode}`); } const invalidAuthResponse = await retry( "auth invalid credentials", 5, 1000, async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`, (response) => /"ok"\s*:\s*false/.test(response), (response, error) => new Error(`unexpected auth response for invalid credentials: ${response ?? String(error)}`), ); for (let i = 0; i < 10; i += 1) { const response = await runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":0}' ${machineAuthUrl}`; if (!/"ok"\s*:\s*false/.test(response)) { throw new Error(`unexpected auth response during rate-limit smoke: ${response}`); } } const invalidTypeAuthCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"invalid","tx":"0"}' ${machineAuthUrl}`; if (invalidTypeAuthCode.trim() !== "400") { throw new Error(`unexpected auth status for tx as string: ${invalidTypeAuthCode}`); } if (context.mode === "install") { const adminConPass = (await readBootstrapAdminSecret(context)).ADMIN_CON_PASS ?? ""; if (!adminConPass) { throw new Error("admin connection password is empty in bootstrap secret file"); } await retry( "auth valid credentials", 10, 1000, async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data '{"addr":"127.0.0.1:12345","auth":"${adminConPass}","tx":0}' ${machineAuthUrl}`, (response) => /"ok"\s*:\s*true/.test(response), (response, error) => new Error(`unexpected auth response for valid credentials: ${response ?? String(error)}`), ); } await assertAdminLoginWorks(context); await retry( "trafficStats valid secret", 10, 1000, async () => runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: ${context.config.hysteriaTrafficStatsSecret}' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`, (code) => /^2\d\d$/.test(code.trim()), (code, error) => new Error(`unexpected trafficStats status for valid secret: ${code ?? String(error)}`), ); const deniedCode = await runReadOnlySecret`curl -sS --max-time 5 -o /dev/null -w '%{http_code}' -H 'Authorization: invalid-hy2xs-secret' http://127.0.0.1:${context.config.hysteriaTrafficStatsPort}/online`; if (!/(401|403)/.test(deniedCode)) { throw new Error(`unexpected trafficStats status for invalid secret: ${deniedCode}`); } // `nft -c` только разбирает файл и правил не применяет — это проверка // синтаксиса, а не изменение ruleset. await runReadOnly`nft -c -f /etc/nftables.conf`; // Разбор файла ничего не говорит о том, ЧЕЙ это файл. // // Пока единственной проверкой firewall был `nft -c`, сработавший за время // smoke автоматический откат оставался незамеченным: он возвращал прежний — // синтаксически валидный — ruleset, проверка проходила зелёной, и операция // объявляла успешной установку, работающую на firewall, который она же // только что заменила. info("verifying that the effective firewall is the one generated for this configuration"); await assertEffectiveFirewallIsOurs(context); // Семантическая проверка установленного конфига: разбираем YAML и сверяем // с production-профилем, а не ищем подстроки. info("verifying effective Hysteria config against HY2XS production profile"); assertHysteriaConfigMatchesProfile(await readText("/etc/hysteria/config.yaml"), context.config); await assertEffectiveHysteriaVersion(context); } /** * Панель обязана ВПУСКАТЬ, а не просто слушать порт. * * Почему эта проверка появилась. До неё установка отвечала на вопрос «работает * ли панель» тремя фактами: юнит активен, `127.0.0.1:8080` в LISTEN, `/healthz` * отвечает `ok:true`. RC2 доказал, что все три могут быть истинными * одновременно с полностью недоступной панелью: на поле логина стоял тег * несуществующего правила валидации, `POST /api/auth/login` паниковал ещё до * проверки учётных данных, gin.Recovery превращал панику в HTTP 500 — и * установка завершалась `INSTALL EXIT CODE: 0`. * * Разница между «порт открыт» и «оператор может войти» — это весь продукт, * поэтому smoke выполняет НАСТОЯЩИЙ вход теми учётными данными, которые создал * установщик. * * Что здесь важно по деталям: * * - тело собирается JSON.stringify, а не интерполяцией в строку. Пароль * задаёт оператор, и кавычка или обратный слеш в нём иначе сломали бы не * панель, а сам запрос — и проверка объявила бы рабочую установку сломанной; * - обе команды идут через runReadOnlySecret: этот раннер не кладёт команду в * текст ошибки, а команда несёт пароль администратора. Ошибка проверки * уезжает в журнал и в diagnostics-бандл; * - положительная проба выполняется только на install. На reconfigure пароль в * bootstrap-admin.secret устаревает в тот момент, когда оператор сменил его * в панели, и требовать по нему вход значило бы ронять законную операцию; * - отрицательная проба выполняется ВСЕГДА и от актуальности пароля не * зависит. Именно она воспроизводит дефект RC2: заведомо неверные учётные * данные обязаны получить осмысленный отказ, а не 500. */ async function assertAdminLoginWorks(context: RuntimeContext): Promise { const loginUrl = `http://127.0.0.1:${context.config.uiPort}${ADMIN_LOGIN_PATH}`; // Отрицательная проба доказывает ОТКАЗ, а не отсутствие HTTP 500. // // Здесь стояли жёстко записанный пароль `definitely-not-the-admin-password` и // критерий `code.trim() === "200"`. У этой пары было два изъяна. Пароль был // литералом, то есть теоретически мог оказаться настоящим — и тогда проверка // «неверные данные отвергаются» отчиталась бы об успешном ВХОДЕ. А критерий // «HTTP 200» не отличал отказ от успеха вовсе: админка отвечает 200 и на то, // и на другое, причина живёт в конверте. Проверка доказывала ровно одно — // что запрос не закончился пятисоткой. // // Теперь пароль генерируется в момент пробы, а результат сверяется с // конвертом: код 50000, причина `invalid_credentials` и ОТСУТСТВИЕ токена // доступа. Логин берётся настоящий — тот же, что у администратора, — чтобы // запрос шёл тем же путём, что и реальный вход, и доходил до проверки пароля. const rejectedPassword = randomBytes(24).toString("base64url"); const rejectedPayload = JSON.stringify({ username: context.config.adminUser, pass: rejectedPassword }); const rejectedBody = await retry( "admin login rejects wrong credentials", 10, 1000, async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data ${rejectedPayload} ${loginUrl}`, (body) => isRejectedLogin(body), (body, error) => new Error( `admin login did not reject invalid credentials: ${describeRejectionFailure(body, error)}\n` + `Панель обязана отвечать конвертом отказа с причиной ${INVALID_CREDENTIALS_CODE}. ` + `Отсутствие такого конверта означает, что запрос не доживает до проверки учётных данных.` ) ); info(`admin login rejects wrong credentials: ${describeRejection(rejectedBody)}`); if (context.mode !== "install") { return; } // Файл читается ЦЕЛИКОМ и разбирается тем же парсером, которым он написан. // // Здесь стояло `grep … | cut -d= -f2-` с последующим `.trim()`, и это был // третий по счёту слой, срезающий пробелы у пароля, набор символов которого // объявлен неограниченным. Даже с починенными env-транспортом и админкой // проба брала бы не тот пароль и объявляла бы рабочую установку сломанной. // Побочно исчез и прогон секрета через shell-конвейер. const bootstrap = await readBootstrapAdminSecret(context); const adminUser = bootstrap.ADMIN_USER ?? ""; const adminPassword = bootstrap.ADMIN_INITIAL_PASSWORD ?? ""; if (!adminUser) { throw new Error("admin username is empty in bootstrap secret file"); } if (!adminPassword) { throw new Error("admin initial password is empty in bootstrap secret file"); } // Отрицательная проба обязана быть отрицательной. Совпадение случайного // пароля с настоящим невероятно, но проверить это здесь можно точно, и тогда // «отвергнуто» гарантированно означает «отвергнуто», а не «не совпало». if (rejectedPassword === adminPassword) { throw new Error("negative login probe accidentally used the real admin password"); } const payload = JSON.stringify({ username: adminUser, pass: adminPassword }); const response = await retry( "admin login with bootstrap credentials", 10, 1000, async () => runReadOnlySecret`curl -sS --max-time 5 -X POST -H 'Content-Type: application/json' --data ${payload} ${loginUrl}`, (body) => isSuccessfulLogin(body), (body, error) => new Error( `admin panel refused the bootstrap login it created itself: ${describeLoginFailure(body, error)}\n` + `Порт открыт и /healthz отвечает, но войти в панель нельзя — установка не считается выполненной.` ) ); info(`admin login accepted: ${describeIssuedToken(response)}`); } /** * Успех определяется по КОНВЕРТУ, а не по коду HTTP. * * Админка отвечает `200 OK` и на отказ тоже: причина живёт в поле `code` * ответа. Проверка «HTTP 200» приняла бы за успешный вход любой отказ — то есть * ровно ничего бы не проверяла. * * Выданный токен требуется отдельно: `code: 20000` без `accessToken` означал бы * панель, которая пускает и не выдаёт сессию. */ function isSuccessfulLogin(body: string): boolean { return /"code"\s*:\s*20000/.test(body) && /"accessToken"\s*:\s*"[^"]+"/.test(body); } /** * Код причины отказа по учётным данным — тот же литерал, что и * constant.ErrCodeInvalidCredentials в админке. * * Он часть публичного контракта API: панель выбирает по нему локализованную * фразу, и его значение не меняется вместе с формулировками сообщений. */ const INVALID_CREDENTIALS_CODE = "invalid_credentials"; /** * Отказ определяется по КОНВЕРТУ целиком, а не по отсутствию HTTP 500. * * Требуются три признака сразу, и каждый закрывает свой способ ошибиться: * * код ответа 50000 — это отказ уровня операции, а не успех (20000) и * не отказ валидации (50001), который означал бы, * что проба отправила негодный запрос и до проверки * пароля он не дошёл; * причина invalid_credentials — отказ именно по учётным данным, а не, * скажем, по недоступной базе; * отсутствие accessToken — панель не выдала сессию. */ function isRejectedLogin(body: string): boolean { return ( /"code"\s*:\s*50000/.test(body) && new RegExp(`"code"\\s*:\\s*"${INVALID_CREDENTIALS_CODE}"`).test(body) && !/"accessToken"\s*:\s*"[^"]+"/.test(body) ); } /** * Причина, по которой отрицательная проба не признана отказом, — БЕЗ тела * ответа. * * Тело сюда попасть не может по той же причине, что и в describeLoginFailure: * если панель по недоразумению впустила, в ответе лежит токен доступа, а этот * текст уезжает в журнал установки и в diagnostics-бандл. */ function describeRejectionFailure(body: string | undefined, error: unknown): string { if (body === undefined) { return `запрос не выполнен: ${String(error)}`; } if (/"accessToken"\s*:\s*"[^"]+"/.test(body)) { return "панель ВЫДАЛА токен доступа на заведомо неверные учётные данные"; } return describeRejection(body); } /** Как выглядит отказ: код конверта и код причины, без тела. */ function describeRejection(body: string): string { const envelope = body.match(/"code"\s*:\s*(\d+)/); const reason = body.match(/"code"\s*:\s*"([a-z_]+)"/); return `code=${envelope ? envelope[1] : "нет"}, причина=${reason ? reason[1] : "нет"}, токен не выдан`; } // Читает /etc/hy2xs/bootstrap-admin.secret тем же парсером, которым он написан. // // Файл несёт пароль администратора, у которого набор символов не ограничен, а // пробелы по краям являются частью значения. Прежнее чтение — // `grep … | cut -d= -f2-` с `.trim()` — не умело ни того, ни другого, и вдобавок // прогоняло секрет через shell-конвейер. // // Комментарий записан строчными `//`, а не блоком: скан релизных гейтов // отбрасывает только их, и объяснение, называющее убранную конструкцию по // имени, иначе роняет проверку «этой конструкции здесь больше нет». async function readBootstrapAdminSecret(context: RuntimeContext): Promise> { return parseEnvFile(await readText(context.config.bootstrapAdminSecretPath)); } /** * Причина отказа БЕЗ тела ответа. * * Тело сюда попасть не может: в ответе успешного входа лежит токен доступа, а * текст этой ошибки уезжает в журнал установки и в diagnostics-бандл, который * операторы пересылают в переписке. Поэтому наружу выдаётся только код ответа. */ function describeLoginFailure(body: string | undefined, error: unknown): string { if (body === undefined) { return `запрос не выполнен: ${String(error)}`; } const code = body.match(/"code"\s*:\s*(\d+)/); if (code) { return `ответ с code=${code[1]} и без токена доступа`; } return "ответ не является конвертом API админки"; } /** Подтверждение выдачи токена без самого токена. */ function describeIssuedToken(body: string): string { const tokenType = body.match(/"tokenType"\s*:\s*"([^"]*)"/); return tokenType ? `выдан токен типа ${tokenType[1]}` : "выдан токен доступа"; } /** * Установленный бинарник обязан совпадать с версией, замороженной в metadata * пакета. На reconfigure metadata может относиться к другому пакету, поэтому * расхождение там — предупреждение, а не отказ. */ async function assertEffectiveHysteriaVersion(context: RuntimeContext): Promise { const packagedVersion = context.hysteriaVersion.trim(); if (!packagedVersion || packagedVersion === "unknown") { return; } const raw = await runReadOnlySecret`/usr/local/bin/hysteria version`; const match = raw.match(/v\d+\.\d+\.\d+/); const effective = match ? match[0] : raw.trim(); if (effective !== packagedVersion) { const message = `effective Hysteria version ${effective} does not match package metadata ${packagedVersion}`; if (context.mode === "install") { throw new Error(message); } info(`warning: ${message}`); } }