Files
HY2XS_flamy/orchestrator/src/config/env.ts
T
founder cb20d8d28f fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
2026-09-02 23:24:01 +05:00

426 lines
18 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 { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context";
import {
GECKO_DEFAULT_MAX_PACKET_SIZE,
GECKO_DEFAULT_MIN_PACKET_SIZE,
HY2XS_CONFIG_SCHEMA_VERSION,
normalizeHysteriaObfsType,
validateGeckoPacketSizes
} from "./profile";
type EnvMap = Record<string, string>;
function randomSecret(): string {
return randomBytes(24).toString("base64url");
}
function valueOrGenerate(value: string | undefined): string {
if (!value || value === "__GENERATE__") {
return randomSecret();
}
return value;
}
function parseEnv(content: string): EnvMap {
const result: EnvMap = {};
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
const separator = line.indexOf("=");
if (separator < 1) {
throw new Error(`invalid env line: ${rawLine}`);
}
const key = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
result[key] = value;
}
return result;
}
function parsePort(name: string, raw: string, fallback: number): number {
const value = raw ? Number(raw) : fallback;
if (!Number.isInteger(value) || value < 1 || value > 65535) {
throw new Error(`invalid ${name}: ${raw}`);
}
return value;
}
function parseBool(name: string, raw: string, fallback: boolean): boolean {
if (!raw) {
return fallback;
}
if (raw === "true") {
return true;
}
if (raw === "false") {
return false;
}
throw new Error(`invalid ${name}: ${raw}`);
}
function parseFirewallMode(value: string): FirewallMode {
if (value === "managed" || value === "takeover" || value === "external" || value === "off") {
return value;
}
throw new Error(`invalid HY2XS_FIREWALL_MODE: ${value}`);
}
function normalizeFirewallMode(env: EnvMap): FirewallMode {
const raw = env.HY2XS_FIREWALL_MODE;
if (!raw) {
throw new Error("missing required HY2XS_FIREWALL_MODE");
}
return parseFirewallMode(raw);
}
function requireValue(name: string, value: string): string {
if (!value || !value.trim()) {
throw new Error(`missing required ${name}`);
}
return value.trim();
}
function normalizeIpv4Host(name: string, value: string): string {
if (value === "0.0.0.0" || value === "127.0.0.1") {
return value;
}
if (/^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(value)) {
return value;
}
if (value.includes(":")) {
throw new Error(`${name} must be IPv4-only`);
}
throw new Error(`invalid ${name}: ${value}`);
}
function normalizePublicHost(value: string): string {
const host = value.trim();
if (!host) {
throw new Error("missing required HY2XS_PUBLIC_HOST");
}
if (host.includes("/") || host.includes(":") || /\s/.test(host)) {
throw new Error("HY2XS_PUBLIC_HOST must be a domain or IPv4 without scheme, port, path or spaces");
}
const ipv4 = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
const domain = /^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
if (!ipv4.test(host) && !domain.test(host)) {
throw new Error(`invalid HY2XS_PUBLIC_HOST: ${value}`);
}
if (host === "0.0.0.0" || host === "127.0.0.1") {
throw new Error("HY2XS_PUBLIC_HOST must be a routable domain or public IPv4");
}
return host;
}
function normalizeTlsMode(value: string): TlsMode {
if (value === "acme" || value === "file" || value === "self_signed_dev") {
return value;
}
throw new Error(`invalid HY2XS_TLS_MODE: ${value}`);
}
function normalizeAcmeType(value: string): "http" | "tls" | "dns" {
if (value === "http" || value === "tls" || value === "dns") {
return value;
}
throw new Error(`invalid HY2XS_ACME_TYPE: ${value}`);
}
function normalizeDnsAaaaPolicy(value: string | undefined): "strict" | "warn" | "off" {
const policy = value || "strict";
if (policy === "strict" || policy === "warn" || policy === "off") {
return policy;
}
throw new Error(`invalid HY2XS_DNS_AAAA_POLICY: ${value}`);
}
/**
* По умолчанию strict: молча принять DNS, ведущий на чужую машину, HY2XS не
* имеет права. Ослабление — осознанное решение оператора для топологий вне
* baseline (NAT, floating IP), а не поведение по умолчанию.
*/
function normalizePublicEndpointPolicy(value: string | undefined): PublicEndpointPolicy {
const policy = value || "strict";
if (policy === "strict" || policy === "warn" || policy === "off") {
return policy;
}
throw new Error(`invalid HY2XS_PUBLIC_ENDPOINT_POLICY: ${value}`);
}
function normalizeFixedHysteriaAuthMode(value: string | undefined): "http" {
const mode = value || "http";
if (mode !== "http") {
throw new Error("HY2XS_HYSTERIA_AUTH_MODE is fixed in HY2XS production profile: http");
}
return "http";
}
/**
* Версия схемы конфигурации — обязательное поле.
*
* Отсутствие маркера НЕ означает «текущая схема»: до HY2XS v1 этого поля не
* существовало вовсе, поэтому именно пустое значение — самый вероятный признак
* конфигурации 0.x. Любой fallback здесь молча превращал бы legacy-конфиг в
* якобы валидный.
*/
function normalizeConfigSchemaVersion(value: string | undefined): number {
const raw = (value ?? "").trim();
if (!raw) {
throw new Error(
"HY2XS_CONFIG_SCHEMA_VERSION отсутствует в конфигурации.\n" +
"Похоже на конфигурацию предыдущего поколения (0.x) или на неизвестный формат.\n" +
`HY2XS v1 понимает только схему ${HY2XS_CONFIG_SCHEMA_VERSION} и не выполняет миграцию на месте.\n` +
"Очистите старую установку и установите HY2XS v1 с нуля: см. docs/operations/14-legacy-cleanup.md"
);
}
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed < 1) {
throw new Error(`invalid HY2XS_CONFIG_SCHEMA_VERSION: ${value}`);
}
if (parsed !== HY2XS_CONFIG_SCHEMA_VERSION) {
throw new Error(
`unsupported HY2XS_CONFIG_SCHEMA_VERSION: ${parsed}. This package understands schema ${HY2XS_CONFIG_SCHEMA_VERSION}; ` +
"HY2XS v1 requires a clean installation and does not migrate v0 configuration in place."
);
}
return parsed;
}
function normalizeYamlSafeSecret(name: string, value: string): string {
const v = requireValue(name, value);
if (/["\n\r]/.test(v)) {
throw new Error(`${name} contains forbidden characters for HY2XS YAML profile`);
}
return v;
}
function normalizeSafeAbsolutePath(name: string, value: string, options?: { disallowTmp?: boolean }): string {
const v = value.trim();
if (!v.startsWith("/")) {
throw new Error(`${name} must be an absolute path`);
}
if (v === "/") {
throw new Error(`${name} must not be /`);
}
if (v.includes("\0") || /[\r\n]/.test(v)) {
throw new Error(`${name} contains forbidden control characters`);
}
if (/[;&|`$<>]/.test(v)) {
throw new Error(`${name} contains forbidden shell control characters`);
}
if (options?.disallowTmp && (v === "/tmp" || v.startsWith("/tmp/"))) {
throw new Error(`${name} must not be under /tmp`);
}
return v;
}
export function parseRuntimeEnv(content: string): RuntimeConfig {
const env = parseEnv(content);
const uiPort = parsePort("HY2XS_UI_PORT", env.HY2XS_UI_PORT, 8080);
const uiBindHost = normalizeIpv4Host("HY2XS_UI_BIND_HOST", env.HY2XS_UI_BIND_HOST || "127.0.0.1");
const hysteriaPort = parsePort("HY2XS_HYSTERIA_PORT", env.HY2XS_HYSTERIA_PORT, 443);
const trafficStatsPort = parsePort("HY2XS_HYSTERIA_TRAFFIC_STATS_PORT", env.HY2XS_HYSTERIA_TRAFFIC_STATS_PORT, 36712);
const tlsMode = normalizeTlsMode(env.HY2XS_TLS_MODE || "acme");
const acmeType = normalizeAcmeType(env.HY2XS_ACME_TYPE || "http");
const dnsAaaaPolicy = normalizeDnsAaaaPolicy(env.HY2XS_DNS_AAAA_POLICY);
const firewallMode = normalizeFirewallMode(env);
const hysteriaAuthMode = normalizeFixedHysteriaAuthMode(env.HY2XS_HYSTERIA_AUTH_MODE);
const hysteriaObfsType = normalizeHysteriaObfsType(env.HY2XS_HYSTERIA_OBFS_TYPE);
const config: RuntimeConfig = {
configSchemaVersion: normalizeConfigSchemaVersion(env.HY2XS_CONFIG_SCHEMA_VERSION),
domain: env.HY2XS_DOMAIN || "",
dnsAaaaPolicy,
publicEndpointPolicy: normalizePublicEndpointPolicy(env.HY2XS_PUBLIC_ENDPOINT_POLICY),
publicHost: normalizePublicHost(env.HY2XS_PUBLIC_HOST || env.HY2XS_DOMAIN || ""),
publicPort: parsePort("HY2XS_PUBLIC_PORT", env.HY2XS_PUBLIC_PORT, hysteriaPort),
ipv6Enabled: parseBool("HY2XS_IPV6_ENABLED", env.HY2XS_IPV6_ENABLED, false),
sshPort: parsePort("HY2XS_SSH_PORT", env.HY2XS_SSH_PORT, 22),
firewallMode,
firewallStagedApply: parseBool("HY2XS_FIREWALL_STAGED_APPLY", env.HY2XS_FIREWALL_STAGED_APPLY, true),
uiBindHost,
uiPublicAccess: parseBool("HY2XS_UI_PUBLIC_ACCESS", env.HY2XS_UI_PUBLIC_ACCESS, false),
uiPort,
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
adminInitialPassword: valueOrGenerate(env.HY2XS_ADMIN_INITIAL_PASSWORD),
adminConPass: requireValue("HY2XS_ADMIN_CON_PASS", valueOrGenerate(env.HY2XS_ADMIN_CON_PASS)),
forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, false),
allowSelfSignedDev: parseBool("HY2XS_ALLOW_SELF_SIGNED_DEV", env.HY2XS_ALLOW_SELF_SIGNED_DEV, false),
tlsMode,
acmeType,
acmeEmail: env.HY2XS_ACME_EMAIL || "",
tlsCertPath: normalizeSafeAbsolutePath("HY2XS_TLS_CERT_PATH", env.HY2XS_TLS_CERT_PATH || "/etc/hysteria/server.crt"),
tlsKeyPath: normalizeSafeAbsolutePath("HY2XS_TLS_KEY_PATH", env.HY2XS_TLS_KEY_PATH || "/etc/hysteria/server.key"),
hysteriaBindHost: normalizeIpv4Host("HY2XS_HYSTERIA_BIND_HOST", env.HY2XS_HYSTERIA_BIND_HOST || "0.0.0.0"),
hysteriaPort,
hysteriaAuthMode,
hysteriaTrafficStatsHost: normalizeIpv4Host(
"HY2XS_HYSTERIA_TRAFFIC_STATS_HOST",
env.HY2XS_HYSTERIA_TRAFFIC_STATS_HOST || "127.0.0.1"
),
hysteriaTrafficStatsPort: trafficStatsPort,
hysteriaTrafficStatsSecret: normalizeYamlSafeSecret(
"HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET",
valueOrGenerate(env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET)
),
hysteriaObfsType,
hysteriaObfsPassword: normalizeYamlSafeSecret(
"HY2XS_HYSTERIA_OBFS_PASSWORD",
valueOrGenerate(env.HY2XS_HYSTERIA_OBFS_PASSWORD)
),
// Gecko packet sizes не выносятся в env: share URI не умеет их передавать,
// поэтому любое нестандартное значение сделало бы hysteria2:// неполным.
hysteriaGeckoMinPacketSize: GECKO_DEFAULT_MIN_PACKET_SIZE,
hysteriaGeckoMaxPacketSize: GECKO_DEFAULT_MAX_PACKET_SIZE,
hysteriaBandwidthUp: env.HY2XS_HYSTERIA_BANDWIDTH_UP || "50 mbps",
hysteriaBandwidthDown: env.HY2XS_HYSTERIA_BANDWIDTH_DOWN || "50 mbps",
hysteriaIgnoreClientBandwidth: parseBool(
"HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH",
env.HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH,
false
),
hysteriaConfigPath: normalizeSafeAbsolutePath("HY2XS_HYSTERIA_CONFIG_PATH", env.HY2XS_HYSTERIA_CONFIG_PATH || "/etc/hysteria/config.yaml"),
installDir: normalizeSafeAbsolutePath("HY2XS_INSTALL_DIR", env.HY2XS_INSTALL_DIR || "/opt/hy2xs-admin", { disallowTmp: true }),
dataDir: normalizeSafeAbsolutePath("HY2XS_DATA_DIR", env.HY2XS_DATA_DIR || "/var/lib/hy2xs-admin", { disallowTmp: true }),
logDir: normalizeSafeAbsolutePath("HY2XS_LOG_DIR", env.HY2XS_LOG_DIR || "/var/log/hy2xs", { disallowTmp: true }),
bootstrapAdminSecretPath: "/etc/hy2xs/bootstrap-admin.secret"
};
validateRuntimeConfig(config);
return config;
}
export function validateRuntimeConfig(config: RuntimeConfig): void {
if (config.hysteriaObfsType === "gecko") {
validateGeckoPacketSizes(config.hysteriaGeckoMinPacketSize, config.hysteriaGeckoMaxPacketSize);
}
if (config.ipv6Enabled) {
throw new Error("HY2XS is IPv4-only: HY2XS_IPV6_ENABLED must be false");
}
if (config.uiBindHost.includes(":")) {
throw new Error("HY2XS_UI_BIND_HOST must be IPv4-only");
}
if (config.hysteriaBindHost.includes(":")) {
throw new Error("HY2XS_HYSTERIA_BIND_HOST must be IPv4-only");
}
if (config.hysteriaBindHost !== "0.0.0.0") {
throw new Error("HY2XS_HYSTERIA_BIND_HOST must be 0.0.0.0 in production profile");
}
/**
* Traffic Stats API — ВНУТРЕННИЙ control plane, и его адрес не является
* операторской настройкой.
*
* Что было. `normalizeIpv4Host` принимал любой корректный IPv4, шаблон
* честно рендерил `trafficStats.listen: <любой IP>:36712`, и проверка
* профиля сверяла конфиг с тем же значением — то есть валидная с точки
* зрения всех гейтов конфигурация ломала продукт.
*
* Ломала потому, что у второй половины продукта контракт другой: админка
* берёт из `trafficStats.listen` только ПОРТ и всегда ходит на loopback
* (apps/proxy/hysteria2_api.go). При `HY2XS_HYSTERIA_TRAFFIC_STATS_HOST`
* равном, скажем, LAN-адресу, Hysteria слушает один адрес, админка стучится
* в другой, и дальше по цепочке: `/online` недоступен -> лимит устройств
* fail-closed -> отказ авторизации ВСЕМ пирам, учёт трафика и принудительное
* отключение не работают. Всё это — от одной строки в env, прошедшей все
* проверки.
*
* `0.0.0.0` отдельным дефектом не является — админка достучится через
* loopback, — но публикует внутренний API на всех интерфейсах, что при
* `HY2XS_FIREWALL_MODE=external|off` не прикрыто ничем.
*
* Поэтому адрес фиксирован. Делать его настраиваемым на стороне админки —
* неверное направление: у внутреннего API одного процесса на одной машине
* нет сценария, в котором ему нужен другой адрес.
*/
if (config.hysteriaTrafficStatsHost !== "127.0.0.1") {
throw new Error(
"HY2XS_HYSTERIA_TRAFFIC_STATS_HOST is fixed in the HY2XS production profile: 127.0.0.1. " +
"Traffic Stats API is an internal control plane: the admin backend always reaches it over loopback, " +
"so any other address leaves Hysteria and the panel talking past each other."
);
}
if (!config.uiPublicAccess && config.uiBindHost !== "127.0.0.1") {
throw new Error("HY2XS UI must bind to 127.0.0.1 in production baseline");
}
if (config.hysteriaConfigPath !== "/etc/hysteria/config.yaml") {
throw new Error("HY2XS_HYSTERIA_CONFIG_PATH is fixed in production baseline: /etc/hysteria/config.yaml");
}
if (config.tlsMode === "acme") {
if (!config.domain) {
throw new Error("HY2XS_DOMAIN is required for TLS mode acme");
}
if (!config.acmeEmail) {
throw new Error("HY2XS_ACME_EMAIL is required for TLS mode acme");
}
if (config.acmeType === "dns") {
throw new Error("HY2XS_ACME_TYPE=dns is not supported in production profile yet");
}
}
if (config.tlsMode === "file") {
requireValue("HY2XS_TLS_CERT_PATH", config.tlsCertPath);
requireValue("HY2XS_TLS_KEY_PATH", config.tlsKeyPath);
}
if (config.tlsMode === "self_signed_dev" && !config.allowSelfSignedDev) {
throw new Error("HY2XS_TLS_MODE=self_signed_dev requires HY2XS_ALLOW_SELF_SIGNED_DEV=true");
}
if (config.publicHost === "0.0.0.0") {
throw new Error("HY2XS_PUBLIC_HOST must be a routable domain or IPv4, not 0.0.0.0");
}
}
export function renderRuntimeEnv(config: RuntimeConfig): string {
const lines = [
"# HY2XS runtime config (editable)",
`HY2XS_CONFIG_SCHEMA_VERSION=${config.configSchemaVersion}`,
`HY2XS_IPV6_ENABLED=${config.ipv6Enabled}`,
`HY2XS_DOMAIN=${config.domain}`,
`HY2XS_DNS_AAAA_POLICY=${config.dnsAaaaPolicy}`,
`HY2XS_PUBLIC_ENDPOINT_POLICY=${config.publicEndpointPolicy}`,
`HY2XS_PUBLIC_HOST=${config.publicHost}`,
`HY2XS_PUBLIC_PORT=${config.publicPort}`,
`HY2XS_SSH_PORT=${config.sshPort}`,
`HY2XS_FIREWALL_MODE=${config.firewallMode}`,
`HY2XS_FIREWALL_STAGED_APPLY=${config.firewallStagedApply}`,
`HY2XS_UI_BIND_HOST=${config.uiBindHost}`,
`HY2XS_UI_PUBLIC_ACCESS=${config.uiPublicAccess}`,
`HY2XS_UI_PORT=${config.uiPort}`,
`HY2XS_ADMIN_USER=${config.adminUser}`,
`HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`,
`HY2XS_ADMIN_CON_PASS=${config.adminConPass}`,
`HY2XS_FORCE_PASSWORD_CHANGE=${config.forcePasswordChange}`,
`HY2XS_ALLOW_SELF_SIGNED_DEV=${config.allowSelfSignedDev}`,
`HY2XS_TLS_MODE=${config.tlsMode}`,
`HY2XS_ACME_TYPE=${config.acmeType}`,
`HY2XS_ACME_EMAIL=${config.acmeEmail}`,
`HY2XS_TLS_CERT_PATH=${config.tlsCertPath}`,
`HY2XS_TLS_KEY_PATH=${config.tlsKeyPath}`,
`HY2XS_HYSTERIA_BIND_HOST=${config.hysteriaBindHost}`,
`HY2XS_HYSTERIA_PORT=${config.hysteriaPort}`,
`HY2XS_HYSTERIA_AUTH_MODE=${config.hysteriaAuthMode}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=${config.hysteriaTrafficStatsHost}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=${config.hysteriaTrafficStatsPort}`,
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${config.hysteriaTrafficStatsSecret}`,
`HY2XS_HYSTERIA_OBFS_TYPE=${config.hysteriaObfsType}`,
`HY2XS_HYSTERIA_OBFS_PASSWORD=${config.hysteriaObfsPassword}`,
`HY2XS_HYSTERIA_BANDWIDTH_UP=${config.hysteriaBandwidthUp}`,
`HY2XS_HYSTERIA_BANDWIDTH_DOWN=${config.hysteriaBandwidthDown}`,
`HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=${config.hysteriaIgnoreClientBandwidth}`,
`HY2XS_HYSTERIA_CONFIG_PATH=${config.hysteriaConfigPath}`,
`HY2XS_INSTALL_DIR=${config.installDir}`,
`HY2XS_DATA_DIR=${config.dataDir}`,
`HY2XS_LOG_DIR=${config.logDir}`
];
return `${lines.join("\n")}\n`;
}