192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
export interface VaultClientOptions {
|
|
addr: string;
|
|
mountPoint: string;
|
|
roleId: string;
|
|
secretId: string;
|
|
requestTimeoutMs?: number;
|
|
}
|
|
|
|
export interface DatabaseSecret {
|
|
host: string;
|
|
port: number;
|
|
name: string;
|
|
user: string;
|
|
password: string;
|
|
}
|
|
|
|
export interface RabbitMqSecret {
|
|
host: string;
|
|
port: number;
|
|
vhost: string;
|
|
user: string;
|
|
password: string;
|
|
}
|
|
|
|
export interface EthereumSecret {
|
|
rpcUrl?: string;
|
|
hotWalletPrivateKey?: string;
|
|
usdtContractAddress?: string;
|
|
}
|
|
|
|
export interface BootstrappedSecrets {
|
|
database: DatabaseSecret;
|
|
rabbitmq: RabbitMqSecret;
|
|
ethereum?: EthereumSecret;
|
|
}
|
|
|
|
export class VaultClient {
|
|
private readonly baseUrl: string;
|
|
private readonly mountPoint: string;
|
|
private readonly roleId: string;
|
|
private readonly secretId: string;
|
|
private readonly timeoutMs: number;
|
|
private token?: string;
|
|
|
|
constructor(options: VaultClientOptions) {
|
|
this.baseUrl = options.addr.replace(/\/+$/, '');
|
|
this.mountPoint = options.mountPoint;
|
|
this.roleId = options.roleId;
|
|
this.secretId = options.secretId;
|
|
this.timeoutMs = options.requestTimeoutMs ?? 10_000;
|
|
}
|
|
|
|
async bootstrap(): Promise<BootstrappedSecrets> {
|
|
await this.login();
|
|
const [database, rabbitmq, ethereum] = await Promise.all([
|
|
this.readSecret('database'),
|
|
this.readSecret('rabbitmq'),
|
|
this.readOptionalSecret('crypto')
|
|
]);
|
|
|
|
return {
|
|
database: parseDatabaseSecret(database),
|
|
rabbitmq: parseRabbitMqSecret(rabbitmq),
|
|
ethereum: ethereum ? parseEthereumSecret(ethereum) : undefined
|
|
};
|
|
}
|
|
|
|
private async login(): Promise<void> {
|
|
const response = await this.fetch(`${this.baseUrl}/v1/auth/approle/login`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ role_id: this.roleId, secret_id: this.secretId })
|
|
});
|
|
|
|
const json = (await response.json()) as { auth?: { client_token?: string } };
|
|
const token = json.auth?.client_token;
|
|
if (!token) {
|
|
throw new Error('Vault AppRole login did not return a client_token');
|
|
}
|
|
this.token = token;
|
|
}
|
|
|
|
private async readSecret(path: string): Promise<Record<string, unknown>> {
|
|
if (!this.token) {
|
|
throw new Error('Vault token is missing; call bootstrap() first');
|
|
}
|
|
|
|
const url = `${this.baseUrl}/v1/${this.mountPoint}/data/${path}`;
|
|
const response = await this.fetch(url, {
|
|
method: 'GET',
|
|
headers: { 'X-Vault-Token': this.token }
|
|
});
|
|
|
|
const json = (await response.json()) as { data?: { data?: Record<string, unknown> } };
|
|
const data = json.data?.data;
|
|
if (!data || typeof data !== 'object') {
|
|
throw new Error(`Vault secret ${this.mountPoint}/${path} is empty or malformed`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
private async readOptionalSecret(path: string): Promise<Record<string, unknown> | null> {
|
|
try {
|
|
return await this.readSecret(path);
|
|
} catch (error) {
|
|
if (error instanceof Error && (error.message.includes('HTTP 404') || error.message.includes('HTTP 403'))) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async fetch(url: string, init: RequestInit): Promise<Response> {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
try {
|
|
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new Error(`Vault request to ${url} failed with HTTP ${response.status}: ${text}`);
|
|
}
|
|
return response;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function parseDatabaseSecret(raw: Record<string, unknown>): DatabaseSecret {
|
|
return {
|
|
host: requireString(raw, 'host', 'database'),
|
|
port: requirePort(raw, 'port', 'database'),
|
|
name: requireString(raw, 'name', 'database'),
|
|
user: requireString(raw, 'user', 'database'),
|
|
password: requireString(raw, 'password', 'database')
|
|
};
|
|
}
|
|
|
|
|
|
function parseRabbitMqSecret(raw: Record<string, unknown>): RabbitMqSecret {
|
|
return {
|
|
host: requireString(raw, 'host', 'rabbitmq'),
|
|
port: requirePort(raw, 'port', 'rabbitmq'),
|
|
vhost: requireString(raw, 'vhost', 'rabbitmq'),
|
|
user: requireString(raw, 'user', 'rabbitmq'),
|
|
password: requireString(raw, 'password', 'rabbitmq')
|
|
};
|
|
}
|
|
|
|
|
|
function parseEthereumSecret(raw: Record<string, unknown>): EthereumSecret {
|
|
return {
|
|
rpcUrl: optionalString(raw, 'rpc_url', 'ethereum'),
|
|
hotWalletPrivateKey: optionalString(raw, 'hot_wallet_private_key', 'crypto'),
|
|
usdtContractAddress:
|
|
optionalString(raw, 'usdt_contract_address', 'crypto') ??
|
|
optionalString(raw, 'usdt_contract_addres', 'crypto')
|
|
};
|
|
}
|
|
|
|
|
|
function requireString(raw: Record<string, unknown>, key: string, secretName: string): string {
|
|
const value = raw[key];
|
|
if (typeof value !== 'string' || !value) {
|
|
throw new Error(`Vault secret '${secretName}' is missing string field '${key}'`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
|
|
function optionalString(raw: Record<string, unknown>, key: string, secretName: string): string | undefined {
|
|
const value = raw[key];
|
|
if (value === undefined || value === null || value === '') {
|
|
return undefined;
|
|
}
|
|
if (typeof value !== 'string') {
|
|
throw new Error(`Vault secret '${secretName}' field '${key}' must be a string`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
|
|
function requirePort(raw: Record<string, unknown>, key: string, secretName: string): number {
|
|
const value = raw[key];
|
|
const numeric = typeof value === 'number' ? value : Number(value);
|
|
if (!Number.isFinite(numeric) || numeric <= 0 || numeric > 65535) {
|
|
throw new Error(`Vault secret '${secretName}' has invalid port '${String(value)}'`);
|
|
}
|
|
return Math.trunc(numeric);
|
|
}
|