Files
HY2XS_flamy/orchestrator/src/steps/smoke.ts
T
Crimson 65042ee335 fix(auth): контракт пароля администратора расходился с bcrypt в четырёх местах
Верхняя граница пароля была объявлена в 64 СИМВОЛА и обоснована пределом
bcrypt в 72 БАЙТА. Обоснование верно только для ASCII: у 64 символов длина от
64 до 256 байт. golang.org/x/crypto@v0.55.0 (bcrypt.go:96) отвечает на пароль
длиннее 72 байт ErrPasswordTooLong, а не «молча отбрасывает остаток», как
утверждал комментарий, — так вела себя редакция пакета до v0.28.

Следствие: пароль из 64 кириллических букв (128 байт) проходил панель,
оркестратор и DTO, а отказ приходил из хеширования — системной ошибкой на
штатной смене пароля, а при установке падением старта админки, то есть
сервером без администратора после INSTALL EXIT CODE: 0. Хуже самого дефекта
было то, что тест закреплял это значение как ожидаемое.

Вместе с ним закрыты три соседних расхождения того же контракта.

Пароль триммился вопреки собственному контракту. util.HashPassword вёл
проверку len(strings.TrimSpace(password)) < 6, а bootstrap читал
strings.TrimSpace(os.Getenv("HY2XS_ADMIN_INITIAL_PASSWORD")). Значение
"abcde " принимали все двери продукта и не мог захешировать никто, а первая
учётная запись создавалась не с тем паролем, который оператор записал в
hy2xs.env.

Панель считала длину в единицах UTF-16. Element Plus делегирует правила формы
async-validator, а он сравнивает min/max с String.prototype.length: пароль из
трёх эмодзи имел length 6, проходил минимум формы и получал отказ сервера,
который панель не могла объяснить.

hy2xs.env не был форматом. Значения писались интерполяцией, а читались
split("=") с trim(); при этом файл читает не только оркестратор — он объявлен
EnvironmentFile= в юните hy2xs-admin, и у незакавыченного значения systemd
срезает краевые пробелы и трактует обратный слеш как escape.

Что сделано:

- контракт переехал в leaf-пакет apps/credential: его зовут util.HashPassword
  и dao, а service импортирует util — обратный импорт был бы циклическим, и
  именно поэтому HashPassword завёл собственную копию правила;
- AdminPasswordMaxBytes = 72 объявлен отдельной константой и зеркально в
  оркестраторе и панели; сверяется тестами, читающими Go-исходник;
- одно правило adminPassword вместо min=6,max=64 в тегах DTO (границу в
  байтах тегом валидатора не выразить) и код причины admin_password_format,
  называющий обе границы;
- TrimSpace убран из хеширования и из bootstrap-пути; bootstrap проверяет
  контракт сам и падает с текстом, называющим переменную и файл;
- панель считает code points и UTF-8 байты общим adminPasswordFormRule на
  обеих формах вместо встроенных min/max;
- orchestrator/src/lib/envFile.ts — порт конечного автомата
  parse_env_file_internal из systemd и обратный ему кодировщик; экранируются
  только обратный слеш и двойная кавычка, оба из SHELL_NEED_ESCAPE. Обычные
  значения остаются без кавычек, поэтому релизные гейты не меняются. Тем же
  кодировщиком пишется bootstrap-admin.secret;
- управляющие символы запрещены контрактом: формат KEY=VALUE их не несёт, а
  ввести такой пароль в форму входа всё равно нельзя;
- отрицательная проба smoke сверяет конверт отказа (code 50000,
  invalid_credentials, отсутствие accessToken) вместо HTTP 200, а пароль
  генерирует, а не берёт из литерала;
- положительная проба читает bootstrap-секрет парсером формата вместо
  grep | cut -d= -f2- с trim() — третьего по счёту слоя, срезавшего пробелы.

Тесты: граничная таблица (36 x «я», 37 x «я», 18 и 19 эмодзи, 64 x «я»,
«abcde ») прогоняется в четырёх слоях; тест с 64 кириллическими буквами
инвертирован; round-trip env-формата на значениях с кавычками, слешами и
краевыми пробелами; bootstrap-путь на настоящей SQLite. 14 новых гейтов
приёмки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 18:38:04 +05:00

495 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<T>(
label: string,
attempts: number,
delayMs: number,
action: () => Promise<T>,
validate: (value: T) => boolean,
errorFactory: (value: T | undefined, error: unknown) => Error,
): Promise<T> {
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<void> {
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<void> {
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<Record<string, string>> {
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<void> {
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}`);
}
}