Add tests for authentication and security features
- Implement unit tests for password handling in `auth_test.ts`, covering bcrypt and legacy password hashes. - Create a fake database for user management to facilitate testing of the `AdminHandler`. - Validate JWT-based identity verification against database records. - Introduce tests for credential encryption and registry management in `security_test.ts`. - Ensure registry passwords are securely stored and can be decrypted correctly, including legacy support. - Add utility functions for password hashing and verification in `auth.ts`.
This commit is contained in:
@@ -43,6 +43,14 @@ const IV_LENGTH = 12;
|
||||
const SALT_LENGTH = 32;
|
||||
const PBKDF2_ITERATIONS = 100000;
|
||||
|
||||
interface IS3ConnectionInfo {
|
||||
endpoint: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
export class BackupManager {
|
||||
private oneboxRef: Onebox;
|
||||
public archive: plugins.ContainerArchive | null = null;
|
||||
@@ -519,7 +527,8 @@ export class BackupManager {
|
||||
* Get backup password from settings
|
||||
*/
|
||||
private getBackupPassword(): string | null {
|
||||
return this.oneboxRef.database.getSetting('backup_encryption_password');
|
||||
return this.oneboxRef.database.getSetting('backup_encryption_password')
|
||||
|| this.oneboxRef.database.getSetting('backupPassword');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -860,47 +869,48 @@ export class BackupManager {
|
||||
const bucketDir = `${dataDir}/${resource.resourceName}`;
|
||||
await Deno.mkdir(bucketDir, { recursive: true });
|
||||
|
||||
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT;
|
||||
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY;
|
||||
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY;
|
||||
const bucket = credentials.bucket || credentials.S3_BUCKET;
|
||||
const s3Info = this.getS3ConnectionInfo(credentials);
|
||||
const s3Client = this.createS3Client(s3Info);
|
||||
let objectCount = 0;
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
if (!endpoint || !accessKey || !secretKey || !bucket) {
|
||||
throw new Error('MinIO credentials incomplete');
|
||||
}
|
||||
do {
|
||||
const response = await s3Client.send(
|
||||
new plugins.awsS3.ListObjectsV2Command({
|
||||
Bucket: s3Info.bucket,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
const s3Client = new plugins.smartstorage.SmartStorage({
|
||||
endpoint,
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket,
|
||||
});
|
||||
for (const object of response.Contents || []) {
|
||||
const objectKey = object.Key;
|
||||
if (!objectKey) continue;
|
||||
|
||||
await s3Client.start();
|
||||
const objectResponse = await s3Client.send(
|
||||
new plugins.awsS3.GetObjectCommand({
|
||||
Bucket: s3Info.bucket,
|
||||
Key: objectKey,
|
||||
}),
|
||||
);
|
||||
|
||||
const objects = await s3Client.listObjects();
|
||||
if (!objectResponse.Body) continue;
|
||||
|
||||
for (const obj of objects) {
|
||||
const objectKey = obj.Key;
|
||||
if (!objectKey) continue;
|
||||
|
||||
const objectData = await s3Client.getObject(objectKey);
|
||||
if (objectData) {
|
||||
const objectPath = `${bucketDir}/${objectKey}`;
|
||||
const parentDir = plugins.path.dirname(objectPath);
|
||||
await Deno.mkdir(parentDir, { recursive: true });
|
||||
await Deno.writeFile(objectPath, objectData);
|
||||
await Deno.writeFile(objectPath, await objectResponse.Body.transformToByteArray());
|
||||
objectCount++;
|
||||
}
|
||||
}
|
||||
|
||||
await s3Client.stop();
|
||||
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
||||
} while (continuationToken);
|
||||
|
||||
await Deno.writeTextFile(
|
||||
`${bucketDir}/_metadata.json`,
|
||||
JSON.stringify({ bucket, objectCount: objects.length }, null, 2)
|
||||
JSON.stringify({ bucket: s3Info.bucket, objectCount }, null, 2)
|
||||
);
|
||||
|
||||
logger.success(`MinIO bucket exported: ${resource.resourceName} (${objects.length} objects)`);
|
||||
logger.success(`MinIO bucket exported: ${resource.resourceName} (${objectCount} objects)`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1279,40 +1289,26 @@ export class BackupManager {
|
||||
|
||||
const bucketDir = `${dataDir}/${backupResourceName}`;
|
||||
|
||||
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT;
|
||||
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY;
|
||||
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY;
|
||||
const bucket = credentials.bucket || credentials.S3_BUCKET;
|
||||
|
||||
if (!endpoint || !accessKey || !secretKey || !bucket) {
|
||||
throw new Error('MinIO credentials incomplete');
|
||||
}
|
||||
|
||||
const s3Client = new plugins.smartstorage.SmartStorage({
|
||||
endpoint,
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket,
|
||||
});
|
||||
|
||||
await s3Client.start();
|
||||
const s3Info = this.getS3ConnectionInfo(credentials);
|
||||
const s3Client = this.createS3Client(s3Info);
|
||||
|
||||
let uploadedCount = 0;
|
||||
|
||||
for await (const entry of Deno.readDir(bucketDir)) {
|
||||
if (entry.name === '_metadata.json') continue;
|
||||
for await (const filePath of this.walkFiles(bucketDir)) {
|
||||
if (plugins.path.basename(filePath) === '_metadata.json') continue;
|
||||
|
||||
const filePath = `${bucketDir}/${entry.name}`;
|
||||
|
||||
if (entry.isFile) {
|
||||
const fileData = await Deno.readFile(filePath);
|
||||
await s3Client.putObject(entry.name, fileData);
|
||||
uploadedCount++;
|
||||
}
|
||||
const fileData = await Deno.readFile(filePath);
|
||||
const objectKey = plugins.path.relative(bucketDir, filePath).replaceAll('\\', '/');
|
||||
await s3Client.send(
|
||||
new plugins.awsS3.PutObjectCommand({
|
||||
Bucket: s3Info.bucket,
|
||||
Key: objectKey,
|
||||
Body: fileData,
|
||||
}),
|
||||
);
|
||||
uploadedCount++;
|
||||
}
|
||||
|
||||
await s3Client.stop();
|
||||
|
||||
logger.success(`MinIO bucket imported: ${resource.resourceName} (${uploadedCount} objects)`);
|
||||
}
|
||||
|
||||
@@ -1585,7 +1581,7 @@ export class BackupManager {
|
||||
return await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt,
|
||||
salt: this.toArrayBuffer(salt),
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
@@ -1600,8 +1596,54 @@ export class BackupManager {
|
||||
* Compute SHA-256 checksum
|
||||
*/
|
||||
private async computeChecksum(data: Uint8Array): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', this.toArrayBuffer(data));
|
||||
const hashArray = new Uint8Array(hashBuffer);
|
||||
return 'sha256:' + Array.from(hashArray).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
private getS3ConnectionInfo(credentials: Record<string, string>): IS3ConnectionInfo {
|
||||
const endpoint = credentials.endpoint || credentials.S3_ENDPOINT;
|
||||
const accessKey = credentials.accessKey || credentials.S3_ACCESS_KEY;
|
||||
const secretKey = credentials.secretKey || credentials.S3_SECRET_KEY;
|
||||
const bucket = credentials.bucket || credentials.S3_BUCKET;
|
||||
|
||||
if (!endpoint || !accessKey || !secretKey || !bucket) {
|
||||
throw new Error('MinIO credentials incomplete');
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket,
|
||||
region: credentials.region || credentials.AWS_REGION || 'us-east-1',
|
||||
};
|
||||
}
|
||||
|
||||
private createS3Client(s3Info: IS3ConnectionInfo) {
|
||||
return new plugins.awsS3.S3Client({
|
||||
endpoint: s3Info.endpoint,
|
||||
region: s3Info.region,
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: s3Info.accessKey,
|
||||
secretAccessKey: s3Info.secretKey,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async *walkFiles(directory: string): AsyncGenerator<string> {
|
||||
for await (const entry of Deno.readDir(directory)) {
|
||||
const entryPath = plugins.path.join(directory, entry.name);
|
||||
if (entry.isDirectory) {
|
||||
yield* this.walkFiles(entryPath);
|
||||
} else if (entry.isFile) {
|
||||
yield entryPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
return data.slice().buffer as ArrayBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ export class CloudflareDomainSync {
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
try {
|
||||
const apiKey = this.database.getSetting('cloudflareAPIKey');
|
||||
const apiKey = this.database.getSetting('cloudflareAPIKey')
|
||||
|| this.database.getSetting('cloudflareToken');
|
||||
|
||||
if (!apiKey) {
|
||||
logger.warn('Cloudflare API key not configured. Domain sync will be limited.');
|
||||
|
||||
+2
-1
@@ -27,7 +27,8 @@ export class OneboxDnsManager {
|
||||
async init(): Promise<void> {
|
||||
try {
|
||||
// Get Cloudflare credentials from settings
|
||||
const apiKey = this.database.getSetting('cloudflareAPIKey');
|
||||
const apiKey = this.database.getSetting('cloudflareAPIKey')
|
||||
|| this.database.getSetting('cloudflareToken');
|
||||
const serverIP = this.database.getSetting('serverIP');
|
||||
|
||||
if (!apiKey) {
|
||||
|
||||
@@ -97,7 +97,11 @@ export class CredentialEncryption {
|
||||
*/
|
||||
async encrypt(data: Record<string, string>): Promise<string> {
|
||||
if (!this.key) {
|
||||
throw new Error('Encryption not initialized. Call init() first.');
|
||||
await this.init();
|
||||
}
|
||||
const key = this.key;
|
||||
if (!key) {
|
||||
throw new Error('Encryption key initialization failed.');
|
||||
}
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(this.ivLength));
|
||||
@@ -105,7 +109,7 @@ export class CredentialEncryption {
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: this.algorithm, iv },
|
||||
this.key,
|
||||
key,
|
||||
encoded
|
||||
);
|
||||
|
||||
@@ -120,9 +124,15 @@ export class CredentialEncryption {
|
||||
/**
|
||||
* Decrypt a base64 string back to credentials object
|
||||
*/
|
||||
async decrypt(encrypted: string): Promise<Record<string, string>> {
|
||||
async decrypt<T extends Record<string, string> = Record<string, string>>(
|
||||
encrypted: string,
|
||||
): Promise<T> {
|
||||
if (!this.key) {
|
||||
throw new Error('Encryption not initialized. Call init() first.');
|
||||
await this.init();
|
||||
}
|
||||
const key = this.key;
|
||||
if (!key) {
|
||||
throw new Error('Encryption key initialization failed.');
|
||||
}
|
||||
|
||||
const combined = this.base64ToBytes(encrypted);
|
||||
@@ -133,12 +143,12 @@ export class CredentialEncryption {
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: this.algorithm, iv },
|
||||
this.key,
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
|
||||
const decoded = new TextDecoder().decode(decrypted);
|
||||
return JSON.parse(decoded);
|
||||
return JSON.parse(decoded) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+14
-10
@@ -6,6 +6,7 @@
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { hashPassword, needsPasswordUpgrade, verifyPassword } from '../utils/auth.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import type { Onebox } from './onebox.ts';
|
||||
import type {
|
||||
@@ -404,15 +405,17 @@ export class OneboxHttpServer {
|
||||
|
||||
logger.info(`User found: ${username}, checking password...`);
|
||||
|
||||
// Verify password (simple base64 comparison for now)
|
||||
const passwordHash = btoa(password);
|
||||
logger.info(`Password hash: ${passwordHash}, stored hash: ${user.passwordHash}`);
|
||||
|
||||
if (passwordHash !== user.passwordHash) {
|
||||
const passwordMatches = await verifyPassword(password, user.passwordHash);
|
||||
if (!passwordMatches) {
|
||||
logger.info(`Password mismatch for user: ${username}`);
|
||||
return this.jsonResponse({ success: false, error: 'Invalid credentials' }, 401);
|
||||
}
|
||||
|
||||
if (needsPasswordUpgrade(user.passwordHash)) {
|
||||
const upgradedHash = await hashPassword(password);
|
||||
this.oneboxRef.database.updateUserPassword(user.username, upgradedHash);
|
||||
}
|
||||
|
||||
// Generate simple token (in production, use proper JWT)
|
||||
const token = btoa(`${user.username}:${Date.now()}`);
|
||||
|
||||
@@ -1324,7 +1327,7 @@ export class OneboxHttpServer {
|
||||
type: 'service',
|
||||
name: service.name,
|
||||
domain: service.domain || null,
|
||||
targetHost: service.containerIP || 'unknown',
|
||||
targetHost: service.containerID || 'unknown',
|
||||
targetPort: service.port || 80,
|
||||
status: service.status,
|
||||
});
|
||||
@@ -1380,6 +1383,7 @@ export class OneboxHttpServer {
|
||||
rabbitmq: 5672,
|
||||
caddy: 80,
|
||||
clickhouse: 8123,
|
||||
mariadb: 3306,
|
||||
};
|
||||
return ports[type] || 0;
|
||||
}
|
||||
@@ -1396,11 +1400,11 @@ export class OneboxHttpServer {
|
||||
success: true,
|
||||
data: {
|
||||
proxy: {
|
||||
running: proxyStatus.running,
|
||||
httpPort: proxyStatus.httpPort,
|
||||
httpsPort: proxyStatus.httpsPort,
|
||||
running: proxyStatus.http.running || proxyStatus.https.running,
|
||||
httpPort: proxyStatus.http.port,
|
||||
httpsPort: proxyStatus.https.port,
|
||||
routes: proxyStatus.routes,
|
||||
certificates: proxyStatus.certificates,
|
||||
certificates: proxyStatus.https.certificates,
|
||||
},
|
||||
logReceiver: {
|
||||
running: logReceiverStats.running,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import { hashPassword } from '../utils/auth.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
import { OneboxDockerManager } from './docker.ts';
|
||||
import { OneboxServicesManager } from './services.ts';
|
||||
@@ -226,8 +227,7 @@ export class Onebox {
|
||||
if (!adminUser) {
|
||||
logger.info('Creating default admin user...');
|
||||
|
||||
// Simple base64 encoding for now - should use bcrypt in production
|
||||
const passwordHash = btoa('admin');
|
||||
const passwordHash = await hashPassword('admin');
|
||||
|
||||
await this.database.createUser({
|
||||
username: 'admin',
|
||||
|
||||
@@ -76,7 +76,9 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||
// Reuse existing credentials from database
|
||||
logger.info('Reusing existing ClickHouse credentials (data directory already initialized)');
|
||||
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
} else {
|
||||
// Generate new credentials for fresh deployment
|
||||
logger.info('Generating new ClickHouse admin credentials');
|
||||
@@ -191,7 +193,9 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
throw new Error('ClickHouse platform service not found or not configured');
|
||||
}
|
||||
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
const adminCreds = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
const containerName = this.getContainerName();
|
||||
|
||||
// Generate resource names and credentials
|
||||
@@ -247,7 +251,9 @@ export class ClickHouseProvider extends BasePlatformServiceProvider {
|
||||
throw new Error('ClickHouse platform service not found or not configured');
|
||||
}
|
||||
|
||||
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
const adminCreds = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
|
||||
logger.info(`Deprovisioning ClickHouse database '${resource.resourceName}'...`);
|
||||
|
||||
|
||||
@@ -74,7 +74,9 @@ export class MariaDBProvider extends BasePlatformServiceProvider {
|
||||
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||
// Reuse existing credentials from database
|
||||
logger.info('Reusing existing MariaDB credentials (data directory already initialized)');
|
||||
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
} else {
|
||||
// Generate new credentials for fresh deployment
|
||||
logger.info('Generating new MariaDB admin credentials');
|
||||
|
||||
@@ -80,7 +80,9 @@ export class MinioProvider extends BasePlatformServiceProvider {
|
||||
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||
// Reuse existing credentials from database
|
||||
logger.info('Reusing existing MinIO credentials (data directory already initialized)');
|
||||
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
} else {
|
||||
// Generate new credentials for fresh deployment
|
||||
logger.info('Generating new MinIO admin credentials');
|
||||
|
||||
@@ -74,7 +74,9 @@ export class MongoDBProvider extends BasePlatformServiceProvider {
|
||||
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||
// Reuse existing credentials from database
|
||||
logger.info('Reusing existing MongoDB credentials (data directory already initialized)');
|
||||
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
} else {
|
||||
// Generate new credentials for fresh deployment
|
||||
logger.info('Generating new MongoDB admin credentials');
|
||||
|
||||
@@ -76,7 +76,9 @@ export class RedisProvider extends BasePlatformServiceProvider {
|
||||
if (dataExists && platformService?.adminCredentialsEncrypted) {
|
||||
// Reuse existing credentials from database
|
||||
logger.info('Reusing existing Redis credentials (data directory already initialized)');
|
||||
adminCredentials = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||
adminCredentials = await credentialEncryption.decrypt<{ username: string; password: string }>(
|
||||
platformService.adminCredentialsEncrypted,
|
||||
);
|
||||
} else {
|
||||
// Generate new credentials for fresh deployment
|
||||
logger.info('Generating new Redis admin credentials');
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { IRegistry } from '../types.ts';
|
||||
import { logger } from '../logging.ts';
|
||||
import { getErrorMessage } from '../utils/error.ts';
|
||||
import { OneboxDatabase } from './database.ts';
|
||||
import { credentialEncryption } from './encryption.ts';
|
||||
|
||||
const encryptedPasswordPrefix = 'enc:v1:';
|
||||
|
||||
export class OneboxRegistriesManager {
|
||||
private oneboxRef: any; // Will be Onebox instance
|
||||
@@ -22,17 +25,23 @@ export class OneboxRegistriesManager {
|
||||
/**
|
||||
* Encrypt a password (simple base64 for now, should use proper encryption)
|
||||
*/
|
||||
private encryptPassword(password: string): string {
|
||||
// TODO: Use proper encryption with a secret key
|
||||
// For now, using base64 encoding (NOT SECURE, just for structure)
|
||||
return plugins.encoding.encodeBase64(new TextEncoder().encode(password));
|
||||
private async encryptPassword(password: string): Promise<string> {
|
||||
const encrypted = await credentialEncryption.encrypt({ password });
|
||||
return `${encryptedPasswordPrefix}${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a password
|
||||
*/
|
||||
private decryptPassword(encrypted: string): string {
|
||||
// TODO: Use proper decryption
|
||||
private async decryptPassword(encrypted: string): Promise<string> {
|
||||
if (encrypted.startsWith(encryptedPasswordPrefix)) {
|
||||
const decrypted = await credentialEncryption.decrypt<{ password: string }>(
|
||||
encrypted.slice(encryptedPasswordPrefix.length),
|
||||
);
|
||||
return decrypted.password;
|
||||
}
|
||||
|
||||
// Legacy compatibility for older databases that stored base64-encoded passwords.
|
||||
return new TextDecoder().decode(plugins.encoding.decodeBase64(encrypted));
|
||||
}
|
||||
|
||||
@@ -48,7 +57,7 @@ export class OneboxRegistriesManager {
|
||||
}
|
||||
|
||||
// Encrypt password
|
||||
const passwordEncrypted = this.encryptPassword(password);
|
||||
const passwordEncrypted = await this.encryptPassword(password);
|
||||
|
||||
// Create registry in database
|
||||
const registry = await this.database.createRegistry({
|
||||
@@ -111,7 +120,7 @@ export class OneboxRegistriesManager {
|
||||
try {
|
||||
logger.info(`Logging into registry: ${registry.url}`);
|
||||
|
||||
const password = this.decryptPassword(registry.passwordEncrypted);
|
||||
const password = await this.decryptPassword(registry.passwordEncrypted);
|
||||
|
||||
// Use docker login command
|
||||
const command = [
|
||||
|
||||
+2
-1
@@ -39,7 +39,8 @@ export class OneboxSslManager {
|
||||
this.acmeEmail = acmeEmail;
|
||||
|
||||
// Get Cloudflare API key (reuse from DNS manager)
|
||||
const cfApiKey = this.database.getSetting('cloudflareAPIKey');
|
||||
const cfApiKey = this.database.getSetting('cloudflareAPIKey')
|
||||
|| this.database.getSetting('cloudflareToken');
|
||||
|
||||
if (!cfApiKey) {
|
||||
logger.warn('Cloudflare API key not configured. SSL certificate management will be limited.');
|
||||
|
||||
Reference in New Issue
Block a user