## Security Improvements - Fix timing attack in verifyApiKey with fixed 256-byte buffer - Fix sortOrder SQL injection with whitelist validation - Fix rate limiting bypass for non-Cloudflare traffic (fail-closed) - Remove stack trace exposure in error responses - Add request_id for audit trail (X-Request-ID header) - Sanitize origin header to prevent log injection - Add content-length validation for /sync endpoint (10KB limit) - Replace Math.random() with crypto.randomUUID() for sync IDs - Expand sensitive data masking patterns (8 → 18) ## Performance Improvements - Reduce rate limiter KV reads from 3 to 1 per request (66% reduction) - Increase sync batch size from 100 to 500 (80% fewer batches) - Fix health check N+1 query with efficient JOINs - Fix COUNT(*) Cartesian product with COUNT(DISTINCT) - Implement shared logger cache pattern across repositories - Add CacheService singleton pattern in recommend.ts - Add composite index for recommendation queries - Implement Anvil pricing query batching (100 per chunk) ## QA Improvements - Add BATCH_SIZE bounds validation (1-1000) - Add pagination bounds (page >= 1, MAX_OFFSET = 100000) - Add min/max range consistency validation - Add DB reference validation for singleton services - Add type guards for database result validation - Add timeout mechanism for external API calls (10-60s) - Use SUPPORTED_PROVIDERS constant instead of hardcoded list ## Removed - Remove Vault integration (using Wrangler secrets) - Remove 6-hour pricing cron (daily sync only) ## Configuration - Add idx_instance_types_specs_filter composite index - Add CORS Access-Control-Expose-Headers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
427 lines
12 KiB
TypeScript
427 lines
12 KiB
TypeScript
import type { Env, RegionInput, InstanceTypeInput, InstanceFamily, GpuInstanceInput } from '../types';
|
|
import { RateLimiter } from './base';
|
|
import { createLogger } from '../utils/logger';
|
|
import { HTTP_STATUS } from '../constants';
|
|
|
|
/**
|
|
* Vultr API error class
|
|
*/
|
|
export class VultrError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public statusCode?: number,
|
|
public details?: unknown
|
|
) {
|
|
super(message);
|
|
this.name = 'VultrError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Vultr API response types
|
|
*/
|
|
interface VultrRegion {
|
|
id: string;
|
|
city: string;
|
|
country: string;
|
|
continent: string;
|
|
options: string[];
|
|
}
|
|
|
|
interface VultrPlan {
|
|
id: string;
|
|
vcpu_count: number;
|
|
ram: number; // in MB
|
|
disk: number; // in GB
|
|
disk_count: number;
|
|
bandwidth: number; // in GB
|
|
monthly_cost: number;
|
|
type: string;
|
|
locations: string[];
|
|
}
|
|
|
|
interface VultrApiResponse<T> {
|
|
[key: string]: T[];
|
|
}
|
|
|
|
/**
|
|
* Vultr API Connector
|
|
*
|
|
* Features:
|
|
* - Fetches regions and plans from Vultr API via relay server
|
|
* - Rate limiting: 3000 requests/hour
|
|
* - Data normalization for database storage
|
|
* - Comprehensive error handling
|
|
* - Credentials from environment variables
|
|
*
|
|
* @example
|
|
* const connector = new VultrConnector(env);
|
|
* await connector.initialize();
|
|
* const regions = await connector.fetchRegions();
|
|
*
|
|
* @example
|
|
* // Using custom relay URL
|
|
* const connector = new VultrConnector(env, 'https://custom-relay.example.com');
|
|
*
|
|
* @param env - Environment with credentials
|
|
* @param relayUrl - Optional relay server URL (defaults to 'https://vultr-relay.anvil.it.com')
|
|
*/
|
|
export class VultrConnector {
|
|
readonly provider = 'vultr';
|
|
private readonly baseUrl: string;
|
|
private readonly rateLimiter: RateLimiter;
|
|
private readonly requestTimeout = 10000; // 10 seconds
|
|
private readonly logger: ReturnType<typeof createLogger>;
|
|
private apiKey: string | null = null;
|
|
|
|
constructor(
|
|
private env: Env,
|
|
relayUrl?: string
|
|
) {
|
|
// Use relay server by default, allow override via parameter or environment variable
|
|
// Relay server mirrors Vultr API structure: /v2/regions, /v2/plans
|
|
this.baseUrl = relayUrl || 'https://vultr-relay.anvil.it.com/v2';
|
|
|
|
// Rate limit: 3000 requests/hour = ~0.83 requests/second
|
|
// Use 0.8 to be conservative
|
|
this.rateLimiter = new RateLimiter(10, 0.8);
|
|
this.logger = createLogger('[VultrConnector]', env);
|
|
this.logger.info('Initialized', { baseUrl: this.baseUrl });
|
|
}
|
|
|
|
/**
|
|
* Initialize connector by loading credentials from environment
|
|
* Must be called before making API requests
|
|
*/
|
|
async initialize(): Promise<void> {
|
|
this.logger.info('Loading credentials from environment');
|
|
|
|
if (!this.env.VULTR_API_KEY) {
|
|
throw new VultrError(
|
|
'VULTR_API_KEY not found in environment',
|
|
HTTP_STATUS.INTERNAL_ERROR
|
|
);
|
|
}
|
|
|
|
this.apiKey = this.env.VULTR_API_KEY;
|
|
this.logger.info('Credentials loaded successfully');
|
|
}
|
|
|
|
/**
|
|
* Fetch all regions from Vultr API
|
|
*
|
|
* @returns Array of raw Vultr region data
|
|
* @throws VultrError on API failures
|
|
*/
|
|
async fetchRegions(): Promise<VultrRegion[]> {
|
|
this.logger.info('Fetching regions');
|
|
|
|
const response = await this.makeRequest<VultrApiResponse<VultrRegion>>(
|
|
'/regions'
|
|
);
|
|
|
|
this.logger.info('Regions fetched', { count: response.regions.length });
|
|
return response.regions;
|
|
}
|
|
|
|
/**
|
|
* Fetch all plans from Vultr API
|
|
*
|
|
* @returns Array of raw Vultr plan data
|
|
* @throws VultrError on API failures
|
|
*/
|
|
async fetchPlans(): Promise<VultrPlan[]> {
|
|
this.logger.info('Fetching plans');
|
|
|
|
const response = await this.makeRequest<VultrApiResponse<VultrPlan>>(
|
|
'/plans'
|
|
);
|
|
|
|
this.logger.info('Plans fetched', { count: response.plans.length });
|
|
return response.plans;
|
|
}
|
|
|
|
/**
|
|
* Normalize Vultr region data for database storage
|
|
*
|
|
* @param raw - Raw Vultr region data
|
|
* @param providerId - Database provider ID
|
|
* @returns Normalized region data ready for insertion
|
|
*/
|
|
normalizeRegion(raw: VultrRegion, providerId: number): RegionInput {
|
|
return {
|
|
provider_id: providerId,
|
|
region_code: raw.id,
|
|
region_name: `${raw.city}, ${raw.country}`,
|
|
country_code: this.getCountryCode(raw.country),
|
|
latitude: null, // Vultr doesn't provide coordinates
|
|
longitude: null,
|
|
available: 1, // Vultr only returns available regions
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize Vultr plan data for database storage
|
|
*
|
|
* @param raw - Raw Vultr plan data
|
|
* @param providerId - Database provider ID
|
|
* @returns Normalized instance type data ready for insertion
|
|
*/
|
|
normalizeInstance(raw: VultrPlan, providerId: number): InstanceTypeInput {
|
|
// Calculate hourly price: monthly_cost / 730 hours
|
|
const hourlyPrice = raw.monthly_cost / 730;
|
|
|
|
return {
|
|
provider_id: providerId,
|
|
instance_id: raw.id,
|
|
instance_name: raw.id,
|
|
vcpu: raw.vcpu_count,
|
|
memory_mb: raw.ram, // Already in MB
|
|
storage_gb: raw.disk, // Already in GB
|
|
transfer_tb: raw.bandwidth / 1000, // Convert GB to TB
|
|
network_speed_gbps: null, // Vultr doesn't provide network speed
|
|
gpu_count: 0, // Vultr doesn't expose GPU in plans API
|
|
gpu_type: null,
|
|
instance_family: this.mapInstanceFamily(raw.type),
|
|
metadata: JSON.stringify({
|
|
type: raw.type,
|
|
disk_count: raw.disk_count,
|
|
locations: raw.locations,
|
|
hourly_price: hourlyPrice,
|
|
monthly_price: raw.monthly_cost,
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Normalize Vultr GPU plan data for database storage
|
|
*
|
|
* @param raw - Raw Vultr plan data for GPU instance (vcg type)
|
|
* @param providerId - Database provider ID
|
|
* @returns Normalized GPU instance data ready for insertion
|
|
*/
|
|
normalizeGpuInstance(raw: VultrPlan, providerId: number): GpuInstanceInput {
|
|
const hourlyPrice = raw.monthly_cost / 730;
|
|
|
|
// Extract GPU type from vcg prefix
|
|
// vcg-* instances are NVIDIA-based GPU instances
|
|
const gpuType = 'NVIDIA';
|
|
|
|
return {
|
|
provider_id: providerId,
|
|
instance_id: raw.id,
|
|
instance_name: raw.id,
|
|
vcpu: raw.vcpu_count,
|
|
memory_mb: raw.ram,
|
|
storage_gb: raw.disk,
|
|
transfer_tb: raw.bandwidth / 1000,
|
|
network_speed_gbps: null,
|
|
gpu_count: 1, // Vultr vcg instances have 1 GPU
|
|
gpu_type: gpuType,
|
|
gpu_memory_gb: null, // Vultr doesn't expose GPU memory in plans API
|
|
metadata: JSON.stringify({
|
|
type: raw.type,
|
|
disk_count: raw.disk_count,
|
|
locations: raw.locations,
|
|
hourly_price: hourlyPrice,
|
|
monthly_price: raw.monthly_cost,
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map Vultr instance type to standard instance family
|
|
*
|
|
* @param vultrType - Vultr instance type
|
|
* @returns Standard instance family type
|
|
*/
|
|
private mapInstanceFamily(vultrType: string): InstanceFamily {
|
|
const typeLower = vultrType.toLowerCase();
|
|
|
|
if (typeLower === 'vc2' || typeLower === 'vhf') {
|
|
return 'general';
|
|
}
|
|
if (typeLower === 'vhp') {
|
|
return 'compute';
|
|
}
|
|
if (typeLower === 'vdc') {
|
|
return 'compute'; // dedicated CPU → compute family
|
|
}
|
|
if (typeLower === 'vcg') {
|
|
return 'gpu';
|
|
}
|
|
|
|
// Default to general for unknown types
|
|
this.logger.warn('Unknown instance type, defaulting to general', { type: vultrType });
|
|
return 'general';
|
|
}
|
|
|
|
/**
|
|
* Map country name to ISO 3166-1 alpha-2 country code
|
|
*
|
|
* @param countryName - Full country name
|
|
* @returns Lowercase ISO alpha-2 country code or null if not found
|
|
*/
|
|
private getCountryCode(countryName: string): string | null {
|
|
const countryMap: Record<string, string> = {
|
|
'US': 'us',
|
|
'United States': 'us',
|
|
'Canada': 'ca',
|
|
'UK': 'gb',
|
|
'United Kingdom': 'gb',
|
|
'Germany': 'de',
|
|
'France': 'fr',
|
|
'Netherlands': 'nl',
|
|
'Australia': 'au',
|
|
'Japan': 'jp',
|
|
'Singapore': 'sg',
|
|
'South Korea': 'kr',
|
|
'India': 'in',
|
|
'Spain': 'es',
|
|
'Poland': 'pl',
|
|
'Sweden': 'se',
|
|
'Israel': 'il',
|
|
'Mexico': 'mx',
|
|
'Brazil': 'br',
|
|
};
|
|
|
|
return countryMap[countryName] || null;
|
|
}
|
|
|
|
/**
|
|
* Make authenticated request to Vultr API with rate limiting
|
|
*
|
|
* @param endpoint - API endpoint (e.g., '/regions')
|
|
* @returns Parsed API response
|
|
* @throws VultrError on API failures
|
|
*/
|
|
private async makeRequest<T>(endpoint: string): Promise<T> {
|
|
if (!this.apiKey) {
|
|
throw new VultrError(
|
|
'Connector not initialized. Call initialize() first.',
|
|
500
|
|
);
|
|
}
|
|
|
|
// Apply rate limiting
|
|
await this.rateLimiter.waitForToken();
|
|
|
|
const url = `${this.baseUrl}${endpoint}`;
|
|
this.logger.debug('Making request', { endpoint });
|
|
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
|
|
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Authorization': `Bearer ${this.apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'Mozilla/5.0 (compatible; CloudInstancesAPI/1.0)',
|
|
},
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
// Handle HTTP errors
|
|
if (!response.ok) {
|
|
await this.handleHttpError(response);
|
|
}
|
|
|
|
const data = await response.json() as T;
|
|
return data;
|
|
|
|
} catch (error) {
|
|
// Handle timeout
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
this.logger.error('Request timeout', { endpoint, timeout_ms: this.requestTimeout });
|
|
throw new VultrError(
|
|
`Request to Vultr API timed out after ${this.requestTimeout}ms`,
|
|
504
|
|
);
|
|
}
|
|
|
|
// Re-throw VultrError
|
|
if (error instanceof VultrError) {
|
|
throw error;
|
|
}
|
|
|
|
// Handle unexpected errors
|
|
this.logger.error('Unexpected error', { endpoint, error: error instanceof Error ? error.message : String(error) });
|
|
throw new VultrError(
|
|
`Failed to fetch from Vultr API: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
HTTP_STATUS.INTERNAL_ERROR,
|
|
error
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle HTTP error responses from Vultr API
|
|
* This method always throws a VultrError
|
|
*/
|
|
private async handleHttpError(response: Response): Promise<never> {
|
|
const statusCode = response.status;
|
|
let errorMessage: string;
|
|
let errorDetails: unknown;
|
|
|
|
try {
|
|
const errorData = await response.json() as { error?: string; message?: string };
|
|
errorMessage = errorData.error || errorData.message || response.statusText;
|
|
errorDetails = errorData;
|
|
} catch {
|
|
errorMessage = response.statusText;
|
|
errorDetails = null;
|
|
}
|
|
|
|
this.logger.error('HTTP error', { statusCode, errorMessage });
|
|
|
|
if (statusCode === 401) {
|
|
throw new VultrError(
|
|
'Vultr authentication failed: Invalid or expired API key',
|
|
401,
|
|
errorDetails
|
|
);
|
|
}
|
|
|
|
if (statusCode === 403) {
|
|
throw new VultrError(
|
|
'Vultr authorization failed: Insufficient permissions',
|
|
403,
|
|
errorDetails
|
|
);
|
|
}
|
|
|
|
if (statusCode === HTTP_STATUS.TOO_MANY_REQUESTS) {
|
|
// Check for Retry-After header
|
|
const retryAfter = response.headers.get('Retry-After');
|
|
const retryMessage = retryAfter
|
|
? ` Retry after ${retryAfter} seconds.`
|
|
: '';
|
|
|
|
throw new VultrError(
|
|
`Vultr rate limit exceeded: Too many requests.${retryMessage}`,
|
|
HTTP_STATUS.TOO_MANY_REQUESTS,
|
|
errorDetails
|
|
);
|
|
}
|
|
|
|
if (statusCode >= 500 && statusCode < 600) {
|
|
throw new VultrError(
|
|
`Vultr server error: ${errorMessage}`,
|
|
statusCode,
|
|
errorDetails
|
|
);
|
|
}
|
|
|
|
throw new VultrError(
|
|
`Vultr API request failed: ${errorMessage}`,
|
|
statusCode,
|
|
errorDetails
|
|
);
|
|
}
|
|
}
|