feat: integrate toast notifications in settings and layout components
- Added ToastService for managing toast notifications. - Replaced alert in settings component with toast notifications for success and error messages. - Included ToastComponent in layout for displaying notifications. - Created loading spinner component for better user experience. - Implemented domain detail component with detailed views for certificates, requirements, and services. - Added functionality to manage and display SSL certificates and their statuses. - Introduced a registry manager class for handling Docker registry operations.
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
import { OneboxSslManager } from './sslmanager.ts';
|
||||
import { OneboxSslManager } from './ssl.ts';
|
||||
import type { ICertRequirement, ICertificate, IDomain } from '../types.ts';
|
||||
|
||||
export class CertRequirementManager {
|
||||
|
||||
@@ -22,6 +22,8 @@ export class OneboxDaemon {
|
||||
private monitoringInterval: number | null = null;
|
||||
private metricsInterval = 60000; // 1 minute
|
||||
private pidFilePath: string = PID_FILE_PATH;
|
||||
private lastDomainSync = 0; // Timestamp of last Cloudflare domain sync
|
||||
private domainSyncInterval = 6 * 60 * 60 * 1000; // 6 hours
|
||||
|
||||
constructor(oneboxRef: Onebox) {
|
||||
this.oneboxRef = oneboxRef;
|
||||
@@ -211,6 +213,18 @@ export class OneboxDaemon {
|
||||
// Check SSL certificate expiration
|
||||
await this.checkSSLExpiration();
|
||||
|
||||
// Process pending certificate requirements
|
||||
await this.processCertRequirements();
|
||||
|
||||
// Check for certificate renewal (every tick)
|
||||
await this.checkCertificateRenewal();
|
||||
|
||||
// Clean up old certificates (every tick, but cleanup has built-in 90-day threshold)
|
||||
await this.cleanupOldCertificates();
|
||||
|
||||
// Sync Cloudflare domains (less frequently - every 6 hours)
|
||||
await this.syncCloudflareDomainsIfNeeded();
|
||||
|
||||
// Check service health (TODO: implement health checks)
|
||||
|
||||
logger.debug('Monitoring tick complete');
|
||||
@@ -267,6 +281,62 @@ export class OneboxDaemon {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending certificate requirements
|
||||
*/
|
||||
private async processCertRequirements(): Promise<void> {
|
||||
try {
|
||||
await this.oneboxRef.certRequirementManager.processPendingRequirements();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to process cert requirements: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check certificates for renewal (30-day threshold)
|
||||
*/
|
||||
private async checkCertificateRenewal(): Promise<void> {
|
||||
try {
|
||||
await this.oneboxRef.certRequirementManager.checkCertificateRenewal();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check certificate renewal: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old invalid certificates (90+ days old)
|
||||
*/
|
||||
private async cleanupOldCertificates(): Promise<void> {
|
||||
try {
|
||||
await this.oneboxRef.certRequirementManager.cleanupOldCertificates();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cleanup old certificates: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Cloudflare domains if needed (every 6 hours)
|
||||
*/
|
||||
private async syncCloudflareDomainsIfNeeded(): Promise<void> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if it's time to sync (every 6 hours)
|
||||
if (now - this.lastDomainSync < this.domainSyncInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.oneboxRef.cloudflareDomainSync.isConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.oneboxRef.cloudflareDomainSync.syncZones();
|
||||
this.lastDomainSync = now;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to sync Cloudflare domains: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep process alive
|
||||
*/
|
||||
|
||||
@@ -505,6 +505,35 @@ export class OneboxDatabase {
|
||||
this.setMigrationVersion(3);
|
||||
logger.success('Migration 3 completed: Domain management tables created');
|
||||
}
|
||||
|
||||
// Migration 4: Add Onebox Registry support columns to services table
|
||||
const version4 = this.getMigrationVersion();
|
||||
if (version4 < 4) {
|
||||
logger.info('Running migration 4: Adding Onebox Registry columns to services table...');
|
||||
|
||||
// Add new columns for registry support
|
||||
this.query(`
|
||||
ALTER TABLE services ADD COLUMN use_onebox_registry INTEGER DEFAULT 0
|
||||
`);
|
||||
this.query(`
|
||||
ALTER TABLE services ADD COLUMN registry_repository TEXT
|
||||
`);
|
||||
this.query(`
|
||||
ALTER TABLE services ADD COLUMN registry_token TEXT
|
||||
`);
|
||||
this.query(`
|
||||
ALTER TABLE services ADD COLUMN registry_image_tag TEXT DEFAULT 'latest'
|
||||
`);
|
||||
this.query(`
|
||||
ALTER TABLE services ADD COLUMN auto_update_on_push INTEGER DEFAULT 0
|
||||
`);
|
||||
this.query(`
|
||||
ALTER TABLE services ADD COLUMN image_digest TEXT
|
||||
`);
|
||||
|
||||
this.setMigrationVersion(4);
|
||||
logger.success('Migration 4 completed: Onebox Registry columns added to services table');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Migration failed: ${error.message}`);
|
||||
logger.error(`Stack: ${error.stack}`);
|
||||
@@ -589,8 +618,12 @@ export class OneboxDatabase {
|
||||
|
||||
const now = Date.now();
|
||||
this.query(
|
||||
`INSERT INTO services (name, image, registry, env_vars, port, domain, container_id, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO services (
|
||||
name, image, registry, env_vars, port, domain, container_id, status,
|
||||
created_at, updated_at,
|
||||
use_onebox_registry, registry_repository, registry_token, registry_image_tag,
|
||||
auto_update_on_push, image_digest
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
service.name,
|
||||
service.image,
|
||||
@@ -602,6 +635,12 @@ export class OneboxDatabase {
|
||||
service.status,
|
||||
now,
|
||||
now,
|
||||
service.useOneboxRegistry ? 1 : 0,
|
||||
service.registryRepository || null,
|
||||
service.registryToken || null,
|
||||
service.registryImageTag || 'latest',
|
||||
service.autoUpdateOnPush ? 1 : 0,
|
||||
service.imageDigest || null,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -663,6 +702,31 @@ export class OneboxDatabase {
|
||||
fields.push('status = ?');
|
||||
values.push(updates.status);
|
||||
}
|
||||
// Onebox Registry fields
|
||||
if (updates.useOneboxRegistry !== undefined) {
|
||||
fields.push('use_onebox_registry = ?');
|
||||
values.push(updates.useOneboxRegistry ? 1 : 0);
|
||||
}
|
||||
if (updates.registryRepository !== undefined) {
|
||||
fields.push('registry_repository = ?');
|
||||
values.push(updates.registryRepository);
|
||||
}
|
||||
if (updates.registryToken !== undefined) {
|
||||
fields.push('registry_token = ?');
|
||||
values.push(updates.registryToken);
|
||||
}
|
||||
if (updates.registryImageTag !== undefined) {
|
||||
fields.push('registry_image_tag = ?');
|
||||
values.push(updates.registryImageTag);
|
||||
}
|
||||
if (updates.autoUpdateOnPush !== undefined) {
|
||||
fields.push('auto_update_on_push = ?');
|
||||
values.push(updates.autoUpdateOnPush ? 1 : 0);
|
||||
}
|
||||
if (updates.imageDigest !== undefined) {
|
||||
fields.push('image_digest = ?');
|
||||
values.push(updates.imageDigest);
|
||||
}
|
||||
|
||||
fields.push('updated_at = ?');
|
||||
values.push(Date.now());
|
||||
@@ -701,6 +765,13 @@ export class OneboxDatabase {
|
||||
status: String(row.status || row[8]) as IService['status'],
|
||||
createdAt: Number(row.created_at || row[9]),
|
||||
updatedAt: Number(row.updated_at || row[10]),
|
||||
// Onebox Registry fields
|
||||
useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : undefined,
|
||||
registryRepository: row.registry_repository ? String(row.registry_repository) : undefined,
|
||||
registryToken: row.registry_token ? String(row.registry_token) : undefined,
|
||||
registryImageTag: row.registry_image_tag ? String(row.registry_image_tag) : undefined,
|
||||
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
|
||||
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,11 @@ export class OneboxHttpServer {
|
||||
return this.handleWebSocketUpgrade(req);
|
||||
}
|
||||
|
||||
// Docker Registry v2 API (no auth required - registry handles it)
|
||||
if (path.startsWith('/v2/')) {
|
||||
return await this.oneboxRef.registry.handleRequest(req);
|
||||
}
|
||||
|
||||
// API routes
|
||||
if (path.startsWith('/api/')) {
|
||||
return await this.handleApiRequest(req, path);
|
||||
@@ -199,6 +204,9 @@ export class OneboxHttpServer {
|
||||
} else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'GET') {
|
||||
const name = path.split('/').pop()!;
|
||||
return await this.handleGetServiceRequest(name);
|
||||
} else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'PUT') {
|
||||
const name = path.split('/').pop()!;
|
||||
return await this.handleUpdateServiceRequest(name, req);
|
||||
} else if (path.match(/^\/api\/services\/[^/]+$/) && method === 'DELETE') {
|
||||
const name = path.split('/').pop()!;
|
||||
return await this.handleDeleteServiceRequest(name);
|
||||
@@ -231,6 +239,21 @@ export class OneboxHttpServer {
|
||||
} else if (path.match(/^\/api\/domains\/[^/]+$/) && method === 'GET') {
|
||||
const domainName = path.split('/').pop()!;
|
||||
return await this.handleGetDomainDetailRequest(domainName);
|
||||
} else if (path === '/api/dns' && method === 'GET') {
|
||||
return await this.handleGetDnsRecordsRequest();
|
||||
} else if (path === '/api/dns' && method === 'POST') {
|
||||
return await this.handleCreateDnsRecordRequest(req);
|
||||
} else if (path.match(/^\/api\/dns\/[^/]+$/) && method === 'DELETE') {
|
||||
const domain = path.split('/').pop()!;
|
||||
return await this.handleDeleteDnsRecordRequest(domain);
|
||||
} else if (path === '/api/dns/sync' && method === 'POST') {
|
||||
return await this.handleSyncDnsRecordsRequest();
|
||||
} else if (path.match(/^\/api\/registry\/tags\/[^/]+$/)) {
|
||||
const serviceName = path.split('/').pop()!;
|
||||
return await this.handleGetRegistryTagsRequest(serviceName);
|
||||
} else if (path.match(/^\/api\/registry\/token\/[^/]+$/)) {
|
||||
const serviceName = path.split('/').pop()!;
|
||||
return await this.handleGetRegistryTokenRequest(serviceName);
|
||||
} else {
|
||||
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
||||
}
|
||||
@@ -338,6 +361,36 @@ export class OneboxHttpServer {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUpdateServiceRequest(name: string, req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const updates: {
|
||||
image?: string;
|
||||
registry?: string;
|
||||
port?: number;
|
||||
domain?: string;
|
||||
envVars?: Record<string, string>;
|
||||
} = {};
|
||||
|
||||
// Extract valid update fields
|
||||
if (body.image !== undefined) updates.image = body.image;
|
||||
if (body.registry !== undefined) updates.registry = body.registry;
|
||||
if (body.port !== undefined) updates.port = body.port;
|
||||
if (body.domain !== undefined) updates.domain = body.domain;
|
||||
if (body.envVars !== undefined) updates.envVars = body.envVars;
|
||||
|
||||
const service = await this.oneboxRef.services.updateService(name, updates);
|
||||
|
||||
// Broadcast service updated
|
||||
this.broadcastServiceUpdate(name, 'updated', service);
|
||||
|
||||
return this.jsonResponse({ success: true, data: service });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update service ${name}: ${error.message}`);
|
||||
return this.jsonResponse({ success: false, error: error.message || 'Failed to update service' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDeleteServiceRequest(name: string): Promise<Response> {
|
||||
try {
|
||||
await this.oneboxRef.services.removeService(name);
|
||||
@@ -659,6 +712,87 @@ export class OneboxHttpServer {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGetDnsRecordsRequest(): Promise<Response> {
|
||||
try {
|
||||
const records = this.oneboxRef.dns.listDNSRecords();
|
||||
return this.jsonResponse({ success: true, data: records });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get DNS records: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to get DNS records',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCreateDnsRecordRequest(req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { domain, ip } = body;
|
||||
|
||||
if (!domain) {
|
||||
return this.jsonResponse(
|
||||
{ success: false, error: 'Domain is required' },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
await this.oneboxRef.dns.addDNSRecord(domain, ip);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: `DNS record created for ${domain}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to create DNS record: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to create DNS record',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDeleteDnsRecordRequest(domain: string): Promise<Response> {
|
||||
try {
|
||||
await this.oneboxRef.dns.removeDNSRecord(domain);
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: `DNS record deleted for ${domain}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete DNS record for ${domain}: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to delete DNS record',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSyncDnsRecordsRequest(): Promise<Response> {
|
||||
try {
|
||||
if (!this.oneboxRef.dns.isConfigured()) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'DNS manager not configured',
|
||||
}, 400);
|
||||
}
|
||||
|
||||
await this.oneboxRef.dns.syncFromCloudflare();
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
message: 'DNS records synced from Cloudflare',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to sync DNS records: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to sync DNS records',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket upgrade
|
||||
*/
|
||||
@@ -755,6 +889,70 @@ export class OneboxHttpServer {
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Registry Endpoints ============
|
||||
|
||||
private async handleGetRegistryTagsRequest(serviceName: string): Promise<Response> {
|
||||
try {
|
||||
const tags = await this.oneboxRef.registry.getImageTags(serviceName);
|
||||
return this.jsonResponse({ success: true, data: tags });
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get registry tags for ${serviceName}: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to get registry tags',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleGetRegistryTokenRequest(serviceName: string): Promise<Response> {
|
||||
try {
|
||||
// Get the service to verify it exists
|
||||
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
||||
if (!service) {
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: 'Service not found',
|
||||
}, 404);
|
||||
}
|
||||
|
||||
// If service already has a token, return it
|
||||
if (service.registryToken) {
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
token: service.registryToken,
|
||||
repository: serviceName,
|
||||
baseUrl: this.oneboxRef.registry.getBaseUrl(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new token
|
||||
const token = await this.oneboxRef.registry.createServiceToken(serviceName);
|
||||
|
||||
// Save token to database
|
||||
this.oneboxRef.database.updateService(service.id!, {
|
||||
registryToken: token,
|
||||
registryRepository: serviceName,
|
||||
});
|
||||
|
||||
return this.jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
token: token,
|
||||
repository: serviceName,
|
||||
baseUrl: this.oneboxRef.registry.getBaseUrl(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get registry token for ${serviceName}: ${error.message}`);
|
||||
return this.jsonResponse({
|
||||
success: false,
|
||||
error: error.message || 'Failed to get registry token',
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create JSON response
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,9 @@ import { OneboxDnsManager } from './dns.ts';
|
||||
import { OneboxSslManager } from './ssl.ts';
|
||||
import { OneboxDaemon } from './daemon.ts';
|
||||
import { OneboxHttpServer } from './httpserver.ts';
|
||||
import { CloudflareDomainSync } from './cloudflare-sync.ts';
|
||||
import { CertRequirementManager } from './cert-requirement-manager.ts';
|
||||
import { RegistryManager } from './registry.ts';
|
||||
|
||||
export class Onebox {
|
||||
public database: OneboxDatabase;
|
||||
@@ -25,6 +28,9 @@ export class Onebox {
|
||||
public ssl: OneboxSslManager;
|
||||
public daemon: OneboxDaemon;
|
||||
public httpServer: OneboxHttpServer;
|
||||
public cloudflareDomainSync: CloudflareDomainSync;
|
||||
public certRequirementManager: CertRequirementManager;
|
||||
public registry: RegistryManager;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
@@ -41,6 +47,15 @@ export class Onebox {
|
||||
this.ssl = new OneboxSslManager(this);
|
||||
this.daemon = new OneboxDaemon(this);
|
||||
this.httpServer = new OneboxHttpServer(this);
|
||||
this.registry = new RegistryManager({
|
||||
dataDir: './.nogit/registry-data',
|
||||
port: 4000,
|
||||
baseUrl: 'localhost:5000',
|
||||
});
|
||||
|
||||
// Initialize domain management
|
||||
this.cloudflareDomainSync = new CloudflareDomainSync(this.database);
|
||||
this.certRequirementManager = new CertRequirementManager(this.database, this.ssl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,9 +91,27 @@ export class Onebox {
|
||||
logger.warn('SSL initialization failed - SSL features will be limited');
|
||||
}
|
||||
|
||||
// Initialize Cloudflare domain sync (non-critical)
|
||||
try {
|
||||
await this.cloudflareDomainSync.init();
|
||||
} catch (error) {
|
||||
logger.warn('Cloudflare domain sync initialization failed - domain sync will be limited');
|
||||
}
|
||||
|
||||
// Initialize Onebox Registry (non-critical)
|
||||
try {
|
||||
await this.registry.init();
|
||||
} catch (error) {
|
||||
logger.warn('Onebox Registry initialization failed - local registry will be disabled');
|
||||
logger.warn(`Error: ${error.message}`);
|
||||
}
|
||||
|
||||
// Login to all registries
|
||||
await this.registries.loginToAllRegistries();
|
||||
|
||||
// Start auto-update monitoring for registry services
|
||||
this.services.startAutoUpdateMonitoring();
|
||||
|
||||
this.initialized = true;
|
||||
logger.success('Onebox initialized successfully');
|
||||
} catch (error) {
|
||||
|
||||
237
ts/classes/registry.ts
Normal file
237
ts/classes/registry.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Onebox Registry Manager
|
||||
*
|
||||
* Manages the local Docker registry using:
|
||||
* - @push.rocks/smarts3 (S3-compatible server with filesystem storage)
|
||||
* - @push.rocks/smartregistry (OCI-compliant Docker registry)
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
|
||||
export class RegistryManager {
|
||||
private s3Server: any = null;
|
||||
private registry: any = null;
|
||||
private jwtSecret: string;
|
||||
private baseUrl: string;
|
||||
private isInitialized = false;
|
||||
|
||||
constructor(private options: {
|
||||
dataDir?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
} = {}) {
|
||||
this.jwtSecret = this.getJwtSecret();
|
||||
this.baseUrl = options.baseUrl || 'localhost:5000';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the registry (start smarts3 and smartregistry)
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.isInitialized) {
|
||||
logger.warn('Registry already initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dataDir = this.options.dataDir || './.nogit/registry-data';
|
||||
const port = this.options.port || 4000;
|
||||
|
||||
logger.info(`Starting smarts3 server on port ${port}...`);
|
||||
|
||||
// 1. Start smarts3 server (S3-compatible storage with filesystem backend)
|
||||
this.s3Server = await plugins.smarts3.Smarts3.createAndStart({
|
||||
server: {
|
||||
port: port,
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
storage: {
|
||||
bucketsDir: dataDir,
|
||||
cleanSlate: false, // Preserve data across restarts
|
||||
},
|
||||
});
|
||||
|
||||
logger.success(`smarts3 server started on port ${port}`);
|
||||
|
||||
// 2. Configure smartregistry to use smarts3
|
||||
logger.info('Initializing smartregistry...');
|
||||
|
||||
this.registry = new plugins.smartregistry.SmartRegistry({
|
||||
storage: {
|
||||
endpoint: 'localhost',
|
||||
port: port,
|
||||
accessKey: 'onebox', // smarts3 doesn't validate credentials
|
||||
accessSecret: 'onebox',
|
||||
useSsl: false,
|
||||
region: 'us-east-1',
|
||||
bucketName: 'onebox-registry',
|
||||
},
|
||||
auth: {
|
||||
jwtSecret: this.jwtSecret,
|
||||
ociTokens: {
|
||||
enabled: true,
|
||||
issuer: 'onebox-registry',
|
||||
service: 'onebox-registry',
|
||||
},
|
||||
},
|
||||
oci: {
|
||||
enabled: true,
|
||||
basePath: '/v2',
|
||||
},
|
||||
});
|
||||
|
||||
await this.registry.init();
|
||||
|
||||
this.isInitialized = true;
|
||||
logger.success('Onebox Registry initialized successfully');
|
||||
} catch (error) {
|
||||
logger.error(`Failed to initialize registry: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming HTTP requests to the registry
|
||||
*/
|
||||
async handleRequest(req: Request): Promise<Response> {
|
||||
if (!this.isInitialized) {
|
||||
return new Response('Registry not initialized', { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.registry.handleRequest(req);
|
||||
} catch (error) {
|
||||
logger.error(`Registry request error: ${error.message}`);
|
||||
return new Response('Internal registry error', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a push/pull token for a service
|
||||
*/
|
||||
async createServiceToken(serviceName: string): Promise<string> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Registry not initialized');
|
||||
}
|
||||
|
||||
const repository = serviceName;
|
||||
const scopes = [
|
||||
`oci:repository:${repository}:push`,
|
||||
`oci:repository:${repository}:pull`,
|
||||
];
|
||||
|
||||
// Create OCI JWT token (expires in 1 year = 365 * 24 * 60 * 60 seconds)
|
||||
const token = await this.registry.authManager.createOciToken(
|
||||
'onebox',
|
||||
scopes,
|
||||
31536000 // 365 days in seconds
|
||||
);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tags for a repository
|
||||
*/
|
||||
async getImageTags(repository: string): Promise<string[]> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Registry not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
const tags = await this.registry.getTags(repository);
|
||||
return tags || [];
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get tags for ${repository}: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the manifest digest for a specific image tag
|
||||
*/
|
||||
async getImageDigest(repository: string, tag: string): Promise<string | null> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Registry not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = await this.registry.getManifest(repository, tag);
|
||||
if (manifest && manifest.digest) {
|
||||
return manifest.digest;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get digest for ${repository}:${tag}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an image by tag
|
||||
*/
|
||||
async deleteImage(repository: string, tag: string): Promise<void> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Registry not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.registry.deleteManifest(repository, tag);
|
||||
logger.info(`Deleted image ${repository}:${tag}`);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to delete image ${repository}:${tag}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or generate the JWT secret for token signing
|
||||
*/
|
||||
private getJwtSecret(): string {
|
||||
// In production, this should be stored securely
|
||||
// For now, use a consistent secret stored in environment or generate one
|
||||
const secret = Deno.env.get('REGISTRY_JWT_SECRET');
|
||||
if (secret) {
|
||||
return secret;
|
||||
}
|
||||
|
||||
// Generate a random secret (this will be different on each restart)
|
||||
// In production, you'd want to persist this
|
||||
const randomSecret = crypto.randomUUID() + crypto.randomUUID();
|
||||
logger.warn('Using generated JWT secret (will be different on restart)');
|
||||
logger.warn('Set REGISTRY_JWT_SECRET environment variable for persistence');
|
||||
return randomSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the registry base URL
|
||||
*/
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full image name for a service
|
||||
*/
|
||||
getImageName(serviceName: string, tag: string = 'latest'): string {
|
||||
return `${this.baseUrl}/${serviceName}:${tag}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the registry and smarts3 server
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.s3Server) {
|
||||
try {
|
||||
await this.s3Server.stop();
|
||||
logger.info('smarts3 server stopped');
|
||||
} catch (error) {
|
||||
logger.error(`Error stopping smarts3: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.isInitialized = false;
|
||||
logger.info('Registry stopped');
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,26 @@ export class OneboxServicesManager {
|
||||
throw new Error(`Service already exists: ${options.name}`);
|
||||
}
|
||||
|
||||
// Handle Onebox Registry setup
|
||||
let registryToken: string | undefined;
|
||||
let imageToPull: string;
|
||||
|
||||
if (options.useOneboxRegistry) {
|
||||
// Generate registry token
|
||||
registryToken = await this.oneboxRef.registry.createServiceToken(options.name);
|
||||
|
||||
// Use onebox registry image name
|
||||
const tag = options.registryImageTag || 'latest';
|
||||
imageToPull = this.oneboxRef.registry.getImageName(options.name, tag);
|
||||
} else {
|
||||
// Use external image
|
||||
imageToPull = options.image;
|
||||
}
|
||||
|
||||
// Create service record in database
|
||||
const service = await this.database.createService({
|
||||
name: options.name,
|
||||
image: options.image,
|
||||
image: options.useOneboxRegistry ? imageToPull : options.image,
|
||||
registry: options.registry,
|
||||
envVars: options.envVars || {},
|
||||
port: options.port,
|
||||
@@ -44,10 +60,18 @@ export class OneboxServicesManager {
|
||||
status: 'stopped',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
// Onebox Registry fields
|
||||
useOneboxRegistry: options.useOneboxRegistry,
|
||||
registryRepository: options.useOneboxRegistry ? options.name : undefined,
|
||||
registryToken: registryToken,
|
||||
registryImageTag: options.registryImageTag || 'latest',
|
||||
autoUpdateOnPush: options.autoUpdateOnPush,
|
||||
});
|
||||
|
||||
// Pull image
|
||||
await this.docker.pullImage(options.image, options.registry);
|
||||
// Pull image (skip if using onebox registry - image might not exist yet)
|
||||
if (!options.useOneboxRegistry) {
|
||||
await this.docker.pullImage(imageToPull, options.registry);
|
||||
}
|
||||
|
||||
// Create container
|
||||
const containerID = await this.docker.createContainer(service);
|
||||
@@ -68,6 +92,47 @@ export class OneboxServicesManager {
|
||||
if (options.domain) {
|
||||
logger.info(`Configuring domain: ${options.domain}`);
|
||||
|
||||
// Validate domain and create CertRequirement
|
||||
try {
|
||||
// Extract base domain (e.g., "api.example.com" -> "example.com")
|
||||
const domainParts = options.domain.split('.');
|
||||
const baseDomain = domainParts.slice(-2).join('.');
|
||||
const subdomain = domainParts.length > 2 ? domainParts.slice(0, -2).join('.') : '';
|
||||
|
||||
// Check if base domain exists in Domain table
|
||||
const domainRecord = this.database.getDomainByName(baseDomain);
|
||||
|
||||
if (!domainRecord) {
|
||||
logger.warn(
|
||||
`Domain ${baseDomain} not found in Domain table. ` +
|
||||
`Service will deploy but certificate management may not work. ` +
|
||||
`Run Cloudflare domain sync or manually add the domain.`
|
||||
);
|
||||
} else if (domainRecord.isObsolete) {
|
||||
logger.warn(
|
||||
`Domain ${baseDomain} is marked as obsolete. ` +
|
||||
`Certificate management may not work properly.`
|
||||
);
|
||||
} else {
|
||||
// Create CertRequirement for automatic certificate management
|
||||
const now = Date.now();
|
||||
this.database.createCertRequirement({
|
||||
serviceId: service.id!,
|
||||
domainId: domainRecord.id!,
|
||||
subdomain: subdomain,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
logger.info(
|
||||
`Created certificate requirement for ${options.domain} ` +
|
||||
`(domain: ${baseDomain}, subdomain: ${subdomain || 'none'})`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to create certificate requirement: ${error.message}`);
|
||||
}
|
||||
|
||||
// Configure DNS (if autoDNS is enabled)
|
||||
if (options.autoDNS !== false) {
|
||||
try {
|
||||
@@ -85,6 +150,8 @@ export class OneboxServicesManager {
|
||||
}
|
||||
|
||||
// Configure SSL (if autoSSL is enabled)
|
||||
// Note: With CertRequirement system, certificates are managed automatically
|
||||
// but we still support the old direct obtainCertificate for backward compatibility
|
||||
if (options.autoSSL !== false) {
|
||||
try {
|
||||
await this.oneboxRef.ssl.obtainCertificate(options.domain);
|
||||
@@ -362,6 +429,121 @@ export class OneboxServicesManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update service configuration (image, port, domain, env vars)
|
||||
* Recreates the container with new configuration and auto-restarts
|
||||
*/
|
||||
async updateService(
|
||||
name: string,
|
||||
updates: {
|
||||
image?: string;
|
||||
registry?: string;
|
||||
port?: number;
|
||||
domain?: string;
|
||||
envVars?: Record<string, string>;
|
||||
}
|
||||
): Promise<IService> {
|
||||
try {
|
||||
const service = this.database.getServiceByName(name);
|
||||
if (!service) {
|
||||
throw new Error(`Service not found: ${name}`);
|
||||
}
|
||||
|
||||
logger.info(`Updating service: ${name}`);
|
||||
const wasRunning = service.status === 'running';
|
||||
const oldContainerID = service.containerID;
|
||||
const oldDomain = service.domain;
|
||||
|
||||
// Stop the container if running
|
||||
if (wasRunning && oldContainerID) {
|
||||
logger.info(`Stopping service ${name} for updates...`);
|
||||
try {
|
||||
await this.docker.stopContainer(oldContainerID);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to stop container: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Pull new image if changed
|
||||
if (updates.image && updates.image !== service.image) {
|
||||
logger.info(`Pulling new image: ${updates.image}`);
|
||||
await this.docker.pullImage(updates.image, updates.registry || service.registry);
|
||||
}
|
||||
|
||||
// Update service in database
|
||||
const updateData: any = {
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (updates.image !== undefined) updateData.image = updates.image;
|
||||
if (updates.registry !== undefined) updateData.registry = updates.registry;
|
||||
if (updates.port !== undefined) updateData.port = updates.port;
|
||||
if (updates.domain !== undefined) updateData.domain = updates.domain;
|
||||
if (updates.envVars !== undefined) updateData.envVars = updates.envVars;
|
||||
|
||||
this.database.updateService(service.id!, updateData);
|
||||
|
||||
// Get updated service
|
||||
const updatedService = this.database.getServiceByName(name)!;
|
||||
|
||||
// Remove old container
|
||||
if (oldContainerID) {
|
||||
try {
|
||||
await this.docker.removeContainer(oldContainerID, true);
|
||||
logger.info(`Removed old container for ${name}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to remove old container: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new container with updated config
|
||||
logger.info(`Creating new container for ${name}...`);
|
||||
const containerID = await this.docker.createContainer(updatedService);
|
||||
this.database.updateService(service.id!, { containerID });
|
||||
|
||||
// Update reverse proxy if domain changed
|
||||
if (updates.domain !== undefined && updates.domain !== oldDomain) {
|
||||
// Remove old route if it existed
|
||||
if (oldDomain) {
|
||||
try {
|
||||
this.oneboxRef.reverseProxy.removeRoute(oldDomain);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to remove old reverse proxy route: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new route if domain specified
|
||||
if (updates.domain) {
|
||||
try {
|
||||
await this.oneboxRef.reverseProxy.addRoute(
|
||||
service.id!,
|
||||
updates.domain,
|
||||
updates.port || service.port
|
||||
);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to configure reverse proxy: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restart the container if it was running
|
||||
if (wasRunning) {
|
||||
logger.info(`Starting updated service ${name}...`);
|
||||
this.database.updateService(service.id!, { status: 'starting' });
|
||||
await this.docker.startContainer(containerID);
|
||||
this.database.updateService(service.id!, { status: 'running' });
|
||||
logger.success(`Service ${name} updated and restarted`);
|
||||
} else {
|
||||
this.database.updateService(service.id!, { status: 'stopped' });
|
||||
logger.success(`Service ${name} updated (not started)`);
|
||||
}
|
||||
|
||||
return this.database.getServiceByName(name)!;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to update service ${name}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync service status from Docker
|
||||
*/
|
||||
@@ -410,4 +592,86 @@ export class OneboxServicesManager {
|
||||
await this.syncServiceStatus(service.name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start auto-update monitoring for registry services
|
||||
* Polls every 30 seconds for digest changes and restarts services if needed
|
||||
*/
|
||||
startAutoUpdateMonitoring(): void {
|
||||
// Check every 30 seconds
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await this.checkForRegistryUpdates();
|
||||
} catch (error) {
|
||||
logger.error(`Auto-update check failed: ${error.message}`);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
logger.info('Auto-update monitoring started (30s interval)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all services using onebox registry for updates
|
||||
*/
|
||||
private async checkForRegistryUpdates(): Promise<void> {
|
||||
const services = this.listServices();
|
||||
|
||||
for (const service of services) {
|
||||
// Skip if not using onebox registry or auto-update is disabled
|
||||
if (!service.useOneboxRegistry || !service.autoUpdateOnPush) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current digest from registry
|
||||
const currentDigest = await this.oneboxRef.registry.getImageDigest(
|
||||
service.registryRepository!,
|
||||
service.registryImageTag || 'latest'
|
||||
);
|
||||
|
||||
// Skip if no digest found (image might not exist yet)
|
||||
if (!currentDigest) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if digest has changed
|
||||
if (service.imageDigest && service.imageDigest !== currentDigest) {
|
||||
logger.info(
|
||||
`Digest changed for ${service.name}: ${service.imageDigest} -> ${currentDigest}`
|
||||
);
|
||||
|
||||
// Update digest in database
|
||||
this.database.updateService(service.id!, {
|
||||
imageDigest: currentDigest,
|
||||
});
|
||||
|
||||
// Pull new image
|
||||
const imageName = this.oneboxRef.registry.getImageName(
|
||||
service.registryRepository!,
|
||||
service.registryImageTag || 'latest'
|
||||
);
|
||||
|
||||
logger.info(`Pulling updated image: ${imageName}`);
|
||||
await this.docker.pullImage(imageName);
|
||||
|
||||
// Restart service
|
||||
logger.info(`Auto-restarting service: ${service.name}`);
|
||||
await this.restartService(service.name);
|
||||
|
||||
// Broadcast update via WebSocket
|
||||
this.oneboxRef.httpServer.broadcastServiceUpdate({
|
||||
action: 'updated',
|
||||
service: this.database.getServiceByName(service.name)!,
|
||||
});
|
||||
} else if (!service.imageDigest) {
|
||||
// First time - just store the digest
|
||||
this.database.updateService(service.id!, {
|
||||
imageDigest: currentDigest,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to check updates for ${service.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,14 @@ export { cloudflare };
|
||||
import * as smartacme from '@push.rocks/smartacme';
|
||||
export { smartacme };
|
||||
|
||||
// Docker Registry (OCI Distribution Specification)
|
||||
import * as smartregistry from '@push.rocks/smartregistry';
|
||||
export { smartregistry };
|
||||
|
||||
// S3-compatible storage server
|
||||
import * as smarts3 from '@push.rocks/smarts3';
|
||||
export { smarts3 };
|
||||
|
||||
// Crypto utilities (for password hashing, encryption)
|
||||
import * as bcrypt from 'https://deno.land/x/bcrypt@v0.4.1/mod.ts';
|
||||
export { bcrypt };
|
||||
|
||||
11
ts/types.ts
11
ts/types.ts
@@ -15,6 +15,13 @@ export interface IService {
|
||||
status: 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
// Onebox Registry fields
|
||||
useOneboxRegistry?: boolean;
|
||||
registryRepository?: string;
|
||||
registryToken?: string;
|
||||
registryImageTag?: string;
|
||||
autoUpdateOnPush?: boolean;
|
||||
imageDigest?: string;
|
||||
}
|
||||
|
||||
// Registry types
|
||||
@@ -182,6 +189,10 @@ export interface IServiceDeployOptions {
|
||||
domain?: string;
|
||||
autoSSL?: boolean;
|
||||
autoDNS?: boolean;
|
||||
// Onebox Registry options
|
||||
useOneboxRegistry?: boolean;
|
||||
registryImageTag?: string;
|
||||
autoUpdateOnPush?: boolean;
|
||||
}
|
||||
|
||||
// HTTP API request/response types
|
||||
|
||||
Reference in New Issue
Block a user