4NK Dev b7c53069db fix: amélioration du chargement des variables d'environnement pour VAULT_CONFS_DIR
- Ajout d'un système de chargement robuste des variables d'environnement
- Recherche automatique des fichiers .env dans plusieurs emplacements
- Rechargement des variables à chaque appel de syncLocalFiles()
- Ajout de logs pour indiquer quel fichier .env a été chargé
- Documentation complète avec section de dépannage
- Solutions pour les problèmes courants avec VAULT_CONFS_DIR

Résout le problème où VAULT_CONFS_DIR n'était pas pris en compte
quand le fichier .env n'était pas dans le répertoire courant du projet.
2025-10-01 14:09:45 +00:00

749 lines
22 KiB
TypeScript

import fetch from 'node-fetch';
import https from 'https';
import dotenv from 'dotenv';
const { chacha20poly1305 } = require('@noble/ciphers/chacha.js');
import fs from 'fs';
import path from 'path';
// Types pour l'API sécurisée
export interface VaultConfig {
baseUrl: string;
verifySsl?: boolean;
timeout?: number;
userId: string; // ID utilisateur obligatoire
}
export interface VaultFile {
content: string;
filename: string;
size: number;
encrypted: boolean;
algorithm?: string | undefined;
user_id?: string | undefined;
key_version?: string | undefined;
timestamp?: string | undefined;
}
export interface VaultHealth {
status: string;
service: string;
encryption: string;
algorithm: string;
authentication?: string;
key_rotation?: string;
timestamp?: string;
}
export interface VaultInfo {
name: string;
version: string;
domain: string;
port: number;
protocol: string;
encryption: string;
authentication?: string;
key_rotation?: string;
endpoints?: Record<string, string>;
}
export interface VaultRoute {
method: string;
path: string;
description: string;
authentication: string;
headers_required: string[];
response_type: string;
parameters?: Record<string, string>;
examples?: string[];
}
export interface VaultRoutes {
routes: VaultRoute[];
total_routes: number;
authentication: {
type: string;
header: string;
description: string;
};
user_id: string;
timestamp: string;
}
export interface SyncOptions {
environment: string;
localDir?: string;
verbose?: boolean;
}
export interface SyncResult {
synced: number;
skipped: number;
errors: number;
details: Array<{
file: string;
status: 'synced' | 'skipped' | 'error';
message?: string;
}>;
}
// Classes d'erreurs personnalisées
export interface VaultError {
message: string;
code?: string | undefined;
statusCode?: number | undefined;
}
export class VaultApiError extends Error implements VaultError {
public readonly code?: string | undefined;
public readonly statusCode?: number | undefined;
constructor(message: string, code?: string, statusCode?: number) {
super(message);
this.name = 'VaultApiError';
this.code = code;
this.statusCode = statusCode;
}
}
export class VaultDecryptionError extends Error implements VaultError {
public readonly code?: string | undefined;
constructor(message: string, code?: string) {
super(message);
this.name = 'VaultDecryptionError';
this.code = code;
}
}
export class VaultAuthenticationError extends Error implements VaultError {
public readonly code?: string | undefined;
public readonly statusCode?: number | undefined;
constructor(message: string, code?: string, statusCode?: number) {
super(message);
this.name = 'VaultAuthenticationError';
this.code = code;
this.statusCode = statusCode;
}
}
/**
* Client sécurisé pour l'API Vault avec authentification par clés utilisateur
* Les clés sont gérées côté serveur avec rotation automatique
*/
export class SecureVaultClient {
private config: VaultConfig;
private vaultKey: string | null = null;
constructor(config?: VaultConfig) {
// Charger les variables d'environnement depuis .env
// Essayer plusieurs emplacements pour le fichier .env
this._loadEnvironmentVariables();
// Si pas de config fournie, utiliser les variables d'environnement
if (!config) {
const envUser = process.env['VAULT_USER'];
const envKey = process.env['VAULT_KEY'];
const envEnv = process.env['VAULT_ENV'];
if (!envUser || !envKey || !envEnv) {
throw new Error('Variables d\'environnement requises: VAULT_USER, VAULT_KEY, VAULT_ENV');
}
config = {
baseUrl: 'https://vault.4nkweb.com:6666',
userId: envUser,
timeout: 30000,
verifySsl: false
};
this.vaultKey = envKey;
}
this.config = {
verifySsl: false,
timeout: 30000,
...config,
};
// Validation de l'ID utilisateur
if (!this.config.userId || this.config.userId.length < 3 || this.config.userId.length > 128) {
throw new Error('ID utilisateur requis (3-128 caractères)');
}
if (!/^[a-zA-Z0-9_-]+$/.test(this.config.userId)) {
throw new Error('ID utilisateur invalide - caractères autorisés: a-z, A-Z, 0-9, _, -');
}
}
/**
* Récupère un fichier depuis l'API Vault
*/
async getFile(env: string, filePath: string): Promise<VaultFile> {
const url = `${this.config.baseUrl}/${env}/${filePath}`;
try {
const response = await this._fetchApi(url);
if (!response.ok) {
if (response.status === 401) {
throw new VaultAuthenticationError(
'Authentification échouée - vérifiez votre ID utilisateur',
'AUTH_FAILED',
response.status
);
}
if (response.status === 403) {
throw new VaultApiError(
'Accès non autorisé à ce fichier',
'ACCESS_DENIED',
response.status
);
}
throw new VaultApiError(
`Erreur API: ${response.status} ${response.statusText}`,
'API_ERROR',
response.status
);
}
const encryptedData = await response.arrayBuffer();
// Extraction des métadonnées depuis les headers
const user_id = response.headers.get('X-User-ID') || undefined;
const keyRotation = response.headers.get('X-Key-Rotation') || undefined;
const algorithm = response.headers.get('X-Algorithm') || undefined;
// Déchiffrement du contenu avec les headers pour la prochaine clé
const decryptedContent = this.decryptContent(Buffer.from(encryptedData), response.headers);
return {
content: decryptedContent,
filename: filePath.split('/').pop() || filePath,
size: decryptedContent.length,
encrypted: true,
algorithm,
user_id,
key_version: keyRotation,
timestamp: new Date().toISOString()
};
} catch (error) {
if (error instanceof VaultApiError || error instanceof VaultAuthenticationError) {
throw error;
}
throw new VaultApiError(
`Erreur lors de la récupération du fichier: ${error instanceof Error ? error.message : 'Inconnue'}`
);
}
}
/**
* Récupère plusieurs fichiers en parallèle
*/
async getFiles(env: string, filePaths: string[]): Promise<VaultFile[]> {
const promises = filePaths.map(filePath => this.getFile(env, filePath));
return Promise.all(promises);
}
/**
* Recherche des fichiers correspondant à un pattern
*/
async searchFiles(_env: string, _pattern: string): Promise<VaultFile[]> {
// Cette fonctionnalité nécessiterait une implémentation côté serveur
// Pour l'instant, retour d'une erreur explicative
throw new VaultApiError(
'Recherche de fichiers non implémentée - contactez l\'administrateur',
'NOT_IMPLEMENTED'
);
}
/**
* Vérifie l'état de santé de l'API
*/
async health(): Promise<VaultHealth> {
const url = `${this.config.baseUrl}/health`;
try {
const response = await this._fetchApi(url);
if (!response.ok) {
throw new VaultApiError(
`Erreur de santé API: ${response.status}`,
'HEALTH_CHECK_FAILED',
response.status
);
}
return await response.json() as VaultHealth;
} catch (error) {
if (error instanceof VaultApiError) {
throw error;
}
throw new VaultApiError(
`Erreur lors du contrôle de santé: ${error instanceof Error ? error.message : 'Inconnue'}`
);
}
}
/**
* Récupère les informations sur l'API
*/
async info(): Promise<VaultInfo> {
const url = `${this.config.baseUrl}/info`;
try {
const response = await this._fetchApi(url);
if (!response.ok) {
throw new VaultApiError(
`Erreur info API: ${response.status}`,
'INFO_ERROR',
response.status
);
}
return await response.json() as VaultInfo;
} catch (error) {
if (error instanceof VaultApiError) {
throw error;
}
throw new VaultApiError(
`Erreur lors de la récupération des informations: ${error instanceof Error ? error.message : 'Inconnue'}`
);
}
}
/**
* Test de connectivité simple
*/
async ping(): Promise<boolean> {
try {
await this.health();
return true;
} catch (error) {
return false;
}
}
/**
* Récupère toutes les routes disponibles de l'API
*/
async getRoutes(): Promise<VaultRoutes> {
const url = `${this.config.baseUrl}/routes`;
try {
const response = await this._fetchApi(url);
if (!response.ok) {
if (response.status === 401) {
throw new VaultAuthenticationError(
'Authentification échouée - vérifiez votre ID utilisateur',
'AUTH_FAILED',
response.status
);
}
throw new VaultApiError(
`Erreur API routes: ${response.status} ${response.statusText}`,
'ROUTES_API_ERROR',
response.status
);
}
return await response.json() as VaultRoutes;
} catch (error) {
if (error instanceof VaultApiError || error instanceof VaultAuthenticationError) {
throw error;
}
throw new VaultApiError(
`Erreur lors de la récupération des routes: ${error instanceof Error ? error.message : 'Inconnue'}`
);
}
}
/**
* Synchronise les fichiers déchiffrés localement
* Route vault /<env>/<project>/<file_name> -> ../confs/<project>/<file_name>
* Les fichiers existants dans confs/ sont toujours écrasés pour avoir le contenu le plus récent
*/
async syncLocalFiles(options: SyncOptions): Promise<SyncResult> {
// Recharger les variables d'environnement au cas où elles auraient changé
this._loadEnvironmentVariables();
// Récupérer le dossier de destination depuis les variables d'environnement
const defaultConfsDir = process.env['VAULT_CONFS_DIR'] || '../confs';
const {
environment,
localDir = defaultConfsDir,
verbose = false
} = options;
const result: SyncResult = {
synced: 0,
skipped: 0,
errors: 0,
details: []
};
try {
// 1. Créer le dossier de destination s'il n'existe pas
const targetDir = path.resolve(localDir);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
if (verbose) {
console.log(`📁 Dossier créé: ${targetDir}`);
}
}
// 2. Récupérer la liste des routes pour identifier les fichiers
const routes = await this.getRoutes();
// 3. Extraire les fichiers disponibles depuis les exemples de routes
const fileRoute = routes.routes.find(route => route.path.includes('<env>') && route.path.includes('<file_path>'));
if (!fileRoute || !fileRoute.examples) {
throw new VaultApiError('Impossible de déterminer les fichiers disponibles');
}
// 4. Parser les exemples pour extraire les projets et fichiers
const filesToSync: Array<{ project: string; fileName: string; vaultPath: string }> = [];
for (const example of fileRoute.examples) {
// Format: /dev/bitcoin/bitcoin.conf -> project: bitcoin, fileName: bitcoin.conf
const pathParts = example.split('/').filter(part => part && part !== environment);
if (pathParts.length >= 2) {
const project = pathParts[0] || 'unknown';
const fileName = pathParts[pathParts.length - 1] || 'unknown';
const vaultPath = pathParts.join('/');
filesToSync.push({
project,
fileName,
vaultPath
});
}
}
// 5. Synchroniser chaque fichier
for (const file of filesToSync) {
try {
const localProjectDir = path.join(targetDir, file.project);
const localFilePath = path.join(localProjectDir, file.fileName);
// Créer le dossier du projet s'il n'existe pas
if (!fs.existsSync(localProjectDir)) {
fs.mkdirSync(localProjectDir, { recursive: true });
if (verbose) {
console.log(`📁 Dossier projet créé: ${localProjectDir}`);
}
}
// Toujours écraser les fichiers dans confs/ pour avoir le contenu le plus récent
// La vérification d'existence est conservée pour le logging uniquement
const fileExists = fs.existsSync(localFilePath);
if (fileExists && verbose) {
console.log(`🔄 Écrasement du fichier existant: ${file.vaultPath}`);
}
// Récupérer le fichier depuis le vault
const vaultFile = await this.getFile(environment, file.vaultPath);
// Extraire le contenu déchiffré (simulation pour le format de démonstration)
let content = vaultFile.content;
// Le contenu est maintenant déchiffré automatiquement par decryptContent
// Pas besoin de récupérer depuis le storage, le contenu vient de l'API déchiffrée
// Écrire le fichier local
fs.writeFileSync(localFilePath, content, 'utf8');
result.synced++;
result.details.push({
file: file.vaultPath,
status: 'synced',
message: `Synchronisé vers ${localFilePath}`
});
if (verbose) {
console.log(`✅ Synchronisé: ${file.vaultPath} -> ${localFilePath}`);
}
} catch (error) {
result.errors++;
const errorMessage = error instanceof Error ? error.message : 'Erreur inconnue';
result.details.push({
file: file.vaultPath,
status: 'error',
message: errorMessage
});
if (verbose) {
console.log(`❌ Erreur: ${file.vaultPath} - ${errorMessage}`);
}
}
}
if (verbose) {
console.log(`\n📊 Résumé de synchronisation:`);
console.log(` ✅ Synchronisés: ${result.synced}`);
console.log(` ⏭️ Ignorés: ${result.skipped}`);
console.log(` ❌ Erreurs: ${result.errors}`);
}
return result;
} catch (error) {
throw new VaultApiError(
`Erreur lors de la synchronisation: ${error instanceof Error ? error.message : 'Inconnue'}`
);
}
}
/**
* Déchiffre le contenu avec les métadonnées utilisateur et gère la prochaine clé
*/
private decryptContent(encryptedData: Buffer, responseHeaders?: Headers): string {
try {
// Décoder le base64
const decoded = Buffer.from(encryptedData.toString(), 'base64');
// Nouveau format: nonce (12 bytes) + taille_métadonnées (4 bytes) + métadonnées + contenu chiffré
if (decoded.length < 16) {
throw new Error('Données chiffrées invalides - format incorrect');
}
const nonce = decoded.subarray(0, 12);
const metadataSize = decoded.readUInt32BE(12);
const metadataJson = decoded.subarray(16, 16 + metadataSize);
const ciphertext = decoded.subarray(16 + metadataSize);
// Parse des métadonnées
const metadata = JSON.parse(metadataJson.toString('utf-8'));
// Vérification de l'utilisateur
if (metadata.user_id !== this.config.userId) {
throw new VaultAuthenticationError(
'Métadonnées utilisateur ne correspondent pas',
'USER_MISMATCH'
);
}
// Récupération de la prochaine clé depuis les headers ou les métadonnées
const nextKey = responseHeaders?.get('X-Next-Key') || metadata.next_key;
// Pour le déchiffrement, utiliser la clé courante d'abord
const keyToUse = this.vaultKey;
if (!keyToUse) {
throw new VaultDecryptionError('Clé de déchiffrement non disponible');
}
console.log(`🔑 Utilisation de la clé courante pour déchiffrement: ${keyToUse.substring(0, 20)}...`);
// Ne pas mettre à jour la clé immédiatement, attendre un déchiffrement réussi
try {
// Convertir la clé base64 en Uint8Array
const key = Buffer.from(keyToUse, 'base64');
// Déchiffrement ChaCha20-Poly1305 avec @noble/ciphers
const cipher = chacha20poly1305(key, nonce);
const decrypted = cipher.decrypt(ciphertext);
// Déchiffrement réussi, mettre à jour la clé pour la prochaine requête
if (nextKey) {
this.updateNextKey(nextKey);
}
// Retourner le contenu déchiffré
return Buffer.from(decrypted).toString('utf-8');
} catch (decryptError) {
// Si le déchiffrement échoue, essayer avec la prochaine clé si elle est différente
if (nextKey && nextKey !== keyToUse) {
console.log(`🔄 Tentative de déchiffrement avec la prochaine clé...`);
try {
const nextKeyBuffer = Buffer.from(nextKey, 'base64');
const cipherNext = chacha20poly1305(nextKeyBuffer, nonce);
const decryptedNext = cipherNext.decrypt(ciphertext);
console.log(`✅ Déchiffrement réussi avec la prochaine clé !`);
// Déchiffrement réussi avec la prochaine clé, mettre à jour
this.updateNextKey(nextKey);
return Buffer.from(decryptedNext).toString('utf-8');
} catch (nextDecryptError) {
console.warn(`⚠️ Déchiffrement avec la prochaine clé échoué: ${nextDecryptError instanceof Error ? nextDecryptError.message : 'Erreur inconnue'}`);
}
}
throw new VaultDecryptionError(
`Erreur de déchiffrement ChaCha20-Poly1305: ${decryptError instanceof Error ? decryptError.message : 'Inconnue'}`
);
}
} catch (error) {
if (error instanceof VaultAuthenticationError || error instanceof VaultDecryptionError) {
throw error;
}
throw new VaultDecryptionError(
`Erreur de déchiffrement: ${error instanceof Error ? error.message : 'Inconnue'}`
);
}
}
/**
* Met à jour la prochaine clé pour les requêtes suivantes et le fichier .env
*/
private updateNextKey(nextKey: string): void {
if (nextKey) {
this.vaultKey = nextKey; // Mettre à jour la clé courante
// Mettre à jour le fichier .env avec la nouvelle clé
this.updateEnvFile(nextKey);
console.log(`🔑 Prochaine clé mise à jour: ${nextKey.substring(0, 20)}...`);
}
}
/**
* Charge les variables d'environnement depuis plusieurs emplacements possibles
*/
private _loadEnvironmentVariables(): void {
const possibleEnvPaths = [
'.env', // Répertoire courant
'../.env', // Répertoire parent
'../../.env', // Répertoire grand-parent
path.join(__dirname, '.env'), // Répertoire du SDK
path.join(__dirname, '../.env'), // Parent du SDK
path.join(__dirname, '../../.env'), // Grand-parent du SDK
path.join(process.cwd(), '.env'), // Répertoire de travail
];
let envLoaded = false;
for (const envPath of possibleEnvPaths) {
try {
if (fs.existsSync(envPath)) {
const result = dotenv.config({ path: envPath });
if (!result.error) {
console.log(`📄 Variables d'environnement chargées depuis: ${envPath}`);
envLoaded = true;
break;
}
}
} catch (error) {
// Ignorer les erreurs et continuer avec le chemin suivant
continue;
}
}
// Si aucun fichier .env n'a été trouvé, essayer le chargement par défaut
if (!envLoaded) {
try {
dotenv.config();
console.log('📄 Variables d\'environnement chargées depuis .env par défaut');
} catch (error) {
console.log('⚠️ Aucun fichier .env trouvé, utilisation des variables système uniquement');
}
}
}
/**
* Met à jour le fichier .env avec la nouvelle clé
*/
private updateEnvFile(newKey: string): void {
try {
const envPath = path.join(__dirname, '../../.env');
// Lire le fichier .env actuel
let envContent = '';
if (fs.existsSync(envPath)) {
envContent = fs.readFileSync(envPath, 'utf8');
}
// Mettre à jour ou ajouter la clé VAULT_KEY
const keyRegex = /^VAULT_KEY=.*$/m;
if (keyRegex.test(envContent)) {
envContent = envContent.replace(keyRegex, `VAULT_KEY="${newKey}"`);
} else {
envContent += `\nVAULT_KEY="${newKey}"\n`;
}
// Écrire le fichier .env mis à jour
fs.writeFileSync(envPath, envContent, 'utf8');
console.log(`📝 Fichier .env mis à jour avec la nouvelle clé`);
} catch (error) {
console.warn(`⚠️ Impossible de mettre à jour le fichier .env: ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
}
}
/**
* Effectue une requête vers l'API avec authentification
*/
private async _fetchApi(url: string): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
try {
const mergedOptions: any = {
method: 'GET',
headers: {
'User-Agent': 'SecureVaultClient/2.0.0',
'X-User-ID': this.config.userId,
'Accept': 'application/octet-stream'
},
signal: controller.signal
};
// Configuration SSL pour les certificats auto-signés
if (this.config.verifySsl === false) {
mergedOptions.agent = new https.Agent({
rejectUnauthorized: false
});
}
const response = await fetch(url, mergedOptions) as any;
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new VaultApiError(
`Timeout de la requête (${this.config.timeout}ms)`,
'TIMEOUT'
);
}
throw new VaultApiError(
`Erreur de connexion: ${error instanceof Error ? error.message : 'Inconnue'}`
);
}
}
}
/**
* Fonction utilitaire pour créer un client sécurisé
*/
export function createSecureVaultClient(baseUrl: string, userId: string): SecureVaultClient {
return new SecureVaultClient({
baseUrl,
userId,
verifySsl: false, // Pour les certificats auto-signés en développement
timeout: 15000
});
}
/**
* Fonction utilitaire pour créer un client sécurisé avec configuration complète
*/
export function createSecureVaultClientWithConfig(config: VaultConfig): SecureVaultClient {
return new SecureVaultClient(config);
}