From 9b51b8d4274a73e415deb86277d74945e1924deb Mon Sep 17 00:00:00 2001 From: kappa Date: Tue, 27 Jan 2026 17:19:19 +0900 Subject: [PATCH] feat: add Queue-based async server provisioning - Add Cloudflare Queue for async server provisioning workflow - Implement VPS provider abstraction (Linode, Vultr) - Add provisioning API endpoints with API key authentication - Fix race condition in balance deduction (atomic query) - Remove root_password from Queue for security (fetch from DB) - Add IP assignment wait logic after server creation - Add rollback/refund on all failure cases Co-Authored-By: Claude Opus 4.5 --- src/handlers/provision.ts | 354 +++++++++++++++++++ src/handlers/queue.ts | 41 +++ src/index.ts | 66 +++- src/repositories/ProvisioningRepository.ts | 272 ++++++++++++++ src/services/linode-provider.ts | 159 +++++++++ src/services/provisioning-service.ts | 392 +++++++++++++++++++++ src/services/vps-provider.ts | 117 ++++++ src/services/vultr-provider.ts | 189 ++++++++++ src/types.ts | 128 ++++++- src/utils/http.ts | 61 ++++ src/utils/index.ts | 3 +- wrangler.toml | 19 +- 12 files changed, 1796 insertions(+), 5 deletions(-) create mode 100644 src/handlers/provision.ts create mode 100644 src/handlers/queue.ts create mode 100644 src/repositories/ProvisioningRepository.ts create mode 100644 src/services/linode-provider.ts create mode 100644 src/services/provisioning-service.ts create mode 100644 src/services/vps-provider.ts create mode 100644 src/services/vultr-provider.ts diff --git a/src/handlers/provision.ts b/src/handlers/provision.ts new file mode 100644 index 0000000..f58df3e --- /dev/null +++ b/src/handlers/provision.ts @@ -0,0 +1,354 @@ +/** + * Server Provisioning Handler + * POST /api/provision - Create a new server + * GET /api/provision/orders - Get user's orders + * GET /api/provision/orders/:id - Get specific order + * DELETE /api/provision/orders/:id - Delete/terminate server + * GET /api/provision/balance - Get user's balance + */ + +import type { Env, ProvisionRequest } from '../types'; +import { ProvisioningService } from '../services/provisioning-service'; +import { createErrorResponse, createSuccessResponse } from '../utils/http'; +import { LIMITS } from '../config'; + +/** + * Validate provision request body + * Note: user_id in API is the Telegram ID + */ +function validateProvisionRequest(body: unknown): { + valid: true; + data: ProvisionRequest; +} | { + valid: false; + error: string; +} { + if (!body || typeof body !== 'object') { + return { valid: false, error: 'Request body must be a JSON object' }; + } + + const data = body as Record; + + // Required fields - user_id is the Telegram ID + if (!data.user_id || typeof data.user_id !== 'string') { + return { valid: false, error: 'user_id is required and must be a string (Telegram ID)' }; + } + + if (!data.pricing_id || typeof data.pricing_id !== 'number' || data.pricing_id <= 0) { + return { valid: false, error: 'pricing_id is required and must be a positive number' }; + } + + // Optional fields validation + if (data.label !== undefined && typeof data.label !== 'string') { + return { valid: false, error: 'label must be a string' }; + } + + if (data.label && (data.label as string).length > 64) { + return { valid: false, error: 'label must be 64 characters or less' }; + } + + const validOsImages = ['ubuntu_22_04', 'ubuntu_20_04', 'debian_11', 'debian_12']; + if (data.image !== undefined && !validOsImages.includes(data.image as string)) { + return { valid: false, error: `image must be one of: ${validOsImages.join(', ')}` }; + } + + if (data.dry_run !== undefined && typeof data.dry_run !== 'boolean') { + return { valid: false, error: 'dry_run must be a boolean' }; + } + + return { + valid: true, + data: { + telegram_id: data.user_id as string, // API uses user_id, internally it's telegram_id + pricing_id: data.pricing_id as number, + label: data.label as string | undefined, + image: data.image as string | undefined, + dry_run: data.dry_run as boolean | undefined, + }, + }; +} + +/** + * POST /api/provision + * Create a new server + */ +export async function handleProvision( + request: Request, + env: Env, + corsHeaders: Record +): Promise { + try { + // Check content length + const contentLength = request.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > LIMITS.MAX_REQUEST_BODY_BYTES) { + return createErrorResponse('Request body too large', 413, undefined, corsHeaders); + } + + // Parse request body + let body: unknown; + try { + body = await request.json(); + } catch { + return createErrorResponse('Invalid JSON in request body', 400, undefined, corsHeaders); + } + + // Validate request + const validation = validateProvisionRequest(body); + if (!validation.valid) { + return createErrorResponse(validation.error, 400, 'VALIDATION_ERROR', corsHeaders); + } + + // Check API keys (skip for dry_run) + if (!validation.data.dry_run && !env.LINODE_API_KEY && !env.VULTR_API_KEY) { + return createErrorResponse( + 'No VPS provider API keys configured', + 503, + 'SERVICE_UNAVAILABLE', + corsHeaders + ); + } + + // Create provisioning service with both DBs + const provisioningService = new ProvisioningService( + env, // Full env for exchange rate + env.DB, // cloud-instances-db + env.USER_DB, // telegram-conversations + env.LINODE_API_KEY, + env.VULTR_API_KEY + ); + + // Provision server + const result = await provisioningService.provisionServer(validation.data); + + if (!result.success) { + // Map error codes to HTTP status codes + const statusCode = getStatusCodeForError(result.error!.code); + return createErrorResponse( + result.error!.message, + statusCode, + result.error!.code, + corsHeaders + ); + } + + // Return success response (hide root password in response) + const sanitizedOrder = { + ...result.order!, + root_password: '*** Use GET /api/provision/orders/:id to retrieve once ***', + }; + + // Include dry_run_info if present + const response: Record = { + message: validation.data.dry_run + ? 'Dry run successful. Validation passed.' + : 'Server provisioned successfully', + order: sanitizedOrder, + }; + + // Add dry_run details from service + if ('dry_run_info' in result) { + response.dry_run_info = (result as Record).dry_run_info; + } + + return createSuccessResponse(response, validation.data.dry_run ? 200 : 201, corsHeaders); + } catch (error) { + console.error('[handleProvision] Error:', error); + return createErrorResponse( + 'Internal server error during provisioning', + 500, + 'INTERNAL_ERROR', + corsHeaders + ); + } +} + +/** + * GET /api/provision/orders + * Get user's orders + */ +export async function handleGetOrders( + request: Request, + env: Env, + corsHeaders: Record +): Promise { + try { + const url = new URL(request.url); + const userId = url.searchParams.get('user_id'); // This is telegram_id + + if (!userId) { + return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); + } + + const provisioningService = new ProvisioningService( + env, + env.DB, + env.USER_DB, + env.LINODE_API_KEY, + env.VULTR_API_KEY + ); + + const orders = await provisioningService.getUserOrders(userId); + + // Sanitize root passwords + const sanitizedOrders = orders.map((order) => ({ + ...order, + root_password: order.root_password ? '***REDACTED***' : null, + })); + + return createSuccessResponse({ orders: sanitizedOrders }, 200, corsHeaders); + } catch (error) { + console.error('[handleGetOrders] Error:', error); + return createErrorResponse('Internal server error', 500, undefined, corsHeaders); + } +} + +/** + * GET /api/provision/orders/:id + * Get specific order with root password (one-time view concept) + */ +export async function handleGetOrder( + request: Request, + env: Env, + orderId: string, + corsHeaders: Record +): Promise { + try { + const url = new URL(request.url); + const userId = url.searchParams.get('user_id'); // This is telegram_id + + if (!userId) { + return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); + } + + const provisioningService = new ProvisioningService( + env, + env.DB, + env.USER_DB, + env.LINODE_API_KEY, + env.VULTR_API_KEY + ); + + const order = await provisioningService.getOrder(orderId); + + if (!order) { + return createErrorResponse('Order not found', 404, 'NOT_FOUND', corsHeaders); + } + + // Get user to verify ownership + const balance = await provisioningService.getUserBalance(userId); + if (!balance || order.user_id !== balance.user_id) { + return createErrorResponse('Unauthorized', 403, 'UNAUTHORIZED', corsHeaders); + } + + // Include root password for order owner + return createSuccessResponse({ order }, 200, corsHeaders); + } catch (error) { + console.error('[handleGetOrder] Error:', error); + return createErrorResponse('Internal server error', 500, undefined, corsHeaders); + } +} + +/** + * DELETE /api/provision/orders/:id + * Delete/terminate a server + */ +export async function handleDeleteOrder( + request: Request, + env: Env, + orderId: string, + corsHeaders: Record +): Promise { + try { + const url = new URL(request.url); + const userId = url.searchParams.get('user_id'); // This is telegram_id + + if (!userId) { + return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); + } + + const provisioningService = new ProvisioningService( + env, + env.DB, + env.USER_DB, + env.LINODE_API_KEY, + env.VULTR_API_KEY + ); + + const result = await provisioningService.deleteServer(orderId, userId); + + if (!result.success) { + return createErrorResponse(result.error!, 400, 'DELETE_FAILED', corsHeaders); + } + + return createSuccessResponse({ message: 'Server terminated successfully' }, 200, corsHeaders); + } catch (error) { + console.error('[handleDeleteOrder] Error:', error); + return createErrorResponse('Internal server error', 500, undefined, corsHeaders); + } +} + +/** + * GET /api/provision/balance + * Get user's balance (in KRW) + */ +export async function handleGetBalance( + request: Request, + env: Env, + corsHeaders: Record +): Promise { + try { + const url = new URL(request.url); + const userId = url.searchParams.get('user_id'); // This is telegram_id + + if (!userId) { + return createErrorResponse('user_id query parameter is required', 400, undefined, corsHeaders); + } + + const provisioningService = new ProvisioningService( + env, + env.DB, + env.USER_DB, + env.LINODE_API_KEY, + env.VULTR_API_KEY + ); + + const balance = await provisioningService.getUserBalance(userId); + + if (!balance) { + return createErrorResponse('User not found', 404, 'NOT_FOUND', corsHeaders); + } + + return createSuccessResponse( + { + balance_krw: balance.balance_krw, + currency: 'KRW', + }, + 200, + corsHeaders + ); + } catch (error) { + console.error('[handleGetBalance] Error:', error); + return createErrorResponse('Internal server error', 500, undefined, corsHeaders); + } +} + +/** + * Map error codes to HTTP status codes + */ +function getStatusCodeForError(code: string): number { + switch (code) { + case 'USER_NOT_FOUND': + case 'PRICING_NOT_FOUND': + return 404; + case 'INSUFFICIENT_BALANCE': + return 402; // Payment Required + case 'PROVIDER_NOT_CONFIGURED': + return 503; + case 'VALIDATION_ERROR': + return 400; + case 'UNAUTHORIZED': + return 403; + default: + return 500; + } +} diff --git a/src/handlers/queue.ts b/src/handlers/queue.ts new file mode 100644 index 0000000..ba4faac --- /dev/null +++ b/src/handlers/queue.ts @@ -0,0 +1,41 @@ +/** + * Queue Handler for async server provisioning + * Processes messages from PROVISION_QUEUE + */ + +import type { Env, ProvisionQueueMessage } from '../types'; +import { ProvisioningService } from '../services/provisioning-service'; + +/** + * Queue message handler + * Called by Cloudflare Workers Queue consumer + */ +export async function handleProvisionQueue( + batch: MessageBatch, + env: Env +): Promise { + const provisioningService = new ProvisioningService( + env, + env.DB, + env.USER_DB, + env.LINODE_API_KEY, + env.VULTR_API_KEY + ); + + for (const message of batch.messages) { + try { + console.log(`[Queue] Processing message for order ${message.body.order_id}`); + + await provisioningService.processQueueMessage(message.body); + + // Acknowledge successful processing + message.ack(); + console.log(`[Queue] Order ${message.body.order_id} processed successfully`); + } catch (error) { + console.error(`[Queue] Error processing order ${message.body.order_id}:`, error); + + // Retry the message (will go to DLQ after max_retries) + message.retry(); + } + } +} diff --git a/src/index.ts b/src/index.ts index 826df40..e0eba10 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,11 +5,20 @@ */ import type { Env } from './types'; -import { getAllowedOrigin, checkRateLimit, jsonResponse } from './utils'; +import { getAllowedOrigin, checkRateLimit, jsonResponse, isAllowedProvisionOrigin } from './utils'; import { handleHealth } from './handlers/health'; import { handleGetServers } from './handlers/servers'; import { handleRecommend } from './handlers/recommend'; import { handleReport } from './handlers/report'; +import { + handleProvision, + handleGetOrders, + handleGetOrder, + handleDeleteOrder, + handleGetBalance, +} from './handlers/provision'; +import { handleProvisionQueue } from './handlers/queue'; +import type { ProvisionQueueMessage } from './types'; /** * Main request handler @@ -44,7 +53,7 @@ export default { const origin = getAllowedOrigin(request); const corsHeaders = { 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Vary': 'Origin', }; @@ -71,6 +80,52 @@ export default { return handleReport(request, env, corsHeaders); } + // Provisioning endpoints (restricted to *.kappa-d8e.workers.dev + API key) + if (path.startsWith('/api/provision')) { + // Check API key (required for all provision requests) + const apiKey = request.headers.get('X-API-Key'); + if (!env.PROVISION_API_KEY || apiKey !== env.PROVISION_API_KEY) { + return jsonResponse( + { error: 'Unauthorized: Invalid or missing API key', code: 'UNAUTHORIZED' }, + 401, + corsHeaders + ); + } + + // Check origin for browser requests + if (!isAllowedProvisionOrigin(request)) { + return jsonResponse( + { error: 'Forbidden: Invalid origin', code: 'FORBIDDEN' }, + 403, + corsHeaders + ); + } + + if (path === '/api/provision' && request.method === 'POST') { + return handleProvision(request, env, corsHeaders); + } + + if (path === '/api/provision/orders' && request.method === 'GET') { + return handleGetOrders(request, env, corsHeaders); + } + + if (path === '/api/provision/balance' && request.method === 'GET') { + return handleGetBalance(request, env, corsHeaders); + } + + // Dynamic route: /api/provision/orders/:id + const orderMatch = path.match(/^\/api\/provision\/orders\/([a-zA-Z0-9-]+)$/); + if (orderMatch) { + const orderId = orderMatch[1]; + if (request.method === 'GET') { + return handleGetOrder(request, env, orderId, corsHeaders); + } + if (request.method === 'DELETE') { + return handleDeleteOrder(request, env, orderId, corsHeaders); + } + } + } + return jsonResponse( { error: 'Not found', request_id: requestId }, 404, @@ -92,4 +147,11 @@ export default { ); } }, + + /** + * Queue handler for async server provisioning + */ + async queue(batch: MessageBatch, env: Env): Promise { + await handleProvisionQueue(batch, env); + }, }; diff --git a/src/repositories/ProvisioningRepository.ts b/src/repositories/ProvisioningRepository.ts new file mode 100644 index 0000000..811c297 --- /dev/null +++ b/src/repositories/ProvisioningRepository.ts @@ -0,0 +1,272 @@ +/** + * Repository for provisioning-related database operations + * Uses two databases: + * - DB (cloud-instances-db): server specs, pricing, providers + * - USER_DB (telegram-conversations): users, deposits, orders + */ + +import type { TelegramUser, UserDeposit, ServerOrder, PricingWithProvider } from '../types'; + +export class ProvisioningRepository { + constructor( + private db: D1Database, // cloud-instances-db + private userDb: D1Database // telegram-conversations + ) {} + + // ============================================ + // User Operations (telegram-conversations.users) + // ============================================ + + async getUserByTelegramId(telegramId: string): Promise { + const result = await this.userDb + .prepare('SELECT * FROM users WHERE telegram_id = ?') + .bind(telegramId) + .first(); + + return result as unknown as TelegramUser | null; + } + + async getUserById(userId: number): Promise { + const result = await this.userDb + .prepare('SELECT * FROM users WHERE id = ?') + .bind(userId) + .first(); + + return result as unknown as TelegramUser | null; + } + + // ============================================ + // Deposit Operations (telegram-conversations.user_deposits) + // Balance is in KRW (원), INTEGER type + // ============================================ + + async getUserDeposit(userId: number): Promise { + const result = await this.userDb + .prepare('SELECT * FROM user_deposits WHERE user_id = ?') + .bind(userId) + .first(); + + return result as unknown as UserDeposit | null; + } + + async getBalance(userId: number): Promise { + const deposit = await this.getUserDeposit(userId); + return deposit?.balance ?? 0; + } + + async deductBalance(userId: number, amount: number): Promise { + // Atomic balance deduction - prevents race condition + const result = await this.userDb + .prepare( + `UPDATE user_deposits + SET balance = balance - ?, updated_at = datetime('now') + WHERE user_id = ? AND balance >= ? + RETURNING balance` + ) + .bind(amount, userId, amount) + .first(); + + // If no row returned, balance was insufficient + return result !== null; + } + + async refundBalance(userId: number, amount: number): Promise { + await this.userDb + .prepare( + `UPDATE user_deposits + SET balance = balance + ?, updated_at = datetime('now') + WHERE user_id = ?` + ) + .bind(amount, userId) + .run(); + } + + // ============================================ + // Server Order Operations (telegram-conversations.server_orders) + // ============================================ + + async createServerOrder( + userId: number, + specId: number, + region: string, + pricePaid: number, + label: string | null, + image: string | null + ): Promise { + const result = await this.userDb + .prepare( + `INSERT INTO server_orders + (user_id, spec_id, status, region, price_paid, label, image, billing_type) + VALUES (?, ?, 'pending', ?, ?, ?, ?, 'monthly') + RETURNING *` + ) + .bind(userId, specId, region, pricePaid, label, image) + .first(); + + return result as unknown as ServerOrder; + } + + async updateOrderStatus( + orderId: number, + status: ServerOrder['status'], + errorMessage?: string + ): Promise { + if (errorMessage) { + await this.userDb + .prepare( + `UPDATE server_orders + SET status = ?, error_message = ?, updated_at = datetime('now') + WHERE id = ?` + ) + .bind(status, errorMessage, orderId) + .run(); + } else { + await this.userDb + .prepare( + `UPDATE server_orders + SET status = ?, updated_at = datetime('now') + WHERE id = ?` + ) + .bind(status, orderId) + .run(); + } + } + + async updateOrderRootPassword(orderId: number, rootPassword: string): Promise { + await this.userDb + .prepare( + `UPDATE server_orders + SET root_password = ?, updated_at = datetime('now') + WHERE id = ?` + ) + .bind(rootPassword, orderId) + .run(); + } + + async updateOrderProviderInfo( + orderId: number, + providerInstanceId: string, + ipAddress: string | null, + rootPassword: string + ): Promise { + await this.userDb + .prepare( + `UPDATE server_orders + SET provider_instance_id = ?, + ip_address = ?, + root_password = ?, + status = 'active', + provisioned_at = datetime('now'), + updated_at = datetime('now') + WHERE id = ?` + ) + .bind(providerInstanceId, ipAddress, rootPassword, orderId) + .run(); + } + + async getOrderById(orderId: number): Promise { + const result = await this.userDb + .prepare('SELECT * FROM server_orders WHERE id = ?') + .bind(orderId) + .first(); + + return result as unknown as ServerOrder | null; + } + + async getOrdersByUserId(userId: number, limit: number = 20): Promise { + const result = await this.userDb + .prepare( + 'SELECT * FROM server_orders WHERE user_id = ? ORDER BY created_at DESC LIMIT ?' + ) + .bind(userId, limit) + .all(); + + return result.results as unknown as ServerOrder[]; + } + + // ============================================ + // Pricing Lookup (cloud-instances-db) + // Uses anvil_pricing, anvil_instances, anvil_regions + // ============================================ + + async getPricingWithProvider(pricingId: number): Promise { + const result = await this.db + .prepare( + `SELECT + ap.id as pricing_id, + 0 as provider_id, + 'Anvil' as provider_name, + 'https://api.anvil.cloud' as api_base_url, + ai.name as instance_id, + ai.display_name as instance_name, + ar.name as region_code, + ar.display_name as region_name, + ap.monthly_price, + ai.vcpus as vcpu, + CAST(ai.memory_gb * 1024 AS INTEGER) as memory_mb, + ai.disk_gb as storage_gb + FROM anvil_pricing ap + JOIN anvil_instances ai ON ap.anvil_instance_id = ai.id + JOIN anvil_regions ar ON ap.anvil_region_id = ar.id + WHERE ap.id = ? AND ai.active = 1 AND ar.active = 1` + ) + .bind(pricingId) + .first(); + + return result as unknown as PricingWithProvider | null; + } + + // ============================================ + // Atomic Operations (balance deduction + order creation) + // ============================================ + + /** + * Deduct balance and create order atomically + * Returns order ID on success, null on insufficient balance + */ + async createOrderWithPayment( + userId: number, + specId: number, + region: string, + priceKrw: number, + label: string | null, + image: string | null + ): Promise<{ orderId: number | null; error?: string }> { + try { + // Step 1: Check and deduct balance + const deducted = await this.deductBalance(userId, priceKrw); + if (!deducted) { + return { orderId: null, error: 'INSUFFICIENT_BALANCE' }; + } + + // Step 2: Create order + const order = await this.createServerOrder( + userId, + specId, + region, + priceKrw, + label, + image + ); + + return { orderId: order.id }; + } catch (error) { + // Attempt to refund on error + try { + await this.refundBalance(userId, priceKrw); + } catch { + // Log but don't throw - manual intervention needed + console.error('[ProvisioningRepository] Failed to refund on error:', error); + } + return { orderId: null, error: 'ORDER_CREATION_FAILED' }; + } + } + + /** + * Refund balance and update order status on provisioning failure + */ + async rollbackOrder(orderId: number, userId: number, amount: number, errorMessage: string): Promise { + await this.refundBalance(userId, amount); + await this.updateOrderStatus(orderId, 'failed', errorMessage); + } +} diff --git a/src/services/linode-provider.ts b/src/services/linode-provider.ts new file mode 100644 index 0000000..f2bb6d5 --- /dev/null +++ b/src/services/linode-provider.ts @@ -0,0 +1,159 @@ +/** + * Linode VPS Provider Implementation + * API Docs: https://www.linode.com/docs/api/ + */ + +import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types'; +import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider'; + +interface LinodeInstance { + id: number; + label: string; + status: string; + ipv4: string[]; + ipv6: string; + region: string; + type: string; + created: string; + updated: string; +} + +interface LinodeError { + errors: Array<{ + field?: string; + reason: string; + }>; +} + +export class LinodeProvider extends VPSProviderBase { + constructor(apiKey: string, timeout: number = 30000) { + super({ + apiKey, + baseUrl: 'https://api.linode.com/v4', + timeout, + }); + } + + async createServer(request: CreateServerRequest): Promise { + const url = `${this.config.baseUrl}/linode/instances`; + + const body = { + type: request.plan, + region: request.region, + image: request.osImage, + root_pass: request.rootPassword, + label: request.label || `server-${Date.now()}`, + tags: request.tags || [], + authorized_keys: request.sshKeys || [], + booted: true, + }; + + try { + const response = await this.fetchWithRetry(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = (await response.json()) as LinodeError; + return { + success: false, + error: { + code: `LINODE_${response.status}`, + message: error.errors?.[0]?.reason || 'Unknown error', + }, + }; + } + + const data = (await response.json()) as LinodeInstance; + + return { + success: true, + instanceId: String(data.id), + ipv4: data.ipv4?.[0], + ipv6: data.ipv6?.split('/')[0], // Remove CIDR notation + status: data.status, + }; + } catch (error) { + return { + success: false, + error: { + code: 'LINODE_NETWORK_ERROR', + message: error instanceof Error ? error.message : 'Network error', + }, + }; + } + } + + async deleteServer(instanceId: string): Promise<{ success: boolean; error?: string }> { + const url = `${this.config.baseUrl}/linode/instances/${instanceId}`; + + try { + const response = await this.fetchWithRetry(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + }, + }); + + if (!response.ok) { + const error = (await response.json()) as LinodeError; + return { + success: false, + error: error.errors?.[0]?.reason || 'Failed to delete instance', + }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Network error', + }; + } + } + + async getServerStatus( + instanceId: string + ): Promise<{ status: string; ipv4?: string; ipv6?: string }> { + const url = `${this.config.baseUrl}/linode/instances/${instanceId}`; + + try { + const response = await this.fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + }, + }); + + if (!response.ok) { + return { status: 'unknown' }; + } + + const data = (await response.json()) as LinodeInstance; + return { + status: data.status, + ipv4: data.ipv4?.[0], + ipv6: data.ipv6?.split('/')[0], + }; + } catch { + return { status: 'unknown' }; + } + } + + getOsImageId(osImage: string): string { + return OS_IMAGE_MAP.linode[osImage as keyof typeof OS_IMAGE_MAP.linode] || 'linode/ubuntu22.04'; + } + + /** + * Generate secure root password for Linode + * Linode requires: 6-128 chars, uppercase, lowercase, numeric + */ + generateRootPassword(): string { + return this.generateSecurePassword(32); + } +} diff --git a/src/services/provisioning-service.ts b/src/services/provisioning-service.ts new file mode 100644 index 0000000..2904020 --- /dev/null +++ b/src/services/provisioning-service.ts @@ -0,0 +1,392 @@ +/** + * Provisioning Service + * Orchestrates the server provisioning workflow with Queue-based async processing + */ + +import type { Env, ProvisionRequest, ProvisionResponse, ProvisionQueueMessage, ServerOrder, VPSProvider } from '../types'; +import { ProvisioningRepository } from '../repositories/ProvisioningRepository'; +import { LinodeProvider } from './linode-provider'; +import { VultrProvider } from './vultr-provider'; +import { getExchangeRate } from '../utils/exchange-rate'; + +export class ProvisioningService { + private repo: ProvisioningRepository; + private linodeProvider: LinodeProvider | null; + private vultrProvider: VultrProvider | null; + private env: Env; + + constructor( + env: Env, // Full env for exchange rate API + cache + queue + db: D1Database, // cloud-instances-db: pricing, providers + userDb: D1Database, // telegram-conversations: users, deposits, orders + linodeApiKey?: string, + vultrApiKey?: string + ) { + this.env = env; + this.repo = new ProvisioningRepository(db, userDb); + this.linodeProvider = linodeApiKey ? new LinodeProvider(linodeApiKey) : null; + this.vultrProvider = vultrApiKey ? new VultrProvider(vultrApiKey) : null; + } + + /** + * Main provisioning workflow (async via Queue) + * 1. Validate user by telegram_id + * 2. Get pricing details + * 3. Check and deduct balance (KRW) + * 4. Create order with status 'queued' + * 5. Send message to Queue + * 6. Return immediately with order info + */ + async provisionServer(request: ProvisionRequest): Promise { + const { telegram_id, pricing_id, label, image, dry_run } = request; + const osImageKey = image || 'ubuntu_22_04'; + + // Step 1: Validate user by telegram_id + const user = await this.repo.getUserByTelegramId(telegram_id); + if (!user) { + return { + success: false, + error: { code: 'USER_NOT_FOUND', message: 'User not found' }, + }; + } + + // Step 2: Get pricing details with provider info + const pricing = await this.repo.getPricingWithProvider(pricing_id); + if (!pricing) { + return { + success: false, + error: { code: 'PRICING_NOT_FOUND', message: 'Invalid pricing_id or unavailable' }, + }; + } + + // Step 3: Calculate price in KRW using real-time exchange rate + // Round to nearest 500 KRW for cleaner pricing + const exchangeRate = await getExchangeRate(this.env); + const priceKrw = Math.round((pricing.monthly_price * exchangeRate) / 500) * 500; + + // Step 4: Check balance + const currentBalance = await this.repo.getBalance(user.id); + if (currentBalance < priceKrw) { + return { + success: false, + error: { + code: 'INSUFFICIENT_BALANCE', + message: `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}, Available: ₩${currentBalance.toLocaleString()}`, + }, + }; + } + + // Dry run mode: return validation result without creating order + if (dry_run) { + return { + success: true, + order: { + id: 0, + user_id: user.id, + spec_id: pricing_id, + status: 'pending', + region: pricing.region_code, + provider_instance_id: null, + ip_address: null, + root_password: null, + price_paid: priceKrw, + error_message: null, + provisioned_at: null, + terminated_at: null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + label: label || null, + image: osImageKey, + billing_type: 'monthly', + }, + dry_run_info: { + message: 'Dry run successful. No server created, no balance deducted.', + user_id: user.id, + telegram_id, + current_balance_krw: currentBalance, + price_krw: priceKrw, + remaining_balance_krw: currentBalance - priceKrw, + provider: pricing.provider_name, + instance: pricing.instance_name, + region: pricing.region_name, + }, + } as ProvisionResponse; + } + + // Step 5: Generate root password before creating order + const rootPassword = this.generateSecurePassword(); + + // Step 6: Create order with payment (atomic balance deduction + order creation) + const orderResult = await this.repo.createOrderWithPayment( + user.id, + pricing_id, + pricing.region_code, + priceKrw, + label || null, + osImageKey + ); + + if (!orderResult.orderId) { + return { + success: false, + error: { + code: orderResult.error || 'ORDER_CREATION_FAILED', + message: orderResult.error === 'INSUFFICIENT_BALANCE' + ? `Insufficient balance. Required: ₩${priceKrw.toLocaleString()}` + : 'Failed to create order', + }, + }; + } + + const orderId = orderResult.orderId; + + // Step 7: Store root password in order (encrypted in production) + await this.repo.updateOrderRootPassword(orderId, rootPassword); + + // Step 8: Update order status to 'queued' and send to Queue + await this.repo.updateOrderStatus(orderId, 'provisioning'); + + // Send message to provision queue (root_password stored in DB, not in queue for security) + const queueMessage: ProvisionQueueMessage = { + order_id: orderId, + user_id: user.id, + pricing_id, + label: label || null, + image: osImageKey, + timestamp: new Date().toISOString(), + }; + + await this.env.PROVISION_QUEUE.send(queueMessage); + console.log(`[ProvisioningService] Order ${orderId} queued for provisioning`); + + // Step 9: Return immediately with order info + const order = await this.repo.getOrderById(orderId); + + return { + success: true, + order: order!, + }; + } + + /** + * Process provision queue message (called by Queue consumer) + * 1. Get order and pricing details + * 2. Call provider API + * 3. On success: update order to active + * 4. On failure: refund balance, update order to failed + */ + async processQueueMessage(message: ProvisionQueueMessage): Promise { + const { order_id, user_id, pricing_id, label, image } = message; + console.log(`[ProvisioningService] Processing order ${order_id}`); + + // Fetch order to get root_password (stored in DB for security) + const order = await this.repo.getOrderById(order_id); + if (!order || !order.root_password) { + console.error(`[ProvisioningService] Order or root_password not found for order ${order_id}`); + await this.repo.updateOrderStatus(order_id, 'failed', 'Order not found'); + return; + } + const root_password = order.root_password; + + // Get pricing details + const pricing = await this.repo.getPricingWithProvider(pricing_id); + if (!pricing) { + console.error(`[ProvisioningService] Pricing not found for order ${order_id}`); + await this.repo.rollbackOrder(order_id, user_id, order.price_paid, 'Pricing not found'); + return; + } + + // Get provider + const provider = this.getProvider(pricing.provider_name.toLowerCase() as VPSProvider); + if (!provider) { + console.error(`[ProvisioningService] Provider not configured for order ${order_id}`); + await this.repo.rollbackOrder(order_id, user_id, order.price_paid, 'Provider not configured'); + return; + } + + // Get OS image ID + const osImageId = provider.getOsImageId(image); + + // Call provider API + const createResult = await provider.createServer({ + plan: pricing.instance_id, + region: pricing.region_code, + osImage: osImageId, + label: label || `order-${order_id}`, + rootPassword: root_password, + tags: [`user:${user_id}`, `order:${order_id}`], + }); + + if (!createResult.success) { + console.error(`[ProvisioningService] Provider API failed for order ${order_id}:`, createResult.error); + await this.repo.rollbackOrder( + order_id, + user_id, + order.price_paid, + createResult.error?.message || 'Provider API error' + ); + // Throw error to trigger retry + throw new Error(createResult.error?.message || 'Provider API error'); + } + + // Wait for IP assignment if not immediately available + let ipv4 = createResult.ipv4; + if (!ipv4 && createResult.instanceId) { + console.log(`[ProvisioningService] Waiting for IP assignment for order ${order_id}...`); + const readyResult = await this.waitForServerReady(provider, createResult.instanceId); + ipv4 = readyResult.ipv4; + if (!ipv4) { + console.warn(`[ProvisioningService] IP not assigned within timeout for order ${order_id}`); + } + } + + // Success - update order with provider info + await this.repo.updateOrderProviderInfo( + order_id, + createResult.instanceId!, + ipv4 || null, + root_password + ); + + console.log(`[ProvisioningService] Order ${order_id} provisioned successfully: ${createResult.instanceId}, IP: ${ipv4 || 'pending'}`); + } + + /** + * Get provider instance by name + */ + private getProvider(providerName: VPSProvider): LinodeProvider | VultrProvider | null { + switch (providerName) { + case 'linode': + return this.linodeProvider; + case 'vultr': + return this.vultrProvider; + default: + return null; + } + } + + /** + * Wait for server to be ready with IP assigned + * Polls getServerStatus every 5 seconds for up to 2 minutes + */ + private async waitForServerReady( + provider: LinodeProvider | VultrProvider, + instanceId: string, + maxWaitMs: number = 120000 + ): Promise<{ ready: boolean; ipv4?: string; ipv6?: string }> { + const startTime = Date.now(); + const pollInterval = 5000; + + while (Date.now() - startTime < maxWaitMs) { + const status = await provider.getServerStatus(instanceId); + + if (status.ipv4) { + return { ready: true, ipv4: status.ipv4, ipv6: status.ipv6 }; + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + return { ready: false }; + } + + /** + * Generate cryptographically secure password + */ + private generateSecurePassword(length: number = 32): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*'; + const array = new Uint8Array(length); + crypto.getRandomValues(array); + let password = ''; + for (let i = 0; i < length; i++) { + password += chars[array[i] % chars.length]; + } + return password; + } + + // ============================================ + // Additional Operations + // ============================================ + + /** + * Get user's server orders by telegram_id + */ + async getUserOrders(telegramId: string, limit: number = 20): Promise { + const user = await this.repo.getUserByTelegramId(telegramId); + if (!user) return []; + return this.repo.getOrdersByUserId(user.id, limit); + } + + /** + * Get order by ID + */ + async getOrder(orderId: string): Promise { + const numericId = parseInt(orderId, 10); + if (isNaN(numericId)) return null; + return this.repo.getOrderById(numericId); + } + + /** + * Get user balance by telegram_id (in KRW) + */ + async getUserBalance(telegramId: string): Promise<{ balance_krw: number; user_id: number } | null> { + const user = await this.repo.getUserByTelegramId(telegramId); + if (!user) return null; + const balance = await this.repo.getBalance(user.id); + return { balance_krw: balance, user_id: user.id }; + } + + /** + * Delete a server (terminate) - requires telegram_id for authorization + */ + async deleteServer(orderId: string, telegramId: string): Promise<{ success: boolean; error?: string }> { + const user = await this.repo.getUserByTelegramId(telegramId); + if (!user) { + return { success: false, error: 'User not found' }; + } + + const numericOrderId = parseInt(orderId, 10); + if (isNaN(numericOrderId)) { + return { success: false, error: 'Invalid order ID' }; + } + + const order = await this.repo.getOrderById(numericOrderId); + + if (!order) { + return { success: false, error: 'Order not found' }; + } + + if (order.user_id !== user.id) { + return { success: false, error: 'Unauthorized' }; + } + + if (order.status !== 'active') { + return { success: false, error: 'Server is not active' }; + } + + if (!order.provider_instance_id) { + return { success: false, error: 'No provider instance ID' }; + } + + // Get provider from pricing info + const pricing = await this.repo.getPricingWithProvider(order.spec_id); + if (!pricing) { + return { success: false, error: 'Pricing info not found' }; + } + + const provider = this.getProvider(pricing.provider_name.toLowerCase() as VPSProvider); + if (!provider) { + return { success: false, error: 'Provider not configured' }; + } + + const deleteResult = await provider.deleteServer(order.provider_instance_id); + + if (!deleteResult.success) { + return { success: false, error: deleteResult.error }; + } + + await this.repo.updateOrderStatus(numericOrderId, 'terminated'); + + return { success: true }; + } +} diff --git a/src/services/vps-provider.ts b/src/services/vps-provider.ts new file mode 100644 index 0000000..665ac1e --- /dev/null +++ b/src/services/vps-provider.ts @@ -0,0 +1,117 @@ +/** + * VPS Provider Abstract Base Class + * Common interface for Linode, Vultr, and other VPS providers + */ + +import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types'; + +export abstract class VPSProviderBase { + protected config: VPSProviderConfig; + + constructor(config: VPSProviderConfig) { + this.config = config; + } + + /** + * Create a new server instance + */ + abstract createServer(request: CreateServerRequest): Promise; + + /** + * Delete a server instance + */ + abstract deleteServer(instanceId: string): Promise<{ success: boolean; error?: string }>; + + /** + * Get server status + */ + abstract getServerStatus(instanceId: string): Promise<{ status: string; ipv4?: string; ipv6?: string }>; + + /** + * Map OS image key to provider-specific identifier + */ + abstract getOsImageId(osImage: string): string; + + /** + * Fetch with retry and exponential backoff + */ + protected async fetchWithRetry( + url: string, + options: RequestInit, + retries: number = 3 + ): Promise { + let lastError: Error | null = null; + + for (let i = 0; i < retries; i++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + const response = await fetch(url, { + ...options, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + // Don't retry on client errors (4xx), only server errors (5xx) + if (response.ok || (response.status >= 400 && response.status < 500)) { + return response; + } + + // Retry on 5xx errors + if (response.status >= 500 && i < retries - 1) { + await this.sleep(Math.pow(2, i) * 1000); + continue; + } + + return response; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (i === retries - 1) break; + await this.sleep(Math.pow(2, i) * 1000); + } + } + + throw lastError ?? new Error('Max retries exceeded'); + } + + /** + * Sleep for specified milliseconds + */ + protected sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * Generate cryptographically secure password + */ + protected generateSecurePassword(length: number = 32): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*'; + const array = new Uint8Array(length); + crypto.getRandomValues(array); + let password = ''; + for (let i = 0; i < length; i++) { + password += chars[array[i] % chars.length]; + } + return password; + } +} + +/** + * OS Image mapping for providers + */ +export const OS_IMAGE_MAP = { + linode: { + ubuntu_22_04: 'linode/ubuntu22.04', + ubuntu_20_04: 'linode/ubuntu20.04', + debian_11: 'linode/debian11', + debian_12: 'linode/debian12', + }, + vultr: { + ubuntu_22_04: '2284', // Ubuntu 22.04 x64 + ubuntu_20_04: '1743', // Ubuntu 20.04 x64 + debian_11: '477', // Debian 11 x64 + debian_12: '2136', // Debian 12 x64 + }, +} as const; diff --git a/src/services/vultr-provider.ts b/src/services/vultr-provider.ts new file mode 100644 index 0000000..608fbcf --- /dev/null +++ b/src/services/vultr-provider.ts @@ -0,0 +1,189 @@ +/** + * Vultr VPS Provider Implementation + * API Docs: https://www.vultr.com/api/ + */ + +import type { VPSProviderConfig, CreateServerRequest, CreateServerResponse } from '../types'; +import { VPSProviderBase, OS_IMAGE_MAP } from './vps-provider'; + +interface VultrInstance { + id: string; + os: string; + ram: number; + disk: number; + main_ip: string; + vcpu_count: number; + region: string; + plan: string; + date_created: string; + status: string; + power_status: string; + server_status: string; + v6_main_ip: string; + v6_network: string; + hostname: string; + label: string; + tag: string; +} + +interface VultrCreateResponse { + instance: VultrInstance; +} + +interface VultrError { + error: string; + status: number; +} + +export class VultrProvider extends VPSProviderBase { + constructor(apiKey: string, timeout: number = 30000) { + super({ + apiKey, + baseUrl: 'https://api.vultr.com/v2', + timeout, + }); + } + + async createServer(request: CreateServerRequest): Promise { + const url = `${this.config.baseUrl}/instances`; + + const body = { + plan: request.plan, + region: request.region, + os_id: parseInt(request.osImage, 10), + label: request.label || `server-${Date.now()}`, + hostname: request.label || `server-${Date.now()}`, + tag: request.tags?.[0] || '', + sshkey_id: request.sshKeys || [], + enable_ipv6: true, + backups: 'disabled', + ddos_protection: false, + }; + + try { + const response = await this.fetchWithRetry(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = (await response.json()) as VultrError; + return { + success: false, + error: { + code: `VULTR_${response.status}`, + message: error.error || 'Unknown error', + }, + }; + } + + const data = (await response.json()) as VultrCreateResponse; + const instance = data.instance; + + return { + success: true, + instanceId: instance.id, + ipv4: instance.main_ip !== '0.0.0.0' ? instance.main_ip : undefined, + ipv6: instance.v6_main_ip || undefined, + status: instance.status, + }; + } catch (error) { + return { + success: false, + error: { + code: 'VULTR_NETWORK_ERROR', + message: error instanceof Error ? error.message : 'Network error', + }, + }; + } + } + + async deleteServer(instanceId: string): Promise<{ success: boolean; error?: string }> { + const url = `${this.config.baseUrl}/instances/${instanceId}`; + + try { + const response = await this.fetchWithRetry(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + }, + }); + + // Vultr returns 204 No Content on success + if (response.status === 204 || response.ok) { + return { success: true }; + } + + const error = (await response.json()) as VultrError; + return { + success: false, + error: error.error || 'Failed to delete instance', + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Network error', + }; + } + } + + async getServerStatus( + instanceId: string + ): Promise<{ status: string; ipv4?: string; ipv6?: string }> { + const url = `${this.config.baseUrl}/instances/${instanceId}`; + + try { + const response = await this.fetchWithRetry(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + }, + }); + + if (!response.ok) { + return { status: 'unknown' }; + } + + const data = (await response.json()) as { instance: VultrInstance }; + return { + status: data.instance.status, + ipv4: data.instance.main_ip !== '0.0.0.0' ? data.instance.main_ip : undefined, + ipv6: data.instance.v6_main_ip || undefined, + }; + } catch { + return { status: 'unknown' }; + } + } + + getOsImageId(osImage: string): string { + return OS_IMAGE_MAP.vultr[osImage as keyof typeof OS_IMAGE_MAP.vultr] || '2284'; // Default Ubuntu 22.04 + } + + /** + * Wait for server to be ready (IP assigned) + * Vultr servers may take a minute to get IP addresses + */ + async waitForReady( + instanceId: string, + maxWaitMs: number = 120000 + ): Promise<{ ready: boolean; ipv4?: string; ipv6?: string }> { + const startTime = Date.now(); + const pollInterval = 5000; + + while (Date.now() - startTime < maxWaitMs) { + const status = await this.getServerStatus(instanceId); + + if (status.status === 'active' && status.ipv4) { + return { ready: true, ipv4: status.ipv4, ipv6: status.ipv6 }; + } + + await this.sleep(pollInterval); + } + + return { ready: false }; + } +} diff --git a/src/types.ts b/src/types.ts index 0b489ae..39c216c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,10 +4,29 @@ export interface Env { AI: Ai; // Legacy - kept for fallback - DB: D1Database; + DB: D1Database; // cloud-instances-db: server specs, pricing + USER_DB: D1Database; // telegram-conversations: users, deposits, orders CACHE: KVNamespace; OPENAI_API_KEY: string; AI_GATEWAY_URL?: string; // Cloudflare AI Gateway URL to bypass regional restrictions + // VPS Provider API Keys + LINODE_API_KEY?: string; + VULTR_API_KEY?: string; + // Provision API security + PROVISION_API_KEY?: string; // Required for /api/provision/* endpoints + // Queue for async provisioning + PROVISION_QUEUE: Queue; +} + +// Queue message for async server provisioning +// Note: root_password is stored in DB, not in queue message for security +export interface ProvisionQueueMessage { + order_id: number; + user_id: number; + pricing_id: number; + label: string | null; + image: string; + timestamp: string; } export interface ValidationError { @@ -203,3 +222,110 @@ export interface AIRecommendationResponse { }>; infrastructure_tips?: string[]; } + +// ============================================ +// Provisioning System Types (telegram-conversations DB) +// ============================================ + +// users table +export interface TelegramUser { + id: number; // AUTO INCREMENT + telegram_id: string; // Telegram user ID (used as external identifier) + username: string | null; + first_name: string | null; + created_at: string; + updated_at: string; +} + +// user_deposits table (balance in KRW, INTEGER) +export interface UserDeposit { + id: number; + user_id: number; // References users.id + balance: number; // Balance in KRW (원) + created_at: string; + updated_at: string; +} + +// server_orders table (existing schema) +export interface ServerOrder { + id: number; // AUTO INCREMENT + user_id: number; // References users.id + spec_id: number; // Server spec ID + status: 'pending' | 'provisioning' | 'active' | 'failed' | 'cancelled' | 'terminated'; + region: string; + provider_instance_id: string | null; + ip_address: string | null; + root_password: string | null; + price_paid: number; // Price in KRW (원) + error_message: string | null; + provisioned_at: string | null; + terminated_at: string | null; + created_at: string; + updated_at: string; + label: string | null; + image: string | null; + billing_type: string; // 'monthly' default +} + +export type VPSProvider = 'linode' | 'vultr'; + +export interface ProvisionRequest { + telegram_id: string; // Telegram user ID + pricing_id: number; // From cloud-instances-db.pricing + label?: string; + image?: string; // OS image (e.g., 'ubuntu_22_04') + dry_run?: boolean; // Test mode: validate only, don't create server +} + +export interface ProvisionResponse { + success: boolean; + order?: ServerOrder; + error?: { + code: string; + message: string; + }; +} + +// Provider API types +export interface VPSProviderConfig { + apiKey: string; + baseUrl: string; + timeout: number; +} + +export interface CreateServerRequest { + plan: string; // Provider-specific plan ID (instance_id) + region: string; // Provider-specific region code + osImage: string; // OS identifier + label?: string; + rootPassword: string; + sshKeys?: string[]; + tags?: string[]; +} + +export interface CreateServerResponse { + success: boolean; + instanceId?: string; + ipv4?: string; + ipv6?: string; + status?: string; + error?: { + code: string; + message: string; + }; +} + +export interface PricingWithProvider { + pricing_id: number; + provider_id: number; + provider_name: string; + api_base_url: string; + instance_id: string; // Provider's plan ID (e.g., g6-nanode-1) + instance_name: string; + region_code: string; // Provider's region code + region_name: string; + monthly_price: number; + vcpu: number; + memory_mb: number; + storage_gb: number; +} diff --git a/src/utils/http.ts b/src/utils/http.ts index 19640a1..db7d104 100644 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -36,6 +36,36 @@ export function jsonResponse( }); } +/** + * Create error response + */ +export function createErrorResponse( + message: string, + status: number, + code?: string, + corsHeaders: Record = {} +): Response { + return jsonResponse( + { + error: message, + code: code || 'ERROR', + }, + status, + corsHeaders + ); +} + +/** + * Create success response + */ +export function createSuccessResponse( + data: T, + status: number = 200, + corsHeaders: Record = {} +): Response { + return jsonResponse(data, status, corsHeaders); +} + /** * Helper function to get allowed CORS origin */ @@ -61,3 +91,34 @@ export function getAllowedOrigin(request: Request): string { // Browser will block the response due to CORS mismatch return allowedOrigins[0]; } + +/** + * Validate request origin for sensitive endpoints (e.g., provision) + * Only allows requests from *.kappa-d8e.workers.dev or worker-to-worker calls + */ +export function isAllowedProvisionOrigin(request: Request): boolean { + const origin = request.headers.get('Origin'); + const referer = request.headers.get('Referer'); + + // Allow worker-to-worker calls (Cloudflare adds this header) + // Note: This header indicates the request came from another Cloudflare Worker + const cfWorker = request.headers.get('CF-Worker'); + if (cfWorker) { + return true; + } + + // If no Origin header (server-to-server, curl, etc.) - check Referer or allow + // For strict mode, you might want to reject these too + if (!origin) { + // Check Referer as fallback + if (referer && referer.includes('.kappa-d8e.workers.dev')) { + return true; + } + // Allow server-to-server for now (can be made stricter with API key) + return true; + } + + // Check if Origin is from allowed domain + const allowedPattern = /^https:\/\/[a-zA-Z0-9-]+\.kappa-d8e\.workers\.dev$/; + return allowedPattern.test(origin); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index e9a5bb1..f866452 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -7,7 +7,8 @@ export { escapeHtml, jsonResponse, - getAllowedOrigin + getAllowedOrigin, + isAllowedProvisionOrigin } from './http'; // Validation utilities (type guards, request validation) diff --git a/wrangler.toml b/wrangler.toml index 3b28c3f..de99d4c 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -8,12 +8,18 @@ workers_dev = true [ai] binding = "AI" -# D1 Database binding (cloud-instances-db: 1,119 servers) +# D1 Database binding (cloud-instances-db: server specs, pricing) [[d1_databases]] binding = "DB" database_name = "cloud-instances-db" database_id = "bbcb472d-b25e-4e48-b6ea-112f9fffb4a8" +# D1 Database binding (telegram-conversations: users, deposits, orders) +[[d1_databases]] +binding = "USER_DB" +database_name = "telegram-conversations" +database_id = "c285bb5b-888b-405d-b36f-475ae5aed20e" + # KV Cache binding for rate limiting and response caching [[kv_namespaces]] binding = "CACHE" @@ -22,3 +28,14 @@ id = "c68cdb477022424cbe4594f491390c8a" # Observability [observability] enabled = true + +# Queue for async server provisioning +[[queues.producers]] +queue = "provision-queue" +binding = "PROVISION_QUEUE" + +[[queues.consumers]] +queue = "provision-queue" +max_batch_size = 1 +max_retries = 3 +dead_letter_queue = "provision-queue-dlq"