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.
This commit is contained in:
2025-11-25 04:20:19 +00:00
parent 9aa6906ca5
commit 8ebd677478
28 changed files with 3462 additions and 490 deletions

View File

@@ -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)}`);
}
}

View File

@@ -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)}`);
}
}

View File

@@ -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<typeof plugins.sqlite.DB> | 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<OldSslCert>('SELECT * FROM ssl_certificates');
const now = Date.now();
const domainMap = new Map<string, number>();
// 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<number, unknown>)[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<number, unknown>)[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<number, unknown>)[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<number, unknown>)[2]),
String(cert.key_path ?? (cert as Record<number, unknown>)[3]),
String(cert.full_chain_path ?? (cert as Record<number, unknown>)[4]),
Number(cert.expiry_date ?? (cert as Record<number, unknown>)[5]),
String(cert.issuer ?? (cert as Record<number, unknown>)[6]),
1, // Assume valid
Number(cert.created_at ?? cert[7]),
Number(cert.updated_at ?? cert[8])
Number(cert.created_at ?? (cert as Record<number, unknown>)[7]),
Number(cert.updated_at ?? (cert as Record<number, unknown>)[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<number, unknown>)[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<T = unknown[]>(sql: string, params: unknown[] = []): T[] {
query<T = Record<string, unknown>>(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, 'id'>): 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, 'id'>): 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<IPlatformService>): 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, 'id'>): 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),
};
}
}

View File

@@ -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<string> {
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<string, Record<string, never>> = {};
const portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> = {};
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;
}
}
}

203
ts/classes/encryption.ts Normal file
View File

@@ -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<void> {
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<CryptoKey> {
// 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<string, string>): Promise<string> {
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<Record<string, string>> {
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();

View File

@@ -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<Response> {
try {
const platformServices = this.oneboxRef.platformServices.getAllPlatformServices();
const providers = this.oneboxRef.platformServices.getAllProviders();
// Build response with provider info
const result = providers.map((provider) => {
const service = platformServices.find((s) => s.type === provider.type);
return {
type: provider.type,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: service?.status || 'not-deployed',
containerId: service?.containerId,
createdAt: service?.createdAt,
updatedAt: service?.updatedAt,
};
});
return this.jsonResponse({ success: true, data: result });
} catch (error) {
logger.error(`Failed to list platform services: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to list platform services',
}, 500);
}
}
private async handleGetPlatformServiceRequest(type: string): Promise<Response> {
try {
const provider = this.oneboxRef.platformServices.getProvider(type);
if (!provider) {
return this.jsonResponse({
success: false,
error: `Unknown platform service type: ${type}`,
}, 404);
}
const service = this.oneboxRef.database.getPlatformServiceByType(type);
// Get resource count
const allResources = service?.id
? this.oneboxRef.database.getPlatformResourcesByPlatformService(service.id)
: [];
return this.jsonResponse({
success: true,
data: {
type: provider.type,
displayName: provider.displayName,
resourceTypes: provider.resourceTypes,
status: service?.status || 'not-deployed',
containerId: service?.containerId,
config: provider.getDefaultConfig(),
resourceCount: allResources.length,
createdAt: service?.createdAt,
updatedAt: service?.updatedAt,
},
});
} catch (error) {
logger.error(`Failed to get platform service ${type}: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to get platform service',
}, 500);
}
}
private async handleStartPlatformServiceRequest(type: string): Promise<Response> {
try {
const provider = this.oneboxRef.platformServices.getProvider(type);
if (!provider) {
return this.jsonResponse({
success: false,
error: `Unknown platform service type: ${type}`,
}, 404);
}
logger.info(`Starting platform service: ${type}`);
const service = await this.oneboxRef.platformServices.ensureRunning(type);
return this.jsonResponse({
success: true,
message: `Platform service ${provider.displayName} started`,
data: {
type: service.type,
status: service.status,
containerId: service.containerId,
},
});
} catch (error) {
logger.error(`Failed to start platform service ${type}: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to start platform service',
}, 500);
}
}
private async handleStopPlatformServiceRequest(type: string): Promise<Response> {
try {
const provider = this.oneboxRef.platformServices.getProvider(type);
if (!provider) {
return this.jsonResponse({
success: false,
error: `Unknown platform service type: ${type}`,
}, 404);
}
logger.info(`Stopping platform service: ${type}`);
await this.oneboxRef.platformServices.stopPlatformService(type);
return this.jsonResponse({
success: true,
message: `Platform service ${provider.displayName} stopped`,
});
} catch (error) {
logger.error(`Failed to stop platform service ${type}: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to stop platform service',
}, 500);
}
}
private async handleGetServicePlatformResourcesRequest(serviceName: string): Promise<Response> {
try {
const service = this.oneboxRef.services.getService(serviceName);
if (!service) {
return this.jsonResponse({
success: false,
error: 'Service not found',
}, 404);
}
const resources = await this.oneboxRef.services.getServicePlatformResources(serviceName);
// Format resources for API response (mask sensitive credentials)
const formattedResources = resources.map((r) => ({
id: r.resource.id,
resourceType: r.resource.resourceType,
resourceName: r.resource.resourceName,
platformService: {
type: r.platformService.type,
name: r.platformService.name,
status: r.platformService.status,
},
// Include env var mappings (show keys, not values)
envVars: Object.keys(r.credentials).reduce((acc, key) => {
// Mask sensitive values
const value = r.credentials[key];
if (key.toLowerCase().includes('password') || key.toLowerCase().includes('secret')) {
acc[key] = '********';
} else {
acc[key] = value;
}
return acc;
}, {} as Record<string, string>),
createdAt: r.resource.createdAt,
}));
return this.jsonResponse({
success: true,
data: formattedResources,
});
} catch (error) {
logger.error(`Failed to get platform resources for service ${serviceName}: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to get platform resources',
}, 500);
}
}
// ============ Registry Endpoints ============
private async handleGetRegistryTagsRequest(serviceName: string): Promise<Response> {
@@ -1047,51 +1243,206 @@ export class OneboxHttpServer {
}
}
private async handleGetRegistryTokenRequest(serviceName: string): Promise<Response> {
// ============ Registry Token Management Endpoints ============
private async handleListRegistryTokensRequest(req: Request): Promise<Response> {
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<Response> {
try {
const body = await req.json() as ICreateRegistryTokenRequest;
// Validate request
if (!body.name || !body.type || !body.scope || !body.expiresIn) {
return this.jsonResponse({
success: false,
error: '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<string, number> = {
'30d': 30,
'90d': 90,
'365d': 365,
};
const days = daysMap[body.expiresIn];
if (days) {
expiresAt = now + (days * 24 * 60 * 60 * 1000);
}
}
// Generate token (random 32 bytes as hex)
const plainToken = crypto.randomUUID() + crypto.randomUUID();
// Hash the token for storage (using simple hash for now)
const encoder = new TextEncoder();
const data = encoder.encode(plainToken);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const tokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
// Get username from auth token
const authHeader = req.headers.get('Authorization');
let createdBy = 'system';
if (authHeader && authHeader.startsWith('Bearer ')) {
try {
const decoded = atob(authHeader.slice(7));
createdBy = decoded.split(':')[0];
} catch {
// Keep default
}
}
// Create token in database
const token = this.oneboxRef.database.createRegistryToken({
name: body.name,
tokenHash,
type: body.type,
scope: body.scope,
expiresAt,
createdAt: now,
lastUsedAt: null,
createdBy,
});
// Build view response
let scopeDisplay: string;
if (token.scope === 'all') {
scopeDisplay = 'All services';
} else if (Array.isArray(token.scope)) {
scopeDisplay = token.scope.length === 1
? token.scope[0]
: `${token.scope.length} services`;
} else {
scopeDisplay = 'Unknown';
}
const tokenView: IRegistryTokenView = {
id: token.id!,
name: token.name,
type: token.type,
scope: token.scope,
scopeDisplay,
expiresAt: token.expiresAt,
createdAt: token.createdAt,
lastUsedAt: token.lastUsedAt,
createdBy: token.createdBy,
isExpired: false,
};
return this.jsonResponse({
success: true,
data: {
token: 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<Response> {
try {
// Check if token exists
const token = this.oneboxRef.database.getRegistryTokenById(tokenId);
if (!token) {
return this.jsonResponse({
success: false,
error: 'Token not found',
}, 404);
}
// Delete the token
this.oneboxRef.database.deleteRegistryToken(tokenId);
return this.jsonResponse({
success: true,
message: 'Token deleted successfully',
});
} catch (error) {
logger.error(`Failed to delete registry token ${tokenId}: ${error.message}`);
return this.jsonResponse({
success: false,
error: error.message || 'Failed to delete registry token',
}, 500);
}
}

View File

@@ -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}`);

View File

@@ -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';

View File

@@ -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<TPlatformServiceType, IPlatformServiceProvider>();
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
}
/**
* Initialize the platform services manager
*/
async init(): Promise<void> {
// 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<IPlatformService> {
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<boolean> {
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<void> {
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<Record<string, string>> {
const requirements = service.platformRequirements;
if (!requirements) {
return {};
}
const allEnvVars: Record<string, string> = {};
// 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<void> {
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<Record<string, string>> {
const resources = this.oneboxRef.database.getPlatformResourcesByService(serviceId);
const allEnvVars: Record<string, string> = {};
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<Array<{
resource: IPlatformResource;
platformService: IPlatformService;
credentials: Record<string, string>;
}>> {
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;
}
}

View File

@@ -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<string>;
/**
* Stop the platform service container
*/
stopContainer(containerId: string): Promise<void>;
/**
* Check if the platform service is healthy and ready to accept connections
*/
healthCheck(): Promise<boolean>;
/**
* 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<IProvisionedResource>;
/**
* Deprovision a resource (e.g., drop database, delete bucket)
* @param resource The resource to deprovision
*/
deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void>;
/**
* 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<string>;
abstract stopContainer(containerId: string): Promise<void>;
abstract healthCheck(): Promise<boolean>;
abstract provisionResource(userService: IService): Promise<IProvisionedResource>;
abstract deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void>;
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}`;
}
}

View File

@@ -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<string> {
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<void> {
logger.info(`Stopping MinIO container ${containerId}...`);
await this.oneboxRef.docker.stopContainer(containerId);
logger.success('MinIO container stopped');
}
async healthCheck(): Promise<boolean> {
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<IProvisionedResource> {
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<string, string> = {
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<string, string> = {};
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<string, string>): Promise<void> {
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;
}
}
}

View File

@@ -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<string> {
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<void> {
logger.info(`Stopping MongoDB container ${containerId}...`);
await this.oneboxRef.docker.stopContainer(containerId);
logger.success('MongoDB container stopped');
}
async healthCheck(): Promise<boolean> {
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<IProvisionedResource> {
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<string, string> = {
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<string, string> = {};
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<string, string>): Promise<void> {
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();
}
}
}

View File

@@ -107,30 +107,6 @@ export class RegistryManager {
}
}
/**
* Create a push/pull token for a service
*/
async createServiceToken(serviceName: string): Promise<string> {
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
*/

View File

@@ -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<string, string> = {};
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
*/

View File

@@ -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;
}
}