Реализован production-hardening по fix1: env/reconfigure, IPv4-only, TLS, secrets, firewall, docs
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { RuntimeConfig, TlsMode } from "../types/context";
|
||||
|
||||
type EnvMap = Record<string, string>;
|
||||
|
||||
function randomSecret(): string {
|
||||
return randomBytes(24).toString("base64url");
|
||||
}
|
||||
|
||||
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 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 {
|
||||
if (!value) {
|
||||
throw new Error("missing required HY2XS_PUBLIC_HOST");
|
||||
}
|
||||
if (value.includes(":")) {
|
||||
throw new Error("HY2XS_PUBLIC_HOST must not contain IPv6");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTlsMode(value: string): TlsMode {
|
||||
if (value === "acme" || value === "file" || value === "self_signed_dev") {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`invalid HY2XS_TLS_MODE: ${value}`);
|
||||
}
|
||||
|
||||
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 config: RuntimeConfig = {
|
||||
domain: env.HY2XS_DOMAIN || "",
|
||||
publicHost: normalizePublicHost(env.HY2XS_PUBLIC_HOST || env.HY2XS_DOMAIN || ""),
|
||||
publicPort: parsePort("HY2XS_PUBLIC_PORT", env.HY2XS_PUBLIC_PORT, hysteriaPort),
|
||||
ipv6Enabled: false,
|
||||
sshPort: parsePort("HY2XS_SSH_PORT", env.HY2XS_SSH_PORT, 22),
|
||||
firewallEnabled: parseBool("HY2XS_FIREWALL_ENABLED", env.HY2XS_FIREWALL_ENABLED, true),
|
||||
firewallStagedApply: parseBool("HY2XS_FIREWALL_STAGED_APPLY", env.HY2XS_FIREWALL_STAGED_APPLY, true),
|
||||
uiBindHost,
|
||||
uiPort,
|
||||
adminUser: requireValue("HY2XS_ADMIN_USER", env.HY2XS_ADMIN_USER || "admin"),
|
||||
adminInitialPassword: env.HY2XS_ADMIN_INITIAL_PASSWORD || randomSecret(),
|
||||
forcePasswordChange: parseBool("HY2XS_FORCE_PASSWORD_CHANGE", env.HY2XS_FORCE_PASSWORD_CHANGE, true),
|
||||
tlsMode,
|
||||
acmeEmail: env.HY2XS_ACME_EMAIL || "",
|
||||
tlsCertPath: env.HY2XS_TLS_CERT_PATH || "/etc/hysteria/server.crt",
|
||||
tlsKeyPath: 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: "http",
|
||||
hysteriaAuthUrl: env.HY2XS_HYSTERIA_AUTH_URL || `http://127.0.0.1:${uiPort}/hui/hysteria2/auth`,
|
||||
hysteriaTrafficStatsHost: normalizeIpv4Host(
|
||||
"HY2XS_HYSTERIA_TRAFFIC_STATS_HOST",
|
||||
env.HY2XS_HYSTERIA_TRAFFIC_STATS_HOST || "127.0.0.1"
|
||||
),
|
||||
hysteriaTrafficStatsPort: trafficStatsPort,
|
||||
hysteriaTrafficStatsSecret: env.HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET || randomSecret(),
|
||||
hysteriaObfsType: "salamander",
|
||||
hysteriaObfsPassword: requireValue("HY2XS_HYSTERIA_OBFS_PASSWORD", env.HY2XS_HYSTERIA_OBFS_PASSWORD || randomSecret()),
|
||||
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: env.HY2XS_HYSTERIA_CONFIG_PATH || "/etc/hysteria/config.yaml",
|
||||
hysteriaVersionPolicy: env.HY2XS_HYSTERIA_VERSION || "latest",
|
||||
installDir: env.HY2XS_INSTALL_DIR || "/opt/hy2xs-admin",
|
||||
dataDir: env.HY2XS_DATA_DIR || "/var/lib/hy2xs-admin",
|
||||
logDir: env.HY2XS_LOG_DIR || "/var/log/hy2xs",
|
||||
bootstrapAdminSecretPath: "/etc/hy2xs/bootstrap-admin.secret"
|
||||
};
|
||||
|
||||
validateRuntimeConfig(config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export function defaultRuntimeConfig(): RuntimeConfig {
|
||||
return parseRuntimeEnv(
|
||||
[
|
||||
"HY2XS_IPV6_ENABLED=false",
|
||||
"HY2XS_DOMAIN=",
|
||||
"HY2XS_PUBLIC_HOST=127.0.0.1",
|
||||
"HY2XS_PUBLIC_PORT=443",
|
||||
"HY2XS_SSH_PORT=22",
|
||||
"HY2XS_FIREWALL_ENABLED=true",
|
||||
"HY2XS_FIREWALL_STAGED_APPLY=true",
|
||||
"HY2XS_UI_BIND_HOST=127.0.0.1",
|
||||
"HY2XS_UI_PORT=8080",
|
||||
"HY2XS_ADMIN_USER=admin",
|
||||
`HY2XS_ADMIN_INITIAL_PASSWORD=${randomSecret()}`,
|
||||
"HY2XS_FORCE_PASSWORD_CHANGE=true",
|
||||
"HY2XS_TLS_MODE=self_signed_dev",
|
||||
"HY2XS_ACME_EMAIL=",
|
||||
"HY2XS_TLS_CERT_PATH=/etc/hysteria/server.crt",
|
||||
"HY2XS_TLS_KEY_PATH=/etc/hysteria/server.key",
|
||||
"HY2XS_HYSTERIA_BIND_HOST=0.0.0.0",
|
||||
"HY2XS_HYSTERIA_PORT=443",
|
||||
"HY2XS_HYSTERIA_AUTH_MODE=http",
|
||||
"HY2XS_HYSTERIA_AUTH_URL=http://127.0.0.1:8080/hui/hysteria2/auth",
|
||||
"HY2XS_HYSTERIA_TRAFFIC_STATS_HOST=127.0.0.1",
|
||||
"HY2XS_HYSTERIA_TRAFFIC_STATS_PORT=36712",
|
||||
`HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET=${randomSecret()}`,
|
||||
"HY2XS_HYSTERIA_OBFS_TYPE=salamander",
|
||||
`HY2XS_HYSTERIA_OBFS_PASSWORD=${randomSecret()}`,
|
||||
"HY2XS_HYSTERIA_BANDWIDTH_UP=50 mbps",
|
||||
"HY2XS_HYSTERIA_BANDWIDTH_DOWN=50 mbps",
|
||||
"HY2XS_HYSTERIA_IGNORE_CLIENT_BANDWIDTH=false",
|
||||
"HY2XS_HYSTERIA_CONFIG_PATH=/etc/hysteria/config.yaml",
|
||||
"HY2XS_HYSTERIA_VERSION=latest",
|
||||
"HY2XS_INSTALL_DIR=/opt/hy2xs-admin",
|
||||
"HY2XS_DATA_DIR=/var/lib/hy2xs-admin",
|
||||
"HY2XS_LOG_DIR=/var/log/hy2xs"
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
export function validateRuntimeConfig(config: RuntimeConfig): void {
|
||||
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");
|
||||
}
|
||||
|
||||
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.tlsMode === "file") {
|
||||
requireValue("HY2XS_TLS_CERT_PATH", config.tlsCertPath);
|
||||
requireValue("HY2XS_TLS_KEY_PATH", config.tlsKeyPath);
|
||||
}
|
||||
|
||||
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_IPV6_ENABLED=false",
|
||||
`HY2XS_DOMAIN=${config.domain}`,
|
||||
`HY2XS_PUBLIC_HOST=${config.publicHost}`,
|
||||
`HY2XS_PUBLIC_PORT=${config.publicPort}`,
|
||||
`HY2XS_SSH_PORT=${config.sshPort}`,
|
||||
`HY2XS_FIREWALL_ENABLED=${config.firewallEnabled}`,
|
||||
`HY2XS_FIREWALL_STAGED_APPLY=${config.firewallStagedApply}`,
|
||||
`HY2XS_UI_BIND_HOST=${config.uiBindHost}`,
|
||||
`HY2XS_UI_PORT=${config.uiPort}`,
|
||||
`HY2XS_ADMIN_USER=${config.adminUser}`,
|
||||
`HY2XS_ADMIN_INITIAL_PASSWORD=${config.adminInitialPassword}`,
|
||||
`HY2XS_FORCE_PASSWORD_CHANGE=${config.forcePasswordChange}`,
|
||||
`HY2XS_TLS_MODE=${config.tlsMode}`,
|
||||
`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=http",
|
||||
`HY2XS_HYSTERIA_AUTH_URL=${config.hysteriaAuthUrl}`,
|
||||
`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=salamander",
|
||||
`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_HYSTERIA_VERSION=${config.hysteriaVersionPolicy}`,
|
||||
`HY2XS_INSTALL_DIR=${config.installDir}`,
|
||||
`HY2XS_DATA_DIR=${config.dataDir}`,
|
||||
`HY2XS_LOG_DIR=${config.logDir}`
|
||||
];
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
Reference in New Issue
Block a user