162 lines
5.9 KiB
TypeScript
162 lines
5.9 KiB
TypeScript
import dotenv from 'dotenv';
|
||
import path from 'path';
|
||
import { vaultAppRoleLogin, fetchVaultKV2 } from './vault';
|
||
import { logger } from '../lib/logger';
|
||
|
||
dotenv.config({ path: path.resolve(__dirname, '../../../../.env') });
|
||
|
||
const p = process.env;
|
||
|
||
export let env = {
|
||
db: {
|
||
host: p.DB_HOST || '',
|
||
port: parseInt(p.DB_PORT || '5432'),
|
||
user: p.DB_USER || '',
|
||
password: p.DB_PASSWORD || '',
|
||
name: p.DB_NAME || '',
|
||
},
|
||
jwt: {
|
||
algorithm: p.JWT_ALGORITHM || 'RS256',
|
||
// Намеренно без default — каждый деплой ЯВНО указывает iss/aud, иначе сервис
|
||
// примет любой токен подписанный нашими ключами с любым iss/aud.
|
||
issuer: p.JWT_ISSUER || '',
|
||
audience: p.JWT_AUDIENCE || '',
|
||
},
|
||
vault: {
|
||
addr: p.VAULT_ADDR || '',
|
||
roleId: p.VAULT_ROLE_ID || '',
|
||
secretId: p.VAULT_SECRET_ID || '',
|
||
mount: p.VAULT_MOUNT_POINT || 'dev-secrets',
|
||
secretPath: p.VAULT_SECRET_PATH || 'database',
|
||
jwtKidPath: p.VAULT_JWT_KID_PATH || 'jwt/kid',
|
||
jwtKidsPrefix: p.VAULT_JWT_KIDS_PREFIX || 'jwt/kids',
|
||
csrfPath: p.VAULT_CSRF_PATH || '',
|
||
cryptoKeyPath: p.VAULT_CRYPTO_KEY_PATH || 'crypto/master',
|
||
},
|
||
cors: {
|
||
// Каждый деплой явно указывает CORS_ORIGINS, иначе пустой массив = нет cross-origin.
|
||
// Validate каждый origin через URL parse — отклоняем мусор. Default https-only для prod-safety.
|
||
origins: (p.CORS_ORIGINS || '')
|
||
.split(',')
|
||
.map((o) => o.trim())
|
||
.filter(Boolean)
|
||
.filter((o) => {
|
||
try {
|
||
const u = new URL(o);
|
||
return u.protocol === 'https:' || u.protocol === 'http:';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}),
|
||
// Default = false (fail-secure). Чтобы включить credentials cross-origin —
|
||
// ОБЯЗАТЕЛЬНО явный CORS_ALLOW_CREDENTIALS=true.
|
||
allowCredentials: p.CORS_ALLOW_CREDENTIALS === 'true',
|
||
},
|
||
port: parseInt(p.API_PORT || '3001'),
|
||
redis: {
|
||
host: p.REDIS_HOST || 'keydb',
|
||
port: parseInt(p.REDIS_PORT || '6379'),
|
||
password: p.REDIS_PASSWORD || '',
|
||
db: parseInt(p.REDIS_DB || '0'),
|
||
},
|
||
relayApiKey: p.RELAY_API_KEY || null,
|
||
tronApiKey: p.TRON_API_KEY || null,
|
||
jupiterApiKey: p.JUPITER_API_KEY || null,
|
||
jupiterReferralAccount: p.JUPITER_REFERRAL_ACCOUNT || null,
|
||
jupiterFeeBps: parseInt(p.JUPITER_FEE_BPS || '70'),
|
||
etherscanApiKey: p.ETHERSCAN_API_KEY || null,
|
||
bscscanApiKey: p.BSCSCAN_API_KEY || null,
|
||
};
|
||
|
||
let vaultToken: string | null = null;
|
||
|
||
export function getVaultToken(): string | null {
|
||
return vaultToken;
|
||
}
|
||
|
||
export async function initEnv(): Promise<void> {
|
||
// Fail-fast на отсутствующие критические env vars
|
||
if (!env.jwt.issuer || !env.jwt.audience) {
|
||
throw new Error('JWT_ISSUER and JWT_AUDIENCE must be explicitly set (no defaults)');
|
||
}
|
||
|
||
const { addr, roleId, secretId, mount, secretPath } = env.vault;
|
||
|
||
if (!addr || !roleId || !secretId) {
|
||
logger.info('Vault not configured, using .env');
|
||
return;
|
||
}
|
||
|
||
// H7 — HTTPS-only Vault enforce. Plaintext HTTP means master-key + AppRole secret_id
|
||
// travel through WAN unencrypted. Override via VAULT_ALLOW_INSECURE=true (only для local dev).
|
||
try {
|
||
const parsed = new URL(addr);
|
||
if (parsed.protocol !== 'https:' && p.VAULT_ALLOW_INSECURE !== 'true') {
|
||
throw new Error(`VAULT_ADDR must use https:// (got ${parsed.protocol}). Set VAULT_ALLOW_INSECURE=true only for local dev.`);
|
||
}
|
||
} catch (err: any) {
|
||
if (err.message?.includes('Invalid URL')) {
|
||
throw new Error(`VAULT_ADDR is malformed: ${addr}`);
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
const token = await vaultAppRoleLogin(addr, roleId, secretId);
|
||
if (!token) {
|
||
logger.warn('Vault AppRole login failed, using .env fallback');
|
||
return;
|
||
}
|
||
|
||
vaultToken = token;
|
||
logger.info('Vault AppRole login successful');
|
||
|
||
const secrets = await fetchVaultKV2(addr, token, mount, secretPath);
|
||
if (!secrets) {
|
||
logger.warn('Failed to read DB secrets from Vault');
|
||
return;
|
||
}
|
||
|
||
logger.info('Loaded DB secrets from Vault');
|
||
|
||
const s = (key: string) => secrets[key];
|
||
const si = (key: string, fallback: number) => {
|
||
const v = secrets[key];
|
||
return v ? parseInt(v) : fallback;
|
||
};
|
||
|
||
// Vault stores DB secrets in lowercase (host, user, password, name, port).
|
||
// Accept uppercase DB_* as fallback for compatibility.
|
||
env = {
|
||
...env,
|
||
db: {
|
||
host: s('host') || s('DB_HOST') || env.db.host,
|
||
port: si('port', si('DB_PORT', env.db.port)),
|
||
user: s('user') || s('DB_USER') || env.db.user,
|
||
password: s('password') || s('DB_PASSWORD') || env.db.password,
|
||
name: s('name') || s('DB_NAME') || env.db.name,
|
||
},
|
||
jwt: {
|
||
...env.jwt,
|
||
// H17 — trim whitespace; пустая строка после trim → fallback на env
|
||
issuer: (s('JWT_ISSUER')?.trim() || env.jwt.issuer),
|
||
audience: (s('JWT_AUDIENCE')?.trim() || env.jwt.audience),
|
||
},
|
||
cors: {
|
||
origins: s('CORS_ORIGINS') ? s('CORS_ORIGINS')!.split(',') : env.cors.origins,
|
||
// H5 — fail-secure consistent with line 53: explicit 'true' required, default false.
|
||
// Раньше: `!== 'false'` → defaults TRUE if field missing/empty (security inversion).
|
||
allowCredentials: secrets['CORS_ALLOW_CREDENTIALS'] === 'true',
|
||
},
|
||
relayApiKey: s('RELAY_API_KEY') || env.relayApiKey,
|
||
tronApiKey: s('TRON_API_KEY') || env.tronApiKey,
|
||
jupiterApiKey: s('JUPITER_API_KEY') || env.jupiterApiKey,
|
||
};
|
||
|
||
// Re-validate after Vault load. Vault мог переписать iss/aud — если они теперь пустые
|
||
// или невалидные, fail-fast.
|
||
if (!env.jwt.issuer || !env.jwt.audience) {
|
||
throw new Error('JWT_ISSUER and JWT_AUDIENCE became empty after Vault load');
|
||
}
|
||
logger.info(`JWT validation: iss="${env.jwt.issuer}", aud="${env.jwt.audience}"`);
|
||
}
|