1732 lines
59 KiB
TypeScript
1732 lines
59 KiB
TypeScript
/**
|
|
* HTTP Server for Onebox
|
|
*
|
|
* Serves REST API and Angular UI
|
|
*/
|
|
|
|
import * as plugins from '../plugins.ts';
|
|
import { logger } from '../logging.ts';
|
|
import { getErrorMessage } from '../utils/error.ts';
|
|
import type { Onebox } from './onebox.ts';
|
|
import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView, TPlatformServiceType } from '../types.ts';
|
|
|
|
export class OneboxHttpServer {
|
|
private oneboxRef: Onebox;
|
|
private server: Deno.HttpServer | null = null;
|
|
private port = 3000;
|
|
private wsClients: Set<WebSocket> = new Set();
|
|
|
|
constructor(oneboxRef: Onebox) {
|
|
this.oneboxRef = oneboxRef;
|
|
}
|
|
|
|
/**
|
|
* Start HTTP server
|
|
*/
|
|
async start(port?: number): Promise<void> {
|
|
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: ${getErrorMessage(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop HTTP server
|
|
*/
|
|
async stop(): Promise<void> {
|
|
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: ${getErrorMessage(error)}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle HTTP request
|
|
*/
|
|
private async handleRequest(req: Request): Promise<Response> {
|
|
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);
|
|
}
|
|
|
|
// Network access logs WebSocket
|
|
if (path === '/api/network/logs/stream' && req.headers.get('upgrade') === 'websocket') {
|
|
return this.handleNetworkLogStreamUpgrade(req, new URL(req.url));
|
|
}
|
|
|
|
// Docker Registry v2 Token endpoint (for OCI authentication)
|
|
if (path === '/v2/token') {
|
|
return await this.handleRegistryTokenRequest(req, url);
|
|
}
|
|
|
|
// 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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) }, 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Serve static files from ui/dist
|
|
*/
|
|
private async serveStaticFile(path: string): Promise<Response> {
|
|
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<string, string> = {
|
|
'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<Response> {
|
|
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()! as TPlatformServiceType;
|
|
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] as TPlatformServiceType;
|
|
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] as TPlatformServiceType;
|
|
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);
|
|
// Network endpoints
|
|
} else if (path === '/api/network/targets' && method === 'GET') {
|
|
return await this.handleGetNetworkTargetsRequest();
|
|
} else if (path === '/api/network/stats' && method === 'GET') {
|
|
return await this.handleGetNetworkStatsRequest();
|
|
} else {
|
|
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
|
}
|
|
}
|
|
|
|
// API Handlers
|
|
|
|
private async handleLoginRequest(req: Request): Promise<Response> {
|
|
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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: 'Login failed' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleStatusRequest(): Promise<Response> {
|
|
try {
|
|
const status = await this.oneboxRef.getSystemStatus();
|
|
return this.jsonResponse({ success: true, data: status });
|
|
} catch (error) {
|
|
logger.error(`Failed to get system status: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to get system status' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleListServicesRequest(): Promise<Response> {
|
|
try {
|
|
const services = this.oneboxRef.services.listServices();
|
|
return this.jsonResponse({ success: true, data: services });
|
|
} catch (error) {
|
|
logger.error(`Failed to list services: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to list services' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleDeployServiceRequest(req: Request): Promise<Response> {
|
|
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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to deploy service' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleGetServiceRequest(name: string): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to get service' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleUpdateServiceRequest(name: string, req: Request): Promise<Response> {
|
|
try {
|
|
const body = await req.json();
|
|
const updates: {
|
|
image?: string;
|
|
registry?: string;
|
|
port?: number;
|
|
domain?: string;
|
|
envVars?: Record<string, string>;
|
|
} = {};
|
|
|
|
// 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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to update service' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleDeleteServiceRequest(name: string): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to delete service' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleStartServiceRequest(name: string): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to start service' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleStopServiceRequest(name: string): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to stop service' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleRestartServiceRequest(name: string): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to restart service' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleGetLogsRequest(name: string): Promise<Response> {
|
|
try {
|
|
const logs = await this.oneboxRef.services.getServiceLogs(name);
|
|
logger.debug(`handleGetLogsRequest: logs type = ${typeof logs}, constructor = ${logs?.constructor?.name}`);
|
|
logger.debug(`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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to get logs' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleGetSettingsRequest(): Promise<Response> {
|
|
const settings = this.oneboxRef.database.getAllSettings();
|
|
return this.jsonResponse({ success: true, data: settings });
|
|
}
|
|
|
|
private async handleUpdateSettingsRequest(req: Request): Promise<Response> {
|
|
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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: 'Failed to update settings' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleObtainCertificateRequest(req: Request): Promise<Response> {
|
|
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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to obtain certificate' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleListCertificatesRequest(): Promise<Response> {
|
|
try {
|
|
const certificates = this.oneboxRef.ssl.listCertificates();
|
|
return this.jsonResponse({ success: true, data: certificates });
|
|
} catch (error) {
|
|
logger.error(`Failed to list certificates: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to list certificates' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleGetCertificateRequest(domain: string): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to get certificate' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleRenewCertificateRequest(domain: string): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to renew certificate' }, 500);
|
|
}
|
|
}
|
|
|
|
private async handleGetDomainsRequest(): Promise<Response> {
|
|
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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to get domains',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleSyncDomainsRequest(): Promise<Response> {
|
|
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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to sync Cloudflare zones',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleGetDomainDetailRequest(domainName: string): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to get domain detail',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleGetDnsRecordsRequest(): Promise<Response> {
|
|
try {
|
|
const records = this.oneboxRef.dns.listDNSRecords();
|
|
return this.jsonResponse({ success: true, data: records });
|
|
} catch (error) {
|
|
logger.error(`Failed to get DNS records: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to get DNS records',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleCreateDnsRecordRequest(req: Request): Promise<Response> {
|
|
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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to create DNS record',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleDeleteDnsRecordRequest(domain: string): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to delete DNS record',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleSyncDnsRecordsRequest(): Promise<Response> {
|
|
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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || '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.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.listAllContainers();
|
|
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.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 = new Uint8Array(0);
|
|
|
|
logStream.on('data', (chunk: Uint8Array) => {
|
|
if (socket.readyState !== WebSocket.OPEN) return;
|
|
|
|
// Append new data to buffer
|
|
const newBuffer = new Uint8Array(buffer.length + chunk.length);
|
|
newBuffer.set(buffer);
|
|
newBuffer.set(chunk, buffer.length);
|
|
buffer = newBuffer;
|
|
|
|
// Process complete frames
|
|
while (buffer.length >= 8) {
|
|
// Read frame size from header (bytes 4-7, big-endian)
|
|
const frameSize = (buffer[4] << 24) | (buffer[5] << 16) | (buffer[6] << 8) | buffer[7];
|
|
|
|
// 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(new TextDecoder().decode(frameData));
|
|
|
|
// Remove processed frame from buffer
|
|
buffer = buffer.slice(8 + frameSize);
|
|
}
|
|
});
|
|
|
|
logStream.on('error', (error: Error) => {
|
|
logger.error(`Log stream error for ${serviceName}: ${getErrorMessage(error)}`);
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ error: getErrorMessage(error) }));
|
|
}
|
|
});
|
|
|
|
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}: ${getErrorMessage(error)}`);
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ error: getErrorMessage(error) }));
|
|
socket.close();
|
|
}
|
|
}
|
|
};
|
|
|
|
socket.onerror = (error) => {
|
|
logger.error(`Log stream WebSocket error: ${error}`);
|
|
};
|
|
|
|
return response;
|
|
}
|
|
|
|
/**
|
|
* Handle WebSocket upgrade for network access log streaming
|
|
*/
|
|
private handleNetworkLogStreamUpgrade(req: Request, url: URL): Response {
|
|
const { socket, response } = Deno.upgradeWebSocket(req);
|
|
|
|
// Extract filter from query params
|
|
const filterDomain = url.searchParams.get('domain');
|
|
|
|
// Generate unique client ID
|
|
const clientId = crypto.randomUUID();
|
|
|
|
socket.onopen = () => {
|
|
logger.info(`Network log stream WebSocket connected (client: ${clientId})`);
|
|
|
|
// Register with CaddyLogReceiver
|
|
const filter = filterDomain ? { domain: filterDomain } : {};
|
|
this.oneboxRef.caddyLogReceiver.addClient(clientId, socket, filter);
|
|
|
|
// Send initial connection message
|
|
socket.send(JSON.stringify({
|
|
type: 'connected',
|
|
clientId,
|
|
filter,
|
|
}));
|
|
};
|
|
|
|
socket.onmessage = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data);
|
|
|
|
// Handle filter updates from client
|
|
if (message.type === 'set_filter') {
|
|
const newFilter = {
|
|
domain: message.domain || undefined,
|
|
sampleRate: message.sampleRate || undefined,
|
|
};
|
|
this.oneboxRef.caddyLogReceiver.updateClientFilter(clientId, newFilter);
|
|
|
|
socket.send(JSON.stringify({
|
|
type: 'filter_updated',
|
|
filter: newFilter,
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
logger.debug(`Network log stream message parse error: ${getErrorMessage(error)}`);
|
|
}
|
|
};
|
|
|
|
socket.onclose = () => {
|
|
logger.info(`Network log stream WebSocket closed (client: ${clientId})`);
|
|
this.oneboxRef.caddyLogReceiver.removeClient(clientId);
|
|
};
|
|
|
|
socket.onerror = (error) => {
|
|
logger.error(`Network log stream WebSocket error: ${error}`);
|
|
this.oneboxRef.caddyLogReceiver.removeClient(clientId);
|
|
};
|
|
|
|
return response;
|
|
}
|
|
|
|
// ============ Network Endpoints ============
|
|
|
|
/**
|
|
* Get all traffic targets (services, registry, platform services)
|
|
*/
|
|
private async handleGetNetworkTargetsRequest(): Promise<Response> {
|
|
try {
|
|
const targets: Array<{
|
|
type: 'service' | 'registry' | 'platform';
|
|
name: string;
|
|
domain: string | null;
|
|
targetHost: string;
|
|
targetPort: number;
|
|
status: string;
|
|
}> = [];
|
|
|
|
// Add services
|
|
const services = this.oneboxRef.services.listServices();
|
|
for (const service of services) {
|
|
targets.push({
|
|
type: 'service',
|
|
name: service.name,
|
|
domain: service.domain || null,
|
|
targetHost: service.containerIP || 'unknown',
|
|
targetPort: service.port || 80,
|
|
status: service.status,
|
|
});
|
|
}
|
|
|
|
// Add registry if running
|
|
const registryStatus = this.oneboxRef.registry.getStatus();
|
|
if (registryStatus.running) {
|
|
targets.push({
|
|
type: 'registry',
|
|
name: 'onebox-registry',
|
|
domain: null, // Registry is internal
|
|
targetHost: 'localhost',
|
|
targetPort: registryStatus.port,
|
|
status: 'running',
|
|
});
|
|
}
|
|
|
|
// Add platform services
|
|
const platformServices = this.oneboxRef.platformServices.getAllPlatformServices();
|
|
for (const ps of platformServices) {
|
|
// Get provider info for display name
|
|
const provider = this.oneboxRef.platformServices.getProvider(ps.type);
|
|
targets.push({
|
|
type: 'platform',
|
|
name: provider?.displayName || ps.type,
|
|
domain: null, // Platform services are internal
|
|
targetHost: 'localhost',
|
|
targetPort: this.getPlatformServicePort(ps.type),
|
|
status: ps.status,
|
|
});
|
|
}
|
|
|
|
return this.jsonResponse({ success: true, data: targets });
|
|
} catch (error) {
|
|
logger.error(`Failed to get network targets: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to get network targets',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get default port for a platform service type
|
|
*/
|
|
private getPlatformServicePort(type: TPlatformServiceType): number {
|
|
const ports: Record<TPlatformServiceType, number> = {
|
|
mongodb: 27017,
|
|
minio: 9000,
|
|
redis: 6379,
|
|
postgresql: 5432,
|
|
rabbitmq: 5672,
|
|
};
|
|
return ports[type] || 0;
|
|
}
|
|
|
|
/**
|
|
* Get Caddy/network stats
|
|
*/
|
|
private async handleGetNetworkStatsRequest(): Promise<Response> {
|
|
try {
|
|
const proxyStatus = this.oneboxRef.reverseProxy.getStatus();
|
|
const logReceiverStats = this.oneboxRef.caddyLogReceiver.getStats();
|
|
|
|
return this.jsonResponse({
|
|
success: true,
|
|
data: {
|
|
proxy: {
|
|
running: proxyStatus.running,
|
|
httpPort: proxyStatus.httpPort,
|
|
httpsPort: proxyStatus.httpsPort,
|
|
routes: proxyStatus.routes,
|
|
certificates: proxyStatus.certificates,
|
|
},
|
|
logReceiver: {
|
|
running: logReceiverStats.running,
|
|
port: logReceiverStats.port,
|
|
clients: logReceiverStats.clients,
|
|
connections: logReceiverStats.connections,
|
|
sampleRate: logReceiverStats.sampleRate,
|
|
recentLogsCount: logReceiverStats.recentLogsCount,
|
|
},
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logger.error(`Failed to get network stats: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to get network stats',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Broadcast message to all connected WebSocket clients
|
|
*/
|
|
broadcast(message: Record<string, any>): 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: ${getErrorMessage(error)}`);
|
|
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<Response> {
|
|
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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to list platform services',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleGetPlatformServiceRequest(type: TPlatformServiceType): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to get platform service',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleStartPlatformServiceRequest(type: TPlatformServiceType): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to start platform service',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleStopPlatformServiceRequest(type: TPlatformServiceType): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to stop platform service',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleGetServicePlatformResourcesRequest(serviceName: string): Promise<Response> {
|
|
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<string, string>),
|
|
createdAt: r.resource.createdAt,
|
|
}));
|
|
|
|
return this.jsonResponse({
|
|
success: true,
|
|
data: formattedResources,
|
|
});
|
|
} catch (error) {
|
|
logger.error(`Failed to get platform resources for service ${serviceName}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to get platform resources',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
// ============ Registry Endpoints ============
|
|
|
|
/**
|
|
* Handle Docker registry token request (OCI token authentication)
|
|
* Docker calls this endpoint to get a bearer token for registry operations
|
|
*
|
|
* Query params:
|
|
* - service: The registry service name
|
|
* - scope: Permission scope (e.g., "repository:hello-world:push,pull")
|
|
* - account: Optional account name (for basic auth)
|
|
*/
|
|
private async handleRegistryTokenRequest(req: Request, url: URL): Promise<Response> {
|
|
try {
|
|
const service = url.searchParams.get('service') || 'onebox-registry';
|
|
const scope = url.searchParams.get('scope');
|
|
const account = url.searchParams.get('account') || 'anonymous';
|
|
|
|
logger.info(`Registry token request: service=${service}, scope=${scope}, account=${account}`);
|
|
|
|
// Parse scope to extract repository and actions
|
|
// Format: repository:name:action1,action2 (e.g., "repository:hello-world:push,pull")
|
|
let scopes: string[] = [];
|
|
|
|
if (scope) {
|
|
const scopeParts = scope.split(':');
|
|
if (scopeParts.length >= 3 && scopeParts[0] === 'repository') {
|
|
const repository = scopeParts[1];
|
|
// For now, grant both push and pull for any repository request
|
|
// This allows anonymous push to the local registry
|
|
// TODO: Add authentication and authorization to restrict access
|
|
scopes = [
|
|
`oci:repository:${repository}:push`,
|
|
`oci:repository:${repository}:pull`,
|
|
];
|
|
}
|
|
}
|
|
|
|
// If no scope specified, grant basic access
|
|
if (scopes.length === 0) {
|
|
scopes = ['oci:repository:*:pull'];
|
|
}
|
|
|
|
logger.info(`Creating OCI token with scopes: ${scopes.join(', ')}`);
|
|
|
|
// Use the registry's auth manager to create a token
|
|
// smartregistry v2.0.0 returns proper JWT format (header.payload.signature)
|
|
const authManager = this.oneboxRef.registry.getAuthManager();
|
|
const token = await authManager.createOciToken(account, scopes, 3600);
|
|
|
|
logger.info(`Token created (JWT length: ${token.length})`);
|
|
|
|
// Return in Docker-expected format
|
|
return new Response(JSON.stringify({
|
|
token,
|
|
access_token: token,
|
|
expires_in: 3600,
|
|
issued_at: new Date().toISOString(),
|
|
}), {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logger.error(`Registry token error: ${getErrorMessage(error)}`);
|
|
return new Response(JSON.stringify({
|
|
error: 'token_error',
|
|
error_description: getErrorMessage(error),
|
|
}), {
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
}
|
|
|
|
private async handleGetRegistryTagsRequest(serviceName: string): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to get registry tags',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
// ============ Registry Token Management Endpoints ============
|
|
|
|
private async handleListRegistryTokensRequest(req: Request): Promise<Response> {
|
|
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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to list registry tokens',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleCreateRegistryTokenRequest(req: Request): Promise<Response> {
|
|
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<string, number> = {
|
|
'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: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || 'Failed to create registry token',
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
private async handleDeleteRegistryTokenRequest(tokenId: number): Promise<Response> {
|
|
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}: ${getErrorMessage(error)}`);
|
|
return this.jsonResponse({
|
|
success: false,
|
|
error: getErrorMessage(error) || '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' },
|
|
});
|
|
}
|
|
}
|