From 3da7e431c276430f552f411f17fe26ca52be3fd4 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 28 Apr 2026 14:35:26 +0000 Subject: [PATCH] refactor: complete opsserver migration --- readme.md | 139 +- ts/classes/apiclient.ts | 210 -- ts/classes/httpserver.ts | 2721 ---------------------- ts/classes/onebox.ts | 3 - ts/index.ts | 2 - ts/opsserver/classes.opsserver.ts | 73 + ts/opsserver/handlers/backups.handler.ts | 2 +- ts/types.ts | 10 +- 8 files changed, 102 insertions(+), 3058 deletions(-) delete mode 100644 ts/classes/apiclient.ts delete mode 100644 ts/classes/httpserver.ts diff --git a/readme.md b/readme.md index 40cef7a..4711f67 100644 --- a/readme.md +++ b/readme.md @@ -2,7 +2,7 @@ > 🚀 Self-hosted Docker Swarm platform with Caddy reverse proxy, automatic SSL, and real-time WebSocket updates -**Onebox** transforms any Linux server into a powerful container hosting platform. Deploy Docker Swarm services with automatic HTTPS, DNS configuration, and Caddy reverse proxy running as a Docker service - all managed through a beautiful Angular web interface with real-time updates. +**Onebox** transforms any Linux server into a powerful container hosting platform. Deploy Docker Swarm services with automatic HTTPS, DNS configuration, and Caddy reverse proxy running as a Docker service - all managed through a modern web interface with real-time updates. ## Issue Reporting and Security @@ -17,7 +17,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community - **Private Registry Included** - Built-in Docker registry with token-based auth and auto-deploy on push - **Zero Config SSL** - Automatic Let's Encrypt certificates with inline `load_pem` (no volume mounts needed) - **Cloudflare Integration** - Automatic DNS record management and zone synchronization -- **Modern Stack** - Deno runtime + SQLite database + Angular 19 UI +- **Modern Stack** - Deno runtime + SQLite database + typed web UI ## Features ✨ @@ -34,7 +34,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community - 📊 **Metrics Collection** - Historical CPU, memory, and network stats (every 60s) - 📝 **Centralized Logging** - Container logs with streaming and retention policies -- 🎨 **Angular Web UI** - Modern, responsive interface with real-time updates +- 🎨 **Web UI** - Modern, responsive interface with real-time updates - 👥 **Multi-user Support** - Role-based access control (admin/user) - 💾 **SQLite Database** - Embedded, zero-configuration storage @@ -43,7 +43,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community - 🚀 **Auto-update on Push** - Push to registry and services update automatically - 🔐 **Private Registry Support** - Use Docker Hub, Gitea, or custom registries - 🔄 **Systemd Integration** - Run as a daemon with auto-restart -- 🎛️ **Full CLI & API** - Manage everything from terminal or HTTP API +- 🎛️ **Full CLI** - Manage everything from terminal or web interface ## Quick Start 🏁 @@ -103,13 +103,13 @@ Onebox is built with modern technologies for performance and developer experienc ``` ┌─────────────────────────────────────────────────┐ -│ Angular 19 Web UI │ +│ Web UI │ │ (Real-time WebSocket Updates) │ └─────────────────┬───────────────────────────────┘ │ HTTP/WS ┌─────────────────▼───────────────────────────────┐ -│ Deno HTTP Server (Port 3000) │ -│ REST API + WebSocket Broadcast │ +│ OpsServer (Port 3000) │ +│ TypedRequest + TypedSocket │ └─────────────────┬───────────────────────────────┘ │ ┌─────────────────▼───────────────────────────────┐ @@ -140,7 +140,7 @@ Onebox is built with modern technologies for performance and developer experienc | **Caddy Reverse Proxy** | Docker Swarm service with HTTP/2, HTTP/3, SNI, and WebSocket support | | **Docker Swarm** | Container orchestration (all workloads run as services) | | **SQLite Database** | Configuration, metrics, and user data | -| **WebSocket Server** | Real-time bidirectional communication | +| **OpsServer** | TypedRequest API and TypedSocket real-time updates | | **Let's Encrypt** | Automatic SSL certificate management | | **Cloudflare API** | DNS record automation | @@ -235,9 +235,8 @@ onebox config show onebox config set # Example: Configure Cloudflare -onebox config set cloudflareAPIKey your-api-key -onebox config set cloudflareEmail your@email.com -onebox config set cloudflareZoneID your-zone-id +onebox config set cloudflareToken your-api-token +onebox config set cloudflareZoneId your-zone-id ``` ### System Status @@ -324,7 +323,6 @@ onebox/ │ │ ├── reverseproxy.ts # Reverse proxy orchestration │ │ ├── caddy.ts # Caddy Docker service management │ │ ├── docker.ts # Docker Swarm API -│ │ ├── httpserver.ts # REST API + WebSocket │ │ ├── services.ts # Service orchestration │ │ ├── certmanager.ts # SSL certificate management │ │ ├── cert-requirement-manager.ts # Certificate requirements @@ -333,8 +331,10 @@ onebox/ │ │ ├── registries.ts # External registry management │ │ ├── dns.ts # DNS record management │ │ ├── cloudflare-sync.ts # Cloudflare zone sync -│ │ ├── daemon.ts # Systemd daemon management -│ │ └── apiclient.ts # API client utilities +│ │ └── daemon.ts # Systemd daemon management +│ ├── opsserver/ # Active server implementation +│ │ ├── classes.opsserver.ts # TypedRequest + TypedSocket server +│ │ └── handlers/ # Typed request handlers │ ├── database/ # Database layer (repository pattern) │ │ ├── index.ts # Main OneboxDatabase class │ │ ├── base.repository.ts # Base repository class @@ -348,105 +348,17 @@ onebox/ │ ├── types.ts # TypeScript interfaces │ ├── logging.ts # Logging utilities │ └── plugins.ts # Dependency imports -├── ui/ # Angular 19 web interface +├── ts_web/ # Web interface source ├── test/ # Test files ├── mod.ts # Main entry point └── deno.json # Deno configuration ``` -### API Endpoints +### Active Server Surface -The HTTP server exposes a comprehensive REST API: +The active server surface is the `OpsServer`, which serves the bundled web UI and exposes typed operations via `TypedRequest` and real-time events via `TypedSocket`. -#### Authentication - -| Method | Endpoint | Description | -| ------ | ----------------- | ----------------------------------- | -| `POST` | `/api/auth/login` | User authentication (returns token) | - -#### Services - -| Method | Endpoint | Description | -| -------- | --------------------------------- | ------------------------- | -| `GET` | `/api/services` | List all services | -| `POST` | `/api/services` | Create/deploy service | -| `GET` | `/api/services/:name` | Get service details | -| `PUT` | `/api/services/:name` | Update service | -| `DELETE` | `/api/services/:name` | Delete service | -| `POST` | `/api/services/:name/start` | Start service | -| `POST` | `/api/services/:name/stop` | Stop service | -| `POST` | `/api/services/:name/restart` | Restart service | -| `GET` | `/api/services/:name/logs` | Get service logs | -| `WS` | `/api/services/:name/logs/stream` | Stream logs via WebSocket | - -#### SSL Certificates - -| Method | Endpoint | Description | -| ------ | ------------------------ | ----------------------- | -| `GET` | `/api/ssl/list` | List all certificates | -| `GET` | `/api/ssl/:domain` | Get certificate details | -| `POST` | `/api/ssl/obtain` | Request new certificate | -| `POST` | `/api/ssl/:domain/renew` | Force renew certificate | - -#### Domains - -| Method | Endpoint | Description | -| ------ | ---------------------- | ---------------------------- | -| `GET` | `/api/domains` | List all domains | -| `GET` | `/api/domains/:domain` | Get domain details | -| `POST` | `/api/domains/sync` | Sync domains from Cloudflare | - -#### DNS Records - -| Method | Endpoint | Description | -| -------- | ------------------ | ------------------------ | -| `GET` | `/api/dns` | List DNS records | -| `POST` | `/api/dns` | Create DNS record | -| `DELETE` | `/api/dns/:domain` | Delete DNS record | -| `POST` | `/api/dns/sync` | Sync DNS from Cloudflare | - -#### Registry - -| Method | Endpoint | Description | -| -------- | ----------------------------- | ----------------------------- | -| `GET` | `/api/registry/tags/:service` | Get registry tags for service | -| `GET` | `/api/registry/tokens` | List registry tokens | -| `POST` | `/api/registry/tokens` | Create registry token | -| `DELETE` | `/api/registry/tokens/:id` | Delete registry token | - -#### System - -| Method | Endpoint | Description | -| ------ | --------------- | ------------------------------- | -| `GET` | `/api/status` | System status | -| `GET` | `/api/settings` | Get settings | -| `PUT` | `/api/settings` | Update settings | -| `WS` | `/api/ws` | WebSocket for real-time updates | - -### WebSocket Messages - -Real-time updates are broadcast via WebSocket: - -```typescript -// Service lifecycle updates -{ - type: 'service_update', - action: 'created' | 'updated' | 'deleted' | 'started' | 'stopped', - service: { id, name, status, ... } -} - -// Service status changes -{ - type: 'service_status', - service: { id, name, status, ... } -} - -// System status updates -{ - type: 'system_status', - status: { docker, reverseProxy, services, ... } -} -``` +The previously documented legacy `/api/*` REST interface has been removed. ## Advanced Usage 🚀 @@ -472,13 +384,7 @@ docker push localhost:4000/myapp:latest ### Registry Token Management ```bash -# Create a CI/CD token via API -curl -X POST http://localhost:3000/api/registry/tokens \ - -H "Authorization: Bearer $TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name": "github-actions", "type": "ci", "scope": ["myapp"], "expiresIn": "90d"}' - -# Use token for docker login +# Create a CI/CD token in the web UI, then use it for docker login docker login localhost:4000 -u ci -p ``` @@ -486,9 +392,8 @@ docker login localhost:4000 -u ci -p ```bash # Configure Cloudflare (one-time setup) -onebox config set cloudflareAPIKey your-api-key -onebox config set cloudflareEmail your@email.com -onebox config set cloudflareZoneID your-zone-id +onebox config set cloudflareToken your-api-token +onebox config set cloudflareZoneId your-zone-id # Deploy with automatic DNS onebox service add myapp \ @@ -562,7 +467,7 @@ onebox ssl force-renew yourdomain.com - ✅ Ensure firewall allows WebSocket connections - ✅ Check browser console for connection errors -- ✅ Verify `/api/ws` endpoint is accessible +- ✅ Verify the dashboard socket connection is established ### Service Not Starting diff --git a/ts/classes/apiclient.ts b/ts/classes/apiclient.ts deleted file mode 100644 index 0f04f01..0000000 --- a/ts/classes/apiclient.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * API Client for communicating with Onebox daemon - * - * Provides methods for CLI commands to interact with running daemon via HTTP API - */ - -import type { - IService, - IRegistry, - IDnsRecord, - ISslCertificate, - IServiceDeployOptions, -} from '../types.ts'; -import { getErrorMessage } from '../utils/error.ts'; - -export class OneboxApiClient { - private baseUrl: string; - private token?: string; - - constructor(port = 3000) { - this.baseUrl = `http://localhost:${port}`; - } - - /** - * Check if daemon is reachable - */ - async isReachable(): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/status`, { - signal: AbortSignal.timeout(5000), // 5 second timeout - }); - return response.ok; - } catch { - return false; - } - } - - // ============ Service Operations ============ - - async deployService(config: IServiceDeployOptions): Promise { - return await this.request('POST', '/api/services', config); - } - - async removeService(name: string): Promise { - await this.request('DELETE', `/api/services/${name}`); - } - - async startService(name: string): Promise { - await this.request('POST', `/api/services/${name}/start`); - } - - async stopService(name: string): Promise { - await this.request('POST', `/api/services/${name}/stop`); - } - - async restartService(name: string): Promise { - await this.request('POST', `/api/services/${name}/restart`); - } - - async listServices(): Promise { - return await this.request('GET', '/api/services'); - } - - async getServiceLogs(name: string, limit = 1000): Promise { - const result = await this.request<{ logs: string[] }>( - 'GET', - `/api/services/${name}/logs?limit=${limit}` - ); - return result.logs; - } - - // ============ Registry Operations ============ - - async addRegistry(url: string, username: string, password: string): Promise { - await this.request('POST', '/api/registries', { url, username, password }); - } - - async removeRegistry(url: string): Promise { - await this.request('DELETE', `/api/registries/${encodeURIComponent(url)}`); - } - - async listRegistries(): Promise { - return await this.request('GET', '/api/registries'); - } - - // ============ DNS Operations ============ - - async addDnsRecord(domain: string): Promise { - await this.request('POST', '/api/dns', { domain }); - } - - async removeDnsRecord(domain: string): Promise { - await this.request('DELETE', `/api/dns/${domain}`); - } - - async listDnsRecords(): Promise { - return await this.request('GET', '/api/dns'); - } - - async syncDns(): Promise { - await this.request('POST', '/api/dns/sync'); - } - - // ============ SSL Operations ============ - - async renewCertificate(domain?: string): Promise { - const path = domain ? `/api/ssl/renew/${domain}` : '/api/ssl/renew'; - await this.request('POST', path); - } - - async listCertificates(): Promise { - return await this.request('GET', '/api/ssl'); - } - - async forceRenewCertificate(domain: string): Promise { - await this.request('POST', `/api/ssl/renew/${domain}?force=true`); - } - - // ============ Nginx Operations ============ - - async reloadNginx(): Promise { - await this.request('POST', '/api/nginx/reload'); - } - - async testNginx(): Promise<{ success: boolean; output: string }> { - return await this.request('POST', '/api/nginx/test'); - } - - async getNginxStatus(): Promise<{ status: string }> { - return await this.request('GET', '/api/nginx/status'); - } - - // ============ Config Operations ============ - - async getSettings(): Promise> { - return await this.request>('GET', '/api/config'); - } - - async setSetting(key: string, value: string): Promise { - await this.request('POST', '/api/config', { key, value }); - } - - // ============ System Operations ============ - - async getStatus(): Promise<{ - services: { total: number; running: number; stopped: number }; - uptime: number; - }> { - return await this.request('GET', '/api/status'); - } - - // ============ Helper Methods ============ - - /** - * Make HTTP request to daemon - */ - private async request( - method: string, - path: string, - body?: unknown - ): Promise { - const url = `${this.baseUrl}${path}`; - - const headers: Record = { - 'Content-Type': 'application/json', - }; - - if (this.token) { - headers['Authorization'] = `Bearer ${this.token}`; - } - - const options: RequestInit = { - method, - headers, - signal: AbortSignal.timeout(30000), // 30 second timeout - }; - - if (body) { - options.body = JSON.stringify(body); - } - - try { - const response = await fetch(url, options); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ message: response.statusText })); - throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); - } - - // For DELETE and some POST requests, there might be no content - if (response.status === 204 || response.headers.get('content-length') === '0') { - return undefined as T; - } - - return await response.json(); - } catch (error) { - if (error instanceof Error && error.name === 'TimeoutError') { - throw new Error('Request timed out. Daemon might be unresponsive.'); - } - throw error; - } - } - - /** - * Set authentication token - */ - setToken(token: string): void { - this.token = token; - } -} diff --git a/ts/classes/httpserver.ts b/ts/classes/httpserver.ts deleted file mode 100644 index 794a39e..0000000 --- a/ts/classes/httpserver.ts +++ /dev/null @@ -1,2721 +0,0 @@ -/** - * HTTP Server for Onebox - * - * Serves REST API and Angular UI - */ - -import * as plugins from '../plugins.ts'; -import { logger } from '../logging.ts'; -import { hashPassword, needsPasswordUpgrade, verifyPassword } from '../utils/auth.ts'; -import { getErrorMessage } from '../utils/error.ts'; -import type { Onebox } from './onebox.ts'; -import type { - IApiResponse, - ICreateRegistryTokenRequest, - IRegistryTokenView, - TPlatformServiceType, - IContainerStats, - IBackupScheduleCreate, - IBackupScheduleUpdate, -} 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: ${getErrorMessage(error)}`); - 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: ${getErrorMessage(error)}`); - 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); - } - - // Platform service log streaming WebSocket - if (path.startsWith('/api/platform-services/') && path.endsWith('/logs/stream') && req.headers.get('upgrade') === 'websocket') { - const platformType = path.split('/')[3]; - return this.handlePlatformLogStreamUpgrade(req, platformType); - } - - // 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 { - 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.match(/^\/api\/services\/[^/]+\/stats$/) && method === 'GET') { - const name = path.split('/')[3]; - return await this.handleGetServiceStatsRequest(name); - } else if (path.match(/^\/api\/services\/[^/]+\/metrics$/) && method === 'GET') { - const name = path.split('/')[3]; - const limit = new URL(req.url).searchParams.get('limit'); - return await this.handleGetServiceMetricsRequest(name, limit ? parseInt(limit, 10) : 60); - } 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|caddy|clickhouse)$/) && 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|caddy|clickhouse)\/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|caddy|clickhouse)\/stop$/) && method === 'POST') { - const type = path.split('/')[3] as TPlatformServiceType; - return await this.handleStopPlatformServiceRequest(type); - } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy|clickhouse)\/stats$/) && method === 'GET') { - const type = path.split('/')[3] as TPlatformServiceType; - return await this.handleGetPlatformServiceStatsRequest(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 if (path === '/api/network/traffic-stats' && method === 'GET') { - return await this.handleGetTrafficStatsRequest(new URL(req.url)); - // Backup endpoints - } else if (path === '/api/backups' && method === 'GET') { - return await this.handleListBackupsRequest(); - } else if (path.match(/^\/api\/services\/[^/]+\/backups$/) && method === 'GET') { - const serviceName = path.split('/')[3]; - return await this.handleListServiceBackupsRequest(serviceName); - } else if (path.match(/^\/api\/services\/[^/]+\/backup$/) && method === 'POST') { - const serviceName = path.split('/')[3]; - return await this.handleCreateBackupRequest(serviceName); - } else if (path.match(/^\/api\/backups\/\d+$/) && method === 'GET') { - const backupId = Number(path.split('/').pop()); - return await this.handleGetBackupRequest(backupId); - } else if (path.match(/^\/api\/backups\/\d+\/download$/) && method === 'GET') { - const backupId = Number(path.split('/')[3]); - return await this.handleDownloadBackupRequest(backupId); - } else if (path.match(/^\/api\/backups\/\d+$/) && method === 'DELETE') { - const backupId = Number(path.split('/').pop()); - return await this.handleDeleteBackupRequest(backupId); - } else if (path === '/api/backups/restore' && method === 'POST') { - return await this.handleRestoreBackupRequest(req); - } else if (path === '/api/backups/import' && method === 'POST') { - return await this.handleImportBackupRequest(req); - } else if (path === '/api/settings/backup-password' && method === 'POST') { - return await this.handleSetBackupPasswordRequest(req); - } else if (path === '/api/settings/backup-password' && method === 'GET') { - return await this.handleCheckBackupPasswordRequest(); - // Backup Schedule endpoints - } else if (path === '/api/backup-schedules' && method === 'GET') { - return await this.handleListBackupSchedulesRequest(); - } else if (path === '/api/backup-schedules' && method === 'POST') { - return await this.handleCreateBackupScheduleRequest(req); - } else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'GET') { - const scheduleId = Number(path.split('/').pop()); - return await this.handleGetBackupScheduleRequest(scheduleId); - } else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'PUT') { - const scheduleId = Number(path.split('/').pop()); - return await this.handleUpdateBackupScheduleRequest(scheduleId, req); - } else if (path.match(/^\/api\/backup-schedules\/\d+$/) && method === 'DELETE') { - const scheduleId = Number(path.split('/').pop()); - return await this.handleDeleteBackupScheduleRequest(scheduleId); - } else if (path.match(/^\/api\/backup-schedules\/\d+\/trigger$/) && method === 'POST') { - const scheduleId = Number(path.split('/')[3]); - return await this.handleTriggerBackupScheduleRequest(scheduleId); - } else if (path.match(/^\/api\/services\/[^/]+\/backup-schedules$/) && method === 'GET') { - const serviceName = path.split('/')[3]; - return await this.handleListServiceBackupSchedulesRequest(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...`); - - const passwordMatches = await verifyPassword(password, user.passwordHash); - if (!passwordMatches) { - logger.info(`Password mismatch for user: ${username}`); - return this.jsonResponse({ success: false, error: 'Invalid credentials' }, 401); - } - - if (needsPasswordUpgrade(user.passwordHash)) { - const upgradedHash = await hashPassword(password); - this.oneboxRef.database.updateUserPassword(user.username, upgradedHash); - } - - // 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 { - 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 { - 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 { - 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 { - 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 { - 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}: ${getErrorMessage(error)}`); - return this.jsonResponse({ success: false, error: getErrorMessage(error) || '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}: ${getErrorMessage(error)}`); - return this.jsonResponse({ success: false, error: getErrorMessage(error) || '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}: ${getErrorMessage(error)}`); - return this.jsonResponse({ success: false, error: getErrorMessage(error) || '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}: ${getErrorMessage(error)}`); - return this.jsonResponse({ success: false, error: getErrorMessage(error) || '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}: ${getErrorMessage(error)}`); - return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to restart service' }, 500); - } - } - - private async handleGetLogsRequest(name: string): Promise { - 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 handleGetServiceStatsRequest(name: string): Promise { - try { - const service = this.oneboxRef.services.getService(name); - if (!service) { - return this.jsonResponse({ success: false, error: 'Service not found' }, 404); - } - - if (!service.containerID) { - return this.jsonResponse({ success: false, error: 'Service has no container' }, 400); - } - - // Get live container stats - const stats = await this.oneboxRef.docker.getContainerStats(service.containerID); - if (!stats) { - return this.jsonResponse({ success: false, error: 'Could not retrieve container stats' }, 500); - } - - return this.jsonResponse({ success: true, data: stats }); - } catch (error) { - logger.error(`Failed to get stats for service ${name}: ${getErrorMessage(error)}`); - return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to get stats' }, 500); - } - } - - private async handleGetServiceMetricsRequest(name: string, limit: number): Promise { - try { - const service = this.oneboxRef.services.getService(name); - if (!service) { - return this.jsonResponse({ success: false, error: 'Service not found' }, 404); - } - - if (!service.id) { - return this.jsonResponse({ success: false, error: 'Service has no ID' }, 400); - } - - // Get historical metrics from database - const metrics = this.oneboxRef.database.getMetrics(service.id, limit); - - return this.jsonResponse({ success: true, data: metrics }); - } catch (error) { - logger.error(`Failed to get metrics for service ${name}: ${getErrorMessage(error)}`); - return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to get metrics' }, 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: ${getErrorMessage(error)}`); - 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: ${getErrorMessage(error)}`); - return this.jsonResponse({ success: false, error: getErrorMessage(error) || '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: ${getErrorMessage(error)}`); - return this.jsonResponse({ success: false, error: getErrorMessage(error) || '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}: ${getErrorMessage(error)}`); - return this.jsonResponse({ success: false, error: getErrorMessage(error) || '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}: ${getErrorMessage(error)}`); - return this.jsonResponse({ success: false, error: getErrorMessage(error) || '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: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || '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: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || '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}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || '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: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || '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: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || '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}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || '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: ${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 platform service log streaming - */ - private handlePlatformLogStreamUpgrade(req: Request, platformType: string): Response { - const { socket, response } = Deno.upgradeWebSocket(req); - - socket.onopen = async () => { - logger.info(`Platform log stream WebSocket connected for: ${platformType}`); - - try { - // Get the platform service from database - const platformService = this.oneboxRef.database.getPlatformServiceByType(platformType as any); - if (!platformService) { - socket.send(JSON.stringify({ error: 'Platform service not found' })); - socket.close(); - return; - } - - if (!platformService.containerId) { - socket.send(JSON.stringify({ error: 'Platform service has no container' })); - socket.close(); - return; - } - - // Get the container - logger.info(`Looking up container for platform service ${platformType}, containerID: ${platformService.containerId}`); - const container = await this.oneboxRef.docker.getContainerById(platformService.containerId); - - if (!container) { - logger.error(`Container not found for platform service ${platformType}, containerID: ${platformService.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: platformType, - })); - - // 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(`Platform log stream error for ${platformType}: ${getErrorMessage(error)}`); - if (socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify({ error: getErrorMessage(error) })); - } - }); - - logStream.on('end', () => { - logger.info(`Platform log stream ended for ${platformType}`); - socket.close(); - }); - - // Clean up on close - socket.onclose = () => { - logger.info(`Platform log stream WebSocket closed for ${platformType}`); - logStream.destroy(); - }; - - } catch (error) { - logger.error(`Failed to start platform log stream for ${platformType}: ${getErrorMessage(error)}`); - if (socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify({ error: getErrorMessage(error) })); - socket.close(); - } - } - }; - - socket.onerror = (error) => { - logger.error(`Platform 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 { - 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.containerID || '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 = { - mongodb: 27017, - minio: 9000, - redis: 6379, - postgresql: 5432, - rabbitmq: 5672, - caddy: 80, - clickhouse: 8123, - mariadb: 3306, - }; - return ports[type] || 0; - } - - /** - * Get Caddy/network stats - */ - private async handleGetNetworkStatsRequest(): Promise { - try { - const proxyStatus = this.oneboxRef.reverseProxy.getStatus(); - const logReceiverStats = this.oneboxRef.caddyLogReceiver.getStats(); - - return this.jsonResponse({ - success: true, - data: { - proxy: { - running: proxyStatus.http.running || proxyStatus.https.running, - httpPort: proxyStatus.http.port, - httpsPort: proxyStatus.https.port, - routes: proxyStatus.routes, - certificates: proxyStatus.https.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); - } - } - - /** - * Get traffic stats from Caddy access logs - */ - private async handleGetTrafficStatsRequest(url: URL): Promise { - try { - // Get minutes parameter (default: 60) - const minutesParam = url.searchParams.get('minutes'); - const minutes = minutesParam ? parseInt(minutesParam, 10) : 60; - - if (isNaN(minutes) || minutes < 1 || minutes > 60) { - return this.jsonResponse({ - success: false, - error: 'Invalid minutes parameter. Must be between 1 and 60.', - }, 400); - } - - const trafficStats = this.oneboxRef.caddyLogReceiver.getTrafficStats(minutes); - - return this.jsonResponse({ - success: true, - data: trafficStats, - }); - } catch (error) { - logger.error(`Failed to get traffic stats: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to get traffic stats', - }, 500); - } - } - - /** - * 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: ${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(), - }); - } - - /** - * Broadcast stats update for a service - */ - broadcastStatsUpdate(serviceName: string, stats: IContainerStats): void { - this.broadcast({ - type: 'stats_update', - serviceName, - stats, - 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); - // Check if provider has isCore property (like CaddyProvider) - const isCore = 'isCore' in provider && (provider as any).isCore === true; - - // For Caddy, check actual runtime status since it starts without a DB record - let status = service?.status || 'not-deployed'; - if (provider.type === 'caddy') { - const proxyStatus = this.oneboxRef.reverseProxy.getStatus(); - status = proxyStatus.http.running ? 'running' : 'stopped'; - } - - return { - type: provider.type, - displayName: provider.displayName, - resourceTypes: provider.resourceTypes, - status, - containerId: service?.containerId, - isCore, - 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 { - 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) - : []; - - // For Caddy, check actual runtime status since it starts without a DB record - let status = service?.status || 'not-deployed'; - if (type === 'caddy') { - const proxyStatus = this.oneboxRef.reverseProxy.getStatus(); - status = proxyStatus.http.running ? 'running' : 'stopped'; - } - - return this.jsonResponse({ - success: true, - data: { - type: provider.type, - displayName: provider.displayName, - resourceTypes: provider.resourceTypes, - status, - 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 { - 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 { - try { - const provider = this.oneboxRef.platformServices.getProvider(type); - if (!provider) { - return this.jsonResponse({ - success: false, - error: `Unknown platform service type: ${type}`, - }, 404); - } - - // Check if this is a core service that cannot be stopped - const isCore = 'isCore' in provider && (provider as any).isCore === true; - if (isCore) { - return this.jsonResponse({ - success: false, - error: `${provider.displayName} is a core service and cannot be stopped`, - }, 400); - } - - 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 handleGetPlatformServiceStatsRequest(type: TPlatformServiceType): Promise { - try { - const provider = this.oneboxRef.platformServices.getProvider(type); - if (!provider) { - return this.jsonResponse({ - success: false, - error: `Unknown platform service type: ${type}`, - }, 404); - } - - // For Caddy, return proxy stats instead of container stats - if (type === 'caddy') { - const proxyStatus = this.oneboxRef.reverseProxy.getStatus(); - return this.jsonResponse({ - success: true, - data: { - type: 'caddy', - running: proxyStatus.http.running, - httpPort: proxyStatus.http.port, - httpsPort: proxyStatus.https.port, - routes: proxyStatus.routes, - certificates: proxyStatus.https.certificates, - }, - }); - } - - const service = this.oneboxRef.database.getPlatformServiceByType(type); - if (!service || !service.containerId) { - return this.jsonResponse({ success: false, error: 'Platform service has no container' }, 400); - } - - const stats = await this.oneboxRef.docker.getContainerStats(service.containerId); - if (!stats) { - return this.jsonResponse({ success: false, error: 'Could not retrieve container stats' }, 500); - } - - return this.jsonResponse({ success: true, data: stats }); - } catch (error) { - logger.error(`Failed to get stats for platform service ${type}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to get platform service stats', - }, 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}: ${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 { - 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 { - 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 { - 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 { - 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: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || '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}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to delete registry token', - }, 500); - } - } - - // ============ Backup Endpoints ============ - - /** - * List all backups - */ - private async handleListBackupsRequest(): Promise { - try { - const backups = this.oneboxRef.backupManager.listBackups(); - return this.jsonResponse({ success: true, data: backups }); - } catch (error) { - logger.error(`Failed to list backups: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to list backups', - }, 500); - } - } - - /** - * List backups for a specific service - */ - private async handleListServiceBackupsRequest(serviceName: string): Promise { - try { - const service = this.oneboxRef.services.getService(serviceName); - if (!service) { - return this.jsonResponse({ success: false, error: 'Service not found' }, 404); - } - - const backups = this.oneboxRef.backupManager.listBackups(serviceName); - return this.jsonResponse({ success: true, data: backups }); - } catch (error) { - logger.error(`Failed to list backups for service ${serviceName}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to list backups', - }, 500); - } - } - - /** - * Create a backup for a service - */ - private async handleCreateBackupRequest(serviceName: string): Promise { - try { - const service = this.oneboxRef.services.getService(serviceName); - if (!service) { - return this.jsonResponse({ success: false, error: 'Service not found' }, 404); - } - - const result = await this.oneboxRef.backupManager.createBackup(serviceName); - - return this.jsonResponse({ - success: true, - message: `Backup created for service ${serviceName}`, - data: result.backup, - }); - } catch (error) { - logger.error(`Failed to create backup for service ${serviceName}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to create backup', - }, 500); - } - } - - /** - * Get a specific backup by ID - */ - private async handleGetBackupRequest(backupId: number): Promise { - try { - const backup = this.oneboxRef.database.getBackupById(backupId); - if (!backup) { - return this.jsonResponse({ success: false, error: 'Backup not found' }, 404); - } - - return this.jsonResponse({ success: true, data: backup }); - } catch (error) { - logger.error(`Failed to get backup ${backupId}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to get backup', - }, 500); - } - } - - /** - * Download a backup file - */ - private async handleDownloadBackupRequest(backupId: number): Promise { - try { - const backup = this.oneboxRef.database.getBackupById(backupId); - if (!backup) { - return this.jsonResponse({ success: false, error: 'Backup not found' }, 404); - } - - let downloadPath: string | null = null; - let tempExport = false; - - if (backup.snapshotId) { - // ContainerArchive backup: export as encrypted tar - downloadPath = await this.oneboxRef.backupManager.getBackupExportPath(backupId); - tempExport = true; - } else { - // Legacy file-based backup - downloadPath = this.oneboxRef.backupManager.getBackupFilePath(backupId); - } - - if (!downloadPath) { - return this.jsonResponse({ success: false, error: 'Backup file not available' }, 404); - } - - // Check if file exists - try { - await Deno.stat(downloadPath); - } catch { - return this.jsonResponse({ success: false, error: 'Backup file not found on disk' }, 404); - } - - const file = await Deno.readFile(downloadPath); - const filename = backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`; - - // Clean up temp export file - if (tempExport) { - try { await Deno.remove(downloadPath); } catch { /* ignore */ } - } - - return new Response(file, { - status: 200, - headers: { - 'Content-Type': 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${filename}"`, - 'Content-Length': String(file.length), - }, - }); - } catch (error) { - logger.error(`Failed to download backup ${backupId}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to download backup', - }, 500); - } - } - - /** - * Delete a backup - */ - private async handleDeleteBackupRequest(backupId: number): Promise { - try { - const backup = this.oneboxRef.database.getBackupById(backupId); - if (!backup) { - return this.jsonResponse({ success: false, error: 'Backup not found' }, 404); - } - - await this.oneboxRef.backupManager.deleteBackup(backupId); - - return this.jsonResponse({ - success: true, - message: 'Backup deleted successfully', - }); - } catch (error) { - logger.error(`Failed to delete backup ${backupId}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to delete backup', - }, 500); - } - } - - /** - * Restore a backup - */ - private async handleRestoreBackupRequest(req: Request): Promise { - try { - const body = await req.json(); - const { backupId, mode, newServiceName, overwriteExisting, skipPlatformData } = body; - - if (!backupId) { - return this.jsonResponse({ - success: false, - error: 'Backup ID is required', - }, 400); - } - - if (!mode || !['restore', 'import', 'clone'].includes(mode)) { - return this.jsonResponse({ - success: false, - error: 'Valid mode required: restore, import, or clone', - }, 400); - } - - // Validate mode-specific requirements - if ((mode === 'import' || mode === 'clone') && !newServiceName) { - return this.jsonResponse({ - success: false, - error: `New service name required for '${mode}' mode`, - }, 400); - } - - const result = await this.oneboxRef.backupManager.restoreBackup(backupId, { - mode, - newServiceName, - overwriteExisting: overwriteExisting === true, - skipPlatformData: skipPlatformData === true, - }); - - return this.jsonResponse({ - success: true, - message: `Backup restored successfully as service '${result.service.name}'`, - data: { - service: result.service, - platformResourcesRestored: result.platformResourcesRestored, - warnings: result.warnings, - }, - }); - } catch (error) { - logger.error(`Failed to restore backup: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to restore backup', - }, 500); - } - } - - /** - * Import a backup from file upload or URL - */ - private async handleImportBackupRequest(req: Request): Promise { - try { - const contentType = req.headers.get('content-type') || ''; - let filePath: string | null = null; - let newServiceName: string | undefined; - let tempFile = false; - - if (contentType.includes('multipart/form-data')) { - // Handle file upload - const formData = await req.formData(); - const file = formData.get('file'); - newServiceName = formData.get('newServiceName')?.toString() || undefined; - - if (!file || !(file instanceof File)) { - return this.jsonResponse({ - success: false, - error: 'No file provided', - }, 400); - } - - // Validate file extension - if (!file.name.endsWith('.tar.enc')) { - return this.jsonResponse({ - success: false, - error: 'Invalid file format. Expected .tar.enc file', - }, 400); - } - - // Save to temp location - const tempDir = './.nogit/temp-imports'; - await Deno.mkdir(tempDir, { recursive: true }); - filePath = `${tempDir}/${Date.now()}-${file.name}`; - tempFile = true; - - const arrayBuffer = await file.arrayBuffer(); - await Deno.writeFile(filePath, new Uint8Array(arrayBuffer)); - - logger.info(`Saved uploaded backup to ${filePath}`); - } else { - // Handle JSON body with URL - const body = await req.json(); - const { url, newServiceName: serviceName } = body; - newServiceName = serviceName; - - if (!url) { - return this.jsonResponse({ - success: false, - error: 'URL is required when not uploading a file', - }, 400); - } - - // Download from URL - const tempDir = './.nogit/temp-imports'; - await Deno.mkdir(tempDir, { recursive: true }); - - const urlFilename = url.split('/').pop() || 'backup.tar.enc'; - filePath = `${tempDir}/${Date.now()}-${urlFilename}`; - tempFile = true; - - logger.info(`Downloading backup from ${url}...`); - const response = await fetch(url); - if (!response.ok) { - return this.jsonResponse({ - success: false, - error: `Failed to download from URL: ${response.statusText}`, - }, 400); - } - - const arrayBuffer = await response.arrayBuffer(); - await Deno.writeFile(filePath, new Uint8Array(arrayBuffer)); - - logger.info(`Downloaded backup to ${filePath}`); - } - - // Import using restoreBackup with mode='import' - const result = await this.oneboxRef.backupManager.restoreBackup(filePath, { - mode: 'import', - newServiceName, - overwriteExisting: false, - skipPlatformData: false, - }); - - // Clean up temp file - if (tempFile && filePath) { - try { - await Deno.remove(filePath); - } catch { - // Ignore cleanup errors - } - } - - return this.jsonResponse({ - success: true, - message: `Backup imported successfully as service '${result.service.name}'`, - data: { - service: result.service, - platformResourcesRestored: result.platformResourcesRestored, - warnings: result.warnings, - }, - }); - } catch (error) { - logger.error(`Failed to import backup: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to import backup', - }, 500); - } - } - - /** - * Set backup encryption password - */ - private async handleSetBackupPasswordRequest(req: Request): Promise { - try { - const body = await req.json(); - const { password } = body; - - if (!password || typeof password !== 'string') { - return this.jsonResponse({ - success: false, - error: 'Password is required', - }, 400); - } - - if (password.length < 8) { - return this.jsonResponse({ - success: false, - error: 'Password must be at least 8 characters', - }, 400); - } - - // Store password in settings - this.oneboxRef.database.setSetting('backup_encryption_password', password); - - return this.jsonResponse({ - success: true, - message: 'Backup password set successfully', - }); - } catch (error) { - logger.error(`Failed to set backup password: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to set backup password', - }, 500); - } - } - - /** - * Check if backup password is configured - */ - private async handleCheckBackupPasswordRequest(): Promise { - try { - const password = this.oneboxRef.database.getSetting('backup_encryption_password'); - const isConfigured = password !== null && password.length > 0; - - return this.jsonResponse({ - success: true, - data: { - isConfigured, - }, - }); - } catch (error) { - logger.error(`Failed to check backup password: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to check backup password', - }, 500); - } - } - - // ============ Backup Schedule Endpoints ============ - - /** - * List all backup schedules - */ - private async handleListBackupSchedulesRequest(): Promise { - try { - const schedules = this.oneboxRef.backupScheduler.getAllSchedules(); - return this.jsonResponse({ success: true, data: schedules }); - } catch (error) { - logger.error(`Failed to list backup schedules: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to list backup schedules', - }, 500); - } - } - - /** - * Create a new backup schedule - */ - private async handleCreateBackupScheduleRequest(req: Request): Promise { - try { - const body = await req.json() as IBackupScheduleCreate; - - // Validate scope type - if (!body.scopeType) { - return this.jsonResponse({ - success: false, - error: 'Scope type is required (all, pattern, or service)', - }, 400); - } - - if (!['all', 'pattern', 'service'].includes(body.scopeType)) { - return this.jsonResponse({ - success: false, - error: 'Invalid scope type. Must be: all, pattern, or service', - }, 400); - } - - // Validate scope-specific requirements - if (body.scopeType === 'service' && !body.serviceName) { - return this.jsonResponse({ - success: false, - error: 'Service name is required for service-specific schedules', - }, 400); - } - - if (body.scopeType === 'pattern' && !body.scopePattern) { - return this.jsonResponse({ - success: false, - error: 'Scope pattern is required for pattern-based schedules', - }, 400); - } - - if (!body.cronExpression) { - return this.jsonResponse({ - success: false, - error: 'Cron expression is required', - }, 400); - } - - if (!body.retention) { - return this.jsonResponse({ - success: false, - error: 'Retention policy is required', - }, 400); - } - - // Validate retention policy - const { hourly, daily, weekly, monthly } = body.retention; - if (typeof hourly !== 'number' || typeof daily !== 'number' || - typeof weekly !== 'number' || typeof monthly !== 'number') { - return this.jsonResponse({ - success: false, - error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers', - }, 400); - } - - if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) { - return this.jsonResponse({ - success: false, - error: 'Retention values must be non-negative', - }, 400); - } - - const schedule = await this.oneboxRef.backupScheduler.createSchedule(body); - - // Build descriptive message based on scope type - let scopeDesc: string; - switch (body.scopeType) { - case 'all': - scopeDesc = 'all services'; - break; - case 'pattern': - scopeDesc = `pattern '${body.scopePattern}'`; - break; - case 'service': - scopeDesc = `service '${body.serviceName}'`; - break; - } - - return this.jsonResponse({ - success: true, - message: `Backup schedule created for ${scopeDesc}`, - data: schedule, - }); - } catch (error) { - logger.error(`Failed to create backup schedule: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to create backup schedule', - }, 500); - } - } - - /** - * Get a specific backup schedule - */ - private async handleGetBackupScheduleRequest(scheduleId: number): Promise { - try { - const schedule = this.oneboxRef.backupScheduler.getScheduleById(scheduleId); - if (!schedule) { - return this.jsonResponse({ success: false, error: 'Backup schedule not found' }, 404); - } - - return this.jsonResponse({ success: true, data: schedule }); - } catch (error) { - logger.error(`Failed to get backup schedule ${scheduleId}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to get backup schedule', - }, 500); - } - } - - /** - * Update a backup schedule - */ - private async handleUpdateBackupScheduleRequest(scheduleId: number, req: Request): Promise { - try { - const body = await req.json() as IBackupScheduleUpdate; - - // Validate retention policy if provided - if (body.retention) { - const { hourly, daily, weekly, monthly } = body.retention; - if (typeof hourly !== 'number' || typeof daily !== 'number' || - typeof weekly !== 'number' || typeof monthly !== 'number') { - return this.jsonResponse({ - success: false, - error: 'Retention policy must have hourly, daily, weekly, and monthly as numbers', - }, 400); - } - if (hourly < 0 || daily < 0 || weekly < 0 || monthly < 0) { - return this.jsonResponse({ - success: false, - error: 'Retention values must be non-negative', - }, 400); - } - } - - const schedule = await this.oneboxRef.backupScheduler.updateSchedule(scheduleId, body); - - return this.jsonResponse({ - success: true, - message: 'Backup schedule updated', - data: schedule, - }); - } catch (error) { - logger.error(`Failed to update backup schedule ${scheduleId}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to update backup schedule', - }, 500); - } - } - - /** - * Delete a backup schedule - */ - private async handleDeleteBackupScheduleRequest(scheduleId: number): Promise { - try { - await this.oneboxRef.backupScheduler.deleteSchedule(scheduleId); - - return this.jsonResponse({ - success: true, - message: 'Backup schedule deleted', - }); - } catch (error) { - logger.error(`Failed to delete backup schedule ${scheduleId}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to delete backup schedule', - }, 500); - } - } - - /** - * Trigger immediate backup for a schedule - */ - private async handleTriggerBackupScheduleRequest(scheduleId: number): Promise { - try { - await this.oneboxRef.backupScheduler.triggerBackup(scheduleId); - - return this.jsonResponse({ - success: true, - message: 'Backup triggered successfully', - }); - } catch (error) { - logger.error(`Failed to trigger backup for schedule ${scheduleId}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to trigger backup', - }, 500); - } - } - - /** - * List backup schedules for a specific service - */ - private async handleListServiceBackupSchedulesRequest(serviceName: string): Promise { - try { - const service = this.oneboxRef.services.getService(serviceName); - if (!service) { - return this.jsonResponse({ success: false, error: 'Service not found' }, 404); - } - - const schedules = this.oneboxRef.backupScheduler.getSchedulesForService(serviceName); - return this.jsonResponse({ success: true, data: schedules }); - } catch (error) { - logger.error(`Failed to list backup schedules for service ${serviceName}: ${getErrorMessage(error)}`); - return this.jsonResponse({ - success: false, - error: getErrorMessage(error) || 'Failed to list backup schedules', - }, 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' }, - }); - } -} diff --git a/ts/classes/onebox.ts b/ts/classes/onebox.ts index 1386336..1fad8f3 100644 --- a/ts/classes/onebox.ts +++ b/ts/classes/onebox.ts @@ -16,7 +16,6 @@ import { OneboxDnsManager } from './dns.ts'; import { OneboxSslManager } from './ssl.ts'; import { OneboxDaemon } from './daemon.ts'; import { OneboxSystemd } from './systemd.ts'; -import type { OneboxHttpServer } from './httpserver.ts'; import { CloudflareDomainSync } from './cloudflare-sync.ts'; import { CertRequirementManager } from './cert-requirement-manager.ts'; import { RegistryManager } from './registry.ts'; @@ -37,7 +36,6 @@ export class Onebox { public ssl: OneboxSslManager; public daemon: OneboxDaemon; public systemd: OneboxSystemd; - public httpServer: OneboxHttpServer | null; public cloudflareDomainSync: CloudflareDomainSync; public certRequirementManager: CertRequirementManager; public registry: RegistryManager; @@ -63,7 +61,6 @@ export class Onebox { this.ssl = new OneboxSslManager(this); this.daemon = new OneboxDaemon(this); this.systemd = new OneboxSystemd(); - this.httpServer = null; this.registry = new RegistryManager({ dataDir: './.nogit/registry-data', port: 4000, diff --git a/ts/index.ts b/ts/index.ts index 350e223..8a5084b 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -13,8 +13,6 @@ export { OneboxDnsManager } from './classes/dns.ts'; export { OneboxSslManager } from './classes/ssl.ts'; export { OneboxDaemon } from './classes/daemon.ts'; export { OneboxSystemd } from './classes/systemd.ts'; -export { OneboxHttpServer } from './classes/httpserver.ts'; -export { OneboxApiClient } from './classes/apiclient.ts'; // Types export * from './types.ts'; diff --git a/ts/opsserver/classes.opsserver.ts b/ts/opsserver/classes.opsserver.ts index bef48bf..1da7d68 100644 --- a/ts/opsserver/classes.opsserver.ts +++ b/ts/opsserver/classes.opsserver.ts @@ -43,6 +43,7 @@ export class OpsServer { // Set up all handlers await this.setupHandlers(); + this.registerCustomRoutes(); await this.server.start(port); logger.success(`OpsServer started on http://localhost:${port}`); @@ -72,6 +73,78 @@ export class OpsServer { logger.success('OpsServer TypedRequest handlers initialized'); } + private registerCustomRoutes(): void { + this.server.typedserver.addRoute( + '/backups/:backupId/download', + 'GET', + async (ctx) => { + const jwt = ctx.query.jwt; + if (!jwt) { + return new Response('Missing JWT', { status: 401 }); + } + + try { + await this.adminHandler.getVerifiedAdminIdentity({ + jwt, + userId: '', + username: '', + expiresAt: 0, + role: 'user', + }); + } catch { + return new Response('Unauthorized', { status: 401 }); + } + + const backupId = Number(ctx.params.backupId); + if (!Number.isInteger(backupId) || backupId < 1) { + return new Response('Invalid backup id', { status: 400 }); + } + + const backup = this.oneboxRef.database.getBackupById(backupId); + if (!backup) { + return new Response('Backup not found', { status: 404 }); + } + + const filename = this.sanitizeDownloadFilename( + backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`, + ); + + let filePath = this.oneboxRef.backupManager.getBackupFilePath(backupId); + let shouldCleanup = false; + + if (!filePath) { + filePath = await this.oneboxRef.backupManager.getBackupExportPath(backupId); + shouldCleanup = !!filePath; + } + + if (!filePath) { + return new Response('Backup export unavailable', { status: 404 }); + } + + try { + const fileData = await Deno.readFile(filePath); + return new Response(fileData, { + status: 200, + headers: { + 'content-type': 'application/octet-stream', + 'content-disposition': `attachment; filename="${filename}"`, + 'content-length': String(fileData.byteLength), + 'cache-control': 'no-store', + }, + }); + } finally { + if (shouldCleanup) { + await Deno.remove(filePath).catch(() => {}); + } + } + }, + ); + } + + private sanitizeDownloadFilename(filename: string): string { + return filename.replace(/["\\\r\n]/g, '_'); + } + public async stop() { if (this.server) { await this.server.stop(); diff --git a/ts/opsserver/handlers/backups.handler.ts b/ts/opsserver/handlers/backups.handler.ts index 210b8c9..5ba3980 100644 --- a/ts/opsserver/handlers/backups.handler.ts +++ b/ts/opsserver/handlers/backups.handler.ts @@ -83,7 +83,7 @@ export class BackupsHandler { // Return a download URL that the client can fetch directly const filename = backup.filename || `${backup.serviceName}-${backup.createdAt}.tar.enc`; return { - downloadUrl: `/api/backups/${dataArg.backupId}/download`, + downloadUrl: `/backups/${dataArg.backupId}/download?jwt=${encodeURIComponent(dataArg.identity.jwt)}`, filename, }; }, diff --git a/ts/types.ts b/ts/types.ts index e02573c..56d384b 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -257,14 +257,16 @@ export interface ISetting { // Application settings export interface IAppSettings { serverIP?: string; - cloudflareAPIKey?: string; - cloudflareEmail?: string; - cloudflareZoneID?: string; + cloudflareToken?: string; + cloudflareZoneId?: string; acmeEmail?: string; - nginxConfigDir?: string; dataDir?: string; httpPort?: number; + httpsPort?: number; metricsInterval?: number; + autoRenewCerts?: boolean; + renewalThreshold?: number; + forceHttps?: boolean; logRetentionDays?: number; }