65042ee335
Верхняя граница пароля была объявлена в 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>
495 lines
27 KiB
TypeScript
495 lines
27 KiB
TypeScript
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}`);
|
||
}
|
||
}
|