Initial commit: USDT transfer worker (RabbitMQ + Postgres + Vault)
This commit is contained in:
144
src/secrets/VaultClient.ts
Normal file
144
src/secrets/VaultClient.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
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 BootstrappedSecrets {
|
||||
database: DatabaseSecret;
|
||||
rabbitmq: RabbitMqSecret;
|
||||
}
|
||||
|
||||
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] = await Promise.all([
|
||||
this.readSecret("database"),
|
||||
this.readSecret("rabbitmq")
|
||||
]);
|
||||
|
||||
return {
|
||||
database: parseDatabaseSecret(database),
|
||||
rabbitmq: parseRabbitMqSecret(rabbitmq)
|
||||
};
|
||||
}
|
||||
|
||||
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 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 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 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);
|
||||
}
|
||||
Reference in New Issue
Block a user