Files
cloud-server/src/connectors/vultr.ts
kappa 3a8dd705e6 refactor: comprehensive code review fixes (security, performance, QA)
## 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>
2026-01-25 23:50:37 +09:00

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
);
}
}