/** * HTTP Server for Onebox * * Serves REST API and Angular UI */ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import type { Onebox } from './onebox.ts'; import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView } from '../types.ts'; export class OneboxHttpServer { private oneboxRef: Onebox; private server: Deno.HttpServer | null = null; private port = 3000; private wsClients: Set = new Set(); constructor(oneboxRef: Onebox) { this.oneboxRef = oneboxRef; } /** * Start HTTP server */ async start(port?: number): Promise { try { if (this.server) { logger.warn('HTTP server already running'); return; } this.port = port || 3000; logger.info(`Starting HTTP server on port ${this.port}...`); this.server = Deno.serve({ port: this.port }, (req) => this.handleRequest(req)); logger.success(`HTTP server started on http://localhost:${this.port}`); } catch (error) { logger.error(`Failed to start HTTP server: ${error.message}`); throw error; } } /** * Stop HTTP server */ async stop(): Promise { try { if (!this.server) { return; } logger.info('Stopping HTTP server...'); await this.server.shutdown(); this.server = null; logger.success('HTTP server stopped'); } catch (error) { logger.error(`Failed to stop HTTP server: ${error.message}`); throw error; } } /** * Handle HTTP request */ private async handleRequest(req: Request): Promise { const url = new URL(req.url); const path = url.pathname; logger.debug(`${req.method} ${path}`); try { // WebSocket upgrade if (path === '/api/ws' && req.headers.get('upgrade') === 'websocket') { return this.handleWebSocketUpgrade(req); } // Log streaming WebSocket if (path.startsWith('/api/services/') && path.endsWith('/logs/stream') && req.headers.get('upgrade') === 'websocket') { const serviceName = path.split('/')[3]; return this.handleLogStreamUpgrade(req, serviceName); } // Docker Registry v2 API (no auth required - registry handles it) if (path.startsWith('/v2/')) { return await this.oneboxRef.registry.handleRequest(req); } // API routes if (path.startsWith('/api/')) { return await this.handleApiRequest(req, path); } // Serve Angular UI return await this.serveStaticFile(path); } catch (error) { logger.error(`Request error: ${error.message}`); return this.jsonResponse({ success: false, error: error.message }, 500); } } /** * Serve static files from ui/dist */ private async serveStaticFile(path: string): Promise { try { // Default to index.html for root and non-file paths let filePath = path === '/' ? '/index.html' : path; // For Angular routing - serve index.html for non-asset paths if (!filePath.includes('.') && filePath !== '/index.html') { filePath = '/index.html'; } const fullPath = `./ui/dist/ui/browser${filePath}`; // Read file const file = await Deno.readFile(fullPath); // Determine content type const contentType = this.getContentType(filePath); // Prevent stale bundles in dev (no hashed filenames) while allowing long-lived caching for hashed prod assets const isHashedAsset = /\.[a-f0-9]{8,}\./i.test(filePath); const cacheControl = filePath === '/index.html' || !isHashedAsset ? 'no-cache' : 'public, max-age=31536000, immutable'; return new Response(file, { headers: { 'Content-Type': contentType, 'Cache-Control': cacheControl, }, }); } catch (error) { // File not found - serve index.html for Angular routing if (error instanceof Deno.errors.NotFound) { try { const indexFile = await Deno.readFile('./ui/dist/ui/browser/index.html'); return new Response(indexFile, { headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache', }, }); } catch { return new Response('UI not built. Run: cd ui && npm run build', { status: 404, headers: { 'Content-Type': 'text/plain' }, }); } } return new Response('File not found', { status: 404, headers: { 'Content-Type': 'text/plain' }, }); } } /** * Get content type for file */ private getContentType(path: string): string { const ext = path.split('.').pop()?.toLowerCase(); const mimeTypes: Record = { 'html': 'text/html', 'css': 'text/css', 'js': 'application/javascript', 'json': 'application/json', 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', 'svg': 'image/svg+xml', 'ico': 'image/x-icon', 'woff': 'font/woff', 'woff2': 'font/woff2', 'ttf': 'font/ttf', 'eot': 'application/vnd.ms-fontobject', }; return mimeTypes[ext || ''] || 'application/octet-stream'; } /** * Handle API requests */ private async handleApiRequest(req: Request, path: string): Promise { const method = req.method; // Auth check (simplified - should use proper JWT middleware) // Skip auth for login endpoint if (path !== '/api/auth/login') { const authHeader = req.headers.get('Authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return this.jsonResponse({ success: false, error: 'Unauthorized' }, 401); } } // Route to appropriate handler if (path === '/api/auth/login' && method === 'POST') { return await this.handleLoginRequest(req); } else if (path === '/api/status' && method === 'GET') { return await this.handleStatusRequest(); } else if (path === '/api/settings' && method === 'GET') { return await this.handleGetSettingsRequest(); } else if (path === '/api/settings' && (method === 'PUT' || method === 'POST')) { return await this.handleUpdateSettingsRequest(req); } else if (path === '/api/services' && method === 'GET') { return await this.handleListServicesRequest(); } else if (path === '/api/services' && method === 'POST') { return await this.handleDeployServiceRequest(req); } else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'GET') { const name = path.split('/').pop()!; return await this.handleGetServiceRequest(name); } else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'PUT') { const name = path.split('/').pop()!; return await this.handleUpdateServiceRequest(name, req); } else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'DELETE') { const name = path.split('/').pop()!; return await this.handleDeleteServiceRequest(name); } else if (path.match(/^\/api\/services\/[^/]+\/start$/) && method === 'POST') { const name = path.split('/')[3]; return await this.handleStartServiceRequest(name); } else if (path.match(/^\/api\/services\/[^/]+\/stop$/) && method === 'POST') { const name = path.split('/')[3]; return await this.handleStopServiceRequest(name); } else if (path.match(/^\/api\/services\/[^/]+\/restart$/) && method === 'POST') { const name = path.split('/')[3]; return await this.handleRestartServiceRequest(name); } else if (path.match(/^\/api\/services\/[^/]+\/logs$/) && method === 'GET') { const name = path.split('/')[3]; return await this.handleGetLogsRequest(name); } else if (path === '/api/ssl/obtain' && method === 'POST') { return await this.handleObtainCertificateRequest(req); } else if (path === '/api/ssl/list' && method === 'GET') { return await this.handleListCertificatesRequest(); } else if (path.match(/^\/api\/ssl\/[^/]+$/) && method === 'GET') { const domain = path.split('/').pop()!; return await this.handleGetCertificateRequest(domain); } else if (path.match(/^\/api\/ssl\/[^/]+\/renew$/) && method === 'POST') { const domain = path.split('/')[3]; return await this.handleRenewCertificateRequest(domain); } else if (path === '/api/domains' && method === 'GET') { return await this.handleGetDomainsRequest(); } else if (path === '/api/domains/sync' && method === 'POST') { return await this.handleSyncDomainsRequest(); } else if (path.match(/^\/api\/domains\/[^/]+$/) && method === 'GET') { const domainName = path.split('/').pop()!; return await this.handleGetDomainDetailRequest(domainName); } else if (path === '/api/dns' && method === 'GET') { return await this.handleGetDnsRecordsRequest(); } else if (path === '/api/dns' && method === 'POST') { return await this.handleCreateDnsRecordRequest(req); } else if (path.match(/^\/api\/dns\/[^/]+$/) && method === 'DELETE') { const domain = path.split('/').pop()!; return await this.handleDeleteDnsRecordRequest(domain); } else if (path === '/api/dns/sync' && method === 'POST') { return await this.handleSyncDnsRecordsRequest(); } else if (path.match(/^\/api\/registry\/tags\/[^/]+$/)) { const serviceName = path.split('/').pop()!; return await this.handleGetRegistryTagsRequest(serviceName); } else if (path === '/api/registry/tokens' && method === 'GET') { return await this.handleListRegistryTokensRequest(req); } else if (path === '/api/registry/tokens' && method === 'POST') { return await this.handleCreateRegistryTokenRequest(req); } else if (path.match(/^\/api\/registry\/tokens\/\d+$/) && method === 'DELETE') { const tokenId = Number(path.split('/').pop()); return await this.handleDeleteRegistryTokenRequest(tokenId); // Platform Services endpoints } else if (path === '/api/platform-services' && method === 'GET') { return await this.handleListPlatformServicesRequest(); } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)$/) && method === 'GET') { const type = path.split('/').pop()!; return await this.handleGetPlatformServiceRequest(type); } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/start$/) && method === 'POST') { const type = path.split('/')[3]; return await this.handleStartPlatformServiceRequest(type); } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/stop$/) && method === 'POST') { const type = path.split('/')[3]; return await this.handleStopPlatformServiceRequest(type); } else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') { const serviceName = path.split('/')[3]; return await this.handleGetServicePlatformResourcesRequest(serviceName); } else { return this.jsonResponse({ success: false, error: 'Not found' }, 404); } } // API Handlers private async handleLoginRequest(req: Request): Promise { try { const body = await req.json(); const { username, password } = body; logger.info(`Login attempt for user: ${username}`); if (!username || !password) { return this.jsonResponse( { success: false, error: 'Username and password required' }, 400 ); } // Get user from database const user = this.oneboxRef.database.getUserByUsername(username); if (!user) { logger.info(`User not found: ${username}`); return this.jsonResponse({ success: false, error: 'Invalid credentials' }, 401); } logger.info(`User found: ${username}, checking password...`); // Verify password (simple base64 comparison for now) const passwordHash = btoa(password); logger.info(`Password hash: ${passwordHash}, stored hash: ${user.passwordHash}`); if (passwordHash !== user.passwordHash) { logger.info(`Password mismatch for user: ${username}`); return this.jsonResponse({ success: false, error: 'Invalid credentials' }, 401); } // Generate simple token (in production, use proper JWT) const token = btoa(`${user.username}:${Date.now()}`); return this.jsonResponse({ success: true, data: { token, user: { username: user.username, role: user.role, }, }, }); } catch (error) { logger.error(`Login error: ${error.message}`); return this.jsonResponse({ success: false, error: 'Login failed' }, 500); } } private async handleStatusRequest(): Promise { try { const status = await this.oneboxRef.getSystemStatus(); return this.jsonResponse({ success: true, data: status }); } catch (error) { logger.error(`Failed to get system status: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to get system status' }, 500); } } private async handleListServicesRequest(): Promise { try { const services = this.oneboxRef.services.listServices(); return this.jsonResponse({ success: true, data: services }); } catch (error) { logger.error(`Failed to list services: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to list services' }, 500); } } private async handleDeployServiceRequest(req: Request): Promise { try { const body = await req.json(); const service = await this.oneboxRef.services.deployService(body); // Broadcast service created this.broadcastServiceUpdate(service.name, 'created', service); return this.jsonResponse({ success: true, data: service }); } catch (error) { logger.error(`Failed to deploy service: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to deploy service' }, 500); } } private async handleGetServiceRequest(name: string): Promise { try { const service = this.oneboxRef.services.getService(name); if (!service) { return this.jsonResponse({ success: false, error: 'Service not found' }, 404); } return this.jsonResponse({ success: true, data: service }); } catch (error) { logger.error(`Failed to get service ${name}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to get service' }, 500); } } private async handleUpdateServiceRequest(name: string, req: Request): Promise { try { const body = await req.json(); const updates: { image?: string; registry?: string; port?: number; domain?: string; envVars?: Record; } = {}; // Extract valid update fields if (body.image !== undefined) updates.image = body.image; if (body.registry !== undefined) updates.registry = body.registry; if (body.port !== undefined) updates.port = body.port; if (body.domain !== undefined) updates.domain = body.domain; if (body.envVars !== undefined) updates.envVars = body.envVars; const service = await this.oneboxRef.services.updateService(name, updates); // Broadcast service updated this.broadcastServiceUpdate(name, 'updated', service); return this.jsonResponse({ success: true, data: service }); } catch (error) { logger.error(`Failed to update service ${name}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to update service' }, 500); } } private async handleDeleteServiceRequest(name: string): Promise { try { await this.oneboxRef.services.removeService(name); // Broadcast service deleted this.broadcastServiceUpdate(name, 'deleted'); return this.jsonResponse({ success: true, message: 'Service removed' }); } catch (error) { logger.error(`Failed to delete service ${name}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to delete service' }, 500); } } private async handleStartServiceRequest(name: string): Promise { try { await this.oneboxRef.services.startService(name); // Broadcast service started this.broadcastServiceUpdate(name, 'started'); return this.jsonResponse({ success: true, message: 'Service started' }); } catch (error) { logger.error(`Failed to start service ${name}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to start service' }, 500); } } private async handleStopServiceRequest(name: string): Promise { try { await this.oneboxRef.services.stopService(name); // Broadcast service stopped this.broadcastServiceUpdate(name, 'stopped'); return this.jsonResponse({ success: true, message: 'Service stopped' }); } catch (error) { logger.error(`Failed to stop service ${name}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to stop service' }, 500); } } private async handleRestartServiceRequest(name: string): Promise { try { await this.oneboxRef.services.restartService(name); // Broadcast service updated this.broadcastServiceUpdate(name, 'updated'); return this.jsonResponse({ success: true, message: 'Service restarted' }); } catch (error) { logger.error(`Failed to restart service ${name}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to restart service' }, 500); } } private async handleGetLogsRequest(name: string): Promise { try { const logs = await this.oneboxRef.services.getServiceLogs(name); logger.log(`handleGetLogsRequest: logs type = ${typeof logs}, constructor = ${logs?.constructor?.name}`); logger.log(`handleGetLogsRequest: logs value = ${String(logs).slice(0, 100)}`); return this.jsonResponse({ success: true, data: logs }); } catch (error) { logger.error(`Failed to get logs for service ${name}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to get logs' }, 500); } } private async handleGetSettingsRequest(): Promise { const settings = this.oneboxRef.database.getAllSettings(); return this.jsonResponse({ success: true, data: settings }); } private async handleUpdateSettingsRequest(req: Request): Promise { try { const body = await req.json(); if (!body || typeof body !== 'object') { return this.jsonResponse( { success: false, error: 'Invalid request body' }, 400 ); } // Handle three formats: // 1. Single setting: { key: "settingName", value: "settingValue" } // 2. Array format: [{ key: "name1", value: "val1" }, ...] // 3. Object format: { settingName1: "value1", settingName2: "value2", ... } if (Array.isArray(body)) { // Array format from UI for (const item of body) { if (item.key && typeof item.value === 'string') { this.oneboxRef.database.setSetting(item.key, item.value); logger.info(`Setting updated: ${item.key} = ${item.value}`); } } } else if (body.key && body.value !== undefined) { // Single setting format: { key: "name", value: "val" } if (typeof body.value === 'string') { this.oneboxRef.database.setSetting(body.key, body.value); logger.info(`Setting updated: ${body.key} = ${body.value}`); } } else { // Object format: { name1: "val1", name2: "val2", ... } for (const [key, value] of Object.entries(body)) { if (typeof value === 'string') { this.oneboxRef.database.setSetting(key, value); logger.info(`Setting updated: ${key} = ${value}`); } } } return this.jsonResponse({ success: true, message: 'Settings updated successfully' }); } catch (error) { logger.error(`Failed to update settings: ${error.message}`); return this.jsonResponse({ success: false, error: 'Failed to update settings' }, 500); } } private async handleObtainCertificateRequest(req: Request): Promise { try { const body = await req.json(); const { domain, includeWildcard } = body; if (!domain) { return this.jsonResponse( { success: false, error: 'Domain is required' }, 400 ); } await this.oneboxRef.ssl.obtainCertificate(domain, includeWildcard || false); return this.jsonResponse({ success: true, message: `Certificate obtained for ${domain}`, }); } catch (error) { logger.error(`Failed to obtain certificate: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to obtain certificate' }, 500); } } private async handleListCertificatesRequest(): Promise { try { const certificates = this.oneboxRef.ssl.listCertificates(); return this.jsonResponse({ success: true, data: certificates }); } catch (error) { logger.error(`Failed to list certificates: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to list certificates' }, 500); } } private async handleGetCertificateRequest(domain: string): Promise { try { const certificate = this.oneboxRef.ssl.getCertificate(domain); if (!certificate) { return this.jsonResponse({ success: false, error: 'Certificate not found' }, 404); } return this.jsonResponse({ success: true, data: certificate }); } catch (error) { logger.error(`Failed to get certificate for ${domain}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to get certificate' }, 500); } } private async handleRenewCertificateRequest(domain: string): Promise { try { await this.oneboxRef.ssl.renewCertificate(domain); return this.jsonResponse({ success: true, message: `Certificate renewed for ${domain}`, }); } catch (error) { logger.error(`Failed to renew certificate for ${domain}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to renew certificate' }, 500); } } private async handleGetDomainsRequest(): Promise { try { const domains = this.oneboxRef.database.getAllDomains(); const certManager = this.oneboxRef.certRequirementManager; // Build domain views with certificate and service information const domainViews = domains.map((domain) => { const certificates = this.oneboxRef.database.getCertificatesByDomain(domain.id!); const requirements = this.oneboxRef.database.getCertRequirementsByDomain(domain.id!); // Count services using this domain const allServices = this.oneboxRef.database.getAllServices(); const serviceCount = allServices.filter((service) => { if (!service.domain) return false; // Extract base domain from service domain const baseDomain = service.domain.split('.').slice(-2).join('.'); return baseDomain === domain.domain; }).length; // Determine certificate status let certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none' = 'none'; let daysRemaining: number | null = null; const validCerts = certificates.filter((cert) => cert.isValid && cert.expiryDate > Date.now()); if (validCerts.length > 0) { // Find cert with furthest expiry const latestCert = validCerts.reduce((latest, cert) => cert.expiryDate > latest.expiryDate ? cert : latest ); daysRemaining = Math.floor((latestCert.expiryDate - Date.now()) / (24 * 60 * 60 * 1000)); if (daysRemaining <= 30) { certificateStatus = 'expiring-soon'; } else { certificateStatus = 'valid'; } } else if (certificates.some((cert) => !cert.isValid)) { certificateStatus = 'expired'; } else if (requirements.some((req) => req.status === 'pending')) { certificateStatus = 'pending'; } return { domain, certificates, requirements, serviceCount, certificateStatus, daysRemaining, }; }); return this.jsonResponse({ success: true, data: domainViews }); } catch (error) { logger.error(`Failed to get domains: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to get domains', }, 500); } } private async handleSyncDomainsRequest(): Promise { try { if (!this.oneboxRef.cloudflareDomainSync) { return this.jsonResponse({ success: false, error: 'Cloudflare domain sync not configured', }, 400); } await this.oneboxRef.cloudflareDomainSync.syncZones(); return this.jsonResponse({ success: true, message: 'Cloudflare zones synced successfully', }); } catch (error) { logger.error(`Failed to sync Cloudflare zones: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to sync Cloudflare zones', }, 500); } } private async handleGetDomainDetailRequest(domainName: string): Promise { try { const domain = this.oneboxRef.database.getDomainByName(domainName); if (!domain) { return this.jsonResponse({ success: false, error: 'Domain not found' }, 404); } const certificates = this.oneboxRef.database.getCertificatesByDomain(domain.id!); const requirements = this.oneboxRef.database.getCertRequirementsByDomain(domain.id!); // Get services using this domain const allServices = this.oneboxRef.database.getAllServices(); const services = allServices.filter((service) => { if (!service.domain) return false; const baseDomain = service.domain.split('.').slice(-2).join('.'); return baseDomain === domain.domain; }); // Build detailed view const domainDetail = { domain, certificates: certificates.map((cert) => ({ ...cert, isExpired: cert.expiryDate <= Date.now(), daysRemaining: Math.floor((cert.expiryDate - Date.now()) / (24 * 60 * 60 * 1000)), })), requirements: requirements.map((req) => { const service = allServices.find((s) => s.id === req.serviceId); return { ...req, serviceName: service?.name || 'Unknown', }; }), services: services.map((s) => ({ id: s.id, name: s.name, domain: s.domain, status: s.status, })), }; return this.jsonResponse({ success: true, data: domainDetail }); } catch (error) { logger.error(`Failed to get domain detail for ${domainName}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to get domain detail', }, 500); } } private async handleGetDnsRecordsRequest(): Promise { try { const records = this.oneboxRef.dns.listDNSRecords(); return this.jsonResponse({ success: true, data: records }); } catch (error) { logger.error(`Failed to get DNS records: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to get DNS records', }, 500); } } private async handleCreateDnsRecordRequest(req: Request): Promise { try { const body = await req.json(); const { domain, ip } = body; if (!domain) { return this.jsonResponse( { success: false, error: 'Domain is required' }, 400 ); } await this.oneboxRef.dns.addDNSRecord(domain, ip); return this.jsonResponse({ success: true, message: `DNS record created for ${domain}`, }); } catch (error) { logger.error(`Failed to create DNS record: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to create DNS record', }, 500); } } private async handleDeleteDnsRecordRequest(domain: string): Promise { try { await this.oneboxRef.dns.removeDNSRecord(domain); return this.jsonResponse({ success: true, message: `DNS record deleted for ${domain}`, }); } catch (error) { logger.error(`Failed to delete DNS record for ${domain}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to delete DNS record', }, 500); } } private async handleSyncDnsRecordsRequest(): Promise { try { if (!this.oneboxRef.dns.isConfigured()) { return this.jsonResponse({ success: false, error: 'DNS manager not configured', }, 400); } await this.oneboxRef.dns.syncFromCloudflare(); return this.jsonResponse({ success: true, message: 'DNS records synced from Cloudflare', }); } catch (error) { logger.error(`Failed to sync DNS records: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to sync DNS records', }, 500); } } /** * Handle WebSocket upgrade */ private handleWebSocketUpgrade(req: Request): Response { const { socket, response } = Deno.upgradeWebSocket(req); socket.onopen = () => { this.wsClients.add(socket); logger.info(`WebSocket client connected (${this.wsClients.size} total)`); // Send initial connection message socket.send(JSON.stringify({ type: 'connected', message: 'Connected to Onebox server', timestamp: Date.now(), })); }; socket.onclose = () => { this.wsClients.delete(socket); logger.info(`WebSocket client disconnected (${this.wsClients.size} remaining)`); }; socket.onerror = (error) => { logger.error(`WebSocket error: ${error}`); this.wsClients.delete(socket); }; return response; } /** * Handle WebSocket upgrade for log streaming */ private handleLogStreamUpgrade(req: Request, serviceName: string): Response { const { socket, response } = Deno.upgradeWebSocket(req); socket.onopen = async () => { logger.info(`Log stream WebSocket connected for service: ${serviceName}`); try { // Get the service from database const service = this.oneboxRef.database.getServiceByName(serviceName); if (!service) { socket.send(JSON.stringify({ error: 'Service not found' })); socket.close(); return; } // Get the container (handle both direct container IDs and service IDs) logger.info(`Looking up container for service ${serviceName}, containerID: ${service.containerID}`); let container = await this.oneboxRef.docker.dockerClient!.getContainerById(service.containerID!); logger.info(`Direct lookup result: ${container ? 'found' : 'null'}`); // If not found, it might be a service ID - try to get the actual container ID if (!container) { logger.info('Listing all containers to find matching service...'); const containers = await this.oneboxRef.docker.dockerClient!.listContainers(); logger.info(`Found ${containers.length} containers`); const serviceContainer = containers.find((c: any) => { const labels = c.Labels || {}; return labels['com.docker.swarm.service.id'] === service.containerID; }); if (serviceContainer) { logger.info(`Found matching container: ${serviceContainer.Id}`); container = await this.oneboxRef.docker.dockerClient!.getContainerById(serviceContainer.Id); logger.info(`Second lookup result: ${container ? 'found' : 'null'}`); } else { logger.error(`No container found with service label matching ${service.containerID}`); } } if (!container) { logger.error(`Container not found for service ${serviceName}, containerID: ${service.containerID}`); socket.send(JSON.stringify({ error: 'Container not found' })); socket.close(); return; } // Start streaming logs const logStream = await container.streamLogs({ stdout: true, stderr: true, timestamps: true, tail: 100, // Start with last 100 lines }); // Send initial connection message socket.send(JSON.stringify({ type: 'connected', serviceName: service.name, })); // Demultiplex and pipe log data to WebSocket // Docker streams use 8-byte headers: [STREAM_TYPE, 0, 0, 0, SIZE_BYTE1, SIZE_BYTE2, SIZE_BYTE3, SIZE_BYTE4] let buffer = Buffer.alloc(0); logStream.on('data', (chunk: Buffer) => { if (socket.readyState !== WebSocket.OPEN) return; // Append new data to buffer buffer = Buffer.concat([buffer, chunk]); // Process complete frames while (buffer.length >= 8) { // Read frame size from header (bytes 4-7, big-endian) const frameSize = buffer.readUInt32BE(4); // Check if we have the complete frame if (buffer.length < 8 + frameSize) { break; // Wait for more data } // Extract the frame data (skip 8-byte header) const frameData = buffer.slice(8, 8 + frameSize); // Send the clean log line socket.send(frameData.toString('utf8')); // Remove processed frame from buffer buffer = buffer.slice(8 + frameSize); } }); logStream.on('error', (error: Error) => { logger.error(`Log stream error for ${serviceName}: ${error.message}`); if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ error: error.message })); } }); logStream.on('end', () => { logger.info(`Log stream ended for ${serviceName}`); socket.close(); }); // Clean up on close socket.onclose = () => { logger.info(`Log stream WebSocket closed for ${serviceName}`); logStream.destroy(); }; } catch (error) { logger.error(`Failed to start log stream for ${serviceName}: ${error.message}`); if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ error: error.message })); socket.close(); } } }; socket.onerror = (error) => { logger.error(`Log stream WebSocket error: ${error}`); }; return response; } /** * Broadcast message to all connected WebSocket clients */ broadcast(message: Record): void { const data = JSON.stringify(message); let successCount = 0; let failCount = 0; for (const client of this.wsClients) { try { if (client.readyState === WebSocket.OPEN) { client.send(data); successCount++; } else { this.wsClients.delete(client); failCount++; } } catch (error) { logger.error(`Failed to send to WebSocket client: ${error.message}`); this.wsClients.delete(client); failCount++; } } if (successCount > 0) { logger.debug(`Broadcast to ${successCount} clients (${failCount} failed)`); } } /** * Broadcast service update */ broadcastServiceUpdate(serviceName: string, action: 'created' | 'updated' | 'deleted' | 'started' | 'stopped', data?: any): void { this.broadcast({ type: 'service_update', action, serviceName, data, timestamp: Date.now(), }); } /** * Broadcast service status update */ broadcastServiceStatus(serviceName: string, status: string): void { this.broadcast({ type: 'service_status', serviceName, status, timestamp: Date.now(), }); } /** * Broadcast system status update */ broadcastSystemStatus(status: any): void { this.broadcast({ type: 'system_status', data: status, timestamp: Date.now(), }); } // ============ Platform Services Endpoints ============ private async handleListPlatformServicesRequest(): Promise { try { const platformServices = this.oneboxRef.platformServices.getAllPlatformServices(); const providers = this.oneboxRef.platformServices.getAllProviders(); // Build response with provider info const result = providers.map((provider) => { const service = platformServices.find((s) => s.type === provider.type); return { type: provider.type, displayName: provider.displayName, resourceTypes: provider.resourceTypes, status: service?.status || 'not-deployed', containerId: service?.containerId, createdAt: service?.createdAt, updatedAt: service?.updatedAt, }; }); return this.jsonResponse({ success: true, data: result }); } catch (error) { logger.error(`Failed to list platform services: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to list platform services', }, 500); } } private async handleGetPlatformServiceRequest(type: string): Promise { try { const provider = this.oneboxRef.platformServices.getProvider(type); if (!provider) { return this.jsonResponse({ success: false, error: `Unknown platform service type: ${type}`, }, 404); } const service = this.oneboxRef.database.getPlatformServiceByType(type); // Get resource count const allResources = service?.id ? this.oneboxRef.database.getPlatformResourcesByPlatformService(service.id) : []; return this.jsonResponse({ success: true, data: { type: provider.type, displayName: provider.displayName, resourceTypes: provider.resourceTypes, status: service?.status || 'not-deployed', containerId: service?.containerId, config: provider.getDefaultConfig(), resourceCount: allResources.length, createdAt: service?.createdAt, updatedAt: service?.updatedAt, }, }); } catch (error) { logger.error(`Failed to get platform service ${type}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to get platform service', }, 500); } } private async handleStartPlatformServiceRequest(type: string): Promise { try { const provider = this.oneboxRef.platformServices.getProvider(type); if (!provider) { return this.jsonResponse({ success: false, error: `Unknown platform service type: ${type}`, }, 404); } logger.info(`Starting platform service: ${type}`); const service = await this.oneboxRef.platformServices.ensureRunning(type); return this.jsonResponse({ success: true, message: `Platform service ${provider.displayName} started`, data: { type: service.type, status: service.status, containerId: service.containerId, }, }); } catch (error) { logger.error(`Failed to start platform service ${type}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to start platform service', }, 500); } } private async handleStopPlatformServiceRequest(type: string): Promise { try { const provider = this.oneboxRef.platformServices.getProvider(type); if (!provider) { return this.jsonResponse({ success: false, error: `Unknown platform service type: ${type}`, }, 404); } logger.info(`Stopping platform service: ${type}`); await this.oneboxRef.platformServices.stopPlatformService(type); return this.jsonResponse({ success: true, message: `Platform service ${provider.displayName} stopped`, }); } catch (error) { logger.error(`Failed to stop platform service ${type}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to stop platform service', }, 500); } } private async handleGetServicePlatformResourcesRequest(serviceName: string): Promise { try { const service = this.oneboxRef.services.getService(serviceName); if (!service) { return this.jsonResponse({ success: false, error: 'Service not found', }, 404); } const resources = await this.oneboxRef.services.getServicePlatformResources(serviceName); // Format resources for API response (mask sensitive credentials) const formattedResources = resources.map((r) => ({ id: r.resource.id, resourceType: r.resource.resourceType, resourceName: r.resource.resourceName, platformService: { type: r.platformService.type, name: r.platformService.name, status: r.platformService.status, }, // Include env var mappings (show keys, not values) envVars: Object.keys(r.credentials).reduce((acc, key) => { // Mask sensitive values const value = r.credentials[key]; if (key.toLowerCase().includes('password') || key.toLowerCase().includes('secret')) { acc[key] = '********'; } else { acc[key] = value; } return acc; }, {} as Record), createdAt: r.resource.createdAt, })); return this.jsonResponse({ success: true, data: formattedResources, }); } catch (error) { logger.error(`Failed to get platform resources for service ${serviceName}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to get platform resources', }, 500); } } // ============ Registry Endpoints ============ private async handleGetRegistryTagsRequest(serviceName: string): Promise { try { const tags = await this.oneboxRef.registry.getImageTags(serviceName); return this.jsonResponse({ success: true, data: tags }); } catch (error) { logger.error(`Failed to get registry tags for ${serviceName}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to get registry tags', }, 500); } } // ============ Registry Token Management Endpoints ============ private async handleListRegistryTokensRequest(req: Request): Promise { try { const tokens = this.oneboxRef.database.getAllRegistryTokens(); // Convert to view format (mask token hash, add computed fields) const tokenViews: IRegistryTokenView[] = tokens.map(token => { const now = Date.now(); const isExpired = token.expiresAt !== null && token.expiresAt < now; // Generate scope display string let scopeDisplay: string; if (token.scope === 'all') { scopeDisplay = 'All services'; } else if (Array.isArray(token.scope)) { scopeDisplay = token.scope.length === 1 ? token.scope[0] : `${token.scope.length} services`; } else { scopeDisplay = 'Unknown'; } return { id: token.id!, name: token.name, type: token.type, scope: token.scope, scopeDisplay, expiresAt: token.expiresAt, createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, createdBy: token.createdBy, isExpired, }; }); return this.jsonResponse({ success: true, data: tokenViews }); } catch (error) { logger.error(`Failed to list registry tokens: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to list registry tokens', }, 500); } } private async handleCreateRegistryTokenRequest(req: Request): Promise { try { const body = await req.json() as ICreateRegistryTokenRequest; // Validate request if (!body.name || !body.type || !body.scope || !body.expiresIn) { return this.jsonResponse({ success: false, error: 'Missing required fields: name, type, scope, expiresIn', }, 400); } if (body.type !== 'global' && body.type !== 'ci') { return this.jsonResponse({ success: false, error: 'Invalid token type. Must be "global" or "ci"', }, 400); } // Validate scope if (body.scope !== 'all' && !Array.isArray(body.scope)) { return this.jsonResponse({ success: false, error: 'Scope must be "all" or an array of service names', }, 400); } // If scope is array of services, validate they exist if (Array.isArray(body.scope)) { for (const serviceName of body.scope) { const service = this.oneboxRef.database.getServiceByName(serviceName); if (!service) { return this.jsonResponse({ success: false, error: `Service not found: ${serviceName}`, }, 400); } } } // Calculate expiration timestamp const now = Date.now(); let expiresAt: number | null = null; if (body.expiresIn !== 'never') { const daysMap: Record = { '30d': 30, '90d': 90, '365d': 365, }; const days = daysMap[body.expiresIn]; if (days) { expiresAt = now + (days * 24 * 60 * 60 * 1000); } } // Generate token (random 32 bytes as hex) const plainToken = crypto.randomUUID() + crypto.randomUUID(); // Hash the token for storage (using simple hash for now) const encoder = new TextEncoder(); const data = encoder.encode(plainToken); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); const tokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); // Get username from auth token const authHeader = req.headers.get('Authorization'); let createdBy = 'system'; if (authHeader && authHeader.startsWith('Bearer ')) { try { const decoded = atob(authHeader.slice(7)); createdBy = decoded.split(':')[0]; } catch { // Keep default } } // Create token in database const token = this.oneboxRef.database.createRegistryToken({ name: body.name, tokenHash, type: body.type, scope: body.scope, expiresAt, createdAt: now, lastUsedAt: null, createdBy, }); // Build view response let scopeDisplay: string; if (token.scope === 'all') { scopeDisplay = 'All services'; } else if (Array.isArray(token.scope)) { scopeDisplay = token.scope.length === 1 ? token.scope[0] : `${token.scope.length} services`; } else { scopeDisplay = 'Unknown'; } const tokenView: IRegistryTokenView = { id: token.id!, name: token.name, type: token.type, scope: token.scope, scopeDisplay, expiresAt: token.expiresAt, createdAt: token.createdAt, lastUsedAt: token.lastUsedAt, createdBy: token.createdBy, isExpired: false, }; return this.jsonResponse({ success: true, data: { token: tokenView, plainToken, // Only returned once at creation }, }); } catch (error) { logger.error(`Failed to create registry token: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to create registry token', }, 500); } } private async handleDeleteRegistryTokenRequest(tokenId: number): Promise { try { // Check if token exists const token = this.oneboxRef.database.getRegistryTokenById(tokenId); if (!token) { return this.jsonResponse({ success: false, error: 'Token not found', }, 404); } // Delete the token this.oneboxRef.database.deleteRegistryToken(tokenId); return this.jsonResponse({ success: true, message: 'Token deleted successfully', }); } catch (error) { logger.error(`Failed to delete registry token ${tokenId}: ${error.message}`); return this.jsonResponse({ success: false, error: error.message || 'Failed to delete registry token', }, 500); } } /** * Helper to create JSON response */ private jsonResponse(data: IApiResponse, status = 200): Response { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' }, }); } }