update
This commit is contained in:
209
ts/classes/apiclient.ts
Normal file
209
ts/classes/apiclient.ts
Normal 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
425
ts/classes/daemon.ts
Normal 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
681
ts/classes/database.ts
Normal 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
301
ts/classes/dns.ts
Normal 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
489
ts/classes/docker.ts
Normal 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
362
ts/classes/httpserver.ts
Normal 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
217
ts/classes/onebox.ts
Normal 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
195
ts/classes/registries.ts
Normal 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
495
ts/classes/reverseproxy.ts
Normal 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
405
ts/classes/services.ts
Normal 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
317
ts/classes/ssl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user