This commit is contained in:
2025-11-18 00:03:24 +00:00
parent 246a6073e0
commit 8f538ab9c0
50 changed files with 12836 additions and 531 deletions

209
ts/classes/apiclient.ts Normal file
View File

@@ -0,0 +1,209 @@
/**
* API Client for communicating with Onebox daemon
*
* Provides methods for CLI commands to interact with running daemon via HTTP API
*/
import type {
IService,
IRegistry,
IDnsRecord,
ISslCertificate,
IServiceDeployOptions,
} from '../types.ts';
export class OneboxApiClient {
private baseUrl: string;
private token?: string;
constructor(port = 3000) {
this.baseUrl = `http://localhost:${port}`;
}
/**
* Check if daemon is reachable
*/
async isReachable(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/api/status`, {
signal: AbortSignal.timeout(5000), // 5 second timeout
});
return response.ok;
} catch {
return false;
}
}
// ============ Service Operations ============
async deployService(config: IServiceDeployOptions): Promise<IService> {
return await this.request<IService>('POST', '/api/services', config);
}
async removeService(name: string): Promise<void> {
await this.request('DELETE', `/api/services/${name}`);
}
async startService(name: string): Promise<void> {
await this.request('POST', `/api/services/${name}/start`);
}
async stopService(name: string): Promise<void> {
await this.request('POST', `/api/services/${name}/stop`);
}
async restartService(name: string): Promise<void> {
await this.request('POST', `/api/services/${name}/restart`);
}
async listServices(): Promise<IService[]> {
return await this.request<IService[]>('GET', '/api/services');
}
async getServiceLogs(name: string, limit = 1000): Promise<string[]> {
const result = await this.request<{ logs: string[] }>(
'GET',
`/api/services/${name}/logs?limit=${limit}`
);
return result.logs;
}
// ============ Registry Operations ============
async addRegistry(url: string, username: string, password: string): Promise<void> {
await this.request('POST', '/api/registries', { url, username, password });
}
async removeRegistry(url: string): Promise<void> {
await this.request('DELETE', `/api/registries/${encodeURIComponent(url)}`);
}
async listRegistries(): Promise<IRegistry[]> {
return await this.request<IRegistry[]>('GET', '/api/registries');
}
// ============ DNS Operations ============
async addDnsRecord(domain: string): Promise<void> {
await this.request('POST', '/api/dns', { domain });
}
async removeDnsRecord(domain: string): Promise<void> {
await this.request('DELETE', `/api/dns/${domain}`);
}
async listDnsRecords(): Promise<IDnsRecord[]> {
return await this.request<IDnsRecord[]>('GET', '/api/dns');
}
async syncDns(): Promise<void> {
await this.request('POST', '/api/dns/sync');
}
// ============ SSL Operations ============
async renewCertificate(domain?: string): Promise<void> {
const path = domain ? `/api/ssl/renew/${domain}` : '/api/ssl/renew';
await this.request('POST', path);
}
async listCertificates(): Promise<ISslCertificate[]> {
return await this.request<ISslCertificate[]>('GET', '/api/ssl');
}
async forceRenewCertificate(domain: string): Promise<void> {
await this.request('POST', `/api/ssl/renew/${domain}?force=true`);
}
// ============ Nginx Operations ============
async reloadNginx(): Promise<void> {
await this.request('POST', '/api/nginx/reload');
}
async testNginx(): Promise<{ success: boolean; output: string }> {
return await this.request('POST', '/api/nginx/test');
}
async getNginxStatus(): Promise<{ status: string }> {
return await this.request('GET', '/api/nginx/status');
}
// ============ Config Operations ============
async getSettings(): Promise<Record<string, string>> {
return await this.request<Record<string, string>>('GET', '/api/config');
}
async setSetting(key: string, value: string): Promise<void> {
await this.request('POST', '/api/config', { key, value });
}
// ============ System Operations ============
async getStatus(): Promise<{
services: { total: number; running: number; stopped: number };
uptime: number;
}> {
return await this.request('GET', '/api/status');
}
// ============ Helper Methods ============
/**
* Make HTTP request to daemon
*/
private async request<T = unknown>(
method: string,
path: string,
body?: unknown
): Promise<T> {
const url = `${this.baseUrl}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const options: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000), // 30 second timeout
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
// For DELETE and some POST requests, there might be no content
if (response.status === 204 || response.headers.get('content-length') === '0') {
return undefined as T;
}
return await response.json();
} catch (error) {
if (error.name === 'TimeoutError') {
throw new Error('Request timed out. Daemon might be unresponsive.');
}
throw error;
}
}
/**
* Set authentication token
*/
setToken(token: string): void {
this.token = token;
}
}

425
ts/classes/daemon.ts Normal file
View File

@@ -0,0 +1,425 @@
/**
* Daemon Manager for Onebox
*
* Handles background monitoring, metrics collection, and automatic tasks
*/
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { projectInfo } from '../info.ts';
import type { Onebox } from './onebox.ts';
// PID file constants
const PID_FILE_PATH = '/var/run/onebox/onebox.pid';
const PID_DIR = '/var/run/onebox';
const FALLBACK_PID_DIR = `${Deno.env.get('HOME')}/.onebox`;
const FALLBACK_PID_FILE = `${FALLBACK_PID_DIR}/onebox.pid`;
export class OneboxDaemon {
private oneboxRef: Onebox;
private smartdaemon: plugins.smartdaemon.SmartDaemon | null = null;
private running = false;
private monitoringInterval: number | null = null;
private metricsInterval = 60000; // 1 minute
private pidFilePath: string = PID_FILE_PATH;
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
}
/**
* Load settings from database (call after database init)
*/
private loadSettings(): void {
try {
const customInterval = this.oneboxRef.database.getSetting('metricsInterval');
if (customInterval) {
this.metricsInterval = parseInt(customInterval, 10);
}
} catch {
// Database not initialized yet - use defaults
}
}
/**
* Install systemd service
*/
async installService(): Promise<void> {
try {
logger.info('Installing Onebox daemon service...');
// Initialize smartdaemon if needed
if (!this.smartdaemon) {
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
}
// Get installation directory
const execPath = Deno.execPath();
const service = await this.smartdaemon.addService({
name: 'onebox',
version: projectInfo.version,
command: `${execPath} run --allow-all ${Deno.cwd()}/mod.ts daemon start`,
description: 'Onebox - Self-hosted container platform',
workingDir: Deno.cwd(),
});
await service.save();
await service.enable();
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}`);
throw error;
}
}
/**
* Uninstall systemd service
*/
async uninstallService(): Promise<void> {
try {
logger.info('Uninstalling Onebox daemon service...');
// Initialize smartdaemon if needed
if (!this.smartdaemon) {
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
}
const service = await this.smartdaemon.getService('onebox');
if (service) {
await service.stop();
await service.disable();
await service.delete();
}
logger.success('Onebox daemon service uninstalled');
} catch (error) {
logger.error(`Failed to uninstall daemon service: ${error.message}`);
throw error;
}
}
/**
* Start daemon mode (background monitoring)
*/
async start(): Promise<void> {
try {
if (this.running) {
logger.warn('Daemon already running');
return;
}
logger.info('Starting Onebox daemon...');
this.running = true;
// Load settings from database
this.loadSettings();
// Write PID file
await this.writePidFile();
// Start monitoring loop
this.startMonitoring();
// Start HTTP server
const httpPort = parseInt(this.oneboxRef.database.getSetting('httpPort') || '3000', 10);
await this.oneboxRef.httpServer.start(httpPort);
logger.success('Onebox daemon started');
logger.info(`Web UI available at http://localhost:${httpPort}`);
// Keep process alive
await this.keepAlive();
} catch (error) {
logger.error(`Failed to start daemon: ${error.message}`);
this.running = false;
throw error;
}
}
/**
* Stop daemon mode
*/
async stop(): Promise<void> {
try {
if (!this.running) {
return;
}
logger.info('Stopping Onebox daemon...');
this.running = false;
// Stop monitoring
this.stopMonitoring();
// Stop HTTP server
await this.oneboxRef.httpServer.stop();
// Remove PID file
await this.removePidFile();
logger.success('Onebox daemon stopped');
} catch (error) {
logger.error(`Failed to stop daemon: ${error.message}`);
throw error;
}
}
/**
* Start monitoring loop
*/
public startMonitoring(): void {
logger.info('Starting monitoring loop...');
this.monitoringInterval = setInterval(async () => {
await this.monitoringTick();
}, this.metricsInterval);
// Run first tick immediately
this.monitoringTick();
}
/**
* Stop monitoring loop
*/
public stopMonitoring(): void {
if (this.monitoringInterval !== null) {
clearInterval(this.monitoringInterval);
this.monitoringInterval = null;
logger.debug('Monitoring loop stopped');
}
}
/**
* Single monitoring tick
*/
private async monitoringTick(): Promise<void> {
try {
logger.debug('Running monitoring tick...');
// Collect metrics for all services
await this.collectMetrics();
// Sync service statuses
await this.oneboxRef.services.syncAllServiceStatuses();
// Check SSL certificate expiration
await this.checkSSLExpiration();
// Check service health (TODO: implement health checks)
logger.debug('Monitoring tick complete');
} catch (error) {
logger.error(`Monitoring tick failed: ${error.message}`);
}
}
/**
* Collect metrics for all services
*/
private async collectMetrics(): Promise<void> {
try {
const services = this.oneboxRef.services.listServices();
for (const service of services) {
if (service.status === 'running' && service.containerID) {
try {
const stats = await this.oneboxRef.docker.getContainerStats(service.containerID);
if (stats) {
this.oneboxRef.database.addMetric({
serviceId: service.id!,
timestamp: Date.now(),
cpuPercent: stats.cpuPercent,
memoryUsed: stats.memoryUsed,
memoryLimit: stats.memoryLimit,
networkRxBytes: stats.networkRx,
networkTxBytes: stats.networkTx,
});
}
} catch (error) {
logger.debug(`Failed to collect metrics for ${service.name}: ${error.message}`);
}
}
}
} catch (error) {
logger.error(`Failed to collect metrics: ${error.message}`);
}
}
/**
* Check SSL certificate expiration
*/
private async checkSSLExpiration(): Promise<void> {
try {
if (!this.oneboxRef.ssl.isConfigured()) {
return;
}
await this.oneboxRef.ssl.renewExpiring();
} catch (error) {
logger.error(`Failed to check SSL expiration: ${error.message}`);
}
}
/**
* Keep process alive
*/
private async keepAlive(): Promise<void> {
// Set up signal handlers
const signalHandler = () => {
logger.info('Received shutdown signal');
this.stop().then(() => {
Deno.exit(0);
});
};
Deno.addSignalListener('SIGINT', signalHandler);
Deno.addSignalListener('SIGTERM', signalHandler);
// Keep event loop alive
while (this.running) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
/**
* Get daemon status
*/
isRunning(): boolean {
return this.running;
}
/**
* Write PID file
*/
private async writePidFile(): Promise<void> {
try {
// Try primary location first
try {
await Deno.mkdir(PID_DIR, { recursive: true });
await Deno.writeTextFile(PID_FILE_PATH, Deno.pid.toString());
this.pidFilePath = PID_FILE_PATH;
logger.debug(`PID file written: ${PID_FILE_PATH}`);
return;
} catch (error) {
// Permission denied - try fallback location
logger.debug(`Cannot write to ${PID_DIR}, using fallback location`);
}
// Fallback to user directory
await Deno.mkdir(FALLBACK_PID_DIR, { recursive: true });
await Deno.writeTextFile(FALLBACK_PID_FILE, Deno.pid.toString());
this.pidFilePath = FALLBACK_PID_FILE;
logger.debug(`PID file written: ${FALLBACK_PID_FILE}`);
} catch (error) {
logger.warn(`Failed to write PID file: ${error.message}`);
// Non-fatal - daemon can still run
}
}
/**
* Remove PID file
*/
private async removePidFile(): Promise<void> {
try {
await Deno.remove(this.pidFilePath);
logger.debug(`PID file removed: ${this.pidFilePath}`);
} catch (error) {
// Ignore errors - file might not exist
logger.debug(`Could not remove PID file: ${error.message}`);
}
}
/**
* Check if daemon is running
*/
static async isDaemonRunning(): Promise<boolean> {
const pid = await OneboxDaemon.getDaemonPid();
if (!pid) {
return false;
}
try {
// Check if process exists
await Deno.stat(`/proc/${pid}`);
return true;
} catch {
// Process doesn't exist - clean up stale PID file
logger.debug(`Cleaning up stale PID file`);
try {
await Deno.remove(PID_FILE_PATH);
} catch {
try {
await Deno.remove(FALLBACK_PID_FILE);
} catch {
// Ignore
}
}
return false;
}
}
/**
* Get daemon PID
*/
static async getDaemonPid(): Promise<number | null> {
try {
// Try primary location
try {
const pidText = await Deno.readTextFile(PID_FILE_PATH);
return parseInt(pidText.trim(), 10);
} catch {
// Try fallback location
const pidText = await Deno.readTextFile(FALLBACK_PID_FILE);
return parseInt(pidText.trim(), 10);
}
} catch {
return null;
}
}
/**
* Ensure no daemon is running
*/
static async ensureNoDaemon(): Promise<void> {
const running = await OneboxDaemon.isDaemonRunning();
if (running) {
throw new Error('Daemon is already running. Please stop it first with: onebox daemon stop');
}
}
/**
* Get service status from systemd
*/
async getServiceStatus(): Promise<string> {
try {
// Don't need smartdaemon to check status, just use systemctl directly
const command = new Deno.Command('systemctl', {
args: ['status', 'smartdaemon_onebox'],
stdout: 'piped',
stderr: 'piped',
});
const { code, stdout } = await command.output();
const output = new TextDecoder().decode(stdout);
if (code === 0 || output.includes('active (running)')) {
return 'running';
} else if (output.includes('inactive') || output.includes('dead')) {
return 'stopped';
} else if (output.includes('failed')) {
return 'failed';
} else {
return 'unknown';
}
} catch (error) {
return 'not-installed';
}
}
}

681
ts/classes/database.ts Normal file
View File

@@ -0,0 +1,681 @@
/**
* Database layer for Onebox using SQLite
*/
import * as plugins from '../plugins.ts';
import type {
IService,
IRegistry,
INginxConfig,
ISslCertificate,
IDnsRecord,
IMetric,
ILogEntry,
IUser,
ISetting,
} from '../types.ts';
import { logger } from '../logging.ts';
export class OneboxDatabase {
private db: plugins.sqlite.DB | null = null;
private dbPath: string;
constructor(dbPath = './.nogit/onebox.db') {
this.dbPath = dbPath;
}
/**
* Initialize database connection and create tables
*/
async init(): Promise<void> {
try {
// Ensure data directory exists
const dbDir = plugins.path.dirname(this.dbPath);
await Deno.mkdir(dbDir, { recursive: true });
// Open database
this.db = new plugins.sqlite.DB(this.dbPath);
logger.info(`Database initialized at ${this.dbPath}`);
// Create tables
await this.createTables();
// Run migrations if needed
await this.runMigrations();
} catch (error) {
logger.error(`Failed to initialize database: ${error.message}`);
throw error;
}
}
/**
* Create all database tables
*/
private async createTables(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
// Services table
this.query(`
CREATE TABLE IF NOT EXISTS services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
image TEXT NOT NULL,
registry TEXT,
env_vars TEXT NOT NULL,
port INTEGER NOT NULL,
domain TEXT,
container_id TEXT,
status TEXT NOT NULL DEFAULT 'stopped',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Registries table
this.query(`
CREATE TABLE IF NOT EXISTS registries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
password_encrypted TEXT NOT NULL,
created_at INTEGER NOT NULL
)
`);
// Nginx configs table
this.query(`
CREATE TABLE IF NOT EXISTS nginx_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
domain TEXT NOT NULL,
port INTEGER NOT NULL,
ssl_enabled INTEGER NOT NULL DEFAULT 0,
config_template TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
// SSL certificates table
this.query(`
CREATE TABLE IF NOT EXISTS ssl_certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
cert_path TEXT NOT NULL,
key_path TEXT NOT NULL,
full_chain_path TEXT NOT NULL,
expiry_date INTEGER NOT NULL,
issuer TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// DNS records table
this.query(`
CREATE TABLE IF NOT EXISTS dns_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
value TEXT NOT NULL,
cloudflare_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Metrics table
this.query(`
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
cpu_percent REAL NOT NULL,
memory_used INTEGER NOT NULL,
memory_limit INTEGER NOT NULL,
network_rx_bytes INTEGER NOT NULL,
network_tx_bytes INTEGER NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
// Create index for metrics queries
this.query(`
CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp
ON metrics(service_id, timestamp DESC)
`);
// Logs table
this.query(`
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
message TEXT NOT NULL,
level TEXT NOT NULL,
source TEXT NOT NULL,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
)
`);
// Create index for logs queries
this.query(`
CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp
ON logs(service_id, timestamp DESC)
`);
// Users table
this.query(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Settings table
this.query(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
)
`);
// Version table for migrations
this.query(`
CREATE TABLE IF NOT EXISTS migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
)
`);
logger.debug('Database tables created successfully');
}
/**
* Run database migrations
*/
private async runMigrations(): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const currentVersion = this.getMigrationVersion();
logger.debug(`Current database version: ${currentVersion}`);
// Add migration logic here as needed
// For now, just set version to 1
if (currentVersion === 0) {
this.setMigrationVersion(1);
}
}
/**
* Get current migration version
*/
private getMigrationVersion(): number {
if (!this.db) throw new Error('Database not initialized');
try {
const result = this.query('SELECT MAX(version) as version FROM migrations');
return result.length > 0 && result[0][0] !== null ? Number(result[0][0]) : 0;
} catch {
return 0;
}
}
/**
* Set migration version
*/
private setMigrationVersion(version: number): void {
if (!this.db) throw new Error('Database not initialized');
this.query('INSERT INTO migrations (version, applied_at) VALUES (?, ?)', [
version,
Date.now(),
]);
logger.debug(`Migration version set to ${version}`);
}
/**
* Close database connection
*/
close(): void {
if (this.db) {
this.db.close();
this.db = null;
logger.debug('Database connection closed');
}
}
/**
* Execute a raw query
*/
query<T = unknown[]>(sql: string, params: unknown[] = []): T[] {
if (!this.db) {
const error = new Error('Database not initialized');
console.error('Database access before initialization!');
console.error('Stack trace:', error.stack);
throw error;
}
// For queries without parameters, use exec for better performance
if (params.length === 0 && !sql.trim().toUpperCase().startsWith('SELECT')) {
this.db.exec(sql);
return [] as T[];
}
// For SELECT queries or statements with parameters, use prepare().all()
const stmt = this.db.prepare(sql);
try {
const result = stmt.all(...params);
return result as T[];
} finally {
stmt.finalize();
}
}
// ============ Services CRUD ============
async createService(service: Omit<IService, 'id'>): Promise<IService> {
if (!this.db) throw new Error('Database not initialized');
const now = Date.now();
this.query(
`INSERT INTO services (name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
service.name,
service.image,
service.registry || null,
JSON.stringify(service.envVars),
service.port,
service.domain || null,
service.containerID || null,
service.status,
now,
now,
]
);
return this.getServiceByName(service.name)!;
}
getServiceByName(name: string): IService | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT * FROM services WHERE name = ?', [name]);
return rows.length > 0 ? this.rowToService(rows[0]) : null;
}
getServiceByID(id: number): IService | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT * FROM services WHERE id = ?', [id]);
return rows.length > 0 ? this.rowToService(rows[0]) : null;
}
getAllServices(): IService[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT * FROM services ORDER BY created_at DESC');
return rows.map((row) => this.rowToService(row));
}
updateService(id: number, updates: Partial<IService>): void {
if (!this.db) throw new Error('Database not initialized');
const fields: string[] = [];
const values: unknown[] = [];
if (updates.image !== undefined) {
fields.push('image = ?');
values.push(updates.image);
}
if (updates.registry !== undefined) {
fields.push('registry = ?');
values.push(updates.registry);
}
if (updates.envVars !== undefined) {
fields.push('env_vars = ?');
values.push(JSON.stringify(updates.envVars));
}
if (updates.port !== undefined) {
fields.push('port = ?');
values.push(updates.port);
}
if (updates.domain !== undefined) {
fields.push('domain = ?');
values.push(updates.domain);
}
if (updates.containerID !== undefined) {
fields.push('container_id = ?');
values.push(updates.containerID);
}
if (updates.status !== undefined) {
fields.push('status = ?');
values.push(updates.status);
}
fields.push('updated_at = ?');
values.push(Date.now());
values.push(id);
this.query(`UPDATE services SET ${fields.join(', ')} WHERE id = ?`, values);
}
deleteService(id: number): void {
if (!this.db) throw new Error('Database not initialized');
this.query('DELETE FROM services WHERE id = ?', [id]);
}
private rowToService(row: unknown[]): IService {
return {
id: Number(row[0]),
name: String(row[1]),
image: String(row[2]),
registry: row[3] ? String(row[3]) : undefined,
envVars: JSON.parse(String(row[4])),
port: Number(row[5]),
domain: row[6] ? String(row[6]) : undefined,
containerID: row[7] ? String(row[7]) : undefined,
status: String(row[8]) as IService['status'],
createdAt: Number(row[9]),
updatedAt: Number(row[10]),
};
}
// ============ Registries CRUD ============
async createRegistry(registry: Omit<IRegistry, 'id'>): Promise<IRegistry> {
if (!this.db) throw new Error('Database not initialized');
const now = Date.now();
this.query(
'INSERT INTO registries (url, username, password_encrypted, created_at) VALUES (?, ?, ?, ?)',
[registry.url, registry.username, registry.passwordEncrypted, now]
);
return this.getRegistryByURL(registry.url)!;
}
getRegistryByURL(url: string): IRegistry | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT * FROM registries WHERE url = ?', [url]);
return rows.length > 0 ? this.rowToRegistry(rows[0]) : null;
}
getAllRegistries(): IRegistry[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT * FROM registries ORDER BY created_at DESC');
return rows.map((row) => this.rowToRegistry(row));
}
deleteRegistry(url: string): void {
if (!this.db) throw new Error('Database not initialized');
this.query('DELETE FROM registries WHERE url = ?', [url]);
}
private rowToRegistry(row: unknown[]): IRegistry {
return {
id: Number(row[0]),
url: String(row[1]),
username: String(row[2]),
passwordEncrypted: String(row[3]),
createdAt: Number(row[4]),
};
}
// ============ Settings CRUD ============
getSetting(key: string): string | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT value FROM settings WHERE key = ?', [key]);
return rows.length > 0 ? String(rows[0][0]) : null;
}
setSetting(key: string, value: string): void {
if (!this.db) throw new Error('Database not initialized');
const now = Date.now();
this.query(
'INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, ?)',
[key, value, now]
);
}
getAllSettings(): Record<string, string> {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT key, value FROM settings');
const settings: Record<string, string> = {};
for (const row of rows) {
// @db/sqlite returns rows as objects with column names as keys
const key = (row as any).key || row[0];
const value = (row as any).value || row[1];
settings[String(key)] = String(value);
}
return settings;
}
// ============ Users CRUD ============
async createUser(user: Omit<IUser, 'id'>): Promise<IUser> {
if (!this.db) throw new Error('Database not initialized');
const now = Date.now();
this.query(
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
[user.username, user.passwordHash, user.role, now, now]
);
return this.getUserByUsername(user.username)!;
}
getUserByUsername(username: string): IUser | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT * FROM users WHERE username = ?', [username]);
return rows.length > 0 ? this.rowToUser(rows[0]) : null;
}
getAllUsers(): IUser[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT * FROM users ORDER BY created_at DESC');
return rows.map((row) => this.rowToUser(row));
}
updateUserPassword(username: string, passwordHash: string): void {
if (!this.db) throw new Error('Database not initialized');
this.query('UPDATE users SET password_hash = ?, updated_at = ? WHERE username = ?', [
passwordHash,
Date.now(),
username,
]);
}
deleteUser(username: string): void {
if (!this.db) throw new Error('Database not initialized');
this.query('DELETE FROM users WHERE username = ?', [username]);
}
private rowToUser(row: any): IUser {
return {
id: Number(row.id || row[0]),
username: String(row.username || row[1]),
passwordHash: String(row.password_hash || row[2]),
role: String(row.role || row[3]) as IUser['role'],
createdAt: Number(row.created_at || row[4]),
updatedAt: Number(row.updated_at || row[5]),
};
}
// ============ Metrics ============
addMetric(metric: Omit<IMetric, 'id'>): void {
if (!this.db) throw new Error('Database not initialized');
this.query(
`INSERT INTO metrics (service_id, timestamp, cpu_percent, memory_used, memory_limit, network_rx_bytes, network_tx_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
metric.serviceId,
metric.timestamp,
metric.cpuPercent,
metric.memoryUsed,
metric.memoryLimit,
metric.networkRxBytes,
metric.networkTxBytes,
]
);
}
getMetrics(serviceId: number, limit = 100): IMetric[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query(
'SELECT * FROM metrics WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?',
[serviceId, limit]
);
return rows.map((row) => this.rowToMetric(row));
}
private rowToMetric(row: unknown[]): IMetric {
return {
id: Number(row[0]),
serviceId: Number(row[1]),
timestamp: Number(row[2]),
cpuPercent: Number(row[3]),
memoryUsed: Number(row[4]),
memoryLimit: Number(row[5]),
networkRxBytes: Number(row[6]),
networkTxBytes: Number(row[7]),
};
}
// ============ Logs ============
addLog(log: Omit<ILogEntry, 'id'>): void {
if (!this.db) throw new Error('Database not initialized');
this.query(
'INSERT INTO logs (service_id, timestamp, message, level, source) VALUES (?, ?, ?, ?, ?)',
[log.serviceId, log.timestamp, log.message, log.level, log.source]
);
}
getLogs(serviceId: number, limit = 1000): ILogEntry[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query(
'SELECT * FROM logs WHERE service_id = ? ORDER BY timestamp DESC LIMIT ?',
[serviceId, limit]
);
return rows.map((row) => this.rowToLog(row));
}
private rowToLog(row: unknown[]): ILogEntry {
return {
id: Number(row[0]),
serviceId: Number(row[1]),
timestamp: Number(row[2]),
message: String(row[3]),
level: String(row[4]) as ILogEntry['level'],
source: String(row[5]) as ILogEntry['source'],
};
}
// ============ SSL Certificates ============
async createSSLCertificate(cert: Omit<ISslCertificate, 'id'>): Promise<ISslCertificate> {
if (!this.db) throw new Error('Database not initialized');
const now = Date.now();
this.query(
`INSERT INTO ssl_certificates (domain, cert_path, key_path, full_chain_path, expiry_date, issuer, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
cert.domain,
cert.certPath,
cert.keyPath,
cert.fullChainPath,
cert.expiryDate,
cert.issuer,
now,
now,
]
);
return this.getSSLCertificate(cert.domain)!;
}
getSSLCertificate(domain: string): ISslCertificate | null {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT * FROM ssl_certificates WHERE domain = ?', [domain]);
return rows.length > 0 ? this.rowToSSLCert(rows[0]) : null;
}
getAllSSLCertificates(): ISslCertificate[] {
if (!this.db) throw new Error('Database not initialized');
const rows = this.query('SELECT * FROM ssl_certificates ORDER BY expiry_date ASC');
return rows.map((row) => this.rowToSSLCert(row));
}
updateSSLCertificate(domain: string, updates: Partial<ISslCertificate>): void {
if (!this.db) throw new Error('Database not initialized');
const fields: string[] = [];
const values: unknown[] = [];
if (updates.certPath) {
fields.push('cert_path = ?');
values.push(updates.certPath);
}
if (updates.keyPath) {
fields.push('key_path = ?');
values.push(updates.keyPath);
}
if (updates.fullChainPath) {
fields.push('full_chain_path = ?');
values.push(updates.fullChainPath);
}
if (updates.expiryDate) {
fields.push('expiry_date = ?');
values.push(updates.expiryDate);
}
fields.push('updated_at = ?');
values.push(Date.now());
values.push(domain);
this.query(`UPDATE ssl_certificates SET ${fields.join(', ')} WHERE domain = ?`, values);
}
deleteSSLCertificate(domain: string): void {
if (!this.db) throw new Error('Database not initialized');
this.query('DELETE FROM ssl_certificates WHERE domain = ?', [domain]);
}
private rowToSSLCert(row: unknown[]): ISslCertificate {
return {
id: Number(row[0]),
domain: String(row[1]),
certPath: String(row[2]),
keyPath: String(row[3]),
fullChainPath: String(row[4]),
expiryDate: Number(row[5]),
issuer: String(row[6]),
createdAt: Number(row[7]),
updatedAt: Number(row[8]),
};
}
}

301
ts/classes/dns.ts Normal file
View File

@@ -0,0 +1,301 @@
/**
* DNS Manager for Onebox
*
* Manages DNS records via Cloudflare API
*/
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
export class OneboxDnsManager {
private oneboxRef: any;
private database: OneboxDatabase;
private cloudflareClient: plugins.cloudflare.CloudflareAccount | null = null;
private zoneID: string | null = null;
private serverIP: string | null = null;
constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef;
this.database = oneboxRef.database;
}
/**
* Initialize DNS manager with Cloudflare credentials
*/
async init(): Promise<void> {
try {
// Get Cloudflare credentials from settings
const apiKey = this.database.getSetting('cloudflareAPIKey');
const email = this.database.getSetting('cloudflareEmail');
const serverIP = this.database.getSetting('serverIP');
if (!apiKey || !email) {
logger.warn('Cloudflare credentials not configured. DNS management will be disabled.');
logger.info('Configure with: onebox config set cloudflareAPIKey <key>');
logger.info('Configure with: onebox config set cloudflareEmail <email>');
return;
}
this.serverIP = serverIP;
// Initialize Cloudflare client
// The CloudflareAccount class expects just the API key/token
this.cloudflareClient = new plugins.cloudflare.CloudflareAccount(apiKey);
// Try to get zone ID from settings, or auto-detect
let zoneID = this.database.getSetting('cloudflareZoneID');
if (!zoneID) {
// Auto-detect zones
logger.info('No zone ID configured, fetching available zones...');
const zones = await this.cloudflareClient.convenience.listZones();
if (zones.length === 0) {
logger.warn('No Cloudflare zones found for this account');
return;
} else if (zones.length === 1) {
// Auto-select the only zone
zoneID = zones[0].id;
this.database.setSetting('cloudflareZoneID', zoneID);
logger.success(`Auto-selected zone: ${zones[0].name} (${zoneID})`);
} else {
// Multiple zones found - log them for user to choose
logger.info(`Found ${zones.length} Cloudflare zones:`);
for (const zone of zones) {
logger.info(` - ${zone.name} (ID: ${zone.id})`);
}
logger.warn('Multiple zones found. Please set one with: onebox config set cloudflareZoneID <id>');
return;
}
}
this.zoneID = zoneID;
logger.info('DNS manager initialized with Cloudflare');
} catch (error) {
logger.error(`Failed to initialize DNS manager: ${error.message}`);
if (error.message && error.message.includes('Authorization header')) {
logger.error('The provided API key appears to be invalid.');
logger.error('Make sure you are using a Cloudflare API TOKEN (not the global API key).');
logger.info('Create an API Token at: https://dash.cloudflare.com/profile/api-tokens');
logger.info('The token needs "Zone:Read" and "DNS:Edit" permissions.');
}
throw error;
}
}
/**
* Check if DNS manager is configured
*/
isConfigured(): boolean {
return this.cloudflareClient !== null && this.zoneID !== null;
}
/**
* Add a DNS record for a domain
*/
async addDNSRecord(domain: string, ip?: string): Promise<void> {
try {
if (!this.isConfigured()) {
throw new Error('DNS manager not configured');
}
logger.info(`Adding DNS record for ${domain}`);
const targetIP = ip || this.serverIP;
if (!targetIP) {
throw new Error('Server IP not configured. Set with: onebox config set serverIP <ip>');
}
// Check if record already exists
const existing = await this.getDNSRecord(domain);
if (existing) {
logger.info(`DNS record already exists for ${domain}`);
return;
}
// Create A record
const response = await this.cloudflareClient!.zones.dns.records.create(this.zoneID!, {
type: 'A',
name: domain,
content: targetIP,
ttl: 1, // Auto
proxied: false, // Don't proxy through Cloudflare for direct SSL
});
// Store in database
await this.database.query(
'INSERT INTO dns_records (domain, type, value, cloudflare_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
[domain, 'A', targetIP, response.result.id, Date.now(), Date.now()]
);
logger.success(`DNS record created for ${domain}${targetIP}`);
} catch (error) {
logger.error(`Failed to add DNS record for ${domain}: ${error.message}`);
throw error;
}
}
/**
* Remove a DNS record
*/
async removeDNSRecord(domain: string): Promise<void> {
try {
if (!this.isConfigured()) {
throw new Error('DNS manager not configured');
}
logger.info(`Removing DNS record for ${domain}`);
// Get record from database
const rows = this.database.query('SELECT cloudflare_id FROM dns_records WHERE domain = ?', [
domain,
]);
if (rows.length === 0) {
logger.warn(`DNS record not found for ${domain}`);
return;
}
const cloudflareID = String(rows[0][0]);
// Delete from Cloudflare
if (cloudflareID) {
await this.cloudflareClient!.zones.dns.records.delete(this.zoneID!, cloudflareID);
}
// Delete from database
this.database.query('DELETE FROM dns_records WHERE domain = ?', [domain]);
logger.success(`DNS record removed for ${domain}`);
} catch (error) {
logger.error(`Failed to remove DNS record for ${domain}: ${error.message}`);
throw error;
}
}
/**
* Get DNS record for a domain
*/
async getDNSRecord(domain: string): Promise<any> {
try {
if (!this.isConfigured()) {
return null;
}
// Get from database first
const rows = this.database.query('SELECT * FROM dns_records WHERE domain = ?', [domain]);
if (rows.length > 0) {
return {
domain: String(rows[0][1]),
type: String(rows[0][2]),
value: String(rows[0][3]),
cloudflareID: rows[0][4] ? String(rows[0][4]) : null,
};
}
return null;
} catch (error) {
logger.error(`Failed to get DNS record for ${domain}: ${error.message}`);
return null;
}
}
/**
* List all DNS records
*/
listDNSRecords(): any[] {
try {
const rows = this.database.query('SELECT * FROM dns_records ORDER BY created_at DESC');
return rows.map((row) => ({
id: Number(row[0]),
domain: String(row[1]),
type: String(row[2]),
value: String(row[3]),
cloudflareID: row[4] ? String(row[4]) : null,
createdAt: Number(row[5]),
updatedAt: Number(row[6]),
}));
} catch (error) {
logger.error(`Failed to list DNS records: ${error.message}`);
return [];
}
}
/**
* Sync DNS records from Cloudflare
*/
async syncFromCloudflare(): Promise<void> {
try {
if (!this.isConfigured()) {
throw new Error('DNS manager not configured');
}
logger.info('Syncing DNS records from Cloudflare...');
const response = await this.cloudflareClient!.zones.dns.records.list(this.zoneID!);
const records = response.result;
// Only sync A records
const aRecords = records.filter((r: any) => r.type === 'A');
for (const record of aRecords) {
// Check if exists in database
const existing = await this.getDNSRecord(record.name);
if (!existing) {
// Add to database
await this.database.query(
'INSERT INTO dns_records (domain, type, value, cloudflare_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
[record.name, record.type, record.content, record.id, Date.now(), Date.now()]
);
logger.info(`Synced DNS record: ${record.name}`);
}
}
logger.success('DNS records synced from Cloudflare');
} catch (error) {
logger.error(`Failed to sync DNS records: ${error.message}`);
throw error;
}
}
/**
* Check if domain DNS is properly configured
*/
async checkDNS(domain: string): Promise<boolean> {
try {
logger.info(`Checking DNS for ${domain}...`);
// Use dig or nslookup to check DNS resolution
const command = new Deno.Command('dig', {
args: ['+short', domain],
stdout: 'piped',
stderr: 'piped',
});
const { code, stdout } = await command.output();
if (code !== 0) {
logger.warn(`DNS check failed for ${domain}`);
return false;
}
const ip = new TextDecoder().decode(stdout).trim();
if (ip === this.serverIP) {
logger.success(`DNS correctly points to ${ip}`);
return true;
} else {
logger.warn(`DNS points to ${ip}, expected ${this.serverIP}`);
return false;
}
} catch (error) {
logger.error(`Failed to check DNS for ${domain}: ${error.message}`);
return false;
}
}
}

489
ts/classes/docker.ts Normal file
View File

@@ -0,0 +1,489 @@
/**
* Docker Manager for Onebox
*
* Handles all Docker operations: containers, images, networks, volumes
*/
import * as plugins from '../plugins.ts';
import type { IService, IContainerStats } from '../types.ts';
import { logger } from '../logging.ts';
export class OneboxDockerManager {
private dockerClient: plugins.docker.Docker | null = null;
private networkName = 'onebox-network';
/**
* Initialize Docker client and create onebox network
*/
async init(): Promise<void> {
try {
// Initialize Docker client (connects to /var/run/docker.sock by default)
this.dockerClient = new plugins.docker.Docker({
socketPath: '/var/run/docker.sock',
});
// Start the Docker client
await this.dockerClient.start();
logger.info('Docker client initialized');
// Ensure onebox network exists
await this.ensureNetwork();
} catch (error) {
logger.error(`Failed to initialize Docker client: ${error.message}`);
throw error;
}
}
/**
* Ensure onebox network exists
*/
private async ensureNetwork(): Promise<void> {
try {
const networks = await this.dockerClient!.getNetworks();
const existingNetwork = networks.find((n: any) => n.name === this.networkName);
if (!existingNetwork) {
logger.info(`Creating Docker network: ${this.networkName}`);
await this.dockerClient!.createNetwork({
Name: this.networkName,
Driver: 'bridge',
Labels: {
'managed-by': 'onebox',
},
});
logger.success(`Docker network created: ${this.networkName}`);
} else {
logger.debug(`Docker network already exists: ${this.networkName}`);
}
} catch (error) {
logger.error(`Failed to create Docker network: ${error.message}`);
throw error;
}
}
/**
* Pull an image from a registry
*/
async pullImage(image: string, registry?: string): Promise<void> {
try {
logger.info(`Pulling Docker image: ${image}`);
const fullImage = registry ? `${registry}/${image}` : image;
await this.dockerClient!.pull(fullImage, (error: any, stream: any) => {
if (error) {
throw error;
}
// Follow progress
this.dockerClient!.modem.followProgress(stream, (err: any, output: any) => {
if (err) {
throw err;
}
logger.debug('Pull complete:', output);
});
});
logger.success(`Image pulled successfully: ${fullImage}`);
} catch (error) {
logger.error(`Failed to pull image ${image}: ${error.message}`);
throw error;
}
}
/**
* Create and start a container
*/
async createContainer(service: IService): Promise<string> {
try {
logger.info(`Creating container for service: ${service.name}`);
const fullImage = service.registry
? `${service.registry}/${service.image}`
: service.image;
// Prepare environment variables
const env: string[] = [];
for (const [key, value] of Object.entries(service.envVars)) {
env.push(`${key}=${value}`);
}
// Create container
const container = await this.dockerClient!.createContainer({
Image: fullImage,
name: `onebox-${service.name}`,
Env: env,
Labels: {
'managed-by': 'onebox',
'onebox-service': service.name,
},
ExposedPorts: {
[`${service.port}/tcp`]: {},
},
HostConfig: {
NetworkMode: this.networkName,
RestartPolicy: {
Name: 'unless-stopped',
},
PortBindings: {
// Don't bind to host ports - nginx will proxy
[`${service.port}/tcp`]: [],
},
},
});
const containerID = container.id;
logger.success(`Container created: ${containerID}`);
return containerID;
} catch (error) {
logger.error(`Failed to create container for ${service.name}: ${error.message}`);
throw error;
}
}
/**
* Start a container by ID
*/
async startContainer(containerID: string): Promise<void> {
try {
logger.info(`Starting container: ${containerID}`);
const container = this.dockerClient!.getContainer(containerID);
await container.start();
logger.success(`Container started: ${containerID}`);
} catch (error) {
// Ignore "already started" errors
if (error.message.includes('already started')) {
logger.debug(`Container already running: ${containerID}`);
return;
}
logger.error(`Failed to start container ${containerID}: ${error.message}`);
throw error;
}
}
/**
* Stop a container by ID
*/
async stopContainer(containerID: string): Promise<void> {
try {
logger.info(`Stopping container: ${containerID}`);
const container = this.dockerClient!.getContainer(containerID);
await container.stop();
logger.success(`Container stopped: ${containerID}`);
} catch (error) {
// Ignore "already stopped" errors
if (error.message.includes('already stopped') || error.statusCode === 304) {
logger.debug(`Container already stopped: ${containerID}`);
return;
}
logger.error(`Failed to stop container ${containerID}: ${error.message}`);
throw error;
}
}
/**
* Restart a container by ID
*/
async restartContainer(containerID: string): Promise<void> {
try {
logger.info(`Restarting container: ${containerID}`);
const container = this.dockerClient!.getContainer(containerID);
await container.restart();
logger.success(`Container restarted: ${containerID}`);
} catch (error) {
logger.error(`Failed to restart container ${containerID}: ${error.message}`);
throw error;
}
}
/**
* Remove a container by ID
*/
async removeContainer(containerID: string, force = false): Promise<void> {
try {
logger.info(`Removing container: ${containerID}`);
const container = this.dockerClient!.getContainer(containerID);
// Stop first if not forced
if (!force) {
try {
await this.stopContainer(containerID);
} catch (error) {
// Ignore stop errors
logger.debug(`Error stopping container before removal: ${error.message}`);
}
}
await container.remove({ force });
logger.success(`Container removed: ${containerID}`);
} catch (error) {
logger.error(`Failed to remove container ${containerID}: ${error.message}`);
throw error;
}
}
/**
* Get container status
*/
async getContainerStatus(containerID: string): Promise<string> {
try {
const container = this.dockerClient!.getContainer(containerID);
const info = await container.inspect();
return info.State.Status;
} catch (error) {
logger.error(`Failed to get container status ${containerID}: ${error.message}`);
return 'unknown';
}
}
/**
* Get container stats (CPU, memory, network)
*/
async getContainerStats(containerID: string): Promise<IContainerStats | null> {
try {
const container = this.dockerClient!.getContainer(containerID);
const stats = await container.stats({ stream: false });
// Calculate CPU percentage
const cpuDelta =
stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
const cpuPercent =
systemDelta > 0 ? (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100 : 0;
// Memory stats
const memoryUsed = stats.memory_stats.usage || 0;
const memoryLimit = stats.memory_stats.limit || 0;
const memoryPercent = memoryLimit > 0 ? (memoryUsed / memoryLimit) * 100 : 0;
// Network stats
let networkRx = 0;
let networkTx = 0;
if (stats.networks) {
for (const network of Object.values(stats.networks)) {
networkRx += (network as any).rx_bytes || 0;
networkTx += (network as any).tx_bytes || 0;
}
}
return {
cpuPercent,
memoryUsed,
memoryLimit,
memoryPercent,
networkRx,
networkTx,
};
} catch (error) {
logger.error(`Failed to get container stats ${containerID}: ${error.message}`);
return null;
}
}
/**
* Get container logs
*/
async getContainerLogs(
containerID: string,
tail = 100
): Promise<{ stdout: string; stderr: string }> {
try {
const container = this.dockerClient!.getContainer(containerID);
const logs = await container.logs({
stdout: true,
stderr: true,
tail,
timestamps: true,
});
// Parse logs (Docker returns them in a special format)
const stdout: string[] = [];
const stderr: string[] = [];
const lines = logs.toString().split('\n');
for (const line of lines) {
if (line.length === 0) continue;
// Docker log format: first byte indicates stream (1=stdout, 2=stderr)
const streamType = line.charCodeAt(0);
const content = line.slice(8); // Skip header (8 bytes)
if (streamType === 1) {
stdout.push(content);
} else if (streamType === 2) {
stderr.push(content);
}
}
return {
stdout: stdout.join('\n'),
stderr: stderr.join('\n'),
};
} catch (error) {
logger.error(`Failed to get container logs ${containerID}: ${error.message}`);
return { stdout: '', stderr: '' };
}
}
/**
* Stream container logs (real-time)
*/
async streamContainerLogs(
containerID: string,
callback: (line: string, isError: boolean) => void
): Promise<void> {
try {
const container = this.dockerClient!.getContainer(containerID);
const stream = await container.logs({
stdout: true,
stderr: true,
follow: true,
tail: 0,
timestamps: true,
});
stream.on('data', (chunk: Buffer) => {
const streamType = chunk[0];
const content = chunk.slice(8).toString();
callback(content, streamType === 2);
});
stream.on('error', (error: Error) => {
logger.error(`Log stream error for ${containerID}: ${error.message}`);
});
} catch (error) {
logger.error(`Failed to stream container logs ${containerID}: ${error.message}`);
throw error;
}
}
/**
* List all onebox-managed containers
*/
async listContainers(): Promise<any[]> {
try {
const containers = await this.dockerClient!.getContainers();
// Filter for onebox-managed containers
return containers.filter((c: any) =>
c.labels && c.labels['managed-by'] === 'onebox'
);
} catch (error) {
logger.error(`Failed to list containers: ${error.message}`);
return [];
}
}
/**
* Check if Docker is running
*/
async isDockerRunning(): Promise<boolean> {
try {
await this.dockerClient!.ping();
return true;
} catch (error) {
return false;
}
}
/**
* Get Docker version info
*/
async getDockerVersion(): Promise<any> {
try {
return await this.dockerClient!.version();
} catch (error) {
logger.error(`Failed to get Docker version: ${error.message}`);
return null;
}
}
/**
* Prune unused images
*/
async pruneImages(): Promise<void> {
try {
logger.info('Pruning unused Docker images...');
await this.dockerClient!.pruneImages();
logger.success('Unused images pruned successfully');
} catch (error) {
logger.error(`Failed to prune images: ${error.message}`);
throw error;
}
}
/**
* Get container IP address in onebox network
*/
async getContainerIP(containerID: string): Promise<string | null> {
try {
const container = this.dockerClient!.getContainer(containerID);
const info = await container.inspect();
const networks = info.NetworkSettings.Networks;
if (networks && networks[this.networkName]) {
return networks[this.networkName].IPAddress;
}
return null;
} catch (error) {
logger.error(`Failed to get container IP ${containerID}: ${error.message}`);
return null;
}
}
/**
* Execute a command in a running container
*/
async execInContainer(
containerID: string,
cmd: string[]
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
try {
const container = this.dockerClient!.getContainer(containerID);
const exec = await container.exec({
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({ Detach: false });
let stdout = '';
let stderr = '';
stream.on('data', (chunk: Buffer) => {
const streamType = chunk[0];
const content = chunk.slice(8).toString();
if (streamType === 1) {
stdout += content;
} else if (streamType === 2) {
stderr += content;
}
});
// Wait for completion
await new Promise((resolve) => stream.on('end', resolve));
const inspect = await exec.inspect();
const exitCode = inspect.ExitCode || 0;
return { stdout, stderr, exitCode };
} catch (error) {
logger.error(`Failed to exec in container ${containerID}: ${error.message}`);
throw error;
}
}
}

362
ts/classes/httpserver.ts Normal file
View File

@@ -0,0 +1,362 @@
/**
* HTTP Server for Onebox
*
* Serves REST API and Angular UI
*/
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type { Onebox } from './onebox.ts';
import type { IApiResponse } from '../types.ts';
export class OneboxHttpServer {
private oneboxRef: Onebox;
private server: Deno.HttpServer | null = null;
private port = 3000;
constructor(oneboxRef: Onebox) {
this.oneboxRef = oneboxRef;
}
/**
* Start HTTP server
*/
async start(port?: number): Promise<void> {
try {
if (this.server) {
logger.warn('HTTP server already running');
return;
}
this.port = port || 3000;
logger.info(`Starting HTTP server on port ${this.port}...`);
this.server = Deno.serve({ port: this.port }, (req) => this.handleRequest(req));
logger.success(`HTTP server started on http://localhost:${this.port}`);
} catch (error) {
logger.error(`Failed to start HTTP server: ${error.message}`);
throw error;
}
}
/**
* Stop HTTP server
*/
async stop(): Promise<void> {
try {
if (!this.server) {
return;
}
logger.info('Stopping HTTP server...');
await this.server.shutdown();
this.server = null;
logger.success('HTTP server stopped');
} catch (error) {
logger.error(`Failed to stop HTTP server: ${error.message}`);
throw error;
}
}
/**
* Handle HTTP request
*/
private async handleRequest(req: Request): Promise<Response> {
const url = new URL(req.url);
const path = url.pathname;
logger.debug(`${req.method} ${path}`);
try {
// API routes
if (path.startsWith('/api/')) {
return await this.handleApiRequest(req, path);
}
// Serve Angular UI
return await this.serveStaticFile(path);
} catch (error) {
logger.error(`Request error: ${error.message}`);
return this.jsonResponse({ success: false, error: error.message }, 500);
}
}
/**
* Serve static files from ui/dist
*/
private async serveStaticFile(path: string): Promise<Response> {
try {
// Default to index.html for root and non-file paths
let filePath = path === '/' ? '/index.html' : path;
// For Angular routing - serve index.html for non-asset paths
if (!filePath.includes('.') && filePath !== '/index.html') {
filePath = '/index.html';
}
const fullPath = `./ui/dist${filePath}`;
// Read file
const file = await Deno.readFile(fullPath);
// Determine content type
const contentType = this.getContentType(filePath);
return new Response(file, {
headers: {
'Content-Type': contentType,
'Cache-Control': filePath === '/index.html' ? 'no-cache' : 'public, max-age=3600',
},
});
} catch (error) {
// File not found - serve index.html for Angular routing
if (error instanceof Deno.errors.NotFound) {
try {
const indexFile = await Deno.readFile('./ui/dist/index.html');
return new Response(indexFile, {
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache',
},
});
} catch {
return new Response('UI not built. Run: cd ui && npm run build', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
}
return new Response('File not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
}
/**
* Get content type for file
*/
private getContentType(path: string): string {
const ext = path.split('.').pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
'html': 'text/html',
'css': 'text/css',
'js': 'application/javascript',
'json': 'application/json',
'png': 'image/png',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'gif': 'image/gif',
'svg': 'image/svg+xml',
'ico': 'image/x-icon',
'woff': 'font/woff',
'woff2': 'font/woff2',
'ttf': 'font/ttf',
'eot': 'application/vnd.ms-fontobject',
};
return mimeTypes[ext || ''] || 'application/octet-stream';
}
/**
* Handle API requests
*/
private async handleApiRequest(req: Request, path: string): Promise<Response> {
const method = req.method;
// Auth check (simplified - should use proper JWT middleware)
// Skip auth for login endpoint
if (path !== '/api/auth/login') {
const authHeader = req.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return this.jsonResponse({ success: false, error: 'Unauthorized' }, 401);
}
}
// Route to appropriate handler
if (path === '/api/auth/login' && method === 'POST') {
return await this.handleLoginRequest(req);
} else if (path === '/api/status' && method === 'GET') {
return await this.handleStatusRequest();
} else if (path === '/api/settings' && method === 'GET') {
return await this.handleGetSettingsRequest();
} else if (path === '/api/settings' && method === 'PUT') {
return await this.handleUpdateSettingsRequest(req);
} else if (path === '/api/services' && method === 'GET') {
return await this.handleListServicesRequest();
} else if (path === '/api/services' && method === 'POST') {
return await this.handleDeployServiceRequest(req);
} else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'GET') {
const name = path.split('/').pop()!;
return await this.handleGetServiceRequest(name);
} else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'DELETE') {
const name = path.split('/').pop()!;
return await this.handleDeleteServiceRequest(name);
} else if (path.match(/^\/api\/services\/[^/]+\/start$/) && method === 'POST') {
const name = path.split('/')[3];
return await this.handleStartServiceRequest(name);
} else if (path.match(/^\/api\/services\/[^/]+\/stop$/) && method === 'POST') {
const name = path.split('/')[3];
return await this.handleStopServiceRequest(name);
} else if (path.match(/^\/api\/services\/[^/]+\/restart$/) && method === 'POST') {
const name = path.split('/')[3];
return await this.handleRestartServiceRequest(name);
} else if (path.match(/^\/api\/services\/[^/]+\/logs$/) && method === 'GET') {
const name = path.split('/')[3];
return await this.handleGetLogsRequest(name);
} else {
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
}
}
// API Handlers
private async handleLoginRequest(req: Request): Promise<Response> {
try {
const body = await req.json();
const { username, password } = body;
logger.info(`Login attempt for user: ${username}`);
if (!username || !password) {
return this.jsonResponse(
{ success: false, error: 'Username and password required' },
400
);
}
// Get user from database
const user = this.oneboxRef.database.getUserByUsername(username);
if (!user) {
logger.info(`User not found: ${username}`);
return this.jsonResponse({ success: false, error: 'Invalid credentials' }, 401);
}
logger.info(`User found: ${username}, checking password...`);
// Verify password (simple base64 comparison for now)
const passwordHash = btoa(password);
logger.info(`Password hash: ${passwordHash}, stored hash: ${user.passwordHash}`);
if (passwordHash !== user.passwordHash) {
logger.info(`Password mismatch for user: ${username}`);
return this.jsonResponse({ success: false, error: 'Invalid credentials' }, 401);
}
// Generate simple token (in production, use proper JWT)
const token = btoa(`${user.username}:${Date.now()}`);
return this.jsonResponse({
success: true,
data: {
token,
user: {
username: user.username,
role: user.role,
},
},
});
} catch (error) {
logger.error(`Login error: ${error.message}`);
return this.jsonResponse({ success: false, error: 'Login failed' }, 500);
}
}
private async handleStatusRequest(): Promise<Response> {
const status = await this.oneboxRef.getSystemStatus();
return this.jsonResponse({ success: true, data: status });
}
private async handleListServicesRequest(): Promise<Response> {
const services = this.oneboxRef.services.listServices();
return this.jsonResponse({ success: true, data: services });
}
private async handleDeployServiceRequest(req: Request): Promise<Response> {
const body = await req.json();
const service = await this.oneboxRef.services.deployService(body);
return this.jsonResponse({ success: true, data: service });
}
private async handleGetServiceRequest(name: string): Promise<Response> {
const service = this.oneboxRef.services.getService(name);
if (!service) {
return this.jsonResponse({ success: false, error: 'Service not found' }, 404);
}
return this.jsonResponse({ success: true, data: service });
}
private async handleDeleteServiceRequest(name: string): Promise<Response> {
await this.oneboxRef.services.removeService(name);
return this.jsonResponse({ success: true, message: 'Service removed' });
}
private async handleStartServiceRequest(name: string): Promise<Response> {
await this.oneboxRef.services.startService(name);
return this.jsonResponse({ success: true, message: 'Service started' });
}
private async handleStopServiceRequest(name: string): Promise<Response> {
await this.oneboxRef.services.stopService(name);
return this.jsonResponse({ success: true, message: 'Service stopped' });
}
private async handleRestartServiceRequest(name: string): Promise<Response> {
await this.oneboxRef.services.restartService(name);
return this.jsonResponse({ success: true, message: 'Service restarted' });
}
private async handleGetLogsRequest(name: string): Promise<Response> {
const logs = await this.oneboxRef.services.getServiceLogs(name);
return this.jsonResponse({ success: true, data: logs });
}
private async handleGetSettingsRequest(): Promise<Response> {
const settings = this.oneboxRef.database.getAllSettings();
return this.jsonResponse({ success: true, data: settings });
}
private async handleUpdateSettingsRequest(req: Request): Promise<Response> {
try {
const body = await req.json();
if (!body || typeof body !== 'object') {
return this.jsonResponse(
{ success: false, error: 'Invalid request body' },
400
);
}
// Update each setting
for (const [key, value] of Object.entries(body)) {
if (typeof value === 'string') {
this.oneboxRef.database.setSetting(key, value);
logger.info(`Setting updated: ${key}`);
}
}
return this.jsonResponse({
success: true,
message: 'Settings updated successfully'
});
} catch (error) {
logger.error(`Failed to update settings: ${error.message}`);
return this.jsonResponse({ success: false, error: 'Failed to update settings' }, 500);
}
}
/**
* Helper to create JSON response
*/
private jsonResponse(data: IApiResponse, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
}

217
ts/classes/onebox.ts Normal file
View File

@@ -0,0 +1,217 @@
/**
* Main Onebox coordinator class
*
* Coordinates all components and provides the main API
*/
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
import { OneboxDockerManager } from './docker.ts';
import { OneboxServicesManager } from './services.ts';
import { OneboxRegistriesManager } from './registries.ts';
import { OneboxReverseProxy } from './reverseproxy.ts';
import { OneboxDnsManager } from './dns.ts';
import { OneboxSslManager } from './ssl.ts';
import { OneboxDaemon } from './daemon.ts';
import { OneboxHttpServer } from './httpserver.ts';
export class Onebox {
public database: OneboxDatabase;
public docker: OneboxDockerManager;
public services: OneboxServicesManager;
public registries: OneboxRegistriesManager;
public reverseProxy: OneboxReverseProxy;
public dns: OneboxDnsManager;
public ssl: OneboxSslManager;
public daemon: OneboxDaemon;
public httpServer: OneboxHttpServer;
private initialized = false;
constructor() {
// Initialize database first
this.database = new OneboxDatabase();
// Initialize managers (passing reference to main Onebox instance)
this.docker = new OneboxDockerManager();
this.services = new OneboxServicesManager(this);
this.registries = new OneboxRegistriesManager(this);
this.reverseProxy = new OneboxReverseProxy(this);
this.dns = new OneboxDnsManager(this);
this.ssl = new OneboxSslManager(this);
this.daemon = new OneboxDaemon(this);
this.httpServer = new OneboxHttpServer(this);
}
/**
* Initialize all components
*/
async init(): Promise<void> {
try {
logger.info('Initializing Onebox...');
// Initialize database
await this.database.init();
// Ensure default admin user exists
await this.ensureDefaultUser();
// Initialize Docker
await this.docker.init();
// Initialize Reverse Proxy
await this.reverseProxy.init();
// Initialize DNS (non-critical)
try {
await this.dns.init();
} catch (error) {
logger.warn('DNS initialization failed - DNS features will be disabled');
}
// Initialize SSL (non-critical)
try {
await this.ssl.init();
} catch (error) {
logger.warn('SSL initialization failed - SSL features will be limited');
}
// Login to all registries
await this.registries.loginToAllRegistries();
this.initialized = true;
logger.success('Onebox initialized successfully');
} catch (error) {
logger.error(`Failed to initialize Onebox: ${error.message}`);
throw error;
}
}
/**
* Ensure default admin user exists
*/
private async ensureDefaultUser(): Promise<void> {
try {
const adminUser = this.database.getUserByUsername('admin');
if (!adminUser) {
logger.info('Creating default admin user...');
// Simple base64 encoding for now - should use bcrypt in production
const passwordHash = btoa('admin');
await this.database.createUser({
username: 'admin',
passwordHash,
role: 'admin',
createdAt: Date.now(),
updatedAt: Date.now(),
});
logger.warn('Default admin user created with username: admin, password: admin');
logger.warn('IMPORTANT: Change the default password immediately!');
}
} catch (error) {
logger.error(`Failed to create default user: ${error.message}`);
}
}
/**
* Check if Onebox is initialized
*/
isInitialized(): boolean {
return this.initialized;
}
/**
* Get system status
*/
async getSystemStatus() {
try {
const dockerRunning = await this.docker.isDockerRunning();
const proxyStatus = this.reverseProxy.getStatus();
const dnsConfigured = this.dns.isConfigured();
const sslConfigured = this.ssl.isConfigured();
const services = this.services.listServices();
const runningServices = services.filter((s) => s.status === 'running').length;
const totalServices = services.length;
return {
docker: {
running: dockerRunning,
version: dockerRunning ? await this.docker.getDockerVersion() : null,
},
reverseProxy: proxyStatus,
dns: {
configured: dnsConfigured,
},
ssl: {
configured: sslConfigured,
certbotInstalled: await this.ssl.isCertbotInstalled(),
},
services: {
total: totalServices,
running: runningServices,
stopped: totalServices - runningServices,
},
};
} catch (error) {
logger.error(`Failed to get system status: ${error.message}`);
throw error;
}
}
/**
* Start daemon mode
*/
async startDaemon(): Promise<void> {
await this.daemon.start();
}
/**
* Stop daemon mode
*/
async stopDaemon(): Promise<void> {
await this.daemon.stop();
}
/**
* Start HTTP server
*/
async startHttpServer(port?: number): Promise<void> {
await this.httpServer.start(port);
}
/**
* Stop HTTP server
*/
async stopHttpServer(): Promise<void> {
await this.httpServer.stop();
}
/**
* Shutdown Onebox gracefully
*/
async shutdown(): Promise<void> {
try {
logger.info('Shutting down Onebox...');
// Stop daemon if running
await this.daemon.stop();
// Stop HTTP server if running
await this.httpServer.stop();
// Stop reverse proxy if running
await this.reverseProxy.stop();
// Close database
this.database.close();
logger.success('Onebox shutdown complete');
} catch (error) {
logger.error(`Error during shutdown: ${error.message}`);
}
}
}

195
ts/classes/registries.ts Normal file
View File

@@ -0,0 +1,195 @@
/**
* Registry Manager for Onebox
*
* Manages Docker registry credentials and authentication
*/
import * as plugins from '../plugins.ts';
import type { IRegistry } from '../types.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
export class OneboxRegistriesManager {
private oneboxRef: any; // Will be Onebox instance
private database: OneboxDatabase;
constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef;
this.database = oneboxRef.database;
}
/**
* Encrypt a password (simple base64 for now, should use proper encryption)
*/
private encryptPassword(password: string): string {
// TODO: Use proper encryption with a secret key
// For now, using base64 encoding (NOT SECURE, just for structure)
return plugins.encoding.encodeBase64(new TextEncoder().encode(password));
}
/**
* Decrypt a password
*/
private decryptPassword(encrypted: string): string {
// TODO: Use proper decryption
return new TextDecoder().decode(plugins.encoding.decodeBase64(encrypted));
}
/**
* Add a registry
*/
async addRegistry(url: string, username: string, password: string): Promise<IRegistry> {
try {
// Check if registry already exists
const existing = this.database.getRegistryByURL(url);
if (existing) {
throw new Error(`Registry already exists: ${url}`);
}
// Encrypt password
const passwordEncrypted = this.encryptPassword(password);
// Create registry in database
const registry = await this.database.createRegistry({
url,
username,
passwordEncrypted,
createdAt: Date.now(),
});
logger.success(`Registry added: ${url}`);
// Perform Docker login
await this.loginToRegistry(registry);
return registry;
} catch (error) {
logger.error(`Failed to add registry ${url}: ${error.message}`);
throw error;
}
}
/**
* Remove a registry
*/
async removeRegistry(url: string): Promise<void> {
try {
const registry = this.database.getRegistryByURL(url);
if (!registry) {
throw new Error(`Registry not found: ${url}`);
}
this.database.deleteRegistry(url);
logger.success(`Registry removed: ${url}`);
// Note: We don't perform docker logout as it might affect other users
} catch (error) {
logger.error(`Failed to remove registry ${url}: ${error.message}`);
throw error;
}
}
/**
* List all registries
*/
listRegistries(): IRegistry[] {
return this.database.getAllRegistries();
}
/**
* Get registry by URL
*/
getRegistry(url: string): IRegistry | null {
return this.database.getRegistryByURL(url);
}
/**
* Perform Docker login for a registry
*/
async loginToRegistry(registry: IRegistry): Promise<void> {
try {
logger.info(`Logging into registry: ${registry.url}`);
const password = this.decryptPassword(registry.passwordEncrypted);
// Use docker login command
const command = [
'docker',
'login',
registry.url,
'--username',
registry.username,
'--password-stdin',
];
const process = new Deno.Command('docker', {
args: ['login', registry.url, '--username', registry.username, '--password-stdin'],
stdin: 'piped',
stdout: 'piped',
stderr: 'piped',
});
const child = process.spawn();
// Write password to stdin
const writer = child.stdin.getWriter();
await writer.write(new TextEncoder().encode(password));
await writer.close();
const { code, stdout, stderr } = await child.output();
if (code !== 0) {
const errorMsg = new TextDecoder().decode(stderr);
throw new Error(`Docker login failed: ${errorMsg}`);
}
logger.success(`Logged into registry: ${registry.url}`);
} catch (error) {
logger.error(`Failed to login to registry ${registry.url}: ${error.message}`);
throw error;
}
}
/**
* Login to all registries (useful on daemon start)
*/
async loginToAllRegistries(): Promise<void> {
const registries = this.listRegistries();
for (const registry of registries) {
try {
await this.loginToRegistry(registry);
} catch (error) {
logger.warn(`Failed to login to ${registry.url}: ${error.message}`);
// Continue with other registries
}
}
}
/**
* Test registry connection
*/
async testRegistry(url: string, username: string, password: string): Promise<boolean> {
try {
const command = new Deno.Command('docker', {
args: ['login', url, '--username', username, '--password-stdin'],
stdin: 'piped',
stdout: 'piped',
stderr: 'piped',
});
const child = command.spawn();
const writer = child.stdin.getWriter();
await writer.write(new TextEncoder().encode(password));
await writer.close();
const { code } = await child.output();
return code === 0;
} catch (error) {
logger.error(`Failed to test registry ${url}: ${error.message}`);
return false;
}
}
}

495
ts/classes/reverseproxy.ts Normal file
View File

@@ -0,0 +1,495 @@
/**
* Reverse Proxy for Onebox
*
* Native Deno HTTP/HTTPS reverse proxy with WebSocket support
*/
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
interface IProxyRoute {
domain: string;
targetHost: string;
targetPort: number;
serviceId: number;
containerID?: string;
}
interface ITlsConfig {
domain: string;
certPath: string;
keyPath: string;
}
export class OneboxReverseProxy {
private oneboxRef: any;
private database: OneboxDatabase;
private routes: Map<string, IProxyRoute> = new Map();
private httpServer: Deno.HttpServer | null = null;
private httpsServer: Deno.HttpServer | null = null;
private httpPort = 80;
private httpsPort = 443;
private tlsConfigs: Map<string, ITlsConfig> = new Map();
constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef;
this.database = oneboxRef.database;
}
/**
* Initialize reverse proxy
*/
async init(): Promise<void> {
try {
logger.info('Reverse proxy initialized');
} catch (error) {
logger.error(`Failed to initialize reverse proxy: ${error.message}`);
throw error;
}
}
/**
* Start the HTTP reverse proxy server
*/
async startHttp(port?: number): Promise<void> {
if (this.httpServer) {
logger.warn('HTTP reverse proxy already running');
return;
}
if (port) {
this.httpPort = port;
}
try {
logger.info(`Starting HTTP reverse proxy on port ${this.httpPort}...`);
this.httpServer = Deno.serve(
{
port: this.httpPort,
hostname: '0.0.0.0',
onListen: ({ hostname, port }) => {
logger.success(`HTTP reverse proxy listening on http://${hostname}:${port}`);
},
},
(req) => this.handleRequest(req, false)
);
logger.success(`HTTP reverse proxy started on port ${this.httpPort}`);
} catch (error) {
logger.error(`Failed to start HTTP reverse proxy: ${error.message}`);
throw error;
}
}
/**
* Start the HTTPS reverse proxy server
*/
async startHttps(port?: number): Promise<void> {
if (this.httpsServer) {
logger.warn('HTTPS reverse proxy already running');
return;
}
if (port) {
this.httpsPort = port;
}
try {
// Check if we have any TLS configs
if (this.tlsConfigs.size === 0) {
logger.info('No TLS certificates configured, skipping HTTPS server');
return;
}
logger.info(`Starting HTTPS reverse proxy on port ${this.httpsPort}...`);
// Get the first certificate as default
const defaultConfig = Array.from(this.tlsConfigs.values())[0];
this.httpsServer = Deno.serve(
{
port: this.httpsPort,
hostname: '0.0.0.0',
cert: await Deno.readTextFile(defaultConfig.certPath),
key: await Deno.readTextFile(defaultConfig.keyPath),
onListen: ({ hostname, port }) => {
logger.success(`HTTPS reverse proxy listening on https://${hostname}:${port}`);
},
},
(req) => this.handleRequest(req, true)
);
logger.success(`HTTPS reverse proxy started on port ${this.httpsPort}`);
} catch (error) {
logger.error(`Failed to start HTTPS reverse proxy: ${error.message}`);
// Don't throw - HTTPS is optional
logger.warn('Continuing without HTTPS support');
}
}
/**
* Stop all reverse proxy servers
*/
async stop(): Promise<void> {
const promises: Promise<void>[] = [];
if (this.httpServer) {
promises.push(this.httpServer.shutdown());
this.httpServer = null;
logger.info('HTTP reverse proxy stopped');
}
if (this.httpsServer) {
promises.push(this.httpsServer.shutdown());
this.httpsServer = null;
logger.info('HTTPS reverse proxy stopped');
}
await Promise.all(promises);
}
/**
* Handle incoming HTTP/HTTPS request
*/
private async handleRequest(req: Request, isHttps: boolean): Promise<Response> {
const url = new URL(req.url);
const host = req.headers.get('host')?.split(':')[0] || '';
logger.debug(`Proxy request: ${req.method} ${host}${url.pathname}`);
// Find matching route
const route = this.routes.get(host);
if (!route) {
logger.debug(`No route found for host: ${host}`);
return new Response('Service not found', {
status: 404,
headers: { 'Content-Type': 'text/plain' },
});
}
// Check if this is a WebSocket upgrade request
const upgrade = req.headers.get('upgrade')?.toLowerCase();
if (upgrade === 'websocket') {
return await this.handleWebSocketUpgrade(req, route, isHttps);
}
try {
// Build target URL
const targetUrl = `http://${route.targetHost}:${route.targetPort}${url.pathname}${url.search}`;
logger.debug(`Proxying to: ${targetUrl}`);
// Forward request to target
const targetReq = new Request(targetUrl, {
method: req.method,
headers: this.forwardHeaders(req.headers, host, isHttps),
body: req.body,
});
const response = await fetch(targetReq);
// Forward response back to client
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: this.filterResponseHeaders(response.headers),
});
} catch (error) {
logger.error(`Proxy error for ${host}: ${error.message}`);
return new Response('Bad Gateway', {
status: 502,
headers: { 'Content-Type': 'text/plain' },
});
}
}
/**
* Handle WebSocket upgrade and proxy connection
*/
private async handleWebSocketUpgrade(
req: Request,
route: IProxyRoute,
isHttps: boolean
): Promise<Response> {
try {
const url = new URL(req.url);
const targetUrl = `ws://${route.targetHost}:${route.targetPort}${url.pathname}${url.search}`;
logger.info(`WebSocket upgrade: ${url.host} -> ${targetUrl}`);
// Upgrade the client connection
const { socket: clientSocket, response } = Deno.upgradeWebSocket(req);
// Connect to backend WebSocket
const backendSocket = new WebSocket(targetUrl);
// Proxy messages from client to backend
clientSocket.onmessage = (e) => {
if (backendSocket.readyState === WebSocket.OPEN) {
backendSocket.send(e.data);
}
};
// Proxy messages from backend to client
backendSocket.onmessage = (e) => {
if (clientSocket.readyState === WebSocket.OPEN) {
clientSocket.send(e.data);
}
};
// Handle client close
clientSocket.onclose = () => {
logger.debug(`Client WebSocket closed for ${url.host}`);
backendSocket.close();
};
// Handle backend close
backendSocket.onclose = () => {
logger.debug(`Backend WebSocket closed for ${targetUrl}`);
clientSocket.close();
};
// Handle errors
clientSocket.onerror = (e) => {
logger.error(`Client WebSocket error: ${e}`);
backendSocket.close();
};
backendSocket.onerror = (e) => {
logger.error(`Backend WebSocket error: ${e}`);
clientSocket.close();
};
return response;
} catch (error) {
logger.error(`WebSocket upgrade error: ${error.message}`);
return new Response('WebSocket Upgrade Failed', {
status: 500,
headers: { 'Content-Type': 'text/plain' },
});
}
}
/**
* Forward request headers to target, filtering out problematic ones
*/
private forwardHeaders(headers: Headers, originalHost: string, isHttps: boolean): Headers {
const forwarded = new Headers();
// Copy most headers
for (const [key, value] of headers.entries()) {
// Skip headers that should not be forwarded
if (
key.toLowerCase() === 'host' ||
key.toLowerCase() === 'connection' ||
key.toLowerCase() === 'keep-alive' ||
key.toLowerCase() === 'proxy-authenticate' ||
key.toLowerCase() === 'proxy-authorization' ||
key.toLowerCase() === 'te' ||
key.toLowerCase() === 'trailers' ||
key.toLowerCase() === 'transfer-encoding' ||
key.toLowerCase() === 'upgrade'
) {
continue;
}
forwarded.set(key, value);
}
// Add X-Forwarded headers
forwarded.set('X-Forwarded-For', headers.get('x-forwarded-for') || 'unknown');
forwarded.set('X-Forwarded-Host', originalHost);
forwarded.set('X-Forwarded-Proto', isHttps ? 'https' : 'http');
return forwarded;
}
/**
* Filter response headers
*/
private filterResponseHeaders(headers: Headers): Headers {
const filtered = new Headers();
for (const [key, value] of headers.entries()) {
// Skip problematic headers
if (
key.toLowerCase() === 'connection' ||
key.toLowerCase() === 'keep-alive' ||
key.toLowerCase() === 'transfer-encoding'
) {
continue;
}
filtered.set(key, value);
}
return filtered;
}
/**
* Add a route for a service
*/
async addRoute(serviceId: number, domain: string, targetPort: number): Promise<void> {
try {
// Get container IP from Docker
const service = this.database.getServiceByID(serviceId);
if (!service || !service.containerID) {
throw new Error(`Service not found or has no container: ${serviceId}`);
}
// For Docker, we can use the container name or get its IP
// For now, use localhost since containers expose ports
const targetHost = 'localhost'; // TODO: Get actual container IP from Docker network
const route: IProxyRoute = {
domain,
targetHost,
targetPort,
serviceId,
};
this.routes.set(domain, route);
logger.success(`Added proxy route: ${domain} -> ${targetHost}:${targetPort}`);
} catch (error) {
logger.error(`Failed to add route for ${domain}: ${error.message}`);
throw error;
}
}
/**
* Remove a route
*/
removeRoute(domain: string): void {
if (this.routes.delete(domain)) {
logger.success(`Removed proxy route: ${domain}`);
} else {
logger.warn(`Route not found: ${domain}`);
}
}
/**
* Get all routes
*/
getRoutes(): IProxyRoute[] {
return Array.from(this.routes.values());
}
/**
* Reload routes from database
*/
async reloadRoutes(): Promise<void> {
try {
logger.info('Reloading proxy routes...');
this.routes.clear();
const services = this.database.getAllServices();
for (const service of services) {
if (service.domain && service.status === 'running' && service.containerID) {
await this.addRoute(service.id!, service.domain, service.port);
}
}
logger.success(`Loaded ${this.routes.size} proxy routes`);
} catch (error) {
logger.error(`Failed to reload routes: ${error.message}`);
throw error;
}
}
/**
* Add TLS certificate for a domain
*/
async addCertificate(domain: string, certPath: string, keyPath: string): Promise<void> {
try {
// Verify certificate files exist
await Deno.stat(certPath);
await Deno.stat(keyPath);
this.tlsConfigs.set(domain, {
domain,
certPath,
keyPath,
});
logger.success(`Added TLS certificate for ${domain}`);
// If HTTPS server is already running, we need to restart it
// TODO: Implement hot reload for certificates
if (this.httpsServer) {
logger.warn('HTTPS server restart required for new certificate to take effect');
}
} catch (error) {
logger.error(`Failed to add certificate for ${domain}: ${error.message}`);
throw error;
}
}
/**
* Remove TLS certificate for a domain
*/
removeCertificate(domain: string): void {
if (this.tlsConfigs.delete(domain)) {
logger.success(`Removed TLS certificate for ${domain}`);
} else {
logger.warn(`Certificate not found for domain: ${domain}`);
}
}
/**
* Reload TLS certificates from SSL manager
*/
async reloadCertificates(): Promise<void> {
try {
logger.info('Reloading TLS certificates...');
this.tlsConfigs.clear();
const certificates = this.database.getAllSSLCertificates();
for (const cert of certificates) {
if (cert.domain && cert.certPath && cert.keyPath) {
try {
await this.addCertificate(cert.domain, cert.fullChainPath, cert.keyPath);
} catch (error) {
logger.warn(`Failed to load certificate for ${cert.domain}: ${error.message}`);
}
}
}
logger.success(`Loaded ${this.tlsConfigs.size} TLS certificates`);
// Restart HTTPS server if it was running
if (this.httpsServer) {
logger.info('Restarting HTTPS server with new certificates...');
await this.httpsServer.shutdown();
this.httpsServer = null;
await this.startHttps();
}
} catch (error) {
logger.error(`Failed to reload certificates: ${error.message}`);
throw error;
}
}
/**
* Get status of reverse proxy
*/
getStatus() {
return {
http: {
running: this.httpServer !== null,
port: this.httpPort,
},
https: {
running: this.httpsServer !== null,
port: this.httpsPort,
certificates: this.tlsConfigs.size,
},
routes: this.routes.size,
};
}
}

405
ts/classes/services.ts Normal file
View File

@@ -0,0 +1,405 @@
/**
* Services Manager for Onebox
*
* Orchestrates service deployment: Docker + Nginx + DNS + SSL
*/
import type { IService, IServiceDeployOptions } from '../types.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
import { OneboxDockerManager } from './docker.ts';
export class OneboxServicesManager {
private oneboxRef: any; // Will be Onebox instance
private database: OneboxDatabase;
private docker: OneboxDockerManager;
constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef;
this.database = oneboxRef.database;
this.docker = oneboxRef.docker;
}
/**
* Deploy a new service (full workflow)
*/
async deployService(options: IServiceDeployOptions): Promise<IService> {
try {
logger.info(`Deploying service: ${options.name}`);
// Check if service already exists
const existing = this.database.getServiceByName(options.name);
if (existing) {
throw new Error(`Service already exists: ${options.name}`);
}
// Create service record in database
const service = await this.database.createService({
name: options.name,
image: options.image,
registry: options.registry,
envVars: options.envVars || {},
port: options.port,
domain: options.domain,
status: 'stopped',
createdAt: Date.now(),
updatedAt: Date.now(),
});
// Pull image
await this.docker.pullImage(options.image, options.registry);
// Create container
const containerID = await this.docker.createContainer(service);
// Update service with container ID
this.database.updateService(service.id!, {
containerID,
status: 'starting',
});
// Start container
await this.docker.startContainer(containerID);
// Update status
this.database.updateService(service.id!, { status: 'running' });
// If domain is specified, configure nginx, DNS, and SSL
if (options.domain) {
logger.info(`Configuring domain: ${options.domain}`);
// Configure DNS (if autoDNS is enabled)
if (options.autoDNS !== false) {
try {
await this.oneboxRef.dns.addDNSRecord(options.domain);
} catch (error) {
logger.warn(`Failed to configure DNS for ${options.domain}: ${error.message}`);
}
}
// Configure reverse proxy
try {
await this.oneboxRef.reverseProxy.addRoute(service.id!, options.domain, options.port);
} catch (error) {
logger.warn(`Failed to configure reverse proxy for ${options.domain}: ${error.message}`);
}
// Configure SSL (if autoSSL is enabled)
if (options.autoSSL !== false) {
try {
await this.oneboxRef.ssl.obtainCertificate(options.domain);
await this.oneboxRef.reverseProxy.reloadCertificates();
} catch (error) {
logger.warn(`Failed to obtain SSL certificate for ${options.domain}: ${error.message}`);
}
}
}
logger.success(`Service deployed successfully: ${options.name}`);
return this.database.getServiceByName(options.name)!;
} catch (error) {
logger.error(`Failed to deploy service ${options.name}: ${error.message}`);
throw error;
}
}
/**
* Start a service
*/
async startService(name: string): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
logger.info(`Starting service: ${name}`);
this.database.updateService(service.id!, { status: 'starting' });
await this.docker.startContainer(service.containerID);
this.database.updateService(service.id!, { status: 'running' });
logger.success(`Service started: ${name}`);
} catch (error) {
logger.error(`Failed to start service ${name}: ${error.message}`);
this.database.updateService(
this.database.getServiceByName(name)?.id!,
{ status: 'failed' }
);
throw error;
}
}
/**
* Stop a service
*/
async stopService(name: string): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
logger.info(`Stopping service: ${name}`);
this.database.updateService(service.id!, { status: 'stopping' });
await this.docker.stopContainer(service.containerID);
this.database.updateService(service.id!, { status: 'stopped' });
logger.success(`Service stopped: ${name}`);
} catch (error) {
logger.error(`Failed to stop service ${name}: ${error.message}`);
throw error;
}
}
/**
* Restart a service
*/
async restartService(name: string): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
logger.info(`Restarting service: ${name}`);
await this.docker.restartContainer(service.containerID);
this.database.updateService(service.id!, { status: 'running' });
logger.success(`Service restarted: ${name}`);
} catch (error) {
logger.error(`Failed to restart service ${name}: ${error.message}`);
throw error;
}
}
/**
* Remove a service (full cleanup)
*/
async removeService(name: string): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
logger.info(`Removing service: ${name}`);
// Stop and remove container
if (service.containerID) {
try {
await this.docker.removeContainer(service.containerID, true);
} catch (error) {
logger.warn(`Failed to remove container: ${error.message}`);
}
}
// Remove reverse proxy route
if (service.domain) {
try {
this.oneboxRef.reverseProxy.removeRoute(service.domain);
} catch (error) {
logger.warn(`Failed to remove reverse proxy route: ${error.message}`);
}
// Note: We don't remove DNS records or SSL certs automatically
// as they might be used by other services or need manual cleanup
}
// Remove from database
this.database.deleteService(service.id!);
logger.success(`Service removed: ${name}`);
} catch (error) {
logger.error(`Failed to remove service ${name}: ${error.message}`);
throw error;
}
}
/**
* List all services
*/
listServices(): IService[] {
return this.database.getAllServices();
}
/**
* Get service by name
*/
getService(name: string): IService | null {
return this.database.getServiceByName(name);
}
/**
* Get service logs
*/
async getServiceLogs(name: string, tail = 100): Promise<string> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
const logs = await this.docker.getContainerLogs(service.containerID, tail);
return `=== STDOUT ===\n${logs.stdout}\n\n=== STDERR ===\n${logs.stderr}`;
} catch (error) {
logger.error(`Failed to get logs for service ${name}: ${error.message}`);
throw error;
}
}
/**
* Stream service logs (real-time)
*/
async streamServiceLogs(
name: string,
callback: (line: string, isError: boolean) => void
): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
await this.docker.streamContainerLogs(service.containerID, callback);
} catch (error) {
logger.error(`Failed to stream logs for service ${name}: ${error.message}`);
throw error;
}
}
/**
* Get service metrics
*/
async getServiceMetrics(name: string) {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
if (!service.containerID) {
throw new Error(`Service ${name} has no container ID`);
}
const stats = await this.docker.getContainerStats(service.containerID);
return stats;
} catch (error) {
logger.error(`Failed to get metrics for service ${name}: ${error.message}`);
return null;
}
}
/**
* Get service status
*/
async getServiceStatus(name: string): Promise<string> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
return 'not-found';
}
if (!service.containerID) {
return service.status;
}
const status = await this.docker.getContainerStatus(service.containerID);
return status;
} catch (error) {
logger.error(`Failed to get status for service ${name}: ${error.message}`);
return 'unknown';
}
}
/**
* Update service environment variables
*/
async updateServiceEnv(name: string, envVars: Record<string, string>): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service) {
throw new Error(`Service not found: ${name}`);
}
// Update database
this.database.updateService(service.id!, { envVars });
// Note: Requires container restart to take effect
logger.info(`Environment variables updated for ${name}. Restart service to apply changes.`);
} catch (error) {
logger.error(`Failed to update env vars for service ${name}: ${error.message}`);
throw error;
}
}
/**
* Sync service status from Docker
*/
async syncServiceStatus(name: string): Promise<void> {
try {
const service = this.database.getServiceByName(name);
if (!service || !service.containerID) {
return;
}
const status = await this.docker.getContainerStatus(service.containerID);
// Map Docker status to our status
let ourStatus: IService['status'] = 'stopped';
if (status === 'running') {
ourStatus = 'running';
} else if (status === 'exited' || status === 'dead') {
ourStatus = 'stopped';
} else if (status === 'created') {
ourStatus = 'stopped';
} else if (status === 'restarting') {
ourStatus = 'starting';
}
this.database.updateService(service.id!, { status: ourStatus });
} catch (error) {
logger.debug(`Failed to sync status for service ${name}: ${error.message}`);
}
}
/**
* Sync all service statuses from Docker
*/
async syncAllServiceStatuses(): Promise<void> {
const services = this.listServices();
for (const service of services) {
await this.syncServiceStatus(service.name);
}
}
}

317
ts/classes/ssl.ts Normal file
View File

@@ -0,0 +1,317 @@
/**
* SSL Manager for Onebox
*
* Manages SSL certificates via Let's Encrypt (using smartacme)
*/
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { OneboxDatabase } from './database.ts';
export class OneboxSslManager {
private oneboxRef: any;
private database: OneboxDatabase;
private smartacme: plugins.smartacme.SmartAcme | null = null;
private acmeEmail: string | null = null;
constructor(oneboxRef: any) {
this.oneboxRef = oneboxRef;
this.database = oneboxRef.database;
}
/**
* Initialize SSL manager
*/
async init(): Promise<void> {
try {
// Get ACME email from settings
const acmeEmail = this.database.getSetting('acmeEmail');
if (!acmeEmail) {
logger.warn('ACME email not configured. SSL certificate management will be limited.');
logger.info('Configure with: onebox config set acmeEmail <email>');
return;
}
this.acmeEmail = acmeEmail;
// Initialize SmartACME
this.smartacme = new plugins.smartacme.SmartAcme({
email: acmeEmail,
environment: 'production', // or 'staging' for testing
dns: 'cloudflare', // Use Cloudflare DNS challenge
});
logger.info('SSL manager initialized with SmartACME');
} catch (error) {
logger.error(`Failed to initialize SSL manager: ${error.message}`);
throw error;
}
}
/**
* Check if SSL manager is configured
*/
isConfigured(): boolean {
return this.smartacme !== null && this.acmeEmail !== null;
}
/**
* Obtain SSL certificate for a domain
*/
async obtainCertificate(domain: string): Promise<void> {
try {
if (!this.isConfigured()) {
throw new Error('SSL manager not configured');
}
logger.info(`Obtaining SSL certificate for ${domain}...`);
// Check if certificate already exists and is valid
const existing = this.database.getSSLCertificate(domain);
if (existing && existing.expiryDate > Date.now()) {
logger.info(`Valid certificate already exists for ${domain}`);
return;
}
// Use certbot for now (smartacme integration would be more complex)
// This is a simplified version - in production, use proper ACME client
await this.obtainCertificateWithCertbot(domain);
// Store in database
const certPath = `/etc/letsencrypt/live/${domain}/cert.pem`;
const keyPath = `/etc/letsencrypt/live/${domain}/privkey.pem`;
const fullChainPath = `/etc/letsencrypt/live/${domain}/fullchain.pem`;
// Get expiry date (90 days from now for Let's Encrypt)
const expiryDate = Date.now() + 90 * 24 * 60 * 60 * 1000;
if (existing) {
this.database.updateSSLCertificate(domain, {
certPath,
keyPath,
fullChainPath,
expiryDate,
});
} else {
await this.database.createSSLCertificate({
domain,
certPath,
keyPath,
fullChainPath,
expiryDate,
issuer: 'Let\'s Encrypt',
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
// Reload certificates in reverse proxy
await this.oneboxRef.reverseProxy.reloadCertificates();
logger.success(`SSL certificate obtained for ${domain}`);
} catch (error) {
logger.error(`Failed to obtain certificate for ${domain}: ${error.message}`);
throw error;
}
}
/**
* Obtain certificate using certbot
*/
private async obtainCertificateWithCertbot(domain: string): Promise<void> {
try {
logger.info(`Running certbot for ${domain}...`);
// Use webroot method (nginx serves .well-known/acme-challenge)
const command = new Deno.Command('certbot', {
args: [
'certonly',
'--webroot',
'--webroot-path=/var/www/certbot',
'--email',
this.acmeEmail!,
'--agree-tos',
'--no-eff-email',
'--domain',
domain,
'--non-interactive',
],
stdout: 'piped',
stderr: 'piped',
});
const { code, stderr } = await command.output();
if (code !== 0) {
const errorMsg = new TextDecoder().decode(stderr);
throw new Error(`Certbot failed: ${errorMsg}`);
}
logger.success(`Certbot obtained certificate for ${domain}`);
} catch (error) {
throw new Error(`Failed to run certbot: ${error.message}`);
}
}
/**
* Renew certificate for a domain
*/
async renewCertificate(domain: string): Promise<void> {
try {
logger.info(`Renewing SSL certificate for ${domain}...`);
const command = new Deno.Command('certbot', {
args: ['renew', '--cert-name', domain, '--non-interactive'],
stdout: 'piped',
stderr: 'piped',
});
const { code, stderr } = await command.output();
if (code !== 0) {
const errorMsg = new TextDecoder().decode(stderr);
throw new Error(`Certbot renewal failed: ${errorMsg}`);
}
// Update database
const expiryDate = Date.now() + 90 * 24 * 60 * 60 * 1000;
this.database.updateSSLCertificate(domain, {
expiryDate,
});
logger.success(`Certificate renewed for ${domain}`);
// Reload certificates in reverse proxy
await this.oneboxRef.reverseProxy.reloadCertificates();
} catch (error) {
logger.error(`Failed to renew certificate for ${domain}: ${error.message}`);
throw error;
}
}
/**
* List all certificates
*/
listCertificates() {
return this.database.getAllSSLCertificates();
}
/**
* Get certificate info for a domain
*/
getCertificate(domain: string) {
return this.database.getSSLCertificate(domain);
}
/**
* Check certificates that are expiring soon and renew them
*/
async renewExpiring(): Promise<void> {
try {
logger.info('Checking for expiring certificates...');
const certificates = this.listCertificates();
const thirtyDaysFromNow = Date.now() + 30 * 24 * 60 * 60 * 1000;
for (const cert of certificates) {
if (cert.expiryDate < thirtyDaysFromNow) {
logger.info(`Certificate for ${cert.domain} expires soon, renewing...`);
try {
await this.renewCertificate(cert.domain);
} catch (error) {
logger.error(`Failed to renew ${cert.domain}: ${error.message}`);
// Continue with other certificates
}
}
}
logger.success('Certificate renewal check complete');
} catch (error) {
logger.error(`Failed to check expiring certificates: ${error.message}`);
throw error;
}
}
/**
* Force renewal of all certificates
*/
async renewAll(): Promise<void> {
try {
logger.info('Renewing all certificates...');
const command = new Deno.Command('certbot', {
args: ['renew', '--force-renewal', '--non-interactive'],
stdout: 'piped',
stderr: 'piped',
});
const { code, stderr } = await command.output();
if (code !== 0) {
const errorMsg = new TextDecoder().decode(stderr);
throw new Error(`Certbot renewal failed: ${errorMsg}`);
}
logger.success('All certificates renewed');
// Reload certificates in reverse proxy
await this.oneboxRef.reverseProxy.reloadCertificates();
} catch (error) {
logger.error(`Failed to renew all certificates: ${error.message}`);
throw error;
}
}
/**
* Check if certbot is installed
*/
async isCertbotInstalled(): Promise<boolean> {
try {
const command = new Deno.Command('which', {
args: ['certbot'],
stdout: 'piped',
stderr: 'piped',
});
const { code } = await command.output();
return code === 0;
} catch {
return false;
}
}
/**
* Get certificate expiry date from file
*/
async getCertificateExpiry(domain: string): Promise<Date | null> {
try {
const certPath = `/etc/letsencrypt/live/${domain}/cert.pem`;
const command = new Deno.Command('openssl', {
args: ['x509', '-enddate', '-noout', '-in', certPath],
stdout: 'piped',
stderr: 'piped',
});
const { code, stdout } = await command.output();
if (code !== 0) {
return null;
}
const output = new TextDecoder().decode(stdout);
const match = output.match(/notAfter=(.+)/);
if (match) {
return new Date(match[1]);
}
return null;
} catch (error) {
logger.error(`Failed to get certificate expiry for ${domain}: ${error.message}`);
return null;
}
}
}