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:
2025-11-24 01:31:15 +00:00
parent b6ac4f209a
commit c9beae93c8
23 changed files with 2475 additions and 130 deletions

View File

@@ -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 {

View File

@@ -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
*/

View File

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

View File

@@ -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
*/

View File

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

View File

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

View File

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

View File

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