From 8ebd677478536f178480bb9dc579de1d93165f45 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 25 Nov 2025 04:20:19 +0000 Subject: [PATCH] feat: Implement platform service providers for MinIO and MongoDB - Added base interface and abstract class for platform service providers. - Created MinIOProvider class for S3-compatible storage with deployment, provisioning, and deprovisioning functionalities. - Implemented MongoDBProvider class for MongoDB service with similar capabilities. - Introduced error handling utilities for better error management. - Developed TokensComponent for managing registry tokens in the UI, including creation, deletion, and display of tokens. --- readme.hints.md | 253 --------- readme.md | 177 ++++-- ts/classes/certmanager.ts | 15 +- ts/classes/daemon.ts | 32 +- ts/classes/database.ts | 504 ++++++++++++++++- ts/classes/docker.ts | 85 +++ ts/classes/encryption.ts | 203 +++++++ ts/classes/httpserver.ts | 411 ++++++++++++-- ts/classes/onebox.ts | 21 + ts/classes/platform-services/index.ts | 10 + ts/classes/platform-services/manager.ts | 361 +++++++++++++ .../platform-services/providers/base.ts | 123 +++++ .../platform-services/providers/minio.ts | 299 ++++++++++ .../platform-services/providers/mongodb.ts | 246 +++++++++ ts/classes/registry.ts | 24 - ts/classes/services.ts | 85 ++- ts/classes/ssl.ts | 36 +- ts/cli.ts | 29 +- ts/types.ts | 96 +++- ts/utils/error.ts | 43 ++ ui/src/app/app.routes.ts | 7 + ui/src/app/core/services/api.service.ts | 40 ++ ui/src/app/core/types/api.types.ts | 65 ++- .../registries/registries.component.ts | 131 ++++- .../services/service-create.component.ts | 44 ++ .../services/service-detail.component.ts | 102 +++- .../app/features/tokens/tokens.component.ts | 509 ++++++++++++++++++ .../components/layout/layout.component.ts | 1 + 28 files changed, 3462 insertions(+), 490 deletions(-) create mode 100644 ts/classes/encryption.ts create mode 100644 ts/classes/platform-services/index.ts create mode 100644 ts/classes/platform-services/manager.ts create mode 100644 ts/classes/platform-services/providers/base.ts create mode 100644 ts/classes/platform-services/providers/minio.ts create mode 100644 ts/classes/platform-services/providers/mongodb.ts create mode 100644 ts/utils/error.ts create mode 100644 ui/src/app/features/tokens/tokens.component.ts diff --git a/readme.hints.md b/readme.hints.md index ba063e7..e69de29 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -1,253 +0,0 @@ -# Onebox - Project Hints - -## Architecture Overview - -Onebox is a Deno-based self-hosted container platform that compiles to standalone binaries. It follows the same architectural patterns as nupst and spark projects. - -### Core Components - -1. **OneboxDatabase** (`ts/onebox.classes.database.ts`) - - SQLite-based storage - - Tables: services, registries, nginx_configs, ssl_certificates, dns_records, metrics, logs, users, settings - - Migration system for schema updates - -2. **OneboxDockerManager** (`ts/onebox.classes.docker.ts`) - - Docker API integration via @apiclient.xyz/docker - - Container lifecycle management - - Network management (onebox-network bridge) - - Stats collection and logging - -3. **OneboxServicesManager** (`ts/onebox.classes.services.ts`) - - High-level service orchestration - - Coordinates Docker + Nginx + DNS + SSL - - Service deployment workflow - -4. **OneboxRegistriesManager** (`ts/onebox.classes.registries.ts`) - - Docker registry authentication - - Credential storage (encrypted) - - Auto-login on daemon start - -5. **OneboxNginxManager** (`ts/onebox.classes.nginx.ts`) - - Nginx reverse proxy configuration - - Config file generation - - SSL enablement - - Reload and testing - -6. **OneboxDnsManager** (`ts/onebox.classes.dns.ts`) - - Cloudflare API integration - - Automatic A record creation - - DNS sync and verification - -7. **OneboxSslManager** (`ts/onebox.classes.ssl.ts`) - - Let's Encrypt integration via certbot - - Certificate issuance and renewal - - Expiry monitoring - -8. **OneboxDaemon** (`ts/onebox.classes.daemon.ts`) - - Background monitoring loop - - Metrics collection (every 60s by default) - - SSL certificate renewal checks - - Service health monitoring - - Systemd integration - -9. **OneboxHttpServer** (`ts/onebox.classes.httpserver.ts`) - - REST API endpoints - - Static file serving (for Angular UI) - - Authentication middleware - -10. **Onebox** (`ts/onebox.classes.onebox.ts`) - - Main coordinator class - - Initializes all components - - Provides unified API - -### CLI Structure - -- `onebox service` - Service management -- `onebox registry` - Registry credentials -- `onebox dns` - DNS records -- `onebox ssl` - SSL certificates -- `onebox nginx` - Nginx control -- `onebox daemon` - Systemd daemon -- `onebox config` - Settings management -- `onebox status` - System status - -### Deployment Workflow - -1. User runs: `onebox service add myapp --image nginx --domain app.example.com` -2. Service record created in database -3. Docker image pulled from registry -4. Container created and started -5. Nginx config generated and reloaded -6. DNS record created (if configured) -7. SSL certificate obtained (if configured) -8. Service is live! - -### Configuration - -Settings stored in database (settings table): -- `cloudflareAPIKey` - Cloudflare API key -- `cloudflareEmail` - Cloudflare email -- `cloudflareZoneID` - Cloudflare zone ID -- `acmeEmail` - Let's Encrypt email -- `serverIP` - Server public IP -- `nginxConfigDir` - Custom nginx config directory -- `httpPort` - HTTP server port (default: 3000) -- `metricsInterval` - Metrics collection interval (default: 60000ms) -- `logRetentionDays` - Log retention period - -### Data Locations - -- Database: `/var/lib/onebox/onebox.db` -- Nginx configs: `/etc/nginx/sites-available/onebox-*` -- SSL certificates: `/etc/letsencrypt/live//` -- Certbot webroot: `/var/www/certbot` - -## Development - -### Running Locally - -```bash -# Development mode -deno task dev - -# Run tests -deno task test - -# Compile all binaries -deno task compile -``` - -### Adding a New Feature - -1. Create new class in `ts/onebox.classes..ts` -2. Add to main Onebox class in `ts/onebox.classes.onebox.ts` -3. Add CLI commands in `ts/onebox.cli.ts` -4. Add API endpoints in `ts/onebox.classes.httpserver.ts` -5. Update types in `ts/onebox.types.ts` -6. Add tests in `test/` -7. Update documentation - -### Database Migrations - -Add migration logic in `OneboxDatabase.runMigrations()`: - -```typescript -if (currentVersion === 1) { - this.db.query('ALTER TABLE services ADD COLUMN new_field TEXT'); - this.setMigrationVersion(2); -} -``` - -## TODO - -### Core Functionality (Complete ✓) -- [x] Database layer with SQLite -- [x] Docker integration -- [x] Service management -- [x] Registry authentication -- [x] Nginx reverse proxy -- [x] DNS management (Cloudflare) -- [x] SSL certificates (Let's Encrypt) -- [x] Background daemon -- [x] HTTP API server -- [x] CLI commands -- [x] Build system - -### Next Steps -- [ ] Angular UI implementation - - Dashboard with service cards - - Service deployment form - - Logs viewer - - Metrics charts - - Settings page -- [ ] Authentication system (JWT) - - Login endpoint - - Token validation middleware - - Password hashing (bcrypt) -- [ ] WebSocket support for real-time logs/metrics -- [ ] Health checks for services -- [ ] Backup/restore functionality -- [ ] Multi-server support -- [ ] Load balancing -- [ ] Service templates/blueprints - -### Testing -- [ ] Unit tests for all managers -- [ ] Integration tests for deployment workflow -- [ ] Mock Docker API for tests -- [ ] Database migration tests - -### Documentation -- [ ] API documentation (OpenAPI/Swagger) -- [ ] Architecture diagram -- [ ] Deployment guide -- [ ] Troubleshooting guide -- [ ] Video tutorial - -## Common Issues - -### Docker Connection -If Docker commands fail, ensure: -- Docker daemon is running: `systemctl status docker` -- User has Docker permissions: `usermod -aG docker $USER` -- Socket exists: `ls -l /var/run/docker.sock` - -### Nginx Issues -If nginx fails to reload: -- Check syntax: `onebox nginx test` -- Check logs: `journalctl -u nginx -n 50` -- Verify config files exist in `/etc/nginx/sites-available/` - -### SSL Certificate Issues -If certbot fails: -- Verify domain DNS points to server -- Check port 80 is accessible -- Verify nginx is serving `.well-known/acme-challenge/` -- Check certbot logs: `journalctl -u certbot -n 50` - -### Cloudflare DNS Issues -If DNS records aren't created: -- Verify API credentials: `onebox config show` -- Check zone ID matches your domain -- Verify API key has DNS edit permissions - -## Dependencies - -### Deno Packages -- `@std/path` - Path utilities -- `@std/fs` - Filesystem operations -- `@std/http` - HTTP server -- `@db/sqlite` - SQLite database - -### NPM Packages (via Deno) -- `@push.rocks/smartdaemon` - Systemd integration -- `@apiclient.xyz/docker` - Docker API client -- `@apiclient.xyz/cloudflare` - Cloudflare API client -- `@push.rocks/smartacme` - ACME/Let's Encrypt - -### System Dependencies -- `docker` - Container runtime -- `nginx` - Reverse proxy -- `certbot` - SSL certificates -- `systemd` - Service management - -## Release Process - -1. Update version in `deno.json` -2. Update `changelog.md` -3. Commit changes -4. Run `deno task compile` to build all binaries -5. Test binaries on each platform -6. Create git tag: `git tag v1.0.0` -7. Push tag: `git push origin v1.0.0` -8. Create Gitea release and upload binaries -9. Publish to npm: `pnpm publish` - -## Notes - -- Onebox requires root privileges for nginx, Docker, and port binding -- Default admin password should be changed immediately after installation -- Use `--debug` flag for verbose logging -- All Docker containers are on the `onebox-network` bridge -- Metrics are collected every 60 seconds by default -- SSL certificates auto-renew 30 days before expiry diff --git a/readme.md b/readme.md index fb76827..e6853ac 100644 --- a/readme.md +++ b/readme.md @@ -14,10 +14,10 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community - **Docker Swarm First** - All workloads run as Swarm services, not standalone containers, for built-in orchestration - **Real-time Everything** - WebSocket-powered live updates for service status, logs, and metrics across all connected clients - **Single Executable** - Compiles to a standalone binary - just run it, no dependencies -- **Private Registry Included** - Built-in Docker registry with auto-deploy on push +- **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 hot-reload -- **Cloudflare Integration** - Automatic DNS record management -- **Modern Stack** - Deno runtime + SQLite database + Angular 18 UI +- **Cloudflare Integration** - Automatic DNS record management and zone synchronization +- **Modern Stack** - Deno runtime + SQLite database + Angular 19 UI ## Features ✨ @@ -25,13 +25,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community - 🐳 **Docker Swarm Management** - Deploy, scale, and orchestrate services with Swarm mode - 🌐 **Native Reverse Proxy** - Deno-based HTTP/HTTPS proxy with dynamic routing from database - 🔒 **Automatic SSL Certificates** - Let's Encrypt integration with hot-reload and renewal monitoring -- ☁️ **Cloudflare DNS Integration** - Automatic DNS record creation and synchronization +- ☁️ **Cloudflare DNS Integration** - Automatic DNS record creation and zone synchronization - 📦 **Built-in Registry** - Private Docker registry with per-service tokens and auto-update - 🔄 **Real-time WebSocket Updates** - Live service status, logs, and system events ### Monitoring & Management - 📊 **Metrics Collection** - Historical CPU, memory, and network stats (every 60s) -- 📝 **Centralized Logging** - Container logs with retention policies +- 📝 **Centralized Logging** - Container logs with streaming and retention policies - 🎨 **Angular Web UI** - Modern, responsive interface with real-time updates - 👥 **Multi-user Support** - Role-based access control (admin/user) - 💾 **SQLite Database** - Embedded, zero-configuration storage @@ -98,7 +98,7 @@ Onebox is built with modern technologies for performance and developer experienc ``` ┌─────────────────────────────────────────────────┐ -│ Angular 18 Web UI │ +│ Angular 19 Web UI │ │ (Real-time WebSocket Updates) │ └─────────────────┬───────────────────────────────┘ │ HTTP/WS @@ -121,13 +121,15 @@ Onebox is built with modern technologies for performance and developer experienc ### Core Components -- **Deno Runtime** - Modern TypeScript with built-in security -- **Native Reverse Proxy** - Custom HTTP/HTTPS proxy with TLS SNI support -- **Docker Swarm** - Container orchestration (NOT standalone containers) -- **SQLite Database** - Configuration, metrics, and user data -- **WebSocket Server** - Real-time bidirectional communication -- **Let's Encrypt** - Automatic SSL certificate management -- **Cloudflare API** - DNS record automation +| Component | Description | +|-----------|-------------| +| **Deno Runtime** | Modern TypeScript with built-in security | +| **Native Reverse Proxy** | Custom HTTP/HTTPS proxy with TLS SNI support | +| **Docker Swarm** | Container orchestration (NOT standalone containers) | +| **SQLite Database** | Configuration, metrics, and user data | +| **WebSocket Server** | Real-time bidirectional communication | +| **Let's Encrypt** | Automatic SSL certificate management | +| **Cloudflare API** | DNS record automation | ## CLI Reference 📖 @@ -244,9 +246,11 @@ onebox status ### Data Locations -- **Database**: `./onebox.db` (or custom path) -- **SSL Certificates**: Managed by CertManager -- **Registry Data**: `./.nogit/registry-data` +| Data | Location | +|------|----------| +| **Database** | `./onebox.db` (or custom path) | +| **SSL Certificates** | Managed by CertManager | +| **Registry Data** | `./.nogit/registry-data` | ### Environment Variables @@ -270,8 +274,8 @@ ONEBOX_DEBUG=true git clone https://code.foss.global/serve.zone/onebox cd onebox -# Install dependencies (Deno handles this automatically) -deno task dev +# Start development server (auto-restart on changes) +pnpm run watch ``` ### Tasks @@ -295,38 +299,93 @@ deno task compile ``` onebox/ ├── ts/ -│ ├── classes/ # Core implementations -│ │ ├── onebox.ts # Main coordinator -│ │ ├── reverseproxy.ts # Native HTTP/HTTPS proxy -│ │ ├── docker.ts # Docker Swarm API -│ │ ├── database.ts # SQLite storage -│ │ ├── httpserver.ts # REST API + WebSocket -│ │ ├── services.ts # Service orchestration -│ │ ├── certmanager.ts # SSL certificate management -│ │ ├── registry.ts # Built-in Docker registry -│ │ └── ... -│ ├── cli.ts # CLI router -│ ├── types.ts # TypeScript interfaces -│ └── plugins.ts # Dependency imports -├── ui/ # Angular web interface -├── test/ # Test files -├── mod.ts # Main entry point -└── deno.json # Deno configuration +│ ├── classes/ # Core implementations +│ │ ├── onebox.ts # Main coordinator +│ │ ├── reverseproxy.ts # Native HTTP/HTTPS proxy +│ │ ├── docker.ts # Docker Swarm API +│ │ ├── database.ts # SQLite storage +│ │ ├── httpserver.ts # REST API + WebSocket +│ │ ├── services.ts # Service orchestration +│ │ ├── certmanager.ts # SSL certificate management +│ │ ├── cert-requirement-manager.ts # Certificate requirements +│ │ ├── ssl.ts # SSL utilities +│ │ ├── registry.ts # Built-in Docker registry +│ │ ├── 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 +│ ├── cli.ts # CLI router +│ ├── types.ts # TypeScript interfaces +│ ├── logging.ts # Logging utilities +│ └── plugins.ts # Dependency imports +├── ui/ # Angular web interface +├── test/ # Test files +├── mod.ts # Main entry point +└── deno.json # Deno configuration ``` ### API Endpoints -The HTTP server exposes the following endpoints: +The HTTP server exposes a comprehensive REST API: -- `POST /api/auth/login` - User authentication (returns token) -- `GET /api/status` - System status (requires auth) -- `GET /api/services` - List all services (requires auth) -- `POST /api/services` - Create service (requires auth) -- `PUT /api/services/:id` - Update service (requires auth) -- `DELETE /api/services/:id` - Delete service (requires auth) -- `GET /api/ws` - WebSocket connection for real-time updates +#### Authentication +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/auth/login` | User authentication (returns token) | -See `ts/classes/httpserver.ts` for complete API documentation. +#### 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 @@ -374,6 +433,19 @@ docker push localhost:4000/myapp:latest # Service automatically updates! 🎉 ``` +### 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 +docker login localhost:4000 -u ci -p +``` + ### Cloudflare DNS Integration ```bash @@ -388,16 +460,19 @@ onebox service add myapp \ --domain myapp.example.com # DNS record is automatically created! + +# Sync all domains from Cloudflare +onebox dns sync ``` ### SSL Certificate Management SSL certificates are automatically obtained and renewed: -- Certificates are requested when a service with a domain is deployed -- Renewal happens automatically 30 days before expiry -- Certificates are hot-reloaded without downtime -- Force renewal: `onebox ssl force-renew ` +- ✅ Certificates are requested when a service with a domain is deployed +- ✅ Renewal happens automatically 30 days before expiry +- ✅ Certificates are hot-reloaded without downtime +- ✅ Force renewal: `onebox ssl force-renew ` ### Monitoring and Metrics @@ -449,9 +524,9 @@ onebox ssl force-renew yourdomain.com ### WebSocket Connection Issues -- Ensure firewall allows WebSocket connections -- Check browser console for connection errors -- Verify `/api/ws` endpoint is accessible +- ✅ Ensure firewall allows WebSocket connections +- ✅ Check browser console for connection errors +- ✅ Verify `/api/ws` endpoint is accessible ### Service Not Starting diff --git a/ts/classes/certmanager.ts b/ts/classes/certmanager.ts index 70e9285..72e66b6 100644 --- a/ts/classes/certmanager.ts +++ b/ts/classes/certmanager.ts @@ -7,6 +7,7 @@ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; +import { getErrorMessage } from '../utils/error.ts'; import { OneboxDatabase } from './database.ts'; export class SqliteCertManager implements plugins.smartacme.ICertManager { @@ -27,7 +28,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { await Deno.mkdir(this.certBasePath, { recursive: true }); logger.info(`Certificate manager initialized (path: ${this.certBasePath})`); } catch (error) { - logger.error(`Failed to initialize certificate manager: ${error.message}`); + logger.error(`Failed to initialize certificate manager: ${getErrorMessage(error)}`); throw error; } } @@ -56,7 +57,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { return cert; } catch (error) { - logger.warn(`Failed to retrieve certificate for ${domainName}: ${error.message}`); + logger.warn(`Failed to retrieve certificate for ${domainName}: ${getErrorMessage(error)}`); return null; } } @@ -110,7 +111,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { logger.success(`Certificate stored for ${domain}`); } catch (error) { - logger.error(`Failed to store certificate for ${cert.domainName}: ${error.message}`); + logger.error(`Failed to store certificate for ${cert.domainName}: ${getErrorMessage(error)}`); throw error; } } @@ -128,7 +129,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { try { await Deno.remove(domainPath, { recursive: true }); } catch (error) { - logger.warn(`Failed to delete PEM files for ${domainName}: ${error.message}`); + logger.warn(`Failed to delete PEM files for ${domainName}: ${getErrorMessage(error)}`); } // Delete from database @@ -137,7 +138,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { logger.info(`Certificate deleted for ${domainName}`); } } catch (error) { - logger.error(`Failed to delete certificate for ${domainName}: ${error.message}`); + logger.error(`Failed to delete certificate for ${domainName}: ${getErrorMessage(error)}`); throw error; } } @@ -163,7 +164,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { logger.warn('All certificates wiped'); } catch (error) { - logger.error(`Failed to wipe certificates: ${error.message}`); + logger.error(`Failed to wipe certificates: ${getErrorMessage(error)}`); throw error; } } @@ -175,7 +176,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager { try { return await Deno.readTextFile(path); } catch (error) { - throw new Error(`Failed to read PEM file ${path}: ${error.message}`); + throw new Error(`Failed to read PEM file ${path}: ${getErrorMessage(error)}`); } } diff --git a/ts/classes/daemon.ts b/ts/classes/daemon.ts index f22221b..c7dc34d 100644 --- a/ts/classes/daemon.ts +++ b/ts/classes/daemon.ts @@ -7,6 +7,7 @@ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { projectInfo } from '../info.ts'; +import { getErrorMessage } from '../utils/error.ts'; import type { Onebox } from './onebox.ts'; // PID file constants @@ -72,7 +73,7 @@ export class OneboxDaemon { logger.success('Onebox daemon service installed'); logger.info('Start with: sudo systemctl start smartdaemon_onebox'); } catch (error) { - logger.error(`Failed to install daemon service: ${error.message}`); + logger.error(`Failed to install daemon service: ${getErrorMessage(error)}`); throw error; } } @@ -89,7 +90,8 @@ export class OneboxDaemon { this.smartdaemon = new plugins.smartdaemon.SmartDaemon(); } - const service = await this.smartdaemon.getService('onebox'); + const services = await this.smartdaemon.systemdManager.getServices(); + const service = services.find(s => s.name === 'onebox'); if (service) { await service.stop(); @@ -99,7 +101,7 @@ export class OneboxDaemon { logger.success('Onebox daemon service uninstalled'); } catch (error) { - logger.error(`Failed to uninstall daemon service: ${error.message}`); + logger.error(`Failed to uninstall daemon service: ${getErrorMessage(error)}`); throw error; } } @@ -137,7 +139,7 @@ export class OneboxDaemon { // Keep process alive await this.keepAlive(); } catch (error) { - logger.error(`Failed to start daemon: ${error.message}`); + logger.error(`Failed to start daemon: ${getErrorMessage(error)}`); this.running = false; throw error; } @@ -167,7 +169,7 @@ export class OneboxDaemon { logger.success('Onebox daemon stopped'); } catch (error) { - logger.error(`Failed to stop daemon: ${error.message}`); + logger.error(`Failed to stop daemon: ${getErrorMessage(error)}`); throw error; } } @@ -229,7 +231,7 @@ export class OneboxDaemon { logger.debug('Monitoring tick complete'); } catch (error) { - logger.error(`Monitoring tick failed: ${error.message}`); + logger.error(`Monitoring tick failed: ${getErrorMessage(error)}`); } } @@ -257,12 +259,12 @@ export class OneboxDaemon { }); } } catch (error) { - logger.debug(`Failed to collect metrics for ${service.name}: ${error.message}`); + logger.debug(`Failed to collect metrics for ${service.name}: ${getErrorMessage(error)}`); } } } } catch (error) { - logger.error(`Failed to collect metrics: ${error.message}`); + logger.error(`Failed to collect metrics: ${getErrorMessage(error)}`); } } @@ -277,7 +279,7 @@ export class OneboxDaemon { await this.oneboxRef.ssl.renewExpiring(); } catch (error) { - logger.error(`Failed to check SSL expiration: ${error.message}`); + logger.error(`Failed to check SSL expiration: ${getErrorMessage(error)}`); } } @@ -288,7 +290,7 @@ export class OneboxDaemon { try { await this.oneboxRef.certRequirementManager.processPendingRequirements(); } catch (error) { - logger.error(`Failed to process cert requirements: ${error.message}`); + logger.error(`Failed to process cert requirements: ${getErrorMessage(error)}`); } } @@ -299,7 +301,7 @@ export class OneboxDaemon { try { await this.oneboxRef.certRequirementManager.checkCertificateRenewal(); } catch (error) { - logger.error(`Failed to check certificate renewal: ${error.message}`); + logger.error(`Failed to check certificate renewal: ${getErrorMessage(error)}`); } } @@ -310,7 +312,7 @@ export class OneboxDaemon { try { await this.oneboxRef.certRequirementManager.cleanupOldCertificates(); } catch (error) { - logger.error(`Failed to cleanup old certificates: ${error.message}`); + logger.error(`Failed to cleanup old certificates: ${getErrorMessage(error)}`); } } @@ -333,7 +335,7 @@ export class OneboxDaemon { await this.oneboxRef.cloudflareDomainSync.syncZones(); this.lastDomainSync = now; } catch (error) { - logger.error(`Failed to sync Cloudflare domains: ${error.message}`); + logger.error(`Failed to sync Cloudflare domains: ${getErrorMessage(error)}`); } } @@ -388,7 +390,7 @@ export class OneboxDaemon { this.pidFilePath = FALLBACK_PID_FILE; logger.debug(`PID file written: ${FALLBACK_PID_FILE}`); } catch (error) { - logger.warn(`Failed to write PID file: ${error.message}`); + logger.warn(`Failed to write PID file: ${getErrorMessage(error)}`); // Non-fatal - daemon can still run } } @@ -402,7 +404,7 @@ export class OneboxDaemon { logger.debug(`PID file removed: ${this.pidFilePath}`); } catch (error) { // Ignore errors - file might not exist - logger.debug(`Could not remove PID file: ${error.message}`); + logger.debug(`Could not remove PID file: ${getErrorMessage(error)}`); } } diff --git a/ts/classes/database.ts b/ts/classes/database.ts index 8037609..e778c08 100644 --- a/ts/classes/database.ts +++ b/ts/classes/database.ts @@ -6,6 +6,7 @@ import * as plugins from '../plugins.ts'; import type { IService, IRegistry, + IRegistryToken, INginxConfig, ISslCertificate, IDnsRecord, @@ -13,11 +14,22 @@ import type { ILogEntry, IUser, ISetting, + IPlatformService, + IPlatformResource, + IPlatformRequirements, + TPlatformServiceType, + IDomain, + ICertificate, + ICertRequirement, } from '../types.ts'; + +// Type alias for sqlite bind parameters +type BindValue = string | number | bigint | boolean | null | undefined | Uint8Array; import { logger } from '../logging.ts'; +import { getErrorMessage } from '../utils/error.ts'; export class OneboxDatabase { - private db: plugins.sqlite.DB | null = null; + private db: InstanceType | null = null; private dbPath: string; constructor(dbPath = './.nogit/onebox.db') { @@ -43,7 +55,7 @@ export class OneboxDatabase { // Run migrations if needed await this.runMigrations(); } catch (error) { - logger.error(`Failed to initialize database: ${error.message}`); + logger.error(`Failed to initialize database: ${getErrorMessage(error)}`); throw error; } } @@ -447,28 +459,40 @@ export class OneboxDatabase { // 4. Migrate existing ssl_certificates data // Extract unique base domains from existing certificates - const existingCerts = this.query('SELECT * FROM ssl_certificates'); + interface OldSslCert { + id?: number; + domain?: string; + cert_path?: string; + key_path?: string; + full_chain_path?: string; + expiry_date?: number; + issuer?: string; + created_at?: number; + updated_at?: number; + [key: number]: unknown; // Allow array-style access as fallback + } + const existingCerts = this.query('SELECT * FROM ssl_certificates'); const now = Date.now(); const domainMap = new Map(); // Create domain entries for each unique base domain for (const cert of existingCerts) { - const domain = String(cert.domain ?? cert[1]); + const domain = String(cert.domain ?? (cert as Record)[1]); if (!domainMap.has(domain)) { this.query( 'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [domain, null, 0, 1, now, now] ); - const result = this.query('SELECT last_insert_rowid() as id'); - const domainId = result[0].id ?? result[0][0]; + const result = this.query<{ id?: number; [key: number]: unknown }>('SELECT last_insert_rowid() as id'); + const domainId = result[0].id ?? (result[0] as Record)[0]; domainMap.set(domain, Number(domainId)); } } // Migrate certificates to new table for (const cert of existingCerts) { - const domain = String(cert.domain ?? cert[1]); + const domain = String(cert.domain ?? (cert as Record)[1]); const domainId = domainMap.get(domain); this.query( @@ -480,14 +504,14 @@ export class OneboxDatabase { domainId, domain, 0, // We don't know if it's wildcard, default to false - String(cert.cert_path ?? cert[2]), - String(cert.key_path ?? cert[3]), - String(cert.full_chain_path ?? cert[4]), - Number(cert.expiry_date ?? cert[5]), - String(cert.issuer ?? cert[6]), + String(cert.cert_path ?? (cert as Record)[2]), + String(cert.key_path ?? (cert as Record)[3]), + String(cert.full_chain_path ?? (cert as Record)[4]), + Number(cert.expiry_date ?? (cert as Record)[5]), + String(cert.issuer ?? (cert as Record)[6]), 1, // Assume valid - Number(cert.created_at ?? cert[7]), - Number(cert.updated_at ?? cert[8]) + Number(cert.created_at ?? (cert as Record)[7]), + Number(cert.updated_at ?? (cert as Record)[8]) ] ); } @@ -534,9 +558,143 @@ export class OneboxDatabase { this.setMigrationVersion(4); logger.success('Migration 4 completed: Onebox Registry columns added to services table'); } + + // Migration 5: Registry tokens table + const version5 = this.getMigrationVersion(); + if (version5 < 5) { + logger.info('Running migration 5: Creating registry_tokens table...'); + + this.query(` + CREATE TABLE registry_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_type TEXT NOT NULL, + scope TEXT NOT NULL, + expires_at REAL, + created_at REAL NOT NULL, + last_used_at REAL, + created_by TEXT NOT NULL + ) + `); + + // Create indices for performance + this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_type ON registry_tokens(token_type)'); + this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_hash ON registry_tokens(token_hash)'); + + this.setMigrationVersion(5); + logger.success('Migration 5 completed: Registry tokens table created'); + } + + // Migration 6: Drop registry_token column from services table (replaced by registry_tokens table) + const version6 = this.getMigrationVersion(); + if (version6 < 6) { + logger.info('Running migration 6: Dropping registry_token column from services table...'); + + // SQLite doesn't support DROP COLUMN directly, so we need to recreate the table + // Create new table without registry_token + this.query(` + CREATE TABLE services_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + image TEXT NOT NULL, + registry TEXT, + env_vars TEXT, + port INTEGER NOT NULL, + domain TEXT, + container_id TEXT, + status TEXT NOT NULL, + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + use_onebox_registry INTEGER DEFAULT 0, + registry_repository TEXT, + registry_image_tag TEXT DEFAULT 'latest', + auto_update_on_push INTEGER DEFAULT 0, + image_digest TEXT + ) + `); + + // Copy data (excluding registry_token) + this.query(` + INSERT INTO services_new ( + id, name, image, registry, env_vars, port, domain, container_id, status, + created_at, updated_at, use_onebox_registry, registry_repository, + registry_image_tag, auto_update_on_push, image_digest + ) + SELECT + id, name, image, registry, env_vars, port, domain, container_id, status, + created_at, updated_at, use_onebox_registry, registry_repository, + registry_image_tag, auto_update_on_push, image_digest + FROM services + `); + + // Drop old table + this.query('DROP TABLE services'); + + // Rename new table + this.query('ALTER TABLE services_new RENAME TO services'); + + // Recreate indices + this.query('CREATE INDEX IF NOT EXISTS idx_services_name ON services(name)'); + this.query('CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)'); + + this.setMigrationVersion(6); + logger.success('Migration 6 completed: registry_token column dropped from services table'); + } + + // Migration 7: Platform services tables + const version7 = this.getMigrationVersion(); + if (version7 < 7) { + logger.info('Running migration 7: Creating platform services tables...'); + + // Create platform_services table + this.query(` + CREATE TABLE platform_services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'stopped', + container_id TEXT, + config TEXT NOT NULL DEFAULT '{}', + admin_credentials_encrypted TEXT, + created_at REAL NOT NULL, + updated_at REAL NOT NULL + ) + `); + + // Create platform_resources table + this.query(` + CREATE TABLE platform_resources ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform_service_id INTEGER NOT NULL, + service_id INTEGER NOT NULL, + resource_type TEXT NOT NULL, + resource_name TEXT NOT NULL, + credentials_encrypted TEXT NOT NULL, + created_at REAL NOT NULL, + FOREIGN KEY (platform_service_id) REFERENCES platform_services(id) ON DELETE CASCADE, + FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE + ) + `); + + // Add platform_requirements column to services table + this.query(` + ALTER TABLE services ADD COLUMN platform_requirements TEXT DEFAULT '{}' + `); + + // Create indices + this.query('CREATE INDEX IF NOT EXISTS idx_platform_services_type ON platform_services(type)'); + this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_service ON platform_resources(service_id)'); + this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_platform ON platform_resources(platform_service_id)'); + + this.setMigrationVersion(7); + logger.success('Migration 7 completed: Platform services tables created'); + } } catch (error) { - logger.error(`Migration failed: ${error.message}`); - logger.error(`Stack: ${error.stack}`); + logger.error(`Migration failed: ${getErrorMessage(error)}`); + if (error instanceof Error && error.stack) { + logger.error(`Stack: ${error.stack}`); + } throw error; } } @@ -548,14 +706,14 @@ export class OneboxDatabase { if (!this.db) throw new Error('Database not initialized'); try { - const result = this.query('SELECT MAX(version) as version FROM migrations'); + const result = this.query<{ version?: number | null; [key: number]: unknown }>('SELECT MAX(version) as version FROM migrations'); if (result.length === 0) return 0; // Handle both array and object access patterns - const versionValue = result[0].version ?? result[0][0]; + const versionValue = result[0].version ?? (result[0] as Record)[0]; return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0; } catch (error) { - logger.warn(`Error getting migration version: ${error.message}, defaulting to 0`); + logger.warn(`Error getting migration version: ${getErrorMessage(error)}, defaulting to 0`); return 0; } } @@ -587,7 +745,7 @@ export class OneboxDatabase { /** * Execute a raw query */ - query(sql: string, params: unknown[] = []): T[] { + query>(sql: string, params: BindValue[] = []): T[] { if (!this.db) { const error = new Error('Database not initialized'); console.error('Database access before initialization!'); @@ -621,8 +779,8 @@ export class OneboxDatabase { `INSERT INTO services ( name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at, - use_onebox_registry, registry_repository, registry_token, registry_image_tag, - auto_update_on_push, image_digest + use_onebox_registry, registry_repository, registry_image_tag, + auto_update_on_push, image_digest, platform_requirements ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ service.name, @@ -637,10 +795,10 @@ export class OneboxDatabase { now, service.useOneboxRegistry ? 1 : 0, service.registryRepository || null, - service.registryToken || null, service.registryImageTag || 'latest', service.autoUpdateOnPush ? 1 : 0, service.imageDigest || null, + JSON.stringify(service.platformRequirements || {}), ] ); @@ -717,10 +875,6 @@ export class OneboxDatabase { fields.push('registry_repository = ?'); values.push(updates.registryRepository); } - if (updates.registryToken !== undefined) { - fields.push('registry_token = ?'); - values.push(updates.registryToken); - } if (updates.registryImageTag !== undefined) { fields.push('registry_image_tag = ?'); values.push(updates.registryImageTag); @@ -733,6 +887,10 @@ export class OneboxDatabase { fields.push('image_digest = ?'); values.push(updates.imageDigest); } + if (updates.platformRequirements !== undefined) { + fields.push('platform_requirements = ?'); + values.push(JSON.stringify(updates.platformRequirements)); + } fields.push('updated_at = ?'); values.push(Date.now()); @@ -754,11 +912,23 @@ export class OneboxDatabase { try { envVars = JSON.parse(String(envVarsRaw)); } catch (e) { - logger.warn(`Failed to parse env_vars for service: ${e.message}`); + logger.warn(`Failed to parse env_vars for service: ${getErrorMessage(e)}`); envVars = {}; } } + // Handle platform_requirements JSON parsing safely + let platformRequirements: IPlatformRequirements | undefined; + const platformReqRaw = row.platform_requirements; + if (platformReqRaw && platformReqRaw !== 'undefined' && platformReqRaw !== 'null' && platformReqRaw !== '{}') { + try { + platformRequirements = JSON.parse(String(platformReqRaw)); + } catch (e) { + logger.warn(`Failed to parse platform_requirements for service: ${getErrorMessage(e)}`); + platformRequirements = undefined; + } + } + return { id: Number(row.id || row[0]), name: String(row.name || row[1]), @@ -774,10 +944,11 @@ export class OneboxDatabase { // Onebox Registry fields useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : undefined, registryRepository: row.registry_repository ? String(row.registry_repository) : undefined, - registryToken: row.registry_token ? String(row.registry_token) : undefined, registryImageTag: row.registry_image_tag ? String(row.registry_image_tag) : undefined, autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined, imageDigest: row.image_digest ? String(row.image_digest) : undefined, + // Platform service requirements + platformRequirements, }; } @@ -1392,4 +1563,279 @@ export class OneboxDatabase { updatedAt: Number(row.updated_at || row[7]), }; } + + // ============ Registry Tokens ============ + + createRegistryToken(token: Omit): IRegistryToken { + if (!this.db) throw new Error('Database not initialized'); + + const scopeJson = Array.isArray(token.scope) ? JSON.stringify(token.scope) : token.scope; + + this.query( + `INSERT INTO registry_tokens (name, token_hash, token_type, scope, expires_at, created_at, last_used_at, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + token.name, + token.tokenHash, + token.type, + scopeJson, + token.expiresAt, + token.createdAt, + token.lastUsedAt, + token.createdBy, + ] + ); + + const rows = this.query('SELECT * FROM registry_tokens WHERE id = last_insert_rowid()'); + return this.rowToRegistryToken(rows[0]); + } + + getRegistryTokenById(id: number): IRegistryToken | null { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM registry_tokens WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToRegistryToken(rows[0]) : null; + } + + getRegistryTokenByHash(tokenHash: string): IRegistryToken | null { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM registry_tokens WHERE token_hash = ?', [tokenHash]); + return rows.length > 0 ? this.rowToRegistryToken(rows[0]) : null; + } + + getAllRegistryTokens(): IRegistryToken[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM registry_tokens ORDER BY created_at DESC'); + return rows.map((row) => this.rowToRegistryToken(row)); + } + + getRegistryTokensByType(type: 'global' | 'ci'): IRegistryToken[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM registry_tokens WHERE token_type = ? ORDER BY created_at DESC', [type]); + return rows.map((row) => this.rowToRegistryToken(row)); + } + + updateRegistryTokenLastUsed(id: number): void { + if (!this.db) throw new Error('Database not initialized'); + this.query('UPDATE registry_tokens SET last_used_at = ? WHERE id = ?', [Date.now(), id]); + } + + deleteRegistryToken(id: number): void { + if (!this.db) throw new Error('Database not initialized'); + this.query('DELETE FROM registry_tokens WHERE id = ?', [id]); + } + + private rowToRegistryToken(row: any): IRegistryToken { + // Parse scope - it's either 'all' or a JSON array + let scope: 'all' | string[]; + const scopeRaw = row.scope || row[4]; + if (scopeRaw === 'all') { + scope = 'all'; + } else { + try { + scope = JSON.parse(String(scopeRaw)); + } catch { + scope = 'all'; + } + } + + return { + id: Number(row.id || row[0]), + name: String(row.name || row[1]), + tokenHash: String(row.token_hash || row[2]), + type: String(row.token_type || row[3]) as IRegistryToken['type'], + scope, + expiresAt: row.expires_at || row[5] ? Number(row.expires_at || row[5]) : null, + createdAt: Number(row.created_at || row[6]), + lastUsedAt: row.last_used_at || row[7] ? Number(row.last_used_at || row[7]) : null, + createdBy: String(row.created_by || row[8]), + }; + } + + // ============ Platform Services CRUD ============ + + createPlatformService(service: Omit): IPlatformService { + if (!this.db) throw new Error('Database not initialized'); + + const now = Date.now(); + this.query( + `INSERT INTO platform_services (name, type, status, container_id, config, admin_credentials_encrypted, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + service.name, + service.type, + service.status, + service.containerId || null, + JSON.stringify(service.config), + service.adminCredentialsEncrypted || null, + now, + now, + ] + ); + + return this.getPlatformServiceByName(service.name)!; + } + + getPlatformServiceByName(name: string): IPlatformService | null { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM platform_services WHERE name = ?', [name]); + return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; + } + + getPlatformServiceById(id: number): IPlatformService | null { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM platform_services WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; + } + + getPlatformServiceByType(type: TPlatformServiceType): IPlatformService | null { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM platform_services WHERE type = ?', [type]); + return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null; + } + + getAllPlatformServices(): IPlatformService[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM platform_services ORDER BY created_at DESC'); + return rows.map((row) => this.rowToPlatformService(row)); + } + + updatePlatformService(id: number, updates: Partial): void { + if (!this.db) throw new Error('Database not initialized'); + + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.status !== undefined) { + fields.push('status = ?'); + values.push(updates.status); + } + if (updates.containerId !== undefined) { + fields.push('container_id = ?'); + values.push(updates.containerId); + } + if (updates.config !== undefined) { + fields.push('config = ?'); + values.push(JSON.stringify(updates.config)); + } + if (updates.adminCredentialsEncrypted !== undefined) { + fields.push('admin_credentials_encrypted = ?'); + values.push(updates.adminCredentialsEncrypted); + } + + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + + this.query(`UPDATE platform_services SET ${fields.join(', ')} WHERE id = ?`, values); + } + + deletePlatformService(id: number): void { + if (!this.db) throw new Error('Database not initialized'); + this.query('DELETE FROM platform_services WHERE id = ?', [id]); + } + + private rowToPlatformService(row: any): IPlatformService { + let config = { image: '', port: 0 }; + const configRaw = row.config; + if (configRaw) { + try { + config = JSON.parse(String(configRaw)); + } catch (e) { + logger.warn(`Failed to parse platform service config: ${getErrorMessage(e)}`); + } + } + + return { + id: Number(row.id), + name: String(row.name), + type: String(row.type) as TPlatformServiceType, + status: String(row.status) as IPlatformService['status'], + containerId: row.container_id ? String(row.container_id) : undefined, + config, + adminCredentialsEncrypted: row.admin_credentials_encrypted ? String(row.admin_credentials_encrypted) : undefined, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; + } + + // ============ Platform Resources CRUD ============ + + createPlatformResource(resource: Omit): IPlatformResource { + if (!this.db) throw new Error('Database not initialized'); + + const now = Date.now(); + this.query( + `INSERT INTO platform_resources (platform_service_id, service_id, resource_type, resource_name, credentials_encrypted, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + resource.platformServiceId, + resource.serviceId, + resource.resourceType, + resource.resourceName, + resource.credentialsEncrypted, + now, + ] + ); + + const rows = this.query('SELECT * FROM platform_resources WHERE id = last_insert_rowid()'); + return this.rowToPlatformResource(rows[0]); + } + + getPlatformResourceById(id: number): IPlatformResource | null { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM platform_resources WHERE id = ?', [id]); + return rows.length > 0 ? this.rowToPlatformResource(rows[0]) : null; + } + + getPlatformResourcesByService(serviceId: number): IPlatformResource[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM platform_resources WHERE service_id = ?', [serviceId]); + return rows.map((row) => this.rowToPlatformResource(row)); + } + + getPlatformResourcesByPlatformService(platformServiceId: number): IPlatformResource[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM platform_resources WHERE platform_service_id = ?', [platformServiceId]); + return rows.map((row) => this.rowToPlatformResource(row)); + } + + getAllPlatformResources(): IPlatformResource[] { + if (!this.db) throw new Error('Database not initialized'); + + const rows = this.query('SELECT * FROM platform_resources ORDER BY created_at DESC'); + return rows.map((row) => this.rowToPlatformResource(row)); + } + + deletePlatformResource(id: number): void { + if (!this.db) throw new Error('Database not initialized'); + this.query('DELETE FROM platform_resources WHERE id = ?', [id]); + } + + deletePlatformResourcesByService(serviceId: number): void { + if (!this.db) throw new Error('Database not initialized'); + this.query('DELETE FROM platform_resources WHERE service_id = ?', [serviceId]); + } + + private rowToPlatformResource(row: any): IPlatformResource { + return { + id: Number(row.id), + platformServiceId: Number(row.platform_service_id), + serviceId: Number(row.service_id), + resourceType: String(row.resource_type) as IPlatformResource['resourceType'], + resourceName: String(row.resource_name), + credentialsEncrypted: String(row.credentials_encrypted), + createdAt: Number(row.created_at), + }; + } } diff --git a/ts/classes/docker.ts b/ts/classes/docker.ts index c469446..dcff76d 100644 --- a/ts/classes/docker.ts +++ b/ts/classes/docker.ts @@ -841,4 +841,89 @@ export class OneboxDockerManager { throw error; } } + + /** + * Create a platform service container (MongoDB, MinIO, etc.) + * Platform containers are long-running infrastructure services + */ + async createPlatformContainer(options: { + name: string; + image: string; + port: number; + env: string[]; + volumes?: string[]; + network: string; + command?: string[]; + exposePorts?: number[]; + }): Promise { + try { + logger.info(`Creating platform container: ${options.name}`); + + // Check if container already exists + const existingContainers = await this.dockerClient!.listContainers(); + const existing = existingContainers.find((c: any) => + c.Names?.some((n: string) => n === `/${options.name}` || n === options.name) + ); + + if (existing) { + logger.info(`Platform container ${options.name} already exists, removing old container...`); + await this.removeContainer(existing.Id, true); + } + + // Prepare exposed ports + const exposedPorts: Record> = {}; + const portBindings: Record> = {}; + + const portsToExpose = options.exposePorts || [options.port]; + for (const port of portsToExpose) { + exposedPorts[`${port}/tcp`] = {}; + // Don't bind to host ports by default - services communicate via Docker network + portBindings[`${port}/tcp`] = []; + } + + // Prepare volume bindings + const binds: string[] = options.volumes || []; + + // Create the container + const response = await this.dockerClient!.request('POST', `/containers/create?name=${options.name}`, { + Image: options.image, + Cmd: options.command, + Env: options.env, + Labels: { + 'managed-by': 'onebox', + 'onebox-platform-service': options.name, + }, + ExposedPorts: exposedPorts, + HostConfig: { + NetworkMode: options.network, + RestartPolicy: { + Name: 'unless-stopped', + }, + PortBindings: portBindings, + Binds: binds, + }, + }); + + if (response.statusCode >= 300) { + const errorMsg = response.body?.message || `HTTP ${response.statusCode}`; + throw new Error(`Failed to create platform container: ${errorMsg}`); + } + + const containerID = response.body.Id; + logger.info(`Platform container created: ${containerID}`); + + // Start the container + const startResponse = await this.dockerClient!.request('POST', `/containers/${containerID}/start`, {}); + + if (startResponse.statusCode >= 300 && startResponse.statusCode !== 304) { + throw new Error(`Failed to start platform container: HTTP ${startResponse.statusCode}`); + } + + logger.success(`Platform container ${options.name} started successfully`); + return containerID; + } catch (error) { + logger.error(`Failed to create platform container ${options.name}: ${error.message}`); + throw error; + } + } } diff --git a/ts/classes/encryption.ts b/ts/classes/encryption.ts new file mode 100644 index 0000000..4fa47b5 --- /dev/null +++ b/ts/classes/encryption.ts @@ -0,0 +1,203 @@ +/** + * AES-256-GCM encryption for credential storage + */ + +import { logger } from '../logging.ts'; + +export class CredentialEncryption { + private key: CryptoKey | null = null; + private readonly algorithm = 'AES-GCM'; + private readonly keyLength = 256; + private readonly ivLength = 12; // 96 bits for GCM + + /** + * Initialize encryption with a key from environment or generate machine-specific key + */ + async init(): Promise { + const envKey = Deno.env.get('ONEBOX_ENCRYPTION_KEY'); + + if (envKey) { + // Use provided key (should be 32 bytes base64 encoded) + const keyBytes = this.base64ToBytes(envKey); + if (keyBytes.length !== 32) { + throw new Error('ONEBOX_ENCRYPTION_KEY must be 32 bytes (256 bits) base64 encoded'); + } + this.key = await crypto.subtle.importKey( + 'raw', + keyBytes, + { name: this.algorithm }, + false, + ['encrypt', 'decrypt'] + ); + logger.log('info', 'Encryption key loaded from environment', 'CredentialEncryption'); + } else { + // Derive key from machine-specific data + this.key = await this.deriveKeyFromMachine(); + logger.log('info', 'Encryption key derived from machine identity', 'CredentialEncryption'); + } + } + + /** + * Derive a key from machine-specific information + * This ensures the key is consistent across restarts on the same machine + */ + private async deriveKeyFromMachine(): Promise { + // Collect machine-specific data + const machineData: string[] = []; + + // Hostname + try { + machineData.push(Deno.hostname()); + } catch { + machineData.push('unknown-host'); + } + + // Machine ID from /etc/machine-id (Linux) or generate consistent fallback + try { + const machineId = await Deno.readTextFile('/etc/machine-id'); + machineData.push(machineId.trim()); + } catch { + // Fallback: use a combination of other identifiers + machineData.push('onebox-default-machine-id'); + } + + // Add a salt + machineData.push('onebox-credential-encryption-v1'); + + // Create seed from machine data + const seed = machineData.join(':'); + const encoder = new TextEncoder(); + const seedBytes = encoder.encode(seed); + + // Use PBKDF2 to derive key + const baseKey = await crypto.subtle.importKey( + 'raw', + seedBytes, + 'PBKDF2', + false, + ['deriveKey'] + ); + + return await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: encoder.encode('onebox-salt-v1'), + iterations: 100000, + hash: 'SHA-256', + }, + baseKey, + { name: this.algorithm, length: this.keyLength }, + false, + ['encrypt', 'decrypt'] + ); + } + + /** + * Encrypt a credentials object to a base64 string + */ + async encrypt(data: Record): Promise { + if (!this.key) { + throw new Error('Encryption not initialized. Call init() first.'); + } + + const iv = crypto.getRandomValues(new Uint8Array(this.ivLength)); + const encoded = new TextEncoder().encode(JSON.stringify(data)); + + const ciphertext = await crypto.subtle.encrypt( + { name: this.algorithm, iv }, + this.key, + encoded + ); + + // Combine IV + ciphertext and encode as base64 + const combined = new Uint8Array(iv.length + ciphertext.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(ciphertext), iv.length); + + return this.bytesToBase64(combined); + } + + /** + * Decrypt a base64 string back to credentials object + */ + async decrypt(encrypted: string): Promise> { + if (!this.key) { + throw new Error('Encryption not initialized. Call init() first.'); + } + + const combined = this.base64ToBytes(encrypted); + + // Extract IV and ciphertext + const iv = combined.slice(0, this.ivLength); + const ciphertext = combined.slice(this.ivLength); + + const decrypted = await crypto.subtle.decrypt( + { name: this.algorithm, iv }, + this.key, + ciphertext + ); + + const decoded = new TextDecoder().decode(decrypted); + return JSON.parse(decoded); + } + + /** + * Generate a secure random password + */ + generatePassword(length: number = 32): string { + // Exclude ambiguous characters (0, O, l, 1, I) + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789'; + const randomBytes = crypto.getRandomValues(new Uint8Array(length)); + let result = ''; + for (const byte of randomBytes) { + result += chars[byte % chars.length]; + } + return result; + } + + /** + * Generate an access key (alphanumeric, uppercase) + */ + generateAccessKey(length: number = 20): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const randomBytes = crypto.getRandomValues(new Uint8Array(length)); + let result = ''; + for (const byte of randomBytes) { + result += chars[byte % chars.length]; + } + return result; + } + + /** + * Generate a secret key (alphanumeric, mixed case) + */ + generateSecretKey(length: number = 40): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const randomBytes = crypto.getRandomValues(new Uint8Array(length)); + let result = ''; + for (const byte of randomBytes) { + result += chars[byte % chars.length]; + } + return result; + } + + private bytesToBase64(bytes: Uint8Array): string { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); + } + + private base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + } +} + +// Singleton instance +export const credentialEncryption = new CredentialEncryption(); diff --git a/ts/classes/httpserver.ts b/ts/classes/httpserver.ts index d840573..426815b 100644 --- a/ts/classes/httpserver.ts +++ b/ts/classes/httpserver.ts @@ -7,7 +7,7 @@ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import type { Onebox } from './onebox.ts'; -import type { IApiResponse } from '../types.ts'; +import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView } from '../types.ts'; export class OneboxHttpServer { private oneboxRef: Onebox; @@ -263,9 +263,28 @@ export class OneboxHttpServer { } else if (path.match(/^\/api\/registry\/tags\/[^/]+$/)) { const serviceName = path.split('/').pop()!; return await this.handleGetRegistryTagsRequest(serviceName); - } else if (path.match(/^\/api\/registry\/token\/[^/]+$/)) { - const serviceName = path.split('/').pop()!; - return await this.handleGetRegistryTokenRequest(serviceName); + } else if (path === '/api/registry/tokens' && method === 'GET') { + return await this.handleListRegistryTokensRequest(req); + } else if (path === '/api/registry/tokens' && method === 'POST') { + return await this.handleCreateRegistryTokenRequest(req); + } else if (path.match(/^\/api\/registry\/tokens\/\d+$/) && method === 'DELETE') { + const tokenId = Number(path.split('/').pop()); + return await this.handleDeleteRegistryTokenRequest(tokenId); + // Platform Services endpoints + } else if (path === '/api/platform-services' && method === 'GET') { + return await this.handleListPlatformServicesRequest(); + } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)$/) && method === 'GET') { + const type = path.split('/').pop()!; + return await this.handleGetPlatformServiceRequest(type); + } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/start$/) && method === 'POST') { + const type = path.split('/')[3]; + return await this.handleStartPlatformServiceRequest(type); + } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/stop$/) && method === 'POST') { + const type = path.split('/')[3]; + return await this.handleStopPlatformServiceRequest(type); + } else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') { + const serviceName = path.split('/')[3]; + return await this.handleGetServicePlatformResourcesRequest(serviceName); } else { return this.jsonResponse({ success: false, error: 'Not found' }, 404); } @@ -1032,6 +1051,183 @@ export class OneboxHttpServer { }); } + // ============ Platform Services Endpoints ============ + + private async handleListPlatformServicesRequest(): Promise { + try { + const platformServices = this.oneboxRef.platformServices.getAllPlatformServices(); + const providers = this.oneboxRef.platformServices.getAllProviders(); + + // Build response with provider info + const result = providers.map((provider) => { + const service = platformServices.find((s) => s.type === provider.type); + return { + type: provider.type, + displayName: provider.displayName, + resourceTypes: provider.resourceTypes, + status: service?.status || 'not-deployed', + containerId: service?.containerId, + createdAt: service?.createdAt, + updatedAt: service?.updatedAt, + }; + }); + + return this.jsonResponse({ success: true, data: result }); + } catch (error) { + logger.error(`Failed to list platform services: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to list platform services', + }, 500); + } + } + + private async handleGetPlatformServiceRequest(type: string): Promise { + try { + const provider = this.oneboxRef.platformServices.getProvider(type); + if (!provider) { + return this.jsonResponse({ + success: false, + error: `Unknown platform service type: ${type}`, + }, 404); + } + + const service = this.oneboxRef.database.getPlatformServiceByType(type); + + // Get resource count + const allResources = service?.id + ? this.oneboxRef.database.getPlatformResourcesByPlatformService(service.id) + : []; + + return this.jsonResponse({ + success: true, + data: { + type: provider.type, + displayName: provider.displayName, + resourceTypes: provider.resourceTypes, + status: service?.status || 'not-deployed', + containerId: service?.containerId, + config: provider.getDefaultConfig(), + resourceCount: allResources.length, + createdAt: service?.createdAt, + updatedAt: service?.updatedAt, + }, + }); + } catch (error) { + logger.error(`Failed to get platform service ${type}: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to get platform service', + }, 500); + } + } + + private async handleStartPlatformServiceRequest(type: string): Promise { + try { + const provider = this.oneboxRef.platformServices.getProvider(type); + if (!provider) { + return this.jsonResponse({ + success: false, + error: `Unknown platform service type: ${type}`, + }, 404); + } + + logger.info(`Starting platform service: ${type}`); + const service = await this.oneboxRef.platformServices.ensureRunning(type); + + return this.jsonResponse({ + success: true, + message: `Platform service ${provider.displayName} started`, + data: { + type: service.type, + status: service.status, + containerId: service.containerId, + }, + }); + } catch (error) { + logger.error(`Failed to start platform service ${type}: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to start platform service', + }, 500); + } + } + + private async handleStopPlatformServiceRequest(type: string): Promise { + try { + const provider = this.oneboxRef.platformServices.getProvider(type); + if (!provider) { + return this.jsonResponse({ + success: false, + error: `Unknown platform service type: ${type}`, + }, 404); + } + + logger.info(`Stopping platform service: ${type}`); + await this.oneboxRef.platformServices.stopPlatformService(type); + + return this.jsonResponse({ + success: true, + message: `Platform service ${provider.displayName} stopped`, + }); + } catch (error) { + logger.error(`Failed to stop platform service ${type}: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to stop platform service', + }, 500); + } + } + + private async handleGetServicePlatformResourcesRequest(serviceName: string): Promise { + try { + const service = this.oneboxRef.services.getService(serviceName); + if (!service) { + return this.jsonResponse({ + success: false, + error: 'Service not found', + }, 404); + } + + const resources = await this.oneboxRef.services.getServicePlatformResources(serviceName); + + // Format resources for API response (mask sensitive credentials) + const formattedResources = resources.map((r) => ({ + id: r.resource.id, + resourceType: r.resource.resourceType, + resourceName: r.resource.resourceName, + platformService: { + type: r.platformService.type, + name: r.platformService.name, + status: r.platformService.status, + }, + // Include env var mappings (show keys, not values) + envVars: Object.keys(r.credentials).reduce((acc, key) => { + // Mask sensitive values + const value = r.credentials[key]; + if (key.toLowerCase().includes('password') || key.toLowerCase().includes('secret')) { + acc[key] = '********'; + } else { + acc[key] = value; + } + return acc; + }, {} as Record), + createdAt: r.resource.createdAt, + })); + + return this.jsonResponse({ + success: true, + data: formattedResources, + }); + } catch (error) { + logger.error(`Failed to get platform resources for service ${serviceName}: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to get platform resources', + }, 500); + } + } + // ============ Registry Endpoints ============ private async handleGetRegistryTagsRequest(serviceName: string): Promise { @@ -1047,51 +1243,206 @@ export class OneboxHttpServer { } } - private async handleGetRegistryTokenRequest(serviceName: string): Promise { + // ============ Registry Token Management Endpoints ============ + + private async handleListRegistryTokensRequest(req: Request): Promise { try { - // Get the service to verify it exists - const service = this.oneboxRef.database.getServiceByName(serviceName); - if (!service) { + const tokens = this.oneboxRef.database.getAllRegistryTokens(); + + // Convert to view format (mask token hash, add computed fields) + const tokenViews: IRegistryTokenView[] = tokens.map(token => { + const now = Date.now(); + const isExpired = token.expiresAt !== null && token.expiresAt < now; + + // Generate scope display string + let scopeDisplay: string; + if (token.scope === 'all') { + scopeDisplay = 'All services'; + } else if (Array.isArray(token.scope)) { + scopeDisplay = token.scope.length === 1 + ? token.scope[0] + : `${token.scope.length} services`; + } else { + scopeDisplay = 'Unknown'; + } + + return { + id: token.id!, + name: token.name, + type: token.type, + scope: token.scope, + scopeDisplay, + expiresAt: token.expiresAt, + createdAt: token.createdAt, + lastUsedAt: token.lastUsedAt, + createdBy: token.createdBy, + isExpired, + }; + }); + + return this.jsonResponse({ success: true, data: tokenViews }); + } catch (error) { + logger.error(`Failed to list registry tokens: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to list registry tokens', + }, 500); + } + } + + private async handleCreateRegistryTokenRequest(req: Request): Promise { + try { + const body = await req.json() as ICreateRegistryTokenRequest; + + // Validate request + if (!body.name || !body.type || !body.scope || !body.expiresIn) { return this.jsonResponse({ success: false, - error: 'Service not found', - }, 404); + error: 'Missing required fields: name, type, scope, expiresIn', + }, 400); } - // If service already has a token, return it - if (service.registryToken) { + if (body.type !== 'global' && body.type !== 'ci') { return this.jsonResponse({ - success: true, - data: { - token: service.registryToken, - repository: serviceName, - baseUrl: this.oneboxRef.registry.getBaseUrl(), - }, - }); + success: false, + error: 'Invalid token type. Must be "global" or "ci"', + }, 400); } - // Generate new token - const token = await this.oneboxRef.registry.createServiceToken(serviceName); + // 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); + } - // Save token to database - this.oneboxRef.database.updateService(service.id!, { - registryToken: token, - registryRepository: serviceName, + // 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: token, - repository: serviceName, - baseUrl: this.oneboxRef.registry.getBaseUrl(), + token: tokenView, + plainToken, // Only returned once at creation }, }); } catch (error) { - logger.error(`Failed to get registry token for ${serviceName}: ${error.message}`); + logger.error(`Failed to create registry token: ${error.message}`); return this.jsonResponse({ success: false, - error: error.message || 'Failed to get registry token', + error: error.message || 'Failed to create registry token', + }, 500); + } + } + + private async handleDeleteRegistryTokenRequest(tokenId: number): Promise { + try { + // Check if token exists + const token = this.oneboxRef.database.getRegistryTokenById(tokenId); + if (!token) { + return this.jsonResponse({ + success: false, + error: 'Token not found', + }, 404); + } + + // Delete the token + this.oneboxRef.database.deleteRegistryToken(tokenId); + + return this.jsonResponse({ + success: true, + message: 'Token deleted successfully', + }); + } catch (error) { + logger.error(`Failed to delete registry token ${tokenId}: ${error.message}`); + return this.jsonResponse({ + success: false, + error: error.message || 'Failed to delete registry token', }, 500); } } diff --git a/ts/classes/onebox.ts b/ts/classes/onebox.ts index c2e5247..d148aca 100644 --- a/ts/classes/onebox.ts +++ b/ts/classes/onebox.ts @@ -17,6 +17,7 @@ import { OneboxHttpServer } from './httpserver.ts'; import { CloudflareDomainSync } from './cloudflare-sync.ts'; import { CertRequirementManager } from './cert-requirement-manager.ts'; import { RegistryManager } from './registry.ts'; +import { PlatformServicesManager } from './platform-services/index.ts'; export class Onebox { public database: OneboxDatabase; @@ -31,6 +32,7 @@ export class Onebox { public cloudflareDomainSync: CloudflareDomainSync; public certRequirementManager: CertRequirementManager; public registry: RegistryManager; + public platformServices: PlatformServicesManager; private initialized = false; @@ -56,6 +58,9 @@ export class Onebox { // Initialize domain management this.cloudflareDomainSync = new CloudflareDomainSync(this.database); this.certRequirementManager = new CertRequirementManager(this.database, this.ssl); + + // Initialize platform services manager + this.platformServices = new PlatformServicesManager(this); } /** @@ -106,6 +111,14 @@ export class Onebox { logger.warn(`Error: ${error.message}`); } + // Initialize Platform Services (non-critical) + try { + await this.platformServices.init(); + } catch (error) { + logger.warn('Platform services initialization failed - MongoDB/S3 features will be limited'); + logger.warn(`Error: ${error.message}`); + } + // Login to all registries await this.registries.loginToAllRegistries(); @@ -170,6 +183,13 @@ export class Onebox { const runningServices = services.filter((s) => s.status === 'running').length; const totalServices = services.length; + // Get platform services status + const platformServices = this.platformServices.getAllPlatformServices(); + const platformServicesStatus = platformServices.map((ps) => ({ + type: ps.type, + status: ps.status, + })); + return { docker: { running: dockerRunning, @@ -188,6 +208,7 @@ export class Onebox { running: runningServices, stopped: totalServices - runningServices, }, + platformServices: platformServicesStatus, }; } catch (error) { logger.error(`Failed to get system status: ${error.message}`); diff --git a/ts/classes/platform-services/index.ts b/ts/classes/platform-services/index.ts new file mode 100644 index 0000000..2e7e661 --- /dev/null +++ b/ts/classes/platform-services/index.ts @@ -0,0 +1,10 @@ +/** + * Platform Services Module + * Exports all platform service related classes and types + */ + +export { PlatformServicesManager } from './manager.ts'; +export type { IPlatformServiceProvider } from './providers/base.ts'; +export { BasePlatformServiceProvider } from './providers/base.ts'; +export { MongoDBProvider } from './providers/mongodb.ts'; +export { MinioProvider } from './providers/minio.ts'; diff --git a/ts/classes/platform-services/manager.ts b/ts/classes/platform-services/manager.ts new file mode 100644 index 0000000..db86d7e --- /dev/null +++ b/ts/classes/platform-services/manager.ts @@ -0,0 +1,361 @@ +/** + * Platform Services Manager + * Orchestrates platform services (MongoDB, MinIO) and their resources + */ + +import type { + IService, + IPlatformService, + IPlatformResource, + IPlatformRequirements, + IProvisionedResource, + TPlatformServiceType, +} from '../../types.ts'; +import type { IPlatformServiceProvider } from './providers/base.ts'; +import { MongoDBProvider } from './providers/mongodb.ts'; +import { MinioProvider } from './providers/minio.ts'; +import { logger } from '../../logging.ts'; +import { credentialEncryption } from '../encryption.ts'; +import type { Onebox } from '../onebox.ts'; + +export class PlatformServicesManager { + private oneboxRef: Onebox; + private providers = new Map(); + + constructor(oneboxRef: Onebox) { + this.oneboxRef = oneboxRef; + } + + /** + * Initialize the platform services manager + */ + async init(): Promise { + // Initialize encryption + await credentialEncryption.init(); + + // Register providers + this.registerProvider(new MongoDBProvider(this.oneboxRef)); + this.registerProvider(new MinioProvider(this.oneboxRef)); + + logger.info(`Platform services manager initialized with ${this.providers.size} providers`); + } + + /** + * Register a platform service provider + */ + registerProvider(provider: IPlatformServiceProvider): void { + this.providers.set(provider.type, provider); + logger.debug(`Registered platform service provider: ${provider.displayName}`); + } + + /** + * Get a provider by type + */ + getProvider(type: TPlatformServiceType): IPlatformServiceProvider | undefined { + return this.providers.get(type); + } + + /** + * Get all registered providers + */ + getAllProviders(): IPlatformServiceProvider[] { + return Array.from(this.providers.values()); + } + + /** + * Ensure a platform service is running, deploying it if necessary + */ + async ensureRunning(type: TPlatformServiceType): Promise { + const provider = this.providers.get(type); + if (!provider) { + throw new Error(`Unknown platform service type: ${type}`); + } + + // Check if platform service exists in database + let platformService = this.oneboxRef.database.getPlatformServiceByType(type); + + if (!platformService) { + // Create platform service record + logger.info(`Creating new ${provider.displayName} platform service...`); + const config = provider.getDefaultConfig(); + + platformService = this.oneboxRef.database.createPlatformService({ + name: `onebox-${type}`, + type, + status: 'stopped', + config, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + } + + // Check if already running + if (platformService.status === 'running') { + // Verify it's actually healthy + const isHealthy = await provider.healthCheck(); + if (isHealthy) { + logger.debug(`${provider.displayName} is already running and healthy`); + return platformService; + } + logger.warn(`${provider.displayName} reports running but health check failed, restarting...`); + } + + // Deploy if not running + if (platformService.status !== 'running') { + logger.info(`Starting ${provider.displayName} platform service...`); + + try { + this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'starting' }); + + const containerId = await provider.deployContainer(); + + // Wait for health check to pass + const healthy = await this.waitForHealthy(type, 60000); // 60 second timeout + + if (healthy) { + this.oneboxRef.database.updatePlatformService(platformService.id!, { + status: 'running', + containerId, + }); + logger.success(`${provider.displayName} platform service is now running`); + } else { + this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'failed' }); + throw new Error(`${provider.displayName} failed to start within timeout`); + } + + // Refresh platform service from database + platformService = this.oneboxRef.database.getPlatformServiceByType(type)!; + } catch (error) { + logger.error(`Failed to start ${provider.displayName}: ${error.message}`); + this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'failed' }); + throw error; + } + } + + return platformService; + } + + /** + * Wait for a platform service to become healthy + */ + private async waitForHealthy(type: TPlatformServiceType, timeoutMs: number): Promise { + const provider = this.providers.get(type); + if (!provider) return false; + + const startTime = Date.now(); + const checkInterval = 2000; // Check every 2 seconds + + while (Date.now() - startTime < timeoutMs) { + const isHealthy = await provider.healthCheck(); + if (isHealthy) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, checkInterval)); + } + + return false; + } + + /** + * Stop a platform service + */ + async stopPlatformService(type: TPlatformServiceType): Promise { + const provider = this.providers.get(type); + if (!provider) { + throw new Error(`Unknown platform service type: ${type}`); + } + + const platformService = this.oneboxRef.database.getPlatformServiceByType(type); + if (!platformService) { + logger.warn(`Platform service ${type} not found`); + return; + } + + if (!platformService.containerId) { + logger.warn(`Platform service ${type} has no container ID`); + return; + } + + logger.info(`Stopping ${provider.displayName} platform service...`); + this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'stopping' }); + + try { + await provider.stopContainer(platformService.containerId); + this.oneboxRef.database.updatePlatformService(platformService.id!, { + status: 'stopped', + containerId: undefined, + }); + logger.success(`${provider.displayName} platform service stopped`); + } catch (error) { + logger.error(`Failed to stop ${provider.displayName}: ${error.message}`); + this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'failed' }); + throw error; + } + } + + /** + * Provision platform resources for a user service based on its requirements + */ + async provisionForService(service: IService): Promise> { + const requirements = service.platformRequirements; + if (!requirements) { + return {}; + } + + const allEnvVars: Record = {}; + + // Provision MongoDB if requested + if (requirements.mongodb) { + logger.info(`Provisioning MongoDB for service '${service.name}'...`); + + // Ensure MongoDB is running + const mongoService = await this.ensureRunning('mongodb'); + const provider = this.providers.get('mongodb')!; + + // Provision database + const result = await provider.provisionResource(service); + + // Store resource record + const encryptedCreds = await credentialEncryption.encrypt(result.credentials); + this.oneboxRef.database.createPlatformResource({ + platformServiceId: mongoService.id!, + serviceId: service.id!, + resourceType: result.type, + resourceName: result.name, + credentialsEncrypted: encryptedCreds, + createdAt: Date.now(), + }); + + // Merge env vars + Object.assign(allEnvVars, result.envVars); + logger.success(`MongoDB provisioned for service '${service.name}'`); + } + + // Provision S3/MinIO if requested + if (requirements.s3) { + logger.info(`Provisioning S3 storage for service '${service.name}'...`); + + // Ensure MinIO is running + const minioService = await this.ensureRunning('minio'); + const provider = this.providers.get('minio')!; + + // Provision bucket + const result = await provider.provisionResource(service); + + // Store resource record + const encryptedCreds = await credentialEncryption.encrypt(result.credentials); + this.oneboxRef.database.createPlatformResource({ + platformServiceId: minioService.id!, + serviceId: service.id!, + resourceType: result.type, + resourceName: result.name, + credentialsEncrypted: encryptedCreds, + createdAt: Date.now(), + }); + + // Merge env vars + Object.assign(allEnvVars, result.envVars); + logger.success(`S3 storage provisioned for service '${service.name}'`); + } + + return allEnvVars; + } + + /** + * Cleanup platform resources when a user service is deleted + */ + async cleanupForService(serviceId: number): Promise { + const resources = this.oneboxRef.database.getPlatformResourcesByService(serviceId); + + for (const resource of resources) { + try { + const platformService = this.oneboxRef.database.getPlatformServiceById(resource.platformServiceId); + if (!platformService) { + logger.warn(`Platform service not found for resource ${resource.id}`); + continue; + } + + const provider = this.providers.get(platformService.type); + if (!provider) { + logger.warn(`Provider not found for type ${platformService.type}`); + continue; + } + + // Decrypt credentials + const credentials = await credentialEncryption.decrypt(resource.credentialsEncrypted); + + // Deprovision the resource + logger.info(`Cleaning up ${resource.resourceType} '${resource.resourceName}'...`); + await provider.deprovisionResource(resource, credentials); + + // Delete resource record + this.oneboxRef.database.deletePlatformResource(resource.id!); + logger.success(`Cleaned up ${resource.resourceType} '${resource.resourceName}'`); + } catch (error) { + logger.error(`Failed to cleanup resource ${resource.id}: ${error.message}`); + // Continue with other resources even if one fails + } + } + } + + /** + * Get injected environment variables for a service + */ + async getInjectedEnvVars(serviceId: number): Promise> { + const resources = this.oneboxRef.database.getPlatformResourcesByService(serviceId); + const allEnvVars: Record = {}; + + for (const resource of resources) { + const platformService = this.oneboxRef.database.getPlatformServiceById(resource.platformServiceId); + if (!platformService) continue; + + const provider = this.providers.get(platformService.type); + if (!provider) continue; + + const credentials = await credentialEncryption.decrypt(resource.credentialsEncrypted); + const mappings = provider.getEnvVarMappings(); + + for (const mapping of mappings) { + if (credentials[mapping.credentialPath]) { + allEnvVars[mapping.envVar] = credentials[mapping.credentialPath]; + } + } + } + + return allEnvVars; + } + + /** + * Get all platform services with their status + */ + getAllPlatformServices(): IPlatformService[] { + return this.oneboxRef.database.getAllPlatformServices(); + } + + /** + * Get resources for a specific user service + */ + async getResourcesForService(serviceId: number): Promise; + }>> { + const resources = this.oneboxRef.database.getPlatformResourcesByService(serviceId); + const result = []; + + for (const resource of resources) { + const platformService = this.oneboxRef.database.getPlatformServiceById(resource.platformServiceId); + if (!platformService) continue; + + const credentials = await credentialEncryption.decrypt(resource.credentialsEncrypted); + + result.push({ + resource, + platformService, + credentials, + }); + } + + return result; + } +} diff --git a/ts/classes/platform-services/providers/base.ts b/ts/classes/platform-services/providers/base.ts new file mode 100644 index 0000000..9c7d580 --- /dev/null +++ b/ts/classes/platform-services/providers/base.ts @@ -0,0 +1,123 @@ +/** + * Base interface and types for platform service providers + */ + +import type { + IService, + IPlatformService, + IPlatformResource, + IPlatformServiceConfig, + IProvisionedResource, + IEnvVarMapping, + TPlatformServiceType, + TPlatformResourceType, +} from '../../../types.ts'; +import type { Onebox } from '../../onebox.ts'; + +/** + * Interface that all platform service providers must implement + */ +export interface IPlatformServiceProvider { + /** Unique identifier for this provider type */ + readonly type: TPlatformServiceType; + + /** Human-readable display name */ + readonly displayName: string; + + /** Resource types this provider can provision */ + readonly resourceTypes: TPlatformResourceType[]; + + /** + * Get the default configuration for this platform service + */ + getDefaultConfig(): IPlatformServiceConfig; + + /** + * Deploy the platform service container + * @returns The container ID + */ + deployContainer(): Promise; + + /** + * Stop the platform service container + */ + stopContainer(containerId: string): Promise; + + /** + * Check if the platform service is healthy and ready to accept connections + */ + healthCheck(): Promise; + + /** + * Provision a resource for a user service (e.g., create database, bucket) + * @param userService The user service requesting the resource + * @returns Provisioned resource with credentials and env var mappings + */ + provisionResource(userService: IService): Promise; + + /** + * Deprovision a resource (e.g., drop database, delete bucket) + * @param resource The resource to deprovision + */ + deprovisionResource(resource: IPlatformResource, credentials: Record): Promise; + + /** + * Get the environment variable mappings for this provider + */ + getEnvVarMappings(): IEnvVarMapping[]; +} + +/** + * Base class for platform service providers with common functionality + */ +export abstract class BasePlatformServiceProvider implements IPlatformServiceProvider { + abstract readonly type: TPlatformServiceType; + abstract readonly displayName: string; + abstract readonly resourceTypes: TPlatformResourceType[]; + + protected oneboxRef: Onebox; + + constructor(oneboxRef: Onebox) { + this.oneboxRef = oneboxRef; + } + + abstract getDefaultConfig(): IPlatformServiceConfig; + abstract deployContainer(): Promise; + abstract stopContainer(containerId: string): Promise; + abstract healthCheck(): Promise; + abstract provisionResource(userService: IService): Promise; + abstract deprovisionResource(resource: IPlatformResource, credentials: Record): Promise; + abstract getEnvVarMappings(): IEnvVarMapping[]; + + /** + * Get the internal Docker network name for platform services + */ + protected getNetworkName(): string { + return 'onebox-network'; + } + + /** + * Get the container name for this platform service + */ + protected getContainerName(): string { + return `onebox-${this.type}`; + } + + /** + * Generate a resource name from a user service name + */ + protected generateResourceName(serviceName: string, prefix: string = 'onebox'): string { + // Replace dashes with underscores for database compatibility + const sanitized = serviceName.replace(/-/g, '_'); + return `${prefix}_${sanitized}`; + } + + /** + * Generate a bucket name from a user service name + */ + protected generateBucketName(serviceName: string, prefix: string = 'onebox'): string { + // Buckets use dashes, lowercase + const sanitized = serviceName.toLowerCase().replace(/_/g, '-'); + return `${prefix}-${sanitized}`; + } +} diff --git a/ts/classes/platform-services/providers/minio.ts b/ts/classes/platform-services/providers/minio.ts new file mode 100644 index 0000000..245e38b --- /dev/null +++ b/ts/classes/platform-services/providers/minio.ts @@ -0,0 +1,299 @@ +/** + * MinIO (S3-compatible) Platform Service Provider + */ + +import { BasePlatformServiceProvider } from './base.ts'; +import type { + IService, + IPlatformResource, + IPlatformServiceConfig, + IProvisionedResource, + IEnvVarMapping, + TPlatformServiceType, + TPlatformResourceType, +} from '../../../types.ts'; +import { logger } from '../../../logging.ts'; +import { credentialEncryption } from '../../encryption.ts'; +import type { Onebox } from '../../onebox.ts'; + +export class MinioProvider extends BasePlatformServiceProvider { + readonly type: TPlatformServiceType = 'minio'; + readonly displayName = 'S3 Storage (MinIO)'; + readonly resourceTypes: TPlatformResourceType[] = ['bucket']; + + constructor(oneboxRef: Onebox) { + super(oneboxRef); + } + + getDefaultConfig(): IPlatformServiceConfig { + return { + image: 'minio/minio:latest', + port: 9000, + volumes: ['/var/lib/onebox/minio:/data'], + command: 'server /data --console-address :9001', + environment: { + MINIO_ROOT_USER: 'admin', + // Password will be generated and stored encrypted + }, + }; + } + + getEnvVarMappings(): IEnvVarMapping[] { + return [ + { envVar: 'S3_ENDPOINT', credentialPath: 'endpoint' }, + { envVar: 'S3_BUCKET', credentialPath: 'bucket' }, + { envVar: 'S3_ACCESS_KEY', credentialPath: 'accessKey' }, + { envVar: 'S3_SECRET_KEY', credentialPath: 'secretKey' }, + { envVar: 'S3_REGION', credentialPath: 'region' }, + // AWS SDK compatible names + { envVar: 'AWS_ACCESS_KEY_ID', credentialPath: 'accessKey' }, + { envVar: 'AWS_SECRET_ACCESS_KEY', credentialPath: 'secretKey' }, + { envVar: 'AWS_ENDPOINT_URL', credentialPath: 'endpoint' }, + { envVar: 'AWS_REGION', credentialPath: 'region' }, + ]; + } + + async deployContainer(): Promise { + const config = this.getDefaultConfig(); + const containerName = this.getContainerName(); + + // Generate admin credentials + const adminUser = 'admin'; + const adminPassword = credentialEncryption.generatePassword(32); + + const adminCredentials = { + username: adminUser, + password: adminPassword, + }; + + logger.info(`Deploying MinIO platform service as ${containerName}...`); + + // Ensure data directory exists + try { + await Deno.mkdir('/var/lib/onebox/minio', { recursive: true }); + } catch (e) { + if (!(e instanceof Deno.errors.AlreadyExists)) { + logger.warn(`Could not create MinIO data directory: ${e.message}`); + } + } + + // Create container using Docker API + const envVars = [ + `MINIO_ROOT_USER=${adminCredentials.username}`, + `MINIO_ROOT_PASSWORD=${adminCredentials.password}`, + ]; + + const containerId = await this.oneboxRef.docker.createPlatformContainer({ + name: containerName, + image: config.image, + port: config.port, + env: envVars, + volumes: config.volumes, + network: this.getNetworkName(), + command: config.command?.split(' '), + exposePorts: [9000, 9001], // API and Console ports + }); + + // Store encrypted admin credentials + const encryptedCreds = await credentialEncryption.encrypt(adminCredentials); + const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); + if (platformService) { + this.oneboxRef.database.updatePlatformService(platformService.id!, { + containerId, + adminCredentialsEncrypted: encryptedCreds, + status: 'starting', + }); + } + + logger.success(`MinIO container created: ${containerId}`); + return containerId; + } + + async stopContainer(containerId: string): Promise { + logger.info(`Stopping MinIO container ${containerId}...`); + await this.oneboxRef.docker.stopContainer(containerId); + logger.success('MinIO container stopped'); + } + + async healthCheck(): Promise { + try { + const containerName = this.getContainerName(); + const endpoint = `http://${containerName}:9000/minio/health/live`; + + const response = await fetch(endpoint, { + method: 'GET', + signal: AbortSignal.timeout(5000), + }); + + return response.ok; + } catch (error) { + logger.debug(`MinIO health check failed: ${error.message}`); + return false; + } + } + + async provisionResource(userService: IService): Promise { + const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); + if (!platformService || !platformService.adminCredentialsEncrypted) { + throw new Error('MinIO platform service not found or not configured'); + } + + const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); + const containerName = this.getContainerName(); + + // Generate bucket name and credentials + const bucketName = this.generateBucketName(userService.name); + const accessKey = credentialEncryption.generateAccessKey(20); + const secretKey = credentialEncryption.generateSecretKey(40); + + logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`); + + const endpoint = `http://${containerName}:9000`; + + // Import AWS S3 client + const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3'); + + // Create S3 client with admin credentials + const s3Client = new S3Client({ + endpoint, + region: 'us-east-1', + credentials: { + accessKeyId: adminCreds.username, + secretAccessKey: adminCreds.password, + }, + forcePathStyle: true, + }); + + // Create the bucket + try { + await s3Client.send(new CreateBucketCommand({ + Bucket: bucketName, + })); + logger.info(`Created MinIO bucket '${bucketName}'`); + } catch (e: any) { + if (e.name !== 'BucketAlreadyOwnedByYou' && e.name !== 'BucketAlreadyExists') { + throw e; + } + logger.warn(`Bucket '${bucketName}' already exists`); + } + + // Create service account/access key using MinIO Admin API + // MinIO Admin API requires mc client or direct API calls + // For simplicity, we'll use root credentials and bucket policy isolation + // In production, you'd use MinIO's Admin API to create service accounts + + // Set bucket policy to allow access only with this bucket's credentials + const bucketPolicy = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Principal: { AWS: ['*'] }, + Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject', 's3:ListBucket'], + Resource: [ + `arn:aws:s3:::${bucketName}`, + `arn:aws:s3:::${bucketName}/*`, + ], + }, + ], + }; + + try { + await s3Client.send(new PutBucketPolicyCommand({ + Bucket: bucketName, + Policy: JSON.stringify(bucketPolicy), + })); + logger.info(`Set bucket policy for '${bucketName}'`); + } catch (e) { + logger.warn(`Could not set bucket policy: ${e.message}`); + } + + // Note: For proper per-service credentials, MinIO Admin API should be used + // For now, we're providing the bucket with root access + // TODO: Implement MinIO service account creation + logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.'); + + const credentials: Record = { + endpoint, + bucket: bucketName, + accessKey: adminCreds.username, // Using root for now + secretKey: adminCreds.password, + region: 'us-east-1', + }; + + // Map credentials to env vars + const envVars: Record = {}; + for (const mapping of this.getEnvVarMappings()) { + if (credentials[mapping.credentialPath]) { + envVars[mapping.envVar] = credentials[mapping.credentialPath]; + } + } + + logger.success(`MinIO bucket '${bucketName}' provisioned`); + + return { + type: 'bucket', + name: bucketName, + credentials, + envVars, + }; + } + + async deprovisionResource(resource: IPlatformResource, credentials: Record): Promise { + const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); + if (!platformService || !platformService.adminCredentialsEncrypted) { + throw new Error('MinIO platform service not found or not configured'); + } + + const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); + const containerName = this.getContainerName(); + const endpoint = `http://${containerName}:9000`; + + logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`); + + const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3'); + + const s3Client = new S3Client({ + endpoint, + region: 'us-east-1', + credentials: { + accessKeyId: adminCreds.username, + secretAccessKey: adminCreds.password, + }, + forcePathStyle: true, + }); + + try { + // First, delete all objects in the bucket + let continuationToken: string | undefined; + do { + const listResponse = await s3Client.send(new ListObjectsV2Command({ + Bucket: resource.resourceName, + ContinuationToken: continuationToken, + })); + + if (listResponse.Contents && listResponse.Contents.length > 0) { + await s3Client.send(new DeleteObjectsCommand({ + Bucket: resource.resourceName, + Delete: { + Objects: listResponse.Contents.map(obj => ({ Key: obj.Key! })), + }, + })); + logger.info(`Deleted ${listResponse.Contents.length} objects from bucket`); + } + + continuationToken = listResponse.IsTruncated ? listResponse.NextContinuationToken : undefined; + } while (continuationToken); + + // Now delete the bucket + await s3Client.send(new DeleteBucketCommand({ + Bucket: resource.resourceName, + })); + + logger.success(`MinIO bucket '${resource.resourceName}' deleted`); + } catch (e) { + logger.error(`Failed to delete MinIO bucket: ${e.message}`); + throw e; + } + } +} diff --git a/ts/classes/platform-services/providers/mongodb.ts b/ts/classes/platform-services/providers/mongodb.ts new file mode 100644 index 0000000..6ea642b --- /dev/null +++ b/ts/classes/platform-services/providers/mongodb.ts @@ -0,0 +1,246 @@ +/** + * MongoDB Platform Service Provider + */ + +import { BasePlatformServiceProvider } from './base.ts'; +import type { + IService, + IPlatformResource, + IPlatformServiceConfig, + IProvisionedResource, + IEnvVarMapping, + TPlatformServiceType, + TPlatformResourceType, +} from '../../../types.ts'; +import { logger } from '../../../logging.ts'; +import { credentialEncryption } from '../../encryption.ts'; +import type { Onebox } from '../../onebox.ts'; + +export class MongoDBProvider extends BasePlatformServiceProvider { + readonly type: TPlatformServiceType = 'mongodb'; + readonly displayName = 'MongoDB'; + readonly resourceTypes: TPlatformResourceType[] = ['database']; + + constructor(oneboxRef: Onebox) { + super(oneboxRef); + } + + getDefaultConfig(): IPlatformServiceConfig { + return { + image: 'mongo:7', + port: 27017, + volumes: ['/var/lib/onebox/mongodb:/data/db'], + environment: { + MONGO_INITDB_ROOT_USERNAME: 'admin', + // Password will be generated and stored encrypted + }, + }; + } + + getEnvVarMappings(): IEnvVarMapping[] { + return [ + { envVar: 'MONGODB_URI', credentialPath: 'connectionString' }, + { envVar: 'MONGODB_HOST', credentialPath: 'host' }, + { envVar: 'MONGODB_PORT', credentialPath: 'port' }, + { envVar: 'MONGODB_DATABASE', credentialPath: 'database' }, + { envVar: 'MONGODB_USERNAME', credentialPath: 'username' }, + { envVar: 'MONGODB_PASSWORD', credentialPath: 'password' }, + ]; + } + + async deployContainer(): Promise { + const config = this.getDefaultConfig(); + const containerName = this.getContainerName(); + + // Generate admin password + const adminPassword = credentialEncryption.generatePassword(32); + + // Store admin credentials encrypted in the platform service record + const adminCredentials = { + username: 'admin', + password: adminPassword, + }; + + logger.info(`Deploying MongoDB platform service as ${containerName}...`); + + // Ensure data directory exists + try { + await Deno.mkdir('/var/lib/onebox/mongodb', { recursive: true }); + } catch (e) { + // Directory might already exist + if (!(e instanceof Deno.errors.AlreadyExists)) { + logger.warn(`Could not create MongoDB data directory: ${e.message}`); + } + } + + // Create container using Docker API + const envVars = [ + `MONGO_INITDB_ROOT_USERNAME=${adminCredentials.username}`, + `MONGO_INITDB_ROOT_PASSWORD=${adminCredentials.password}`, + ]; + + // Use Docker to create the container + const containerId = await this.oneboxRef.docker.createPlatformContainer({ + name: containerName, + image: config.image, + port: config.port, + env: envVars, + volumes: config.volumes, + network: this.getNetworkName(), + }); + + // Store encrypted admin credentials + const encryptedCreds = await credentialEncryption.encrypt(adminCredentials); + const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); + if (platformService) { + this.oneboxRef.database.updatePlatformService(platformService.id!, { + containerId, + adminCredentialsEncrypted: encryptedCreds, + status: 'starting', + }); + } + + logger.success(`MongoDB container created: ${containerId}`); + return containerId; + } + + async stopContainer(containerId: string): Promise { + logger.info(`Stopping MongoDB container ${containerId}...`); + await this.oneboxRef.docker.stopContainer(containerId); + logger.success('MongoDB container stopped'); + } + + async healthCheck(): Promise { + try { + const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); + if (!platformService || !platformService.adminCredentialsEncrypted) { + return false; + } + + const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); + const containerName = this.getContainerName(); + + // Try to connect to MongoDB using mongosh ping + const { MongoClient } = await import('npm:mongodb@6'); + const uri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`; + + const client = new MongoClient(uri, { + serverSelectionTimeoutMS: 5000, + connectTimeoutMS: 5000, + }); + + await client.connect(); + await client.db('admin').command({ ping: 1 }); + await client.close(); + + return true; + } catch (error) { + logger.debug(`MongoDB health check failed: ${error.message}`); + return false; + } + } + + async provisionResource(userService: IService): Promise { + const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); + if (!platformService || !platformService.adminCredentialsEncrypted) { + throw new Error('MongoDB platform service not found or not configured'); + } + + const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); + const containerName = this.getContainerName(); + + // Generate resource names and credentials + const dbName = this.generateResourceName(userService.name); + const username = this.generateResourceName(userService.name); + const password = credentialEncryption.generatePassword(32); + + logger.info(`Provisioning MongoDB database '${dbName}' for service '${userService.name}'...`); + + // Connect to MongoDB and create database/user + const { MongoClient } = await import('npm:mongodb@6'); + const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`; + + const client = new MongoClient(adminUri); + await client.connect(); + + try { + // Create the database by switching to it (MongoDB creates on first write) + const db = client.db(dbName); + + // Create a collection to ensure the database exists + await db.createCollection('_onebox_init'); + + // Create user with readWrite access to this database + await db.command({ + createUser: username, + pwd: password, + roles: [{ role: 'readWrite', db: dbName }], + }); + + logger.success(`MongoDB database '${dbName}' provisioned with user '${username}'`); + } finally { + await client.close(); + } + + // Build the credentials and env vars + const credentials: Record = { + host: containerName, + port: '27017', + database: dbName, + username, + password, + connectionString: `mongodb://${username}:${password}@${containerName}:27017/${dbName}?authSource=${dbName}`, + }; + + // Map credentials to env vars + const envVars: Record = {}; + for (const mapping of this.getEnvVarMappings()) { + if (credentials[mapping.credentialPath]) { + envVars[mapping.envVar] = credentials[mapping.credentialPath]; + } + } + + return { + type: 'database', + name: dbName, + credentials, + envVars, + }; + } + + async deprovisionResource(resource: IPlatformResource, credentials: Record): Promise { + const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type); + if (!platformService || !platformService.adminCredentialsEncrypted) { + throw new Error('MongoDB platform service not found or not configured'); + } + + const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted); + const containerName = this.getContainerName(); + + logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`); + + const { MongoClient } = await import('npm:mongodb@6'); + const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`; + + const client = new MongoClient(adminUri); + await client.connect(); + + try { + const db = client.db(resource.resourceName); + + // Drop the user + try { + await db.command({ dropUser: credentials.username }); + logger.info(`Dropped MongoDB user '${credentials.username}'`); + } catch (e) { + logger.warn(`Could not drop MongoDB user: ${e.message}`); + } + + // Drop the database + await db.dropDatabase(); + logger.success(`MongoDB database '${resource.resourceName}' dropped`); + } finally { + await client.close(); + } + } +} diff --git a/ts/classes/registry.ts b/ts/classes/registry.ts index a159226..42560aa 100644 --- a/ts/classes/registry.ts +++ b/ts/classes/registry.ts @@ -107,30 +107,6 @@ export class RegistryManager { } } - /** - * Create a push/pull token for a service - */ - async createServiceToken(serviceName: string): Promise { - if (!this.isInitialized) { - throw new Error('Registry not initialized'); - } - - const repository = serviceName; - const scopes = [ - `oci:repository:${repository}:push`, - `oci:repository:${repository}:pull`, - ]; - - // Create OCI JWT token (expires in 1 year = 365 * 24 * 60 * 60 seconds) - const token = await this.registry.authManager.createOciToken( - 'onebox', - scopes, - 31536000 // 365 days in seconds - ); - - return token; - } - /** * Get all tags for a repository */ diff --git a/ts/classes/services.ts b/ts/classes/services.ts index 6b5c856..af8da67 100644 --- a/ts/classes/services.ts +++ b/ts/classes/services.ts @@ -4,10 +4,11 @@ * Orchestrates service deployment: Docker + Nginx + DNS + SSL */ -import type { IService, IServiceDeployOptions } from '../types.ts'; +import type { IService, IServiceDeployOptions, IPlatformRequirements } from '../types.ts'; import { logger } from '../logging.ts'; import { OneboxDatabase } from './database.ts'; import { OneboxDockerManager } from './docker.ts'; +import type { PlatformServicesManager } from './platform-services/index.ts'; export class OneboxServicesManager { private oneboxRef: any; // Will be Onebox instance @@ -34,13 +35,9 @@ export class OneboxServicesManager { } // Handle Onebox Registry setup - let registryToken: string | undefined; let imageToPull: string; if (options.useOneboxRegistry) { - // Generate registry token - registryToken = await this.oneboxRef.registry.createServiceToken(options.name); - // Use onebox registry image name const tag = options.registryImageTag || 'latest'; imageToPull = this.oneboxRef.registry.getImageName(options.name, tag); @@ -49,6 +46,15 @@ export class OneboxServicesManager { imageToPull = options.image; } + // Build platform requirements + const platformRequirements: IPlatformRequirements | undefined = + (options.enableMongoDB || options.enableS3) + ? { + mongodb: options.enableMongoDB, + s3: options.enableS3, + } + : undefined; + // Create service record in database const service = await this.database.createService({ name: options.name, @@ -63,18 +69,46 @@ export class OneboxServicesManager { // Onebox Registry fields useOneboxRegistry: options.useOneboxRegistry, registryRepository: options.useOneboxRegistry ? options.name : undefined, - registryToken: registryToken, registryImageTag: options.registryImageTag || 'latest', autoUpdateOnPush: options.autoUpdateOnPush, + // Platform requirements + platformRequirements, }); + // Provision platform resources if needed + let platformEnvVars: Record = {}; + if (platformRequirements) { + try { + logger.info(`Provisioning platform resources for service '${options.name}'...`); + const platformServices = this.oneboxRef.platformServices as PlatformServicesManager; + platformEnvVars = await platformServices.provisionForService(service); + logger.success(`Platform resources provisioned for service '${options.name}'`); + } catch (error) { + logger.error(`Failed to provision platform resources: ${error.message}`); + // Clean up the service record on failure + this.database.deleteService(service.id!); + throw error; + } + } + + // Merge platform env vars with user-specified env vars (user vars take precedence) + const mergedEnvVars = { ...platformEnvVars, ...(options.envVars || {}) }; + + // Update service with merged env vars + if (Object.keys(platformEnvVars).length > 0) { + this.database.updateService(service.id!, { envVars: mergedEnvVars }); + } + + // Get updated service with merged env vars + const serviceWithEnvVars = this.database.getServiceByName(options.name)!; + // Pull image (skip if using onebox registry - image might not exist yet) if (!options.useOneboxRegistry) { await this.docker.pullImage(imageToPull, options.registry); } - // Create container - const containerID = await this.docker.createContainer(service); + // Create container (uses the updated service with merged env vars) + const containerID = await this.docker.createContainer(serviceWithEnvVars); // Update service with container ID this.database.updateService(service.id!, { @@ -293,6 +327,19 @@ export class OneboxServicesManager { // as they might be used by other services or need manual cleanup } + // Cleanup platform resources (MongoDB databases, S3 buckets, etc.) + if (service.platformRequirements) { + try { + logger.info(`Cleaning up platform resources for service '${name}'...`); + const platformServices = this.oneboxRef.platformServices as PlatformServicesManager; + await platformServices.cleanupForService(service.id!); + logger.success(`Platform resources cleaned up for service '${name}'`); + } catch (error) { + logger.warn(`Failed to cleanup platform resources: ${error.message}`); + // Continue with service deletion even if cleanup fails + } + } + // Remove from database this.database.deleteService(service.id!); @@ -392,6 +439,28 @@ export class OneboxServicesManager { } } + /** + * Get platform resources for a service + */ + async getServicePlatformResources(name: string) { + try { + const service = this.database.getServiceByName(name); + if (!service) { + throw new Error(`Service not found: ${name}`); + } + + if (!service.platformRequirements) { + return []; + } + + const platformServices = this.oneboxRef.platformServices as PlatformServicesManager; + return await platformServices.getResourcesForService(service.id!); + } catch (error) { + logger.error(`Failed to get platform resources for service ${name}: ${error.message}`); + return []; + } + } + /** * Get service status */ diff --git a/ts/classes/ssl.ts b/ts/classes/ssl.ts index 5030528..3465192 100644 --- a/ts/classes/ssl.ts +++ b/ts/classes/ssl.ts @@ -6,6 +6,7 @@ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; +import { getErrorMessage } from '../utils/error.ts'; import { OneboxDatabase } from './database.ts'; import { SqliteCertManager } from './certmanager.ts'; @@ -77,7 +78,7 @@ export class OneboxSslManager { logger.success('SSL manager initialized with SmartACME DNS-01 challenge'); } catch (error) { - logger.error(`Failed to initialize SSL manager: ${error.message}`); + logger.error(`Failed to initialize SSL manager: ${getErrorMessage(error)}`); throw error; } } @@ -121,16 +122,23 @@ export class OneboxSslManager { // Reload certificates in reverse proxy await this.oneboxRef.reverseProxy.reloadCertificates(); - // Return certificate data + // The certManager stores the cert to disk and database during getCertificateForDomain + // Look up the paths from the database + const dbCert = this.database.getSSLCertificate(domain); + if (!dbCert) { + throw new Error(`Certificate stored but not found in database for ${domain}`); + } + + // Return certificate data from database return { - certPath: cert.certFilePath, - keyPath: cert.keyFilePath, - fullChainPath: cert.chainFilePath || cert.certFilePath, + certPath: dbCert.certPath, + keyPath: dbCert.keyPath, + fullChainPath: dbCert.fullChainPath, expiryDate: cert.validUntil, - issuer: cert.issuer || 'Let\'s Encrypt', + issuer: dbCert.issuer || 'Let\'s Encrypt', }; } catch (error) { - logger.error(`Failed to acquire certificate for ${domain}: ${error.message}`); + logger.error(`Failed to acquire certificate for ${domain}: ${getErrorMessage(error)}`); throw error; } } @@ -164,7 +172,7 @@ export class OneboxSslManager { // Reload certificates in reverse proxy await this.oneboxRef.reverseProxy.reloadCertificates(); } catch (error) { - logger.error(`Failed to obtain certificate for ${domain}: ${error.message}`); + logger.error(`Failed to obtain certificate for ${domain}: ${getErrorMessage(error)}`); throw error; } } @@ -203,7 +211,7 @@ export class OneboxSslManager { logger.success(`Certbot obtained certificate for ${domain}`); } catch (error) { - throw new Error(`Failed to run certbot: ${error.message}`); + throw new Error(`Failed to run certbot: ${getErrorMessage(error)}`); } } @@ -227,7 +235,7 @@ export class OneboxSslManager { // Reload certificates in reverse proxy await this.oneboxRef.reverseProxy.reloadCertificates(); } catch (error) { - logger.error(`Failed to renew certificate for ${domain}: ${error.message}`); + logger.error(`Failed to renew certificate for ${domain}: ${getErrorMessage(error)}`); throw error; } } @@ -270,14 +278,14 @@ export class OneboxSslManager { await this.renewCertificate(dbCert.domain); } } catch (error) { - logger.error(`Failed to renew ${dbCert.domain}: ${error.message}`); + logger.error(`Failed to renew ${dbCert.domain}: ${getErrorMessage(error)}`); // Continue with other certificates } } logger.success('Certificate renewal check complete'); } catch (error) { - logger.error(`Failed to check expiring certificates: ${error.message}`); + logger.error(`Failed to check expiring certificates: ${getErrorMessage(error)}`); throw error; } } @@ -307,7 +315,7 @@ export class OneboxSslManager { // Reload certificates in reverse proxy await this.oneboxRef.reverseProxy.reloadCertificates(); } catch (error) { - logger.error(`Failed to renew all certificates: ${error.message}`); + logger.error(`Failed to renew all certificates: ${getErrorMessage(error)}`); throw error; } } @@ -358,7 +366,7 @@ export class OneboxSslManager { return null; } catch (error) { - logger.error(`Failed to get certificate expiry for ${domain}: ${error.message}`); + logger.error(`Failed to get certificate expiry for ${domain}: ${getErrorMessage(error)}`); return null; } } diff --git a/ts/cli.ts b/ts/cli.ts index f678bc1..049fe08 100644 --- a/ts/cli.ts +++ b/ts/cli.ts @@ -4,6 +4,7 @@ import { logger } from './logging.ts'; import { projectInfo } from './info.ts'; +import { getErrorMessage } from './utils/error.ts'; import { Onebox } from './classes/onebox.ts'; import { OneboxDaemon } from './classes/daemon.ts'; @@ -80,7 +81,7 @@ export async function runCli(): Promise { // Cleanup await onebox.shutdown(); } catch (error) { - logger.error(error.message); + logger.error(getErrorMessage(error)); Deno.exit(1); } } @@ -227,20 +228,36 @@ async function handleSslCommand(onebox: Onebox, subcommand: string, args: string } } -// Nginx commands +// Reverse proxy commands (formerly nginx commands) async function handleNginxCommand(onebox: Onebox, subcommand: string, _args: string[]) { switch (subcommand) { case 'reload': - await onebox.nginx.reload(); + // Reload routes and certificates + await onebox.reverseProxy.reloadRoutes(); + await onebox.reverseProxy.reloadCertificates(); + logger.success('Reverse proxy configuration reloaded'); break; case 'test': - await onebox.nginx.test(); + // Verify reverse proxy is running + const proxyStatus = onebox.reverseProxy.getStatus(); + if (proxyStatus.http.running || proxyStatus.https.running) { + logger.success('Reverse proxy is running'); + logger.info(`HTTP: ${proxyStatus.http.running ? 'active' : 'inactive'} (port ${proxyStatus.http.port})`); + logger.info(`HTTPS: ${proxyStatus.https.running ? 'active' : 'inactive'} (port ${proxyStatus.https.port})`); + logger.info(`Routes: ${proxyStatus.routes}, Certificates: ${proxyStatus.https.certificates}`); + } else { + logger.error('Reverse proxy is not running'); + } break; case 'status': { - const status = await onebox.nginx.getStatus(); - logger.info(`Nginx status: ${status}`); + const status = onebox.reverseProxy.getStatus(); + logger.info(`Reverse proxy status:`); + logger.info(` HTTP: ${status.http.running ? 'running' : 'stopped'} (port ${status.http.port})`); + logger.info(` HTTPS: ${status.https.running ? 'running' : 'stopped'} (port ${status.https.port})`); + logger.info(` Routes: ${status.routes}`); + logger.info(` Certificates: ${status.https.certificates}`); break; } diff --git a/ts/types.ts b/ts/types.ts index c3141bc..31dc221 100644 --- a/ts/types.ts +++ b/ts/types.ts @@ -18,10 +18,11 @@ export interface IService { // Onebox Registry fields useOneboxRegistry?: boolean; registryRepository?: string; - registryToken?: string; registryImageTag?: string; autoUpdateOnPush?: boolean; imageDigest?: string; + // Platform service requirements + platformRequirements?: IPlatformRequirements; } // Registry types @@ -33,6 +34,96 @@ export interface IRegistry { createdAt: number; } +// Registry token types +export interface IRegistryToken { + id?: number; + name: string; + tokenHash: string; + type: 'global' | 'ci'; + scope: 'all' | string[]; // 'all' or array of service names + expiresAt: number | null; + createdAt: number; + lastUsedAt: number | null; + createdBy: string; +} + +export interface ICreateRegistryTokenRequest { + name: string; + type: 'global' | 'ci'; + scope: 'all' | string[]; + expiresIn: '30d' | '90d' | '365d' | 'never'; +} + +export interface IRegistryTokenView { + id: number; + name: string; + type: 'global' | 'ci'; + scope: 'all' | string[]; + scopeDisplay: string; + expiresAt: number | null; + createdAt: number; + lastUsedAt: number | null; + createdBy: string; + isExpired: boolean; +} + +export interface ITokenCreatedResponse { + token: IRegistryTokenView; + plainToken: string; // Only shown once at creation +} + +// Platform service types +export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq'; +export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue'; +export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; + +export interface IPlatformService { + id?: number; + name: string; + type: TPlatformServiceType; + status: TPlatformServiceStatus; + containerId?: string; + config: IPlatformServiceConfig; + adminCredentialsEncrypted?: string; + createdAt: number; + updatedAt: number; +} + +export interface IPlatformServiceConfig { + image: string; + port: number; + volumes?: string[]; + command?: string; + environment?: Record; +} + +export interface IPlatformResource { + id?: number; + platformServiceId: number; + serviceId: number; + resourceType: TPlatformResourceType; + resourceName: string; + credentialsEncrypted: string; + createdAt: number; +} + +export interface IPlatformRequirements { + mongodb?: boolean; + s3?: boolean; +} + +export interface IProvisionedResource { + type: TPlatformResourceType; + name: string; + credentials: Record; + envVars: Record; +} + +export interface IEnvVarMapping { + envVar: string; + credentialPath: string; +} + // Nginx configuration types export interface INginxConfig { id?: number; @@ -193,6 +284,9 @@ export interface IServiceDeployOptions { useOneboxRegistry?: boolean; registryImageTag?: string; autoUpdateOnPush?: boolean; + // Platform service requirements + enableMongoDB?: boolean; + enableS3?: boolean; } // HTTP API request/response types diff --git a/ts/utils/error.ts b/ts/utils/error.ts new file mode 100644 index 0000000..e97fe98 --- /dev/null +++ b/ts/utils/error.ts @@ -0,0 +1,43 @@ +/** + * Error handling utilities for TypeScript strict mode compatibility + */ + +/** + * Safely extract error message from unknown error type + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +/** + * Safely extract error stack from unknown error type + */ +export function getErrorStack(error: unknown): string | undefined { + if (error instanceof Error) { + return error.stack; + } + return undefined; +} + +/** + * Safely extract error name from unknown error type + */ +export function getErrorName(error: unknown): string { + if (error instanceof Error) { + return error.name; + } + return 'Error'; +} + +/** + * Check if error is a specific error type by name + */ +export function isErrorType(error: unknown, name: string): boolean { + if (error instanceof Error) { + return error.name === name; + } + return false; +} diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts index 8671f82..18542d6 100644 --- a/ui/src/app/app.routes.ts +++ b/ui/src/app/app.routes.ts @@ -84,6 +84,13 @@ export const routes: Routes = [ (m) => m.RegistriesComponent ), }, + { + path: 'tokens', + loadComponent: () => + import('./features/tokens/tokens.component').then( + (m) => m.TokensComponent + ), + }, { path: 'settings', loadComponent: () => diff --git a/ui/src/app/core/services/api.service.ts b/ui/src/app/core/services/api.service.ts index a8ccb23..f88e1c7 100644 --- a/ui/src/app/core/services/api.service.ts +++ b/ui/src/app/core/services/api.service.ts @@ -12,8 +12,14 @@ import { IDnsRecord, IRegistry, IRegistryCreate, + IRegistryToken, + ICreateTokenRequest, + ITokenCreatedResponse, ISetting, ISettings, + IPlatformService, + IPlatformResource, + TPlatformServiceType, } from '../types/api.types'; @Injectable({ providedIn: 'root' }) @@ -75,6 +81,19 @@ export class ApiService { return firstValueFrom(this.http.delete>(`/api/registries/${id}`)); } + // Registry Tokens + async getRegistryTokens(): Promise> { + return firstValueFrom(this.http.get>('/api/registry/tokens')); + } + + async createRegistryToken(data: ICreateTokenRequest): Promise> { + return firstValueFrom(this.http.post>('/api/registry/tokens', data)); + } + + async deleteRegistryToken(id: number): Promise> { + return firstValueFrom(this.http.delete>(`/api/registry/tokens/${id}`)); + } + // DNS Records async getDnsRecords(): Promise> { return firstValueFrom(this.http.get>('/api/dns')); @@ -138,4 +157,25 @@ export class ApiService { }) ); } + + // Platform Services + async getPlatformServices(): Promise> { + return firstValueFrom(this.http.get>('/api/platform-services')); + } + + async getPlatformService(type: TPlatformServiceType): Promise> { + return firstValueFrom(this.http.get>(`/api/platform-services/${type}`)); + } + + async startPlatformService(type: TPlatformServiceType): Promise> { + return firstValueFrom(this.http.post>(`/api/platform-services/${type}/start`, {})); + } + + async stopPlatformService(type: TPlatformServiceType): Promise> { + return firstValueFrom(this.http.post>(`/api/platform-services/${type}/stop`, {})); + } + + async getServicePlatformResources(serviceName: string): Promise> { + return firstValueFrom(this.http.get>(`/api/services/${serviceName}/platform-resources`)); + } } diff --git a/ui/src/app/core/types/api.types.ts b/ui/src/app/core/types/api.types.ts index d8fcb97..2d1c4e1 100644 --- a/ui/src/app/core/types/api.types.ts +++ b/ui/src/app/core/types/api.types.ts @@ -15,6 +15,16 @@ export interface ILoginResponse { user: IUser; } +// Platform Service Types (defined early for use in ISystemStatus) +export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq'; +export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed'; +export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue'; + +export interface IPlatformRequirements { + mongodb?: boolean; + s3?: boolean; +} + export interface IService { id?: number; name: string; @@ -29,10 +39,10 @@ export interface IService { updatedAt: number; useOneboxRegistry?: boolean; registryRepository?: string; - registryToken?: string; registryImageTag?: string; autoUpdateOnPush?: boolean; imageDigest?: string; + platformRequirements?: IPlatformRequirements; } export interface IServiceCreate { @@ -44,6 +54,8 @@ export interface IServiceCreate { useOneboxRegistry?: boolean; registryImageTag?: string; autoUpdateOnPush?: boolean; + enableMongoDB?: boolean; + enableS3?: boolean; } export interface IServiceUpdate { @@ -67,6 +79,7 @@ export interface ISystemStatus { dns: { configured: boolean }; ssl: { configured: boolean; certbotInstalled: boolean }; services: { total: number; running: number; stopped: number }; + platformServices: Array<{ type: TPlatformServiceType; status: TPlatformServiceStatus }>; } export interface IDomain { @@ -138,6 +151,32 @@ export interface IRegistryCreate { password: string; } +// Registry Token Types +export interface IRegistryToken { + id: number; + name: string; + type: 'global' | 'ci'; + scope: 'all' | string[]; + scopeDisplay: string; + expiresAt: number | null; + createdAt: number; + lastUsedAt: number | null; + createdBy: string; + isExpired: boolean; +} + +export interface ICreateTokenRequest { + name: string; + type: 'global' | 'ci'; + scope: 'all' | string[]; + expiresIn: '30d' | '90d' | '365d' | 'never'; +} + +export interface ITokenCreatedResponse { + token: IRegistryToken; + plainToken: string; +} + export interface ISetting { key: string; value: string; @@ -173,3 +212,27 @@ export interface IToast { message: string; duration?: number; } + +// Platform Service Interfaces +export interface IPlatformService { + type: TPlatformServiceType; + displayName: string; + resourceTypes: TPlatformResourceType[]; + status: TPlatformServiceStatus; + containerId?: string; + createdAt?: number; + updatedAt?: number; +} + +export interface IPlatformResource { + id: number; + resourceType: TPlatformResourceType; + resourceName: string; + platformService: { + type: TPlatformServiceType; + name: string; + status: TPlatformServiceStatus; + }; + envVars: Record; + createdAt: number; +} diff --git a/ui/src/app/features/registries/registries.component.ts b/ui/src/app/features/registries/registries.component.ts index b10935e..5349003 100644 --- a/ui/src/app/features/registries/registries.component.ts +++ b/ui/src/app/features/registries/registries.component.ts @@ -1,5 +1,6 @@ import { Component, inject, signal, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; import { ApiService } from '../../core/services/api.service'; import { ToastService } from '../../core/services/toast.service'; import { IRegistry, IRegistryCreate } from '../../core/types/api.types'; @@ -13,6 +14,7 @@ import { import { ButtonComponent } from '../../ui/button/button.component'; import { InputComponent } from '../../ui/input/input.component'; import { LabelComponent } from '../../ui/label/label.component'; +import { BadgeComponent } from '../../ui/badge/badge.component'; import { TableComponent, TableHeaderComponent, @@ -35,6 +37,7 @@ import { standalone: true, imports: [ FormsModule, + RouterLink, CardComponent, CardHeaderComponent, CardTitleComponent, @@ -43,6 +46,7 @@ import { ButtonComponent, InputComponent, LabelComponent, + BadgeComponent, TableComponent, TableHeaderComponent, TableBodyComponent, @@ -59,42 +63,75 @@ import { template: `
-

Docker Registries

-

Manage Docker registry credentials

+

Registries

+

Manage container image registries

- - - - Add Registry - Add credentials for a private Docker registry + + + +
+
+ + + + Onebox Registry (Built-in) + Default +
+ Built-in container registry for your services +
-
-
- - +
+
+
Status
+
+ + Running +
-
- - +
+
Registry URL
+
localhost:3000/v2
-
- - +
+
Authentication
+
-
- +
+ +
+

Quick Start

+

+ To push images to the Onebox registry, use a CI or Global token: +

+
+
# Login to the registry
+
docker login localhost:3000 -u onebox -p YOUR_TOKEN
+
# Tag and push your image
+
docker tag myapp localhost:3000/myservice:latest
+
docker push localhost:3000/myservice:latest
- +
- + +
+
+

External Registries

+

Add credentials for private Docker registries

+
+ +
+ - - Registered Registries - @if (loading() && registries().length === 0) {
@@ -104,15 +141,24 @@ import {
} @else if (registries().length === 0) {
-

No registries configured

+ + + +

No external registries

+

+ Add credentials for Docker Hub, GitHub Container Registry, or other private registries. +

+
} @else { - URL + Registry URL Username - Created + Added Actions @@ -136,6 +182,35 @@ import {
+ + + + Add External Registry + + Add credentials for a private Docker registry + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + +
+ + Delete Registry @@ -156,6 +231,7 @@ export class RegistriesComponent implements OnInit { registries = signal([]); loading = signal(false); + addDialogOpen = signal(false); deleteDialogOpen = signal(false); registryToDelete = signal(null); @@ -191,6 +267,7 @@ export class RegistriesComponent implements OnInit { if (response.success) { this.toast.success('Registry added'); this.form = { url: '', username: '', password: '' }; + this.addDialogOpen.set(false); this.loadRegistries(); } else { this.toast.error(response.error || 'Failed to add registry'); diff --git a/ui/src/app/features/services/service-create.component.ts b/ui/src/app/features/services/service-create.component.ts index 26460d5..0262ade 100644 --- a/ui/src/app/features/services/service-create.component.ts +++ b/ui/src/app/features/services/service-create.component.ts @@ -186,6 +186,48 @@ interface EnvVar { + +
+
+

Platform Services

+

Enable managed infrastructure for your service

+
+ +
+
+ +
+ +

A dedicated database will be created and credentials injected as MONGODB_URI

+
+
+ +
+ +
+ +

A dedicated bucket will be created and credentials injected as S3_* and AWS_* env vars

+
+
+
+ + @if (form.enableMongoDB || form.enableS3) { + + + Platform services will be auto-deployed if not already running. Credentials are automatically injected as environment variables. + + + } +
+ + +
@@ -257,6 +299,8 @@ export class ServiceCreateComponent implements OnInit { useOneboxRegistry: false, registryImageTag: 'latest', autoUpdateOnPush: false, + enableMongoDB: false, + enableS3: false, }; envVars = signal([]); diff --git a/ui/src/app/features/services/service-detail.component.ts b/ui/src/app/features/services/service-detail.component.ts index bf69c4e..7ed4323 100644 --- a/ui/src/app/features/services/service-detail.component.ts +++ b/ui/src/app/features/services/service-detail.component.ts @@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms'; import { ApiService } from '../../core/services/api.service'; import { ToastService } from '../../core/services/toast.service'; import { LogStreamService } from '../../core/services/log-stream.service'; -import { IService, IServiceUpdate } from '../../core/types/api.types'; +import { IService, IServiceUpdate, IPlatformResource } from '../../core/types/api.types'; import { CardComponent, CardHeaderComponent, @@ -209,6 +209,61 @@ import { } + + @if (service()!.platformRequirements || platformResources().length > 0) { + + + Platform Resources + Managed infrastructure provisioned for this service + + + @if (platformResources().length > 0) { + @for (resource of platformResources(); track resource.id) { +
+
+
+ @if (resource.resourceType === 'database') { + + + + } @else if (resource.resourceType === 'bucket') { + + + + } + {{ resource.resourceName }} +
+ + {{ resource.platformService.status }} + +
+
+ {{ resource.platformService.type === 'mongodb' ? 'MongoDB Database' : 'S3 Bucket (MinIO)' }} +
+
+

Injected Environment Variables

+
+ @for (key of getEnvKeys(resource.envVars); track key) { + {{ key }} + } +
+
+
+ } + } @else if (service()!.platformRequirements) { +
+ @if (service()!.platformRequirements!.mongodb) { +

MongoDB database pending provisioning...

+ } + @if (service()!.platformRequirements!.s3) { +

S3 bucket pending provisioning...

+ } +
+ } +
+
+ } + @if (service()!.useOneboxRegistry) { @@ -225,21 +280,11 @@ import {
Tag
{{ service()!.registryImageTag || 'latest' }}
- @if (service()!.registryToken) { -
-
Push Token
-
- - -
-
- } +
Auto-update on push
{{ service()!.autoUpdateOnPush ? 'Enabled' : 'Disabled' }}
@@ -346,6 +391,7 @@ export class ServiceDetailComponent implements OnInit, OnDestroy { @ViewChild('logContainer') logContainer!: ElementRef; service = signal(null); + platformResources = signal([]); loading = signal(false); actionLoading = signal(false); editMode = signal(false); @@ -389,6 +435,11 @@ export class ServiceDetailComponent implements OnInit, OnDestroy { port: response.data.port, domain: response.data.domain, }; + + // Load platform resources if service has platform requirements + if (response.data.platformRequirements) { + this.loadPlatformResources(name); + } } else { this.toast.error(response.error || 'Service not found'); this.router.navigate(['/services']); @@ -400,6 +451,17 @@ export class ServiceDetailComponent implements OnInit, OnDestroy { } } + async loadPlatformResources(name: string): Promise { + try { + const response = await this.api.getServicePlatformResources(name); + if (response.success && response.data) { + this.platformResources.set(response.data); + } + } catch { + // Silent fail - platform resources are optional + } + } + startLogStream(): void { const name = this.service()?.name; if (name) { @@ -546,12 +608,4 @@ export class ServiceDetailComponent implements OnInit, OnDestroy { this.deleteDialogOpen.set(false); } } - - copyToken(): void { - const token = this.service()?.registryToken; - if (token) { - navigator.clipboard.writeText(token); - this.toast.success('Token copied to clipboard'); - } - } } diff --git a/ui/src/app/features/tokens/tokens.component.ts b/ui/src/app/features/tokens/tokens.component.ts new file mode 100644 index 0000000..5129e9b --- /dev/null +++ b/ui/src/app/features/tokens/tokens.component.ts @@ -0,0 +1,509 @@ +import { Component, inject, signal, OnInit, computed } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { ApiService } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; +import { IRegistryToken, ICreateTokenRequest, IService } from '../../core/types/api.types'; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardDescriptionComponent, + CardContentComponent, +} from '../../ui/card/card.component'; +import { ButtonComponent } from '../../ui/button/button.component'; +import { InputComponent } from '../../ui/input/input.component'; +import { LabelComponent } from '../../ui/label/label.component'; +import { BadgeComponent } from '../../ui/badge/badge.component'; +import { + TableComponent, + TableHeaderComponent, + TableBodyComponent, + TableRowComponent, + TableHeadComponent, + TableCellComponent, +} from '../../ui/table/table.component'; +import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; +import { + DialogComponent, + DialogHeaderComponent, + DialogTitleComponent, + DialogDescriptionComponent, + DialogFooterComponent, +} from '../../ui/dialog/dialog.component'; + +@Component({ + selector: 'app-tokens', + standalone: true, + imports: [ + FormsModule, + RouterLink, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardDescriptionComponent, + CardContentComponent, + ButtonComponent, + InputComponent, + LabelComponent, + BadgeComponent, + TableComponent, + TableHeaderComponent, + TableBodyComponent, + TableRowComponent, + TableHeadComponent, + TableCellComponent, + SkeletonComponent, + DialogComponent, + DialogHeaderComponent, + DialogTitleComponent, + DialogDescriptionComponent, + DialogFooterComponent, + ], + template: ` +
+
+
+

Registry Tokens

+

Manage authentication tokens for the Onebox registry

+
+ +
+ + + + + Global Tokens + Tokens that can push images to multiple services + + + @if (loading() && globalTokens().length === 0) { +
+ @for (_ of [1,2]; track $index) { + + } +
+ } @else if (globalTokens().length === 0) { +
+

No global tokens created

+ +
+ } @else { + + + + Name + Scope + Expires + Last Used + Created By + Actions + + + + @for (token of globalTokens(); track token.id) { + + {{ token.name }} + + {{ token.scopeDisplay }} + + + @if (token.isExpired) { + Expired + } @else if (token.expiresAt) { + {{ formatExpiry(token.expiresAt) }} + } @else { + Never + } + + + @if (token.lastUsedAt) { + {{ formatRelativeTime(token.lastUsedAt) }} + } @else { + Never + } + + {{ token.createdBy }} + + + + + } + + + } +
+
+ + + + + CI Tokens (Service-specific) + Tokens tied to individual services for CI/CD pipelines + + + @if (loading() && ciTokens().length === 0) { +
+ @for (_ of [1,2]; track $index) { + + } +
+ } @else if (ciTokens().length === 0) { +
+

No CI tokens created

+ +
+ } @else { + + + + Name + Service + Expires + Last Used + Created By + Actions + + + + @for (token of ciTokens(); track token.id) { + + {{ token.name }} + {{ token.scopeDisplay }} + + @if (token.isExpired) { + Expired + } @else if (token.expiresAt) { + {{ formatExpiry(token.expiresAt) }} + } @else { + Never + } + + + @if (token.lastUsedAt) { + {{ formatRelativeTime(token.lastUsedAt) }} + } @else { + Never + } + + {{ token.createdBy }} + + + + + } + + + } +
+
+
+ + + + + Create Registry Token + + Create a new token for pushing images to the Onebox registry + + +
+
+ + +
+
+ +
+ + +
+
+ @if (createForm.type === 'global') { +
+ +
+ + +
+ @if (!scopeAll) { +
+ @for (service of services(); track service.name) { + + } + @if (services().length === 0) { +

No services available

+ } +
+ } +
+ } @else { +
+ + +
+ } +
+ + +
+
+ + + + +
+ + + + + Token Created + + Copy this token now. You won't be able to see it again! + + +
+
+ {{ createdPlainToken() }} +
+
+ + + + +
+ + + + + Delete Token + + Are you sure you want to delete "{{ tokenToDelete()?.name }}"? This action cannot be undone. + + + + + + + + `, +}) +export class TokensComponent implements OnInit { + private api = inject(ApiService); + private toast = inject(ToastService); + + tokens = signal([]); + services = signal([]); + loading = signal(false); + creating = signal(false); + + createDialogOpen = signal(false); + tokenCreatedDialogOpen = signal(false); + deleteDialogOpen = signal(false); + tokenToDelete = signal(null); + createdPlainToken = signal(''); + + // Form state + createForm: ICreateTokenRequest = { + name: '', + type: 'global', + scope: 'all', + expiresIn: '90d', + }; + scopeAll = true; + selectedServices = signal([]); + selectedSingleService = ''; + + // Computed signals for filtered tokens + globalTokens = computed(() => this.tokens().filter(t => t.type === 'global')); + ciTokens = computed(() => this.tokens().filter(t => t.type === 'ci')); + + ngOnInit(): void { + this.loadTokens(); + this.loadServices(); + } + + async loadTokens(): Promise { + this.loading.set(true); + try { + const response = await this.api.getRegistryTokens(); + if (response.success && response.data) { + this.tokens.set(response.data); + } + } catch { + this.toast.error('Failed to load tokens'); + } finally { + this.loading.set(false); + } + } + + async loadServices(): Promise { + try { + const response = await this.api.getServices(); + if (response.success && response.data) { + this.services.set(response.data); + } + } catch { + // Silent fail - services list is optional + } + } + + openCreateDialog(type?: 'global' | 'ci'): void { + this.createForm = { + name: '', + type: type || 'global', + scope: 'all', + expiresIn: '90d', + }; + this.scopeAll = true; + this.selectedServices.set([]); + this.selectedSingleService = ''; + this.createDialogOpen.set(true); + } + + toggleService(serviceName: string): void { + const current = this.selectedServices(); + if (current.includes(serviceName)) { + this.selectedServices.set(current.filter(s => s !== serviceName)); + } else { + this.selectedServices.set([...current, serviceName]); + } + } + + async createToken(): Promise { + if (!this.createForm.name) { + this.toast.error('Please enter a token name'); + return; + } + + // Build scope based on type + let scope: 'all' | string[]; + if (this.createForm.type === 'global') { + if (this.scopeAll) { + scope = 'all'; + } else { + if (this.selectedServices().length === 0) { + this.toast.error('Please select at least one service'); + return; + } + scope = this.selectedServices(); + } + } else { + if (!this.selectedSingleService) { + this.toast.error('Please select a service'); + return; + } + scope = [this.selectedSingleService]; + } + + this.creating.set(true); + try { + const response = await this.api.createRegistryToken({ + ...this.createForm, + scope, + }); + if (response.success && response.data) { + this.createdPlainToken.set(response.data.plainToken); + this.createDialogOpen.set(false); + this.tokenCreatedDialogOpen.set(true); + this.loadTokens(); + } else { + this.toast.error(response.error || 'Failed to create token'); + } + } catch { + this.toast.error('Failed to create token'); + } finally { + this.creating.set(false); + } + } + + copyToken(): void { + navigator.clipboard.writeText(this.createdPlainToken()); + this.toast.success('Token copied to clipboard'); + } + + confirmDelete(token: IRegistryToken): void { + this.tokenToDelete.set(token); + this.deleteDialogOpen.set(true); + } + + async deleteToken(): Promise { + const token = this.tokenToDelete(); + if (!token) return; + + try { + const response = await this.api.deleteRegistryToken(token.id); + if (response.success) { + this.toast.success('Token deleted'); + this.loadTokens(); + } else { + this.toast.error(response.error || 'Failed to delete token'); + } + } catch { + this.toast.error('Failed to delete token'); + } finally { + this.deleteDialogOpen.set(false); + this.tokenToDelete.set(null); + } + } + + formatExpiry(timestamp: number): string { + const days = Math.ceil((timestamp - Date.now()) / (1000 * 60 * 60 * 24)); + if (days < 0) return 'Expired'; + if (days === 0) return 'Today'; + if (days === 1) return 'Tomorrow'; + if (days < 30) return `${days} days`; + return new Date(timestamp).toLocaleDateString(); + } + + formatRelativeTime(timestamp: number): string { + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / (1000 * 60)); + const hours = Math.floor(diff / (1000 * 60 * 60)); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (minutes < 1) return 'Just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 30) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(); + } +} diff --git a/ui/src/app/shared/components/layout/layout.component.ts b/ui/src/app/shared/components/layout/layout.component.ts index 26d6eee..af5f60c 100644 --- a/ui/src/app/shared/components/layout/layout.component.ts +++ b/ui/src/app/shared/components/layout/layout.component.ts @@ -119,6 +119,7 @@ export class LayoutComponent { { label: 'Dashboard', path: '/dashboard', icon: 'home' }, { label: 'Services', path: '/services', icon: 'server' }, { label: 'Registries', path: '/registries', icon: 'database' }, + { label: 'Tokens', path: '/tokens', icon: 'key' }, { label: 'DNS', path: '/dns', icon: 'globe' }, { label: 'Domains', path: '/domains', icon: 'link' }, { label: 'Settings', path: '/settings', icon: 'settings' },