import { randomBytes } from "node:crypto"; import type { FirewallMode, PublicEndpointPolicy, RuntimeConfig, TlsMode } from "../types/context"; import { parseEnvFile, renderEnvFile } from "../lib/envFile"; import { GECKO_DEFAULT_MAX_PACKET_SIZE, GECKO_DEFAULT_MIN_PACKET_SIZE, HY2XS_CONFIG_SCHEMA_VERSION, assertValidAdminPassword, assertValidAdminUsername, normalizeHysteriaObfsType, validateGeckoPacketSizes } from "./profile"; type EnvMap = Record; function randomSecret(): string { return randomBytes(24).toString("base64url"); } function valueOrGenerate(value: string | undefined): string { if (!value || value === "__GENERATE__") { return randomSecret(); } return value; } /** * Разбор env-файла делегирован lib/envFile. * * Здесь стоял построчный разбор с `value.trim()`, и он молча срезал пробелы по * краям КАЖДОГО значения. Для пароля администратора это прямое нарушение * контракта — продукт объявляет краевой пробел частью пароля, — а для * значений с кавычками разбор вдобавок расходился с systemd, который читает * этот же файл как EnvironmentFile. */ function parseEnv(content: string): EnvMap { return parseEnvFile(content); } 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, // Умолчание — `hy2xsadmin`, и оно совпадает с package/config/hy2xs.env и с // запасным значением в apps/dao/sqlite.go. Раньше здесь стояло `admin`: // пять символов при минимуме панели в шесть, и третье расходящееся // умолчание про одно и то же имя. Установка при этом завершалась успешно, а // войти было нельзя — отказ приходил не установщику, а оператору, и уже без // объяснения. adminUser: assertValidAdminUsername( "HY2XS_ADMIN_USER", requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "hy2xsadmin") ), // Проверяется и сгенерированный пароль, а не только заданный оператором: // генератор — такой же источник значения, и его расхождение с контрактом // панели обязано ронять установку, а не всплывать на форме входа. adminInitialPassword: assertValidAdminPassword( "HY2XS_ADMIN_INITIAL_PASSWORD", 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"); } } /** * Пишет /etc/hy2xs/hy2xs.env. * * Каждое значение проходит через formatEnvAssignment, а не подставляется в * строку интерполяцией. Раньше подставлялось, и файл поэтому был форматом * только для значений без пробелов по краям, кавычек и обратных слешей: пароль * администратора, у которого набор символов объявлен неограниченным, не * пережил бы обратного чтения — ни нашего, ни systemd'ного. * * Обычные значения (порты, пути, домены, `50 mbps`) кавычек не получают и * остаются побайтово прежними — см. UNQUOTED_SAFE_VALUE в lib/envFile. */ export function renderRuntimeEnv(config: RuntimeConfig): string { const entries: [string, string][] = [ ["HY2XS_CONFIG_SCHEMA_VERSION", String(config.configSchemaVersion)], ["HY2XS_IPV6_ENABLED", String(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", String(config.publicPort)], ["HY2XS_SSH_PORT", String(config.sshPort)], ["HY2XS_FIREWALL_MODE", config.firewallMode], ["HY2XS_FIREWALL_STAGED_APPLY", String(config.firewallStagedApply)], ["HY2XS_UI_BIND_HOST", config.uiBindHost], ["HY2XS_UI_PUBLIC_ACCESS", String(config.uiPublicAccess)], ["HY2XS_UI_PORT", String(config.uiPort)], ["HY2XS_ADMIN_USER", config.adminUser], ["HY2XS_ADMIN_INITIAL_PASSWORD", config.adminInitialPassword], ["HY2XS_ADMIN_CON_PASS", config.adminConPass], ["HY2XS_FORCE_PASSWORD_CHANGE", String(config.forcePasswordChange)], ["HY2XS_ALLOW_SELF_SIGNED_DEV", String(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", String(config.hysteriaPort)], ["HY2XS_HYSTERIA_AUTH_MODE", config.hysteriaAuthMode], ["HY2XS_HYSTERIA_TRAFFIC_STATS_HOST", config.hysteriaTrafficStatsHost], ["HY2XS_HYSTERIA_TRAFFIC_STATS_PORT", String(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", String(config.hysteriaIgnoreClientBandwidth)], ["HY2XS_HYSTERIA_CONFIG_PATH", config.hysteriaConfigPath], ["HY2XS_INSTALL_DIR", config.installDir], ["HY2XS_DATA_DIR", config.dataDir], ["HY2XS_LOG_DIR", config.logDir] ]; return `# HY2XS runtime config (editable)\n${renderEnvFile(entries)}`; }