Files
HY2XS_flamy/orchestrator/src/config/env.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

450 lines
21 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 { 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<string, string>;
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)}`;
}