feat(ssl): Add domain & certificate management, Cloudflare sync, SQLite cert manager, WebSocket realtime updates, and HTTP API SSL endpoints
This commit is contained in:
@@ -36,6 +36,24 @@ Common mistakes to avoid:
|
|||||||
- `ts/classes/` - All class implementations
|
- `ts/classes/` - All class implementations
|
||||||
- `ts/` - Root level utilities (logging, types, plugins, cli, info)
|
- `ts/` - Root level utilities (logging, types, plugins, cli, info)
|
||||||
|
|
||||||
|
### WebSocket Real-time Communication
|
||||||
|
- **Backend**: WebSocket endpoint at `/api/ws` (`ts/classes/httpserver.ts:96-174`)
|
||||||
|
- Connection management with client Set tracking
|
||||||
|
- Broadcast methods: `broadcast()`, `broadcastServiceUpdate()`, `broadcastServiceStatus()`
|
||||||
|
- Integrated with service lifecycle (start/stop/restart actions)
|
||||||
|
- Status monitoring loop broadcasts changes automatically
|
||||||
|
- **Frontend**: Angular WebSocket service (`ui/src/app/core/services/websocket.service.ts`)
|
||||||
|
- Auto-connects on app initialization
|
||||||
|
- Exponential backoff reconnection (max 5 attempts)
|
||||||
|
- RxJS Observable-based message streaming
|
||||||
|
- Components subscribe to real-time updates
|
||||||
|
- **Message Types**:
|
||||||
|
- `connected` - Initial connection confirmation
|
||||||
|
- `service_update` - Service lifecycle changes (action: created/updated/deleted/started/stopped)
|
||||||
|
- `service_status` - Real-time status changes from monitoring loop
|
||||||
|
- `system_status` - System-wide updates
|
||||||
|
- **Testing**: Use `.nogit/test-ws-updates.ts` to monitor WebSocket messages
|
||||||
|
|
||||||
### Docker Configuration
|
### Docker Configuration
|
||||||
- **System Docker**: Uses root Docker at `/var/run/docker.sock` (NOT rootless)
|
- **System Docker**: Uses root Docker at `/var/run/docker.sock` (NOT rootless)
|
||||||
- **Swarm Mode**: Enabled for service orchestration
|
- **Swarm Mode**: Enabled for service orchestration
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
"@std/encoding": "jsr:@std/encoding@^1.0.10",
|
||||||
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
|
"@db/sqlite": "jsr:@db/sqlite@0.12.0",
|
||||||
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0",
|
"@push.rocks/smartdaemon": "npm:@push.rocks/smartdaemon@^2.1.0",
|
||||||
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@2.0.0",
|
"@apiclient.xyz/docker": "npm:@apiclient.xyz/docker@2.1.0",
|
||||||
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
"@apiclient.xyz/cloudflare": "npm:@apiclient.xyz/cloudflare@6.4.3",
|
||||||
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0"
|
"@push.rocks/smartacme": "npm:@push.rocks/smartacme@^8.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
1242
pnpm-lock.yaml
generated
1242
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
338
ts/classes/cert-requirement-manager.ts
Normal file
338
ts/classes/cert-requirement-manager.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* Certificate Requirement Manager
|
||||||
|
*
|
||||||
|
* Manages the lifecycle of SSL certificates based on service requirements.
|
||||||
|
* Automatically acquires, renews, and cleans up certificates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import { logger } from '../logging.ts';
|
||||||
|
import { OneboxDatabase } from './database.ts';
|
||||||
|
import { OneboxSslManager } from './sslmanager.ts';
|
||||||
|
import type { ICertRequirement, ICertificate, IDomain } from '../types.ts';
|
||||||
|
|
||||||
|
export class CertRequirementManager {
|
||||||
|
private database: OneboxDatabase;
|
||||||
|
private sslManager: OneboxSslManager;
|
||||||
|
|
||||||
|
// Certificate renewal threshold (30 days before expiry)
|
||||||
|
private readonly RENEWAL_THRESHOLD_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Certificate cleanup delay (90 days after becoming invalid)
|
||||||
|
private readonly CLEANUP_DELAY_MS = 90 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
constructor(database: OneboxDatabase, sslManager: OneboxSslManager) {
|
||||||
|
this.database = database;
|
||||||
|
this.sslManager = sslManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all pending certificate requirements
|
||||||
|
* Matches requirements to existing certificates or schedules acquisition
|
||||||
|
*/
|
||||||
|
async processPendingRequirements(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const allRequirements = this.database.getAllCertRequirements();
|
||||||
|
const pendingRequirements = allRequirements.filter((req) => req.status === 'pending');
|
||||||
|
|
||||||
|
logger.info(`Processing ${pendingRequirements.length} pending certificate requirement(s)`);
|
||||||
|
|
||||||
|
for (const requirement of pendingRequirements) {
|
||||||
|
try {
|
||||||
|
await this.processRequirement(requirement);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to process requirement ${requirement.id}: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to process pending requirements: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a single certificate requirement
|
||||||
|
*/
|
||||||
|
private async processRequirement(requirement: ICertRequirement): Promise<void> {
|
||||||
|
const domain = this.database.getDomainById(requirement.domainId);
|
||||||
|
if (!domain) {
|
||||||
|
logger.error(`Domain ${requirement.domainId} not found for requirement ${requirement.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct the full domain name
|
||||||
|
const fullDomain = requirement.subdomain
|
||||||
|
? `${requirement.subdomain}.${domain.domain}`
|
||||||
|
: domain.domain;
|
||||||
|
|
||||||
|
logger.debug(`Processing requirement for domain: ${fullDomain}`);
|
||||||
|
|
||||||
|
// Check if a valid certificate already exists
|
||||||
|
const existingCert = this.findValidCertificate(domain, requirement.subdomain);
|
||||||
|
|
||||||
|
if (existingCert) {
|
||||||
|
// Link existing certificate to requirement
|
||||||
|
this.database.updateCertRequirement(requirement.id!, {
|
||||||
|
certificateId: existingCert.id,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
logger.info(`Linked existing certificate to requirement for ${fullDomain}`);
|
||||||
|
} else {
|
||||||
|
// Schedule certificate acquisition
|
||||||
|
await this.acquireCertificate(requirement, domain, fullDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a valid certificate for the given domain and subdomain
|
||||||
|
*/
|
||||||
|
private findValidCertificate(
|
||||||
|
domain: IDomain,
|
||||||
|
subdomain: string
|
||||||
|
): ICertificate | null {
|
||||||
|
const certificates = this.database.getCertificatesByDomain(domain.id!);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const cert of certificates) {
|
||||||
|
// Skip invalid or expired certificates
|
||||||
|
if (!cert.isValid || cert.expiryDate <= now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if certificate covers the required domain
|
||||||
|
if (cert.isWildcard && !subdomain) {
|
||||||
|
// Wildcard cert covers base domain
|
||||||
|
return cert;
|
||||||
|
} else if (cert.isWildcard && subdomain) {
|
||||||
|
// Wildcard cert covers first-level subdomains
|
||||||
|
const levels = subdomain.split('.').length;
|
||||||
|
if (levels === 1) {
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
} else if (!cert.isWildcard && cert.certDomain === domain.domain && !subdomain) {
|
||||||
|
// Exact match for base domain
|
||||||
|
return cert;
|
||||||
|
} else if (!cert.isWildcard && subdomain) {
|
||||||
|
// Exact match for specific subdomain
|
||||||
|
const fullDomain = `${subdomain}.${domain.domain}`;
|
||||||
|
if (cert.certDomain === fullDomain) {
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire a new certificate for the requirement
|
||||||
|
*/
|
||||||
|
private async acquireCertificate(
|
||||||
|
requirement: ICertRequirement,
|
||||||
|
domain: IDomain,
|
||||||
|
fullDomain: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info(`Acquiring certificate for ${fullDomain}...`);
|
||||||
|
|
||||||
|
// Determine if we should use wildcard
|
||||||
|
const useWildcard = domain.defaultWildcard && !requirement.subdomain;
|
||||||
|
|
||||||
|
// Acquire certificate using SSL manager
|
||||||
|
const certData = await this.sslManager.acquireCertificate(
|
||||||
|
domain.domain,
|
||||||
|
useWildcard
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store certificate in database
|
||||||
|
const now = Date.now();
|
||||||
|
const certificate = this.database.createCertificate({
|
||||||
|
domainId: domain.id!,
|
||||||
|
certDomain: domain.domain,
|
||||||
|
isWildcard: useWildcard,
|
||||||
|
certPath: certData.certPath,
|
||||||
|
keyPath: certData.keyPath,
|
||||||
|
fullChainPath: certData.fullChainPath,
|
||||||
|
expiryDate: certData.expiryDate,
|
||||||
|
issuer: certData.issuer,
|
||||||
|
isValid: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link certificate to requirement
|
||||||
|
this.database.updateCertRequirement(requirement.id!, {
|
||||||
|
certificateId: certificate.id,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.success(`Certificate acquired for ${fullDomain}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to acquire certificate for ${fullDomain}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check all certificates for renewal
|
||||||
|
*/
|
||||||
|
async checkCertificateRenewal(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const allCertificates = this.database.getAllCertificates();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const cert of allCertificates) {
|
||||||
|
// Skip invalid certificates
|
||||||
|
if (!cert.isValid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if certificate is expired
|
||||||
|
if (cert.expiryDate <= now) {
|
||||||
|
// Mark as invalid
|
||||||
|
this.database.updateCertificate(cert.id!, { isValid: false });
|
||||||
|
logger.warn(`Certificate ${cert.id} for ${cert.certDomain} has expired`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if certificate needs renewal
|
||||||
|
const timeUntilExpiry = cert.expiryDate - now;
|
||||||
|
if (timeUntilExpiry <= this.RENEWAL_THRESHOLD_MS) {
|
||||||
|
await this.renewCertificate(cert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to check certificate renewal: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renew a certificate
|
||||||
|
*/
|
||||||
|
private async renewCertificate(cert: ICertificate): Promise<void> {
|
||||||
|
try {
|
||||||
|
logger.info(`Renewing certificate for ${cert.certDomain}...`);
|
||||||
|
|
||||||
|
const domain = this.database.getDomainById(cert.domainId);
|
||||||
|
if (!domain) {
|
||||||
|
logger.error(`Domain ${cert.domainId} not found for certificate ${cert.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark all requirements using this certificate as renewing
|
||||||
|
const requirements = this.database.getAllCertRequirements();
|
||||||
|
const relatedRequirements = requirements.filter(
|
||||||
|
(req) => req.certificateId === cert.id
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const req of relatedRequirements) {
|
||||||
|
this.database.updateCertRequirement(req.id!, { status: 'renewing' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire new certificate
|
||||||
|
const certData = await this.sslManager.acquireCertificate(
|
||||||
|
domain.domain,
|
||||||
|
cert.isWildcard
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update certificate in database
|
||||||
|
this.database.updateCertificate(cert.id!, {
|
||||||
|
certPath: certData.certPath,
|
||||||
|
keyPath: certData.keyPath,
|
||||||
|
fullChainPath: certData.fullChainPath,
|
||||||
|
expiryDate: certData.expiryDate,
|
||||||
|
issuer: certData.issuer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark requirements as active again
|
||||||
|
for (const req of relatedRequirements) {
|
||||||
|
this.database.updateCertRequirement(req.id!, { status: 'active' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`Certificate renewed for ${cert.certDomain}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to renew certificate for ${cert.certDomain}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up old invalid certificates (90+ days old)
|
||||||
|
*/
|
||||||
|
async cleanupOldCertificates(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const allCertificates = this.database.getAllCertificates();
|
||||||
|
const now = Date.now();
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
for (const cert of allCertificates) {
|
||||||
|
// Only clean up invalid certificates
|
||||||
|
if (!cert.isValid) {
|
||||||
|
// Check if certificate has been invalid for 90+ days
|
||||||
|
const timeSinceExpiry = now - cert.expiryDate;
|
||||||
|
if (timeSinceExpiry >= this.CLEANUP_DELAY_MS) {
|
||||||
|
// Delete certificate files
|
||||||
|
try {
|
||||||
|
await Deno.remove(cert.certPath);
|
||||||
|
await Deno.remove(cert.keyPath);
|
||||||
|
await Deno.remove(cert.fullChainPath);
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(
|
||||||
|
`Failed to delete certificate files for ${cert.certDomain}: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
this.database.deleteCertificate(cert.id!);
|
||||||
|
deletedCount++;
|
||||||
|
logger.info(
|
||||||
|
`Deleted old certificate ${cert.id} for ${cert.certDomain} (expired ${new Date(
|
||||||
|
cert.expiryDate
|
||||||
|
).toISOString()})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
logger.info(`Cleaned up ${deletedCount} old certificate(s)`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to cleanup old certificates: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get certificate status for a domain
|
||||||
|
*/
|
||||||
|
getCertificateStatus(domainId: number): {
|
||||||
|
valid: number;
|
||||||
|
expiringSoon: number;
|
||||||
|
expired: number;
|
||||||
|
pending: number;
|
||||||
|
} {
|
||||||
|
const certificates = this.database.getCertificatesByDomain(domainId);
|
||||||
|
const requirements = this.database.getCertRequirementsByDomain(domainId);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
let valid = 0;
|
||||||
|
let expiringSoon = 0;
|
||||||
|
let expired = 0;
|
||||||
|
|
||||||
|
for (const cert of certificates) {
|
||||||
|
if (!cert.isValid) {
|
||||||
|
expired++;
|
||||||
|
} else if (cert.expiryDate <= now) {
|
||||||
|
expired++;
|
||||||
|
} else if (cert.expiryDate - now <= this.RENEWAL_THRESHOLD_MS) {
|
||||||
|
expiringSoon++;
|
||||||
|
} else {
|
||||||
|
valid++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = requirements.filter((req) => req.status === 'pending').length;
|
||||||
|
|
||||||
|
return { valid, expiringSoon, expired, pending };
|
||||||
|
}
|
||||||
|
}
|
||||||
189
ts/classes/certmanager.ts
Normal file
189
ts/classes/certmanager.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* SQLite-based Certificate Manager for SmartACME
|
||||||
|
*
|
||||||
|
* Implements ICertManager interface to store SSL certificates in SQLite database
|
||||||
|
* and write PEM files to filesystem for use by the reverse proxy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import { logger } from '../logging.ts';
|
||||||
|
import { OneboxDatabase } from './database.ts';
|
||||||
|
|
||||||
|
export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
||||||
|
private database: OneboxDatabase;
|
||||||
|
private certBasePath: string;
|
||||||
|
|
||||||
|
constructor(database: OneboxDatabase, certBasePath = './.nogit/ssl/live') {
|
||||||
|
this.database = database;
|
||||||
|
this.certBasePath = certBasePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the certificate manager
|
||||||
|
*/
|
||||||
|
async init(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Ensure certificate directory exists
|
||||||
|
await Deno.mkdir(this.certBasePath, { recursive: true });
|
||||||
|
logger.info(`Certificate manager initialized (path: ${this.certBasePath})`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to initialize certificate manager: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve a certificate by domain name
|
||||||
|
*/
|
||||||
|
async retrieveCertificate(domainName: string): Promise<plugins.smartacme.Cert | null> {
|
||||||
|
try {
|
||||||
|
const dbCert = this.database.getSSLCertificate(domainName);
|
||||||
|
|
||||||
|
if (!dbCert) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert database format to SmartacmeCert format
|
||||||
|
const cert = new plugins.smartacme.Cert({
|
||||||
|
id: dbCert.id?.toString() || domainName,
|
||||||
|
domainName: dbCert.domain,
|
||||||
|
created: dbCert.createdAt,
|
||||||
|
privateKey: await this.readPemFile(dbCert.keyPath),
|
||||||
|
publicKey: await this.readPemFile(dbCert.fullChainPath), // Full chain as public key
|
||||||
|
csr: '', // CSR not stored separately
|
||||||
|
validUntil: dbCert.expiryDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return cert;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to retrieve certificate for ${domainName}: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a certificate
|
||||||
|
*/
|
||||||
|
async storeCertificate(cert: plugins.smartacme.Cert): Promise<void> {
|
||||||
|
try {
|
||||||
|
const domain = cert.domainName;
|
||||||
|
const domainPath = `${this.certBasePath}/${domain}`;
|
||||||
|
|
||||||
|
// Create domain-specific directory
|
||||||
|
await Deno.mkdir(domainPath, { recursive: true });
|
||||||
|
|
||||||
|
// Write PEM files
|
||||||
|
const keyPath = `${domainPath}/privkey.pem`;
|
||||||
|
const certPath = `${domainPath}/cert.pem`;
|
||||||
|
const fullChainPath = `${domainPath}/fullchain.pem`;
|
||||||
|
|
||||||
|
await Deno.writeTextFile(keyPath, cert.privateKey);
|
||||||
|
await Deno.writeTextFile(fullChainPath, cert.publicKey);
|
||||||
|
|
||||||
|
// Extract certificate from full chain (first certificate in the chain)
|
||||||
|
const certOnly = this.extractCertFromChain(cert.publicKey);
|
||||||
|
await Deno.writeTextFile(certPath, certOnly);
|
||||||
|
|
||||||
|
// Store/update in database
|
||||||
|
const existing = this.database.getSSLCertificate(domain);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
this.database.updateSSLCertificate(domain, {
|
||||||
|
certPath,
|
||||||
|
keyPath,
|
||||||
|
fullChainPath,
|
||||||
|
expiryDate: cert.validUntil,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.database.createSSLCertificate({
|
||||||
|
domain,
|
||||||
|
certPath,
|
||||||
|
keyPath,
|
||||||
|
fullChainPath,
|
||||||
|
expiryDate: cert.validUntil,
|
||||||
|
issuer: 'Let\'s Encrypt',
|
||||||
|
createdAt: cert.created,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`Certificate stored for ${domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to store certificate for ${cert.domainName}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a certificate
|
||||||
|
*/
|
||||||
|
async deleteCertificate(domainName: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dbCert = this.database.getSSLCertificate(domainName);
|
||||||
|
|
||||||
|
if (dbCert) {
|
||||||
|
// Delete PEM files
|
||||||
|
const domainPath = `${this.certBasePath}/${domainName}`;
|
||||||
|
try {
|
||||||
|
await Deno.remove(domainPath, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to delete PEM files for ${domainName}: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database
|
||||||
|
this.database.deleteSSLCertificate(domainName);
|
||||||
|
|
||||||
|
logger.info(`Certificate deleted for ${domainName}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to delete certificate for ${domainName}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the certificate manager
|
||||||
|
*/
|
||||||
|
async close(): Promise<void> {
|
||||||
|
// SQLite database is managed by OneboxDatabase, nothing to close here
|
||||||
|
logger.info('Certificate manager closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe all certificates (for testing)
|
||||||
|
*/
|
||||||
|
async wipe(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const certs = this.database.getAllSSLCertificates();
|
||||||
|
|
||||||
|
for (const cert of certs) {
|
||||||
|
await this.deleteCertificate(cert.domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('All certificates wiped');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to wipe certificates: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read PEM file from filesystem
|
||||||
|
*/
|
||||||
|
private async readPemFile(path: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await Deno.readTextFile(path);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to read PEM file ${path}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the first certificate from a PEM chain
|
||||||
|
*/
|
||||||
|
private extractCertFromChain(fullChain: string): string {
|
||||||
|
const certMatch = fullChain.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/);
|
||||||
|
return certMatch ? certMatch[0] : fullChain;
|
||||||
|
}
|
||||||
|
}
|
||||||
165
ts/classes/cloudflare-sync.ts
Normal file
165
ts/classes/cloudflare-sync.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Cloudflare Domain Sync Manager
|
||||||
|
*
|
||||||
|
* Synchronizes Cloudflare DNS zones with the local Domain table.
|
||||||
|
* Automatically imports zones and marks obsolete domains when zones are removed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as plugins from '../plugins.ts';
|
||||||
|
import { logger } from '../logging.ts';
|
||||||
|
import { OneboxDatabase } from './database.ts';
|
||||||
|
import type { IDomain } from '../types.ts';
|
||||||
|
|
||||||
|
export class CloudflareDomainSync {
|
||||||
|
private database: OneboxDatabase;
|
||||||
|
private cloudflareAccount: plugins.cloudflare.CloudflareAccount | null = null;
|
||||||
|
|
||||||
|
constructor(database: OneboxDatabase) {
|
||||||
|
this.database = database;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Cloudflare connection
|
||||||
|
*/
|
||||||
|
async init(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const apiKey = this.database.getSetting('cloudflareAPIKey');
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
logger.warn('Cloudflare API key not configured. Domain sync will be limited.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cloudflareAccount = new plugins.cloudflare.CloudflareAccount(apiKey);
|
||||||
|
logger.info('Cloudflare domain sync initialized');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to initialize Cloudflare sync: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Cloudflare is configured
|
||||||
|
*/
|
||||||
|
isConfigured(): boolean {
|
||||||
|
return this.cloudflareAccount !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync all Cloudflare zones with Domain table
|
||||||
|
*/
|
||||||
|
async syncZones(): Promise<void> {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
logger.warn('Cloudflare not configured, skipping zone sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info('Starting Cloudflare zone synchronization...');
|
||||||
|
|
||||||
|
// Fetch all zones from Cloudflare
|
||||||
|
const zones = await this.cloudflareAccount!.getZones();
|
||||||
|
logger.info(`Found ${zones.length} Cloudflare zone(s)`);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const syncedZoneIds = new Set<string>();
|
||||||
|
|
||||||
|
// Sync each zone to the Domain table
|
||||||
|
for (const zone of zones) {
|
||||||
|
try {
|
||||||
|
const domain = zone.name;
|
||||||
|
const zoneId = zone.id;
|
||||||
|
|
||||||
|
syncedZoneIds.add(zoneId);
|
||||||
|
|
||||||
|
// Check if domain already exists
|
||||||
|
const existingDomain = this.database.getDomainByName(domain);
|
||||||
|
|
||||||
|
if (existingDomain) {
|
||||||
|
// Update existing domain
|
||||||
|
this.database.updateDomain(existingDomain.id!, {
|
||||||
|
dnsProvider: 'cloudflare',
|
||||||
|
cloudflareZoneId: zoneId,
|
||||||
|
isObsolete: false, // Re-activate if it was marked obsolete
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
logger.debug(`Updated domain: ${domain}`);
|
||||||
|
} else {
|
||||||
|
// Create new domain
|
||||||
|
this.database.createDomain({
|
||||||
|
domain,
|
||||||
|
dnsProvider: 'cloudflare',
|
||||||
|
cloudflareZoneId: zoneId,
|
||||||
|
isObsolete: false,
|
||||||
|
defaultWildcard: true, // Default to wildcard certificates
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
logger.info(`Added new domain from Cloudflare: ${domain}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to sync zone ${zone.name}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark domains as obsolete if their Cloudflare zones no longer exist
|
||||||
|
await this.markObsoleteDomains(syncedZoneIds);
|
||||||
|
|
||||||
|
logger.success(`Cloudflare zone sync completed: ${zones.length} zone(s) synced`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Cloudflare zone sync failed: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark domains as obsolete if their Cloudflare zones have been removed
|
||||||
|
*/
|
||||||
|
private async markObsoleteDomains(activeZoneIds: Set<string>): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Get all domains managed by Cloudflare
|
||||||
|
const cloudflareDomains = this.database.getDomainsByProvider('cloudflare');
|
||||||
|
|
||||||
|
let obsoleteCount = 0;
|
||||||
|
|
||||||
|
for (const domain of cloudflareDomains) {
|
||||||
|
// If domain has a Cloudflare zone ID but it's not in the active set, mark obsolete
|
||||||
|
if (domain.cloudflareZoneId && !activeZoneIds.has(domain.cloudflareZoneId)) {
|
||||||
|
this.database.updateDomain(domain.id!, {
|
||||||
|
isObsolete: true,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
logger.warn(`Marked domain as obsolete (zone removed): ${domain.domain}`);
|
||||||
|
obsoleteCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obsoleteCount > 0) {
|
||||||
|
logger.info(`Marked ${obsoleteCount} domain(s) as obsolete`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to mark obsolete domains: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sync status information
|
||||||
|
*/
|
||||||
|
getSyncStatus(): {
|
||||||
|
configured: boolean;
|
||||||
|
totalDomains: number;
|
||||||
|
cloudflareDomains: number;
|
||||||
|
obsoleteDomains: number;
|
||||||
|
} {
|
||||||
|
const allDomains = this.database.getAllDomains();
|
||||||
|
const cloudflareDomains = allDomains.filter(d => d.dnsProvider === 'cloudflare');
|
||||||
|
const obsoleteDomains = allDomains.filter(d => d.isObsolete);
|
||||||
|
|
||||||
|
return {
|
||||||
|
configured: this.isConfigured(),
|
||||||
|
totalDomains: allDomains.length,
|
||||||
|
cloudflareDomains: cloudflareDomains.length,
|
||||||
|
obsoleteDomains: obsoleteDomains.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -204,14 +204,312 @@ export class OneboxDatabase {
|
|||||||
private async runMigrations(): Promise<void> {
|
private async runMigrations(): Promise<void> {
|
||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
try {
|
||||||
const currentVersion = this.getMigrationVersion();
|
const currentVersion = this.getMigrationVersion();
|
||||||
logger.debug(`Current database version: ${currentVersion}`);
|
logger.info(`Current database migration version: ${currentVersion}`);
|
||||||
|
|
||||||
// Add migration logic here as needed
|
// Migration 1: Initial schema
|
||||||
// For now, just set version to 1
|
|
||||||
if (currentVersion === 0) {
|
if (currentVersion === 0) {
|
||||||
|
logger.info('Setting initial migration version to 1');
|
||||||
this.setMigrationVersion(1);
|
this.setMigrationVersion(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration 2: Convert timestamp columns from INTEGER to REAL
|
||||||
|
const updatedVersion = this.getMigrationVersion();
|
||||||
|
if (updatedVersion < 2) {
|
||||||
|
logger.info('Running migration 2: Converting timestamps to REAL...');
|
||||||
|
|
||||||
|
// For each table, we need to:
|
||||||
|
// 1. Create new table with REAL timestamps
|
||||||
|
// 2. Copy data
|
||||||
|
// 3. Drop old table
|
||||||
|
// 4. Rename new table
|
||||||
|
|
||||||
|
// SSL certificates
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE ssl_certificates_new (
|
||||||
|
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 REAL NOT NULL,
|
||||||
|
issuer TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.query(`INSERT INTO ssl_certificates_new SELECT * FROM ssl_certificates`);
|
||||||
|
this.query(`DROP TABLE ssl_certificates`);
|
||||||
|
this.query(`ALTER TABLE ssl_certificates_new RENAME TO ssl_certificates`);
|
||||||
|
|
||||||
|
// Services
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE services_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
image TEXT NOT NULL,
|
||||||
|
registry TEXT,
|
||||||
|
env_vars TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
domain TEXT,
|
||||||
|
container_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'stopped',
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.query(`INSERT INTO services_new SELECT * FROM services`);
|
||||||
|
this.query(`DROP TABLE services`);
|
||||||
|
this.query(`ALTER TABLE services_new RENAME TO services`);
|
||||||
|
|
||||||
|
// Registries
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE registries_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password_encrypted TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.query(`INSERT INTO registries_new SELECT * FROM registries`);
|
||||||
|
this.query(`DROP TABLE registries`);
|
||||||
|
this.query(`ALTER TABLE registries_new RENAME TO registries`);
|
||||||
|
|
||||||
|
// Nginx configs
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE nginx_configs_new (
|
||||||
|
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 REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.query(`INSERT INTO nginx_configs_new SELECT * FROM nginx_configs`);
|
||||||
|
this.query(`DROP TABLE nginx_configs`);
|
||||||
|
this.query(`ALTER TABLE nginx_configs_new RENAME TO nginx_configs`);
|
||||||
|
|
||||||
|
// DNS records
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE dns_records_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
domain TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
cloudflare_id TEXT,
|
||||||
|
zone_id TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.query(`INSERT INTO dns_records_new SELECT * FROM dns_records`);
|
||||||
|
this.query(`DROP TABLE dns_records`);
|
||||||
|
this.query(`ALTER TABLE dns_records_new RENAME TO dns_records`);
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE metrics_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
service_id INTEGER NOT NULL,
|
||||||
|
timestamp REAL 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
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.query(`INSERT INTO metrics_new SELECT * FROM metrics`);
|
||||||
|
this.query(`DROP TABLE metrics`);
|
||||||
|
this.query(`ALTER TABLE metrics_new RENAME TO metrics`);
|
||||||
|
this.query(`CREATE INDEX IF NOT EXISTS idx_metrics_service_timestamp ON metrics(service_id, timestamp DESC)`);
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE logs_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
service_id INTEGER NOT NULL,
|
||||||
|
timestamp REAL NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.query(`INSERT INTO logs_new SELECT * FROM logs`);
|
||||||
|
this.query(`DROP TABLE logs`);
|
||||||
|
this.query(`ALTER TABLE logs_new RENAME TO logs`);
|
||||||
|
this.query(`CREATE INDEX IF NOT EXISTS idx_logs_service_timestamp ON logs(service_id, timestamp DESC)`);
|
||||||
|
|
||||||
|
// Users
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE users_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.query(`INSERT INTO users_new SELECT * FROM users`);
|
||||||
|
this.query(`DROP TABLE users`);
|
||||||
|
this.query(`ALTER TABLE users_new RENAME TO users`);
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE settings_new (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.query(`INSERT INTO settings_new SELECT * FROM settings`);
|
||||||
|
this.query(`DROP TABLE settings`);
|
||||||
|
this.query(`ALTER TABLE settings_new RENAME TO settings`);
|
||||||
|
|
||||||
|
// Migrations table itself
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE migrations_new (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
this.query(`INSERT INTO migrations_new SELECT * FROM migrations`);
|
||||||
|
this.query(`DROP TABLE migrations`);
|
||||||
|
this.query(`ALTER TABLE migrations_new RENAME TO migrations`);
|
||||||
|
|
||||||
|
this.setMigrationVersion(2);
|
||||||
|
logger.success('Migration 2 completed: All timestamps converted to REAL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 3: Domain management tables
|
||||||
|
const version3 = this.getMigrationVersion();
|
||||||
|
if (version3 < 3) {
|
||||||
|
logger.info('Running migration 3: Creating domain management tables...');
|
||||||
|
|
||||||
|
// 1. Create domains table
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE domains (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
domain TEXT NOT NULL UNIQUE,
|
||||||
|
dns_provider TEXT,
|
||||||
|
cloudflare_zone_id TEXT,
|
||||||
|
is_obsolete INTEGER NOT NULL DEFAULT 0,
|
||||||
|
default_wildcard INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. Create certificates table (renamed from ssl_certificates)
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE certificates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
domain_id INTEGER NOT NULL,
|
||||||
|
cert_domain TEXT NOT NULL,
|
||||||
|
is_wildcard INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cert_path TEXT NOT NULL,
|
||||||
|
key_path TEXT NOT NULL,
|
||||||
|
full_chain_path TEXT NOT NULL,
|
||||||
|
expiry_date REAL NOT NULL,
|
||||||
|
issuer TEXT NOT NULL,
|
||||||
|
is_valid INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. Create cert_requirements table
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE cert_requirements (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
service_id INTEGER NOT NULL,
|
||||||
|
domain_id INTEGER NOT NULL,
|
||||||
|
subdomain TEXT NOT NULL,
|
||||||
|
certificate_id INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 4. Migrate existing ssl_certificates data
|
||||||
|
// Extract unique base domains from existing certificates
|
||||||
|
const existingCerts = this.query('SELECT * FROM ssl_certificates');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const domainMap = new Map<string, number>();
|
||||||
|
|
||||||
|
// Create domain entries for each unique base domain
|
||||||
|
for (const cert of existingCerts) {
|
||||||
|
const domain = String(cert.domain ?? cert[1]);
|
||||||
|
if (!domainMap.has(domain)) {
|
||||||
|
this.query(
|
||||||
|
'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[domain, null, 0, 1, now, now]
|
||||||
|
);
|
||||||
|
const result = this.query('SELECT last_insert_rowid() as id');
|
||||||
|
const domainId = result[0].id ?? result[0][0];
|
||||||
|
domainMap.set(domain, Number(domainId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate certificates to new table
|
||||||
|
for (const cert of existingCerts) {
|
||||||
|
const domain = String(cert.domain ?? cert[1]);
|
||||||
|
const domainId = domainMap.get(domain);
|
||||||
|
|
||||||
|
this.query(
|
||||||
|
`INSERT INTO certificates (
|
||||||
|
domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path,
|
||||||
|
expiry_date, issuer, is_valid, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
domainId,
|
||||||
|
domain,
|
||||||
|
0, // We don't know if it's wildcard, default to false
|
||||||
|
String(cert.cert_path ?? cert[2]),
|
||||||
|
String(cert.key_path ?? cert[3]),
|
||||||
|
String(cert.full_chain_path ?? cert[4]),
|
||||||
|
Number(cert.expiry_date ?? cert[5]),
|
||||||
|
String(cert.issuer ?? cert[6]),
|
||||||
|
1, // Assume valid
|
||||||
|
Number(cert.created_at ?? cert[7]),
|
||||||
|
Number(cert.updated_at ?? cert[8])
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Drop old ssl_certificates table
|
||||||
|
this.query('DROP TABLE ssl_certificates');
|
||||||
|
|
||||||
|
// 6. Create indices for performance
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_domains_cloudflare_zone ON domains(cloudflare_zone_id)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_certificates_domain ON certificates(domain_id)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_certificates_expiry ON certificates(expiry_date)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_service ON cert_requirements(service_id)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_cert_requirements_domain ON cert_requirements(domain_id)');
|
||||||
|
|
||||||
|
this.setMigrationVersion(3);
|
||||||
|
logger.success('Migration 3 completed: Domain management tables created');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Migration failed: ${error.message}`);
|
||||||
|
logger.error(`Stack: ${error.stack}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -222,8 +520,13 @@ export class OneboxDatabase {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = this.query('SELECT MAX(version) as version FROM migrations');
|
const result = this.query('SELECT MAX(version) as version FROM migrations');
|
||||||
return result.length > 0 && result[0][0] !== null ? Number(result[0][0]) : 0;
|
if (result.length === 0) return 0;
|
||||||
} catch {
|
|
||||||
|
// Handle both array and object access patterns
|
||||||
|
const versionValue = result[0].version ?? result[0][0];
|
||||||
|
return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Error getting migration version: ${error.message}, defaulting to 0`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -695,4 +998,321 @@ export class OneboxDatabase {
|
|||||||
updatedAt: Number(row.updated_at || row[8]),
|
updatedAt: Number(row.updated_at || row[8]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Domains ============
|
||||||
|
|
||||||
|
createDomain(domain: Omit<IDomain, 'id'>): IDomain {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
this.query(
|
||||||
|
`INSERT INTO domains (domain, dns_provider, cloudflare_zone_id, is_obsolete, default_wildcard, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
domain.domain,
|
||||||
|
domain.dnsProvider,
|
||||||
|
domain.cloudflareZoneId,
|
||||||
|
domain.isObsolete ? 1 : 0,
|
||||||
|
domain.defaultWildcard ? 1 : 0,
|
||||||
|
domain.createdAt,
|
||||||
|
domain.updatedAt,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getDomainByName(domain.domain)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDomainByName(domain: string): IDomain | null {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM domains WHERE domain = ?', [domain]);
|
||||||
|
return rows.length > 0 ? this.rowToDomain(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDomainById(id: number): IDomain | null {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM domains WHERE id = ?', [id]);
|
||||||
|
return rows.length > 0 ? this.rowToDomain(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllDomains(): IDomain[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM domains ORDER BY domain ASC');
|
||||||
|
return rows.map((row) => this.rowToDomain(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getDomainsByProvider(provider: 'cloudflare' | 'manual'): IDomain[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM domains WHERE dns_provider = ? ORDER BY domain ASC', [
|
||||||
|
provider,
|
||||||
|
]);
|
||||||
|
return rows.map((row) => this.rowToDomain(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDomain(id: number, updates: Partial<IDomain>): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
|
||||||
|
if (updates.domain !== undefined) {
|
||||||
|
fields.push('domain = ?');
|
||||||
|
values.push(updates.domain);
|
||||||
|
}
|
||||||
|
if (updates.dnsProvider !== undefined) {
|
||||||
|
fields.push('dns_provider = ?');
|
||||||
|
values.push(updates.dnsProvider);
|
||||||
|
}
|
||||||
|
if (updates.cloudflareZoneId !== undefined) {
|
||||||
|
fields.push('cloudflare_zone_id = ?');
|
||||||
|
values.push(updates.cloudflareZoneId);
|
||||||
|
}
|
||||||
|
if (updates.isObsolete !== undefined) {
|
||||||
|
fields.push('is_obsolete = ?');
|
||||||
|
values.push(updates.isObsolete ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (updates.defaultWildcard !== undefined) {
|
||||||
|
fields.push('default_wildcard = ?');
|
||||||
|
values.push(updates.defaultWildcard ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push('updated_at = ?');
|
||||||
|
values.push(Date.now());
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
this.query(`UPDATE domains SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDomain(id: number): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
this.query('DELETE FROM domains WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToDomain(row: any): IDomain {
|
||||||
|
return {
|
||||||
|
id: Number(row.id || row[0]),
|
||||||
|
domain: String(row.domain || row[1]),
|
||||||
|
dnsProvider: (row.dns_provider || row[2]) as IDomain['dnsProvider'],
|
||||||
|
cloudflareZoneId: row.cloudflare_zone_id || row[3] || undefined,
|
||||||
|
isObsolete: Boolean(row.is_obsolete || row[4]),
|
||||||
|
defaultWildcard: Boolean(row.default_wildcard || row[5]),
|
||||||
|
createdAt: Number(row.created_at || row[6]),
|
||||||
|
updatedAt: Number(row.updated_at || row[7]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Certificates ============
|
||||||
|
|
||||||
|
createCertificate(cert: Omit<ICertificate, 'id'>): ICertificate {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
this.query(
|
||||||
|
`INSERT INTO certificates (domain_id, cert_domain, is_wildcard, cert_path, key_path, full_chain_path, expiry_date, issuer, is_valid, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
cert.domainId,
|
||||||
|
cert.certDomain,
|
||||||
|
cert.isWildcard ? 1 : 0,
|
||||||
|
cert.certPath,
|
||||||
|
cert.keyPath,
|
||||||
|
cert.fullChainPath,
|
||||||
|
cert.expiryDate,
|
||||||
|
cert.issuer,
|
||||||
|
cert.isValid ? 1 : 0,
|
||||||
|
cert.createdAt,
|
||||||
|
cert.updatedAt,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM certificates WHERE id = last_insert_rowid()');
|
||||||
|
return this.rowToCertificate(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCertificateById(id: number): ICertificate | null {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM certificates WHERE id = ?', [id]);
|
||||||
|
return rows.length > 0 ? this.rowToCertificate(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCertificatesByDomain(domainId: number): ICertificate[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM certificates WHERE domain_id = ? ORDER BY expiry_date DESC', [
|
||||||
|
domainId,
|
||||||
|
]);
|
||||||
|
return rows.map((row) => this.rowToCertificate(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllCertificates(): ICertificate[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM certificates ORDER BY expiry_date DESC');
|
||||||
|
return rows.map((row) => this.rowToCertificate(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCertificate(id: number, updates: Partial<ICertificate>): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
|
||||||
|
if (updates.certDomain !== undefined) {
|
||||||
|
fields.push('cert_domain = ?');
|
||||||
|
values.push(updates.certDomain);
|
||||||
|
}
|
||||||
|
if (updates.isWildcard !== undefined) {
|
||||||
|
fields.push('is_wildcard = ?');
|
||||||
|
values.push(updates.isWildcard ? 1 : 0);
|
||||||
|
}
|
||||||
|
if (updates.certPath !== undefined) {
|
||||||
|
fields.push('cert_path = ?');
|
||||||
|
values.push(updates.certPath);
|
||||||
|
}
|
||||||
|
if (updates.keyPath !== undefined) {
|
||||||
|
fields.push('key_path = ?');
|
||||||
|
values.push(updates.keyPath);
|
||||||
|
}
|
||||||
|
if (updates.fullChainPath !== undefined) {
|
||||||
|
fields.push('full_chain_path = ?');
|
||||||
|
values.push(updates.fullChainPath);
|
||||||
|
}
|
||||||
|
if (updates.expiryDate !== undefined) {
|
||||||
|
fields.push('expiry_date = ?');
|
||||||
|
values.push(updates.expiryDate);
|
||||||
|
}
|
||||||
|
if (updates.issuer !== undefined) {
|
||||||
|
fields.push('issuer = ?');
|
||||||
|
values.push(updates.issuer);
|
||||||
|
}
|
||||||
|
if (updates.isValid !== undefined) {
|
||||||
|
fields.push('is_valid = ?');
|
||||||
|
values.push(updates.isValid ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push('updated_at = ?');
|
||||||
|
values.push(Date.now());
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
this.query(`UPDATE certificates SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCertificate(id: number): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
this.query('DELETE FROM certificates WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToCertificate(row: any): ICertificate {
|
||||||
|
return {
|
||||||
|
id: Number(row.id || row[0]),
|
||||||
|
domainId: Number(row.domain_id || row[1]),
|
||||||
|
certDomain: String(row.cert_domain || row[2]),
|
||||||
|
isWildcard: Boolean(row.is_wildcard || row[3]),
|
||||||
|
certPath: String(row.cert_path || row[4]),
|
||||||
|
keyPath: String(row.key_path || row[5]),
|
||||||
|
fullChainPath: String(row.full_chain_path || row[6]),
|
||||||
|
expiryDate: Number(row.expiry_date || row[7]),
|
||||||
|
issuer: String(row.issuer || row[8]),
|
||||||
|
isValid: Boolean(row.is_valid || row[9]),
|
||||||
|
createdAt: Number(row.created_at || row[10]),
|
||||||
|
updatedAt: Number(row.updated_at || row[11]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Certificate Requirements ============
|
||||||
|
|
||||||
|
createCertRequirement(req: Omit<ICertRequirement, 'id'>): ICertRequirement {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
this.query(
|
||||||
|
`INSERT INTO cert_requirements (service_id, domain_id, subdomain, certificate_id, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
req.serviceId,
|
||||||
|
req.domainId,
|
||||||
|
req.subdomain,
|
||||||
|
req.certificateId,
|
||||||
|
req.status,
|
||||||
|
req.createdAt,
|
||||||
|
req.updatedAt,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM cert_requirements WHERE id = last_insert_rowid()');
|
||||||
|
return this.rowToCertRequirement(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCertRequirementById(id: number): ICertRequirement | null {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM cert_requirements WHERE id = ?', [id]);
|
||||||
|
return rows.length > 0 ? this.rowToCertRequirement(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCertRequirementsByService(serviceId: number): ICertRequirement[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM cert_requirements WHERE service_id = ?', [serviceId]);
|
||||||
|
return rows.map((row) => this.rowToCertRequirement(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getCertRequirementsByDomain(domainId: number): ICertRequirement[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM cert_requirements WHERE domain_id = ?', [domainId]);
|
||||||
|
return rows.map((row) => this.rowToCertRequirement(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllCertRequirements(): ICertRequirement[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM cert_requirements ORDER BY created_at DESC');
|
||||||
|
return rows.map((row) => this.rowToCertRequirement(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCertRequirement(id: number, updates: Partial<ICertRequirement>): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
|
||||||
|
if (updates.subdomain !== undefined) {
|
||||||
|
fields.push('subdomain = ?');
|
||||||
|
values.push(updates.subdomain);
|
||||||
|
}
|
||||||
|
if (updates.certificateId !== undefined) {
|
||||||
|
fields.push('certificate_id = ?');
|
||||||
|
values.push(updates.certificateId);
|
||||||
|
}
|
||||||
|
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 cert_requirements SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCertRequirement(id: number): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
this.query('DELETE FROM cert_requirements WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToCertRequirement(row: any): ICertRequirement {
|
||||||
|
return {
|
||||||
|
id: Number(row.id || row[0]),
|
||||||
|
serviceId: Number(row.service_id || row[1]),
|
||||||
|
domainId: Number(row.domain_id || row[2]),
|
||||||
|
subdomain: String(row.subdomain || row[3]),
|
||||||
|
certificateId: row.certificate_id || row[4] || undefined,
|
||||||
|
status: String(row.status || row[5]) as ICertRequirement['status'],
|
||||||
|
createdAt: Number(row.created_at || row[6]),
|
||||||
|
updatedAt: Number(row.updated_at || row[7]),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,16 @@ export class OneboxHttpServer {
|
|||||||
} else if (path.match(/^\/api\/services\/[^/]+\/logs$/) && method === 'GET') {
|
} else if (path.match(/^\/api\/services\/[^/]+\/logs$/) && method === 'GET') {
|
||||||
const name = path.split('/')[3];
|
const name = path.split('/')[3];
|
||||||
return await this.handleGetLogsRequest(name);
|
return await this.handleGetLogsRequest(name);
|
||||||
|
} else if (path === '/api/ssl/obtain' && method === 'POST') {
|
||||||
|
return await this.handleObtainCertificateRequest(req);
|
||||||
|
} else if (path === '/api/ssl/list' && method === 'GET') {
|
||||||
|
return await this.handleListCertificatesRequest();
|
||||||
|
} else if (path.match(/^\/api\/ssl\/[^/]+$/) && method === 'GET') {
|
||||||
|
const domain = path.split('/').pop()!;
|
||||||
|
return await this.handleGetCertificateRequest(domain);
|
||||||
|
} else if (path.match(/^\/api\/ssl\/[^/]+\/renew$/) && method === 'POST') {
|
||||||
|
const domain = path.split('/')[3];
|
||||||
|
return await this.handleRenewCertificateRequest(domain);
|
||||||
} else {
|
} else {
|
||||||
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
||||||
}
|
}
|
||||||
@@ -442,6 +452,66 @@ export class OneboxHttpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleObtainCertificateRequest(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const { domain, includeWildcard } = body;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return this.jsonResponse(
|
||||||
|
{ success: false, error: 'Domain is required' },
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.oneboxRef.ssl.obtainCertificate(domain, includeWildcard || false);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: `Certificate obtained for ${domain}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to obtain certificate: ${error.message}`);
|
||||||
|
return this.jsonResponse({ success: false, error: error.message || 'Failed to obtain certificate' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleListCertificatesRequest(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const certificates = this.oneboxRef.ssl.listCertificates();
|
||||||
|
return this.jsonResponse({ success: true, data: certificates });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to list certificates: ${error.message}`);
|
||||||
|
return this.jsonResponse({ success: false, error: error.message || 'Failed to list certificates' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGetCertificateRequest(domain: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const certificate = this.oneboxRef.ssl.getCertificate(domain);
|
||||||
|
if (!certificate) {
|
||||||
|
return this.jsonResponse({ success: false, error: 'Certificate not found' }, 404);
|
||||||
|
}
|
||||||
|
return this.jsonResponse({ success: true, data: certificate });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get certificate for ${domain}: ${error.message}`);
|
||||||
|
return this.jsonResponse({ success: false, error: error.message || 'Failed to get certificate' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleRenewCertificateRequest(domain: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
await this.oneboxRef.ssl.renewCertificate(domain);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: `Certificate renewed for ${domain}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to renew certificate for ${domain}: ${error.message}`);
|
||||||
|
return this.jsonResponse({ success: false, error: error.message || 'Failed to renew certificate' }, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle WebSocket upgrade
|
* Handle WebSocket upgrade
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
import * as plugins from '../plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { OneboxDatabase } from './database.ts';
|
import { OneboxDatabase } from './database.ts';
|
||||||
|
import { SqliteCertManager } from './certmanager.ts';
|
||||||
|
|
||||||
export class OneboxSslManager {
|
export class OneboxSslManager {
|
||||||
private oneboxRef: any;
|
private oneboxRef: any;
|
||||||
private database: OneboxDatabase;
|
private database: OneboxDatabase;
|
||||||
private smartacme: plugins.smartacme.SmartAcme | null = null;
|
private smartacme: plugins.smartacme.SmartAcme | null = null;
|
||||||
|
private certManager: SqliteCertManager | null = null;
|
||||||
private acmeEmail: string | null = null;
|
private acmeEmail: string | null = null;
|
||||||
|
|
||||||
constructor(oneboxRef: any) {
|
constructor(oneboxRef: any) {
|
||||||
@@ -35,14 +37,45 @@ export class OneboxSslManager {
|
|||||||
|
|
||||||
this.acmeEmail = acmeEmail;
|
this.acmeEmail = acmeEmail;
|
||||||
|
|
||||||
// Initialize SmartACME
|
// Get Cloudflare API key (reuse from DNS manager)
|
||||||
|
const cfApiKey = this.database.getSetting('cloudflareAPIKey');
|
||||||
|
|
||||||
|
if (!cfApiKey) {
|
||||||
|
logger.warn('Cloudflare API key not configured. SSL certificate management will be limited.');
|
||||||
|
logger.info('Configure with: onebox config set cloudflareAPIKey <key>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get ACME environment (default: production)
|
||||||
|
const acmeEnvironment = this.database.getSetting('acmeEnvironment') || 'production';
|
||||||
|
|
||||||
|
if (!['production', 'integration'].includes(acmeEnvironment)) {
|
||||||
|
throw new Error('acmeEnvironment must be "production" or "integration"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize certificate manager
|
||||||
|
this.certManager = new SqliteCertManager(this.database);
|
||||||
|
await this.certManager.init();
|
||||||
|
|
||||||
|
// Initialize Cloudflare DNS provider for DNS-01 challenge
|
||||||
|
const cfAccount = new plugins.cloudflare.CloudflareAccount(cfApiKey);
|
||||||
|
|
||||||
|
// Create DNS-01 challenge handler
|
||||||
|
const dns01Handler = new plugins.smartacme.handlers.Dns01Handler(cfAccount);
|
||||||
|
|
||||||
|
// Initialize SmartACME with proper configuration
|
||||||
this.smartacme = new plugins.smartacme.SmartAcme({
|
this.smartacme = new plugins.smartacme.SmartAcme({
|
||||||
email: acmeEmail,
|
accountEmail: acmeEmail,
|
||||||
environment: 'production', // or 'staging' for testing
|
certManager: this.certManager,
|
||||||
dns: 'cloudflare', // Use Cloudflare DNS challenge
|
environment: acmeEnvironment as 'production' | 'integration',
|
||||||
|
challengeHandlers: [dns01Handler],
|
||||||
|
challengePriority: ['dns-01'], // Prefer DNS-01 challenges
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('SSL manager initialized with SmartACME');
|
// Start SmartACME
|
||||||
|
await this.smartacme.start();
|
||||||
|
|
||||||
|
logger.success('SSL manager initialized with SmartACME DNS-01 challenge');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to initialize SSL manager: ${error.message}`);
|
logger.error(`Failed to initialize SSL manager: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -57,59 +90,33 @@ export class OneboxSslManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtain SSL certificate for a domain
|
* Obtain SSL certificate for a domain using SmartACME
|
||||||
*/
|
*/
|
||||||
async obtainCertificate(domain: string): Promise<void> {
|
async obtainCertificate(domain: string, includeWildcard = false): Promise<void> {
|
||||||
try {
|
try {
|
||||||
if (!this.isConfigured()) {
|
if (!this.isConfigured()) {
|
||||||
throw new Error('SSL manager not configured');
|
throw new Error('SSL manager not configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Obtaining SSL certificate for ${domain}...`);
|
logger.info(`Obtaining SSL certificate for ${domain} via SmartACME DNS-01...`);
|
||||||
|
|
||||||
// Check if certificate already exists and is valid
|
// Check if certificate already exists and is valid
|
||||||
const existing = this.database.getSSLCertificate(domain);
|
const existingCert = await this.certManager!.retrieveCertificate(domain);
|
||||||
if (existing && existing.expiryDate > Date.now()) {
|
if (existingCert && existingCert.isStillValid()) {
|
||||||
logger.info(`Valid certificate already exists for ${domain}`);
|
logger.info(`Valid certificate already exists for ${domain}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use certbot for now (smartacme integration would be more complex)
|
// Use SmartACME to obtain certificate via DNS-01 challenge
|
||||||
// This is a simplified version - in production, use proper ACME client
|
const cert = await this.smartacme!.getCertificateForDomain(domain, {
|
||||||
await this.obtainCertificateWithCertbot(domain);
|
includeWildcard,
|
||||||
|
|
||||||
// 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({
|
logger.success(`SSL certificate obtained for ${domain}`);
|
||||||
domain,
|
logger.info(`Certificate valid until: ${new Date(cert.validUntil).toISOString()}`);
|
||||||
certPath,
|
|
||||||
keyPath,
|
|
||||||
fullChainPath,
|
|
||||||
expiryDate,
|
|
||||||
issuer: 'Let\'s Encrypt',
|
|
||||||
createdAt: Date.now(),
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload certificates in reverse proxy
|
// Reload certificates in reverse proxy
|
||||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||||
|
|
||||||
logger.success(`SSL certificate obtained for ${domain}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to obtain certificate for ${domain}: ${error.message}`);
|
logger.error(`Failed to obtain certificate for ${domain}: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -155,32 +162,21 @@ export class OneboxSslManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renew certificate for a domain
|
* Renew certificate for a domain using SmartACME
|
||||||
*/
|
*/
|
||||||
async renewCertificate(domain: string): Promise<void> {
|
async renewCertificate(domain: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info(`Renewing SSL certificate for ${domain}...`);
|
if (!this.isConfigured()) {
|
||||||
|
throw new Error('SSL manager not configured');
|
||||||
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
|
logger.info(`Renewing SSL certificate for ${domain} via SmartACME...`);
|
||||||
const expiryDate = Date.now() + 90 * 24 * 60 * 60 * 1000;
|
|
||||||
this.database.updateSSLCertificate(domain, {
|
// SmartACME will check if renewal is needed and obtain a new certificate
|
||||||
expiryDate,
|
const cert = await this.smartacme!.getCertificateForDomain(domain);
|
||||||
});
|
|
||||||
|
|
||||||
logger.success(`Certificate renewed for ${domain}`);
|
logger.success(`Certificate renewed for ${domain}`);
|
||||||
|
logger.info(`Certificate valid until: ${new Date(cert.validUntil).toISOString()}`);
|
||||||
|
|
||||||
// Reload certificates in reverse proxy
|
// Reload certificates in reverse proxy
|
||||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||||
@@ -209,21 +205,27 @@ export class OneboxSslManager {
|
|||||||
*/
|
*/
|
||||||
async renewExpiring(): Promise<void> {
|
async renewExpiring(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
if (!this.isConfigured()) {
|
||||||
|
logger.warn('SSL manager not configured, skipping renewal check');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('Checking for expiring certificates...');
|
logger.info('Checking for expiring certificates...');
|
||||||
|
|
||||||
const certificates = this.listCertificates();
|
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...`);
|
|
||||||
|
|
||||||
|
for (const dbCert of certificates) {
|
||||||
try {
|
try {
|
||||||
await this.renewCertificate(cert.domain);
|
// Retrieve certificate from cert manager to check if it should be renewed
|
||||||
} catch (error) {
|
const cert = await this.certManager!.retrieveCertificate(dbCert.domain);
|
||||||
logger.error(`Failed to renew ${cert.domain}: ${error.message}`);
|
|
||||||
// Continue with other certificates
|
if (cert && cert.shouldBeRenewed()) {
|
||||||
|
logger.info(`Certificate for ${dbCert.domain} needs renewal, renewing...`);
|
||||||
|
await this.renewCertificate(dbCert.domain);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to renew ${dbCert.domain}: ${error.message}`);
|
||||||
|
// Continue with other certificates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ export { smartdaemon };
|
|||||||
import { DockerHost } from '@apiclient.xyz/docker';
|
import { DockerHost } from '@apiclient.xyz/docker';
|
||||||
export const docker = { Docker: DockerHost };
|
export const docker = { Docker: DockerHost };
|
||||||
|
|
||||||
// Cloudflare DNS Management
|
// Cloudflare DNS Management (API Client)
|
||||||
import Cloudflare from 'npm:cloudflare@5.2.0';
|
import * as cloudflare from '@apiclient.xyz/cloudflare';
|
||||||
export const cloudflare = { Cloudflare };
|
export { cloudflare };
|
||||||
|
|
||||||
// Let's Encrypt / ACME
|
// Let's Encrypt / ACME
|
||||||
import * as smartacme from '@push.rocks/smartacme';
|
import * as smartacme from '@push.rocks/smartacme';
|
||||||
|
|||||||
49
ts/types.ts
49
ts/types.ts
@@ -38,7 +38,54 @@ export interface INginxConfig {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSL certificate types
|
// Domain management types
|
||||||
|
export interface IDomain {
|
||||||
|
id?: number;
|
||||||
|
domain: string;
|
||||||
|
dnsProvider: 'cloudflare' | 'manual' | null;
|
||||||
|
cloudflareZoneId?: string;
|
||||||
|
isObsolete: boolean;
|
||||||
|
defaultWildcard: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICertificate {
|
||||||
|
id?: number;
|
||||||
|
domainId: number;
|
||||||
|
certDomain: string;
|
||||||
|
isWildcard: boolean;
|
||||||
|
certPath: string;
|
||||||
|
keyPath: string;
|
||||||
|
fullChainPath: string;
|
||||||
|
expiryDate: number;
|
||||||
|
issuer: string;
|
||||||
|
isValid: boolean;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICertRequirement {
|
||||||
|
id?: number;
|
||||||
|
serviceId: number;
|
||||||
|
domainId: number;
|
||||||
|
subdomain: string;
|
||||||
|
certificateId?: number;
|
||||||
|
status: 'pending' | 'active' | 'renewing';
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IDomainView {
|
||||||
|
domain: IDomain;
|
||||||
|
certificates: ICertificate[];
|
||||||
|
requirements: ICertRequirement[];
|
||||||
|
serviceCount: number;
|
||||||
|
certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none';
|
||||||
|
daysRemaining: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy SSL certificate type (for backward compatibility)
|
||||||
export interface ISslCertificate {
|
export interface ISslCertificate {
|
||||||
id?: number;
|
id?: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, OnInit, OnDestroy, inject } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { WebSocketService } from './core/services/websocket.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -7,4 +8,16 @@ import { RouterOutlet } from '@angular/router';
|
|||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet],
|
||||||
template: `<router-outlet></router-outlet>`,
|
template: `<router-outlet></router-outlet>`,
|
||||||
})
|
})
|
||||||
export class AppComponent {}
|
export class AppComponent implements OnInit, OnDestroy {
|
||||||
|
private wsService = inject(WebSocketService);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
// Connect to WebSocket when app starts
|
||||||
|
this.wsService.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Disconnect when app is destroyed
|
||||||
|
this.wsService.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,9 +35,17 @@ export interface SystemStatus {
|
|||||||
running: boolean;
|
running: boolean;
|
||||||
version: any;
|
version: any;
|
||||||
};
|
};
|
||||||
nginx: {
|
reverseProxy: {
|
||||||
status: string;
|
http: {
|
||||||
installed: boolean;
|
running: boolean;
|
||||||
|
port: number;
|
||||||
|
};
|
||||||
|
https: {
|
||||||
|
running: boolean;
|
||||||
|
port: number;
|
||||||
|
certificates: number;
|
||||||
|
};
|
||||||
|
routes: number;
|
||||||
};
|
};
|
||||||
dns: {
|
dns: {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
|
|||||||
101
ui/src/app/core/services/websocket.service.ts
Normal file
101
ui/src/app/core/services/websocket.service.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Subject, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
export interface WebSocketMessage {
|
||||||
|
type: string;
|
||||||
|
action?: string;
|
||||||
|
serviceName?: string;
|
||||||
|
data?: any;
|
||||||
|
status?: string;
|
||||||
|
timestamp: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root'
|
||||||
|
})
|
||||||
|
export class WebSocketService {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private messageSubject = new Subject<WebSocketMessage>();
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private maxReconnectAttempts = 5;
|
||||||
|
private reconnectDelay = 3000;
|
||||||
|
private reconnectTimer: any = null;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||||
|
console.log('WebSocket already connected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
|
||||||
|
|
||||||
|
console.log('Connecting to WebSocket:', wsUrl);
|
||||||
|
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
console.log('✓ WebSocket connected');
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message: WebSocketMessage = JSON.parse(event.data);
|
||||||
|
console.log('📨 WebSocket message:', message);
|
||||||
|
this.messageSubject.next(message);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('✖ WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
console.log('⚠ WebSocket closed');
|
||||||
|
this.ws = null;
|
||||||
|
this.attemptReconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private attemptReconnect(): void {
|
||||||
|
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||||
|
console.error('Max WebSocket reconnect attempts reached');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
const delay = this.reconnectDelay * this.reconnectAttempts;
|
||||||
|
|
||||||
|
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||||
|
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getMessages(): Observable<WebSocketMessage> {
|
||||||
|
return this.messageSubject.asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.ws?.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,22 +110,26 @@ import { ApiService, SystemStatus } from '../../core/services/api.service';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nginx -->
|
<!-- Reverse Proxy -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-4">Nginx</h3>
|
<h3 class="text-lg font-medium text-gray-900 mb-4">Reverse Proxy</h3>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-sm text-gray-600">Status</span>
|
<span class="text-sm text-gray-600">HTTP (Port {{ status()!.reverseProxy.http.port }})</span>
|
||||||
<span [ngClass]="status()!.nginx.status === 'running' ? 'badge-success' : 'badge-danger'" class="badge">
|
<span [ngClass]="status()!.reverseProxy.http.running ? 'badge-success' : 'badge-danger'" class="badge">
|
||||||
{{ status()!.nginx.status }}
|
{{ status()!.reverseProxy.http.running ? 'Running' : 'Stopped' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-sm text-gray-600">Installed</span>
|
<span class="text-sm text-gray-600">HTTPS (Port {{ status()!.reverseProxy.https.port }})</span>
|
||||||
<span [ngClass]="status()!.nginx.installed ? 'badge-success' : 'badge-danger'" class="badge">
|
<span [ngClass]="status()!.reverseProxy.https.running ? 'badge-success' : 'badge-danger'" class="badge">
|
||||||
{{ status()!.nginx.installed ? 'Yes' : 'No' }}
|
{{ status()!.reverseProxy.https.running ? 'Running' : 'Stopped' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">SSL Certificates</span>
|
||||||
|
<span class="badge badge-info">{{ status()!.reverseProxy.https.certificates }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { ApiService, Service } from '../../core/services/api.service';
|
import { ApiService, Service } from '../../core/services/api.service';
|
||||||
|
import { WebSocketService } from '../../core/services/websocket.service';
|
||||||
|
import { Subscription } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-services-list',
|
selector: 'app-services-list',
|
||||||
@@ -89,14 +91,42 @@ import { ApiService, Service } from '../../core/services/api.service';
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class ServicesListComponent implements OnInit {
|
export class ServicesListComponent implements OnInit, OnDestroy {
|
||||||
private apiService = inject(ApiService);
|
private apiService = inject(ApiService);
|
||||||
|
private wsService = inject(WebSocketService);
|
||||||
|
private wsSubscription?: Subscription;
|
||||||
|
|
||||||
services = signal<Service[]>([]);
|
services = signal<Service[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
// Initial load
|
||||||
this.loadServices();
|
this.loadServices();
|
||||||
|
|
||||||
|
// Subscribe to WebSocket updates
|
||||||
|
this.wsSubscription = this.wsService.getMessages().subscribe((message) => {
|
||||||
|
this.handleWebSocketMessage(message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.wsSubscription?.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleWebSocketMessage(message: any): void {
|
||||||
|
if (message.type === 'service_update') {
|
||||||
|
// Reload the full service list on any service update
|
||||||
|
this.loadServices();
|
||||||
|
} else if (message.type === 'service_status') {
|
||||||
|
// Update individual service status
|
||||||
|
const currentServices = this.services();
|
||||||
|
const updatedServices = currentServices.map(s =>
|
||||||
|
s.name === message.serviceName
|
||||||
|
? { ...s, status: message.status }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
this.services.set(updatedServices);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadServices(): void {
|
loadServices(): void {
|
||||||
@@ -117,7 +147,7 @@ export class ServicesListComponent implements OnInit {
|
|||||||
startService(service: Service): void {
|
startService(service: Service): void {
|
||||||
this.apiService.startService(service.name).subscribe({
|
this.apiService.startService(service.name).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.loadServices();
|
// WebSocket will handle the update
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -125,7 +155,7 @@ export class ServicesListComponent implements OnInit {
|
|||||||
stopService(service: Service): void {
|
stopService(service: Service): void {
|
||||||
this.apiService.stopService(service.name).subscribe({
|
this.apiService.stopService(service.name).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.loadServices();
|
// WebSocket will handle the update
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -133,7 +163,7 @@ export class ServicesListComponent implements OnInit {
|
|||||||
restartService(service: Service): void {
|
restartService(service: Service): void {
|
||||||
this.apiService.restartService(service.name).subscribe({
|
this.apiService.restartService(service.name).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.loadServices();
|
// WebSocket will handle the update
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -142,7 +172,7 @@ export class ServicesListComponent implements OnInit {
|
|||||||
if (confirm(`Are you sure you want to delete ${service.name}?`)) {
|
if (confirm(`Are you sure you want to delete ${service.name}?`)) {
|
||||||
this.apiService.deleteService(service.name).subscribe({
|
this.apiService.deleteService(service.name).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.loadServices();
|
// WebSocket will handle the update
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user