From 13b18b004db2e94d61735a993c45ed45e96653d7 Mon Sep 17 00:00:00 2001 From: dhavalpiqud Date: Fri, 22 May 2026 16:54:17 +0530 Subject: [PATCH 1/3] feature: add connect and disconnect endpoint for servers, add soft delete and restore functionality for server --- .../migrations/1779272785745-server.ts | 20 +- apps/control-panel-app/seeders/seed.ts | 6 +- .../seeders/templates.seed.ts | 383 +++++++++++++----- apps/control-panel-app/src/constants/error.ts | 13 + .../src/constants/success.ts | 5 + .../database/seeders/seed-from-templates.ts | 300 -------------- apps/control-panel-app/src/main.ts | 2 + .../server-connections.controller.ts | 58 --- .../controllers/servers.controller.ts | 44 +- .../dto/onboard-response.dto.ts | 2 +- .../entities/server-ssh-credential.entity.ts | 14 +- .../entities/server.entity.ts | 2 +- .../server-connections.module.ts | 3 +- .../services/server-connections.service.ts | 383 ++++++++++++++++-- .../templates/postgresql/template.config.json | 43 -- .../templates/redis/template.config.json | 23 -- apps/control-panel-app/tsconfig.json | 43 +- libs/ssh/src/constants/ssh.constants.ts | 5 + libs/ssh/src/errors/error-messages.ts | 3 + .../ssh-connection-manager.service.ts | 15 +- 20 files changed, 767 insertions(+), 600 deletions(-) create mode 100644 apps/control-panel-app/src/constants/error.ts create mode 100644 apps/control-panel-app/src/constants/success.ts delete mode 100644 apps/control-panel-app/src/database/seeders/seed-from-templates.ts delete mode 100644 apps/control-panel-app/src/modules/server-connections/controllers/server-connections.controller.ts delete mode 100644 apps/control-panel-app/templates/postgresql/template.config.json delete mode 100644 apps/control-panel-app/templates/redis/template.config.json create mode 100644 libs/ssh/src/errors/error-messages.ts diff --git a/apps/control-panel-app/migrations/1779272785745-server.ts b/apps/control-panel-app/migrations/1779272785745-server.ts index 201e988..db7586a 100644 --- a/apps/control-panel-app/migrations/1779272785745-server.ts +++ b/apps/control-panel-app/migrations/1779272785745-server.ts @@ -1,4 +1,10 @@ -import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from "typeorm"; +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableUnique, +} from "typeorm"; export class Server1779272785745 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { @@ -137,13 +143,15 @@ export class Server1779272785745 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropIndex("servers", "IDX_servers_status"); - + await queryRunner.dropIndex("servers", "IDX_servers_host"); - - await queryRunner.dropUniqueConstraint("servers", "servers_host_username_unique"); - + + await queryRunner.dropUniqueConstraint( + "servers", + "servers_host_username_unique", + ); + await queryRunner.dropTable("servers"); await queryRunner.query(` diff --git a/apps/control-panel-app/seeders/seed.ts b/apps/control-panel-app/seeders/seed.ts index 5444441..18d041d 100644 --- a/apps/control-panel-app/seeders/seed.ts +++ b/apps/control-panel-app/seeders/seed.ts @@ -1,8 +1,6 @@ import dataSource from "../config/typeorm.config"; import { seedTemplates } from "./templates.seed"; -// import { seedUsers } from "./users.seed"; -// import { seedRoles } from "./roles.seed"; async function run() { const ds = dataSource; @@ -20,7 +18,7 @@ async function run() { console.log("Starting database seeding..."); - await seedTemplates(queryRunner); + await seedTemplates(); await queryRunner.commitTransaction(); @@ -38,4 +36,4 @@ async function run() { } } -void run(); \ No newline at end of file +void run(); diff --git a/apps/control-panel-app/seeders/templates.seed.ts b/apps/control-panel-app/seeders/templates.seed.ts index 91424b9..70a8f9f 100644 --- a/apps/control-panel-app/seeders/templates.seed.ts +++ b/apps/control-panel-app/seeders/templates.seed.ts @@ -1,103 +1,298 @@ +/** + * Seeds service templates directly from source files under apps/control-panel-app/templates. + * Reads docker-compose.yml (and optional template.config.json), then upserts rows by slug. + * + * Run via: npm run seed:templates + */ +import "reflect-metadata"; import * as fs from "fs"; import * as path from "path"; -import { QueryRunner } from "typeorm"; - -interface SeedTemplate { - slug: string; - name: string; - description: string; - category: string; - tags: string[]; - documentation: string; - logo: string; - compose: string; - port: number; - version: string; - env_schema?: unknown; - port_schema?: unknown; - is_active: boolean; +import * as dotenv from "dotenv"; +import { ConfigService } from "@nestjs/config"; +import { DataSource } from "typeorm"; + +import { + buildServiceTemplateRecords, + getDefaultTemplatesDir, +} from "../src/templates/build-template-records.util"; +import { ServiceTemplateEntity } from "../src/modules/templates/entities/service-template.entity"; +import { EntityStatus } from "../src/common/entity/base.entity"; + +const ROOT_DIR = process.cwd(); +const ROOT_ENV_PATH = path.join(ROOT_DIR, ".env"); +const APP_ENV_PATH = path.join(ROOT_DIR, "apps/control-panel-app/.env"); +import dayjs from "dayjs"; + +/** + * Database connection settings required before seeding can start. + */ +const REQUIRED_ENV_KEYS = [ + "DB_HOST", + "DB_PORT", + "DB_USERNAME", + "DB_PASSWORD", + "DB_DATABASE", +] as const; + +/** + * Normalizes unknown thrown values into a readable error message. + * @param error Value caught from a try/catch block. + * @returns Human-readable error text. + */ +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } -export async function seedTemplates(queryRunner: QueryRunner) { - const generatedTemplatesDir = path.join( - process.cwd(), - "apps", - "control-panel-app", - "generated-templates", +/** + * Builds a multi-line fatal error message with consistent formatting. + * @param lines Error detail lines displayed between banner separators. + * @returns Error instance ready to throw. + */ +function createFatalError(lines: string[]): Error { + return new Error( + [ + "", + "========================================================================", + ...lines, + "========================================================================", + "", + ].join("\n"), ); +} + +/** + * Ensures only app-scoped env files are used for seeding. + * Rejects a repo-root .env and requires apps/control-panel-app/.env. + */ +function assertEnvFilePolicy(): void { + try { + if (fs.existsSync(ROOT_ENV_PATH)) { + throw createFatalError([ + `[FATAL] Root .env file detected at: ${ROOT_ENV_PATH}`, + "Root level env files are not allowed.", + "Use only app specific env files:", + " - apps/control-panel-app/.env", + " - apps/agent-app/.env", + ]); + } + + if (!fs.existsSync(APP_ENV_PATH)) { + throw createFatalError([ + `[FATAL] Missing env file: ${APP_ENV_PATH}`, + "Please create the file before running the seed script.", + ]); + } + } catch (error: unknown) { + if (error instanceof Error) { + throw error; + } - const files = fs - .readdirSync(generatedTemplatesDir) - .filter((file) => file.endsWith(".json")); - - for (const file of files) { - const filePath = path.join(generatedTemplatesDir, file); - - const template = JSON.parse( - fs.readFileSync(filePath, "utf8"), - ) as SeedTemplate; - - await queryRunner.query( - ` - INSERT INTO "serviceTemplates" - ( - slug, - name, - description, - category, - tags, - documentation, - logo, - compose, - port, - version, - env_schema, - port_schema, - is_active, - "createdAt", - "updatedAt" - ) - VALUES - ( - $1, $2, $3, $4, $5, - $6, $7, $8, $9, $10, - $11, $12, $13, - EXTRACT(EPOCH FROM NOW()) * 1000, - EXTRACT(EPOCH FROM NOW()) * 1000 - ) - ON CONFLICT (slug) - DO UPDATE SET - name = EXCLUDED.name, - description = EXCLUDED.description, - category = EXCLUDED.category, - tags = EXCLUDED.tags, - documentation = EXCLUDED.documentation, - logo = EXCLUDED.logo, - compose = EXCLUDED.compose, - port = EXCLUDED.port, - version = EXCLUDED.version, - env_schema = EXCLUDED.env_schema, - port_schema = EXCLUDED.port_schema, - is_active = EXCLUDED.is_active, - "updatedAt" = EXTRACT(EPOCH FROM NOW()) * 1000 - `, - [ - template.slug, - template.name, - template.description, - template.category, - template.tags, - template.documentation, - template.logo, - template.compose, - template.port, - template.version, - template.env_schema ?? null, - template.port_schema ?? null, - template.is_active, - ], + throw new Error( + `Failed to validate env file policy: ${toErrorMessage(error)}`, ); + } +} + +/** + * Loads control-panel env values and returns a ConfigService instance. + * @returns ConfigService backed by apps/control-panel-app/.env. + */ +function createConfigService(): ConfigService { + try { + assertEnvFilePolicy(); + dotenv.config({ path: APP_ENV_PATH }); + return new ConfigService(); + } catch (error: unknown) { + if (error instanceof Error) { + throw error; + } + + throw new Error( + `Failed to initialize configuration: ${toErrorMessage(error)}`, + ); + } +} + +/** + * Validates required database env keys and DB_PORT format. + * @param configService Loaded configuration service for the control panel app. + */ +function validateRequiredConfig(configService: ConfigService): void { + try { + const missingEnvKeys = REQUIRED_ENV_KEYS.filter( + (key) => !configService.get(key), + ); + + if (missingEnvKeys.length > 0) { + throw createFatalError([ + "[FATAL] Missing required environment variables:", + ...missingEnvKeys.map((key) => ` - ${key}`), + ]); + } + + const dbPort = Number(configService.get("DB_PORT")); + + if (!Number.isFinite(dbPort) || dbPort <= 0) { + throw createFatalError([ + "[FATAL] DB_PORT must be a positive number.", + ` Received: ${configService.get("DB_PORT")}`, + ]); + } + } catch (error: unknown) { + if (error instanceof Error) { + throw error; + } + + throw new Error( + `Failed to validate configuration: ${toErrorMessage(error)}`, + ); + } +} + +/** + * Builds a TypeORM DataSource from validated config values. + * @param configService Loaded configuration service for the control panel app. + * @returns Uninitialized TypeORM data source for service templates. + */ +function createDataSource(configService: ConfigService): DataSource { + try { + return new DataSource({ + type: "postgres", + host: configService.get("DB_HOST") as string, + port: Number(configService.get("DB_PORT")), + username: configService.get("DB_USERNAME") as string, + password: configService.get("DB_PASSWORD") as string, + database: configService.get("DB_DATABASE") as string, + synchronize: false, + entities: [ServiceTemplateEntity], + }); + } catch (error: unknown) { + throw new Error( + `Failed to create database connection: ${toErrorMessage(error)}`, + ); + } +} - console.log(`Seeded template: ${template.slug}`); +/** + * Initializes the database connection and wraps connection errors with context. + * @param dataSource TypeORM data source to connect. + */ +async function initializeDataSource(dataSource: DataSource): Promise { + try { + await dataSource.initialize(); + } catch (error: unknown) { + throw new Error(`Failed to connect to database: ${toErrorMessage(error)}`); } -} \ No newline at end of file +} + +/** + * Safely closes the database connection when seeding finishes or fails. + * Logs cleanup failures without masking the original seed error. + * @param dataSource TypeORM data source to disconnect. + */ +async function destroyDataSource(dataSource: DataSource): Promise { + if (!dataSource.isInitialized) { + return; + } + + try { + await dataSource.destroy(); + } catch (error: unknown) { + console.error( + `Failed to close database connection: ${toErrorMessage(error)}`, + ); + } +} + +/** + * Reads template sources from disk and upserts them into the database. + * Uses slug as the conflict key so reruns update existing template rows. + * @param configService Loaded configuration service for the control panel app. + */ +async function seedFromTemplates(configService: ConfigService): Promise { + const templatesDir = getDefaultTemplatesDir(ROOT_DIR); + let dataSource: DataSource | undefined; + + try { + let serviceTemplateRecords; + + try { + serviceTemplateRecords = buildServiceTemplateRecords(templatesDir); + } catch (error: unknown) { + throw new Error( + `Failed to build template records from "${templatesDir}": ${toErrorMessage(error)}`, + ); + } + + if (serviceTemplateRecords.length === 0) { + throw new Error(`No templates found in ${templatesDir}`); + } + + console.log( + `Building ${serviceTemplateRecords.length} template record(s) from ${templatesDir}`, + ); + + dataSource = createDataSource(configService); + await initializeDataSource(dataSource); + + const repository = dataSource.getRepository(ServiceTemplateEntity); + + for (const templateRecord of serviceTemplateRecords) { + try { + const payload = { + slug: templateRecord.slug, + name: templateRecord.name, + description: templateRecord.description || null, + category: templateRecord.category || null, + tags: templateRecord.tags.length > 0 ? templateRecord.tags : null, + documentation: templateRecord.documentation || null, + logo: templateRecord.logo || null, + compose: templateRecord.compose, + env_schema: templateRecord.env_schema ?? null, + port_schema: templateRecord.port_schema ?? null, + port: templateRecord.port || null, + version: templateRecord.version || null, + is_active: templateRecord.is_active, + status: EntityStatus.ACTIVE, + createdAt: dayjs().unix(), + updatedAt: dayjs().unix(), + }; + + await repository.upsert(payload, ["slug"]); + + console.log(`Seeded template from source: ${templateRecord.slug}`); + } catch (error: unknown) { + throw new Error( + `Failed to upsert template "${templateRecord.slug}": ${toErrorMessage(error)}`, + ); + } + } + + console.log("Template seeding from source completed successfully"); + } finally { + if (dataSource) { + await destroyDataSource(dataSource); + } + } +} + +/** + * Entry point for the template seed CLI script. + * Loads config, validates env, seeds templates, and exits non-zero on failure. + */ +export async function seedTemplates(): Promise { + try { + const configService = createConfigService(); + validateRequiredConfig(configService); + await seedFromTemplates(configService); + } catch (error: unknown) { + console.error("\n[seed:templates] Failed."); + console.error(toErrorMessage(error)); + + if (error instanceof Error && error.stack) { + console.error(error.stack); + } + + process.exit(1); + } +} diff --git a/apps/control-panel-app/src/constants/error.ts b/apps/control-panel-app/src/constants/error.ts new file mode 100644 index 0000000..c17c58b --- /dev/null +++ b/apps/control-panel-app/src/constants/error.ts @@ -0,0 +1,13 @@ +export const ERROR_MESSAGES = { + SSH: { + SSH_INFO_REQUIRED: "ssh required", + PASSWORD_REQUIRED: "password required for password authtype", + PRIVATE_KEY_REQUIRED: "privateKey required for PRIVATE_KEY authType", + }, + + SERVER: { + NOT_FOUND: "server not found", + ALREADY_EXIST: "server with this host and username already exist", + CREDENTIALS_NOT_FOUND: "ssh credentials not found", + }, +}; diff --git a/apps/control-panel-app/src/constants/success.ts b/apps/control-panel-app/src/constants/success.ts new file mode 100644 index 0000000..4691a9a --- /dev/null +++ b/apps/control-panel-app/src/constants/success.ts @@ -0,0 +1,5 @@ +export const SUCCESS_MESSAGES = { + SERVER: { + CREATED: "server created successfully", + }, +}; diff --git a/apps/control-panel-app/src/database/seeders/seed-from-templates.ts b/apps/control-panel-app/src/database/seeders/seed-from-templates.ts deleted file mode 100644 index 2edeb80..0000000 --- a/apps/control-panel-app/src/database/seeders/seed-from-templates.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Seeds service templates directly from source files under apps/control-panel-app/templates. - * Reads docker-compose.yml (and optional template.config.json), then upserts rows by slug. - * - * Run via: npm run seed:templates - */ -import "reflect-metadata"; -import * as fs from "fs"; -import * as path from "path"; -import * as dotenv from "dotenv"; -import { ConfigService } from "@nestjs/config"; -import { DataSource } from "typeorm"; - -import { - buildServiceTemplateRecords, - getDefaultTemplatesDir, -} from "../../templates/build-template-records.util"; -import { ServiceTemplateEntity } from "../../modules/templates/entities/service-template.entity"; -import { EntityStatus } from "../../common/entity/base.entity"; - -const ROOT_DIR = process.cwd(); -const ROOT_ENV_PATH = path.join(ROOT_DIR, ".env"); -const APP_ENV_PATH = path.join(ROOT_DIR, "apps/control-panel-app/.env"); -import dayjs from "dayjs"; - -/** - * Database connection settings required before seeding can start. - */ -const REQUIRED_ENV_KEYS = [ - "DB_HOST", - "DB_PORT", - "DB_USERNAME", - "DB_PASSWORD", - "DB_DATABASE", -] as const; - -/** - * Normalizes unknown thrown values into a readable error message. - * @param error Value caught from a try/catch block. - * @returns Human-readable error text. - */ -function toErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -/** - * Builds a multi-line fatal error message with consistent formatting. - * @param lines Error detail lines displayed between banner separators. - * @returns Error instance ready to throw. - */ -function createFatalError(lines: string[]): Error { - return new Error( - [ - "", - "========================================================================", - ...lines, - "========================================================================", - "", - ].join("\n"), - ); -} - -/** - * Ensures only app-scoped env files are used for seeding. - * Rejects a repo-root .env and requires apps/control-panel-app/.env. - */ -function assertEnvFilePolicy(): void { - try { - if (fs.existsSync(ROOT_ENV_PATH)) { - throw createFatalError([ - `[FATAL] Root .env file detected at: ${ROOT_ENV_PATH}`, - "Root level env files are not allowed.", - "Use only app specific env files:", - " - apps/control-panel-app/.env", - " - apps/agent-app/.env", - ]); - } - - if (!fs.existsSync(APP_ENV_PATH)) { - throw createFatalError([ - `[FATAL] Missing env file: ${APP_ENV_PATH}`, - "Please create the file before running the seed script.", - ]); - } - } catch (error: unknown) { - if (error instanceof Error) { - throw error; - } - - throw new Error( - `Failed to validate env file policy: ${toErrorMessage(error)}`, - ); - } -} - -/** - * Loads control-panel env values and returns a ConfigService instance. - * @returns ConfigService backed by apps/control-panel-app/.env. - */ -function createConfigService(): ConfigService { - try { - assertEnvFilePolicy(); - dotenv.config({ path: APP_ENV_PATH }); - return new ConfigService(); - } catch (error: unknown) { - if (error instanceof Error) { - throw error; - } - - throw new Error( - `Failed to initialize configuration: ${toErrorMessage(error)}`, - ); - } -} - -/** - * Validates required database env keys and DB_PORT format. - * @param configService Loaded configuration service for the control panel app. - */ -function validateRequiredConfig(configService: ConfigService): void { - try { - const missingEnvKeys = REQUIRED_ENV_KEYS.filter( - (key) => !configService.get(key), - ); - - if (missingEnvKeys.length > 0) { - throw createFatalError([ - "[FATAL] Missing required environment variables:", - ...missingEnvKeys.map((key) => ` - ${key}`), - ]); - } - - const dbPort = Number(configService.get("DB_PORT")); - - if (!Number.isFinite(dbPort) || dbPort <= 0) { - throw createFatalError([ - "[FATAL] DB_PORT must be a positive number.", - ` Received: ${configService.get("DB_PORT")}`, - ]); - } - } catch (error: unknown) { - if (error instanceof Error) { - throw error; - } - - throw new Error( - `Failed to validate configuration: ${toErrorMessage(error)}`, - ); - } -} - -/** - * Builds a TypeORM DataSource from validated config values. - * @param configService Loaded configuration service for the control panel app. - * @returns Uninitialized TypeORM data source for service templates. - */ -function createDataSource(configService: ConfigService): DataSource { - try { - return new DataSource({ - type: "postgres", - host: configService.get("DB_HOST") as string, - port: Number(configService.get("DB_PORT")), - username: configService.get("DB_USERNAME") as string, - password: configService.get("DB_PASSWORD") as string, - database: configService.get("DB_DATABASE") as string, - synchronize: false, - entities: [ServiceTemplateEntity], - }); - } catch (error: unknown) { - throw new Error( - `Failed to create database connection: ${toErrorMessage(error)}`, - ); - } -} - -/** - * Initializes the database connection and wraps connection errors with context. - * @param dataSource TypeORM data source to connect. - */ -async function initializeDataSource(dataSource: DataSource): Promise { - try { - await dataSource.initialize(); - } catch (error: unknown) { - throw new Error(`Failed to connect to database: ${toErrorMessage(error)}`); - } -} - -/** - * Safely closes the database connection when seeding finishes or fails. - * Logs cleanup failures without masking the original seed error. - * @param dataSource TypeORM data source to disconnect. - */ -async function destroyDataSource(dataSource: DataSource): Promise { - if (!dataSource.isInitialized) { - return; - } - - try { - await dataSource.destroy(); - } catch (error: unknown) { - console.error( - `Failed to close database connection: ${toErrorMessage(error)}`, - ); - } -} - -/** - * Reads template sources from disk and upserts them into the database. - * Uses slug as the conflict key so reruns update existing template rows. - * @param configService Loaded configuration service for the control panel app. - */ -async function seedFromTemplates(configService: ConfigService): Promise { - const templatesDir = getDefaultTemplatesDir(ROOT_DIR); - let dataSource: DataSource | undefined; - - try { - let serviceTemplateRecords; - - try { - serviceTemplateRecords = buildServiceTemplateRecords(templatesDir); - } catch (error: unknown) { - throw new Error( - `Failed to build template records from "${templatesDir}": ${toErrorMessage(error)}`, - ); - } - - if (serviceTemplateRecords.length === 0) { - throw new Error(`No templates found in ${templatesDir}`); - } - - console.log( - `Building ${serviceTemplateRecords.length} template record(s) from ${templatesDir}`, - ); - - dataSource = createDataSource(configService); - await initializeDataSource(dataSource); - - const repository = dataSource.getRepository(ServiceTemplateEntity); - - for (const templateRecord of serviceTemplateRecords) { - try { - const payload = { - slug: templateRecord.slug, - name: templateRecord.name, - description: templateRecord.description || null, - category: templateRecord.category || null, - tags: templateRecord.tags.length > 0 ? templateRecord.tags : null, - documentation: templateRecord.documentation || null, - logo: templateRecord.logo || null, - compose: templateRecord.compose, - env_schema: templateRecord.env_schema ?? null, - port_schema: templateRecord.port_schema ?? null, - port: templateRecord.port || null, - version: templateRecord.version || null, - is_active: templateRecord.is_active, - status: EntityStatus.ACTIVE, - createdAt: dayjs().unix(), - updatedAt: dayjs().unix(), - }; - - await repository.upsert(payload, ["slug"]); - - console.log(`Seeded template from source: ${templateRecord.slug}`); - } catch (error: unknown) { - throw new Error( - `Failed to upsert template "${templateRecord.slug}": ${toErrorMessage(error)}`, - ); - } - } - - console.log("Template seeding from source completed successfully"); - } finally { - if (dataSource) { - await destroyDataSource(dataSource); - } - } -} - -/** - * Entry point for the template seed CLI script. - * Loads config, validates env, seeds templates, and exits non-zero on failure. - */ -async function main(): Promise { - try { - const configService = createConfigService(); - validateRequiredConfig(configService); - await seedFromTemplates(configService); - } catch (error: unknown) { - console.error("\n[seed:templates] Failed."); - console.error(toErrorMessage(error)); - - if (error instanceof Error && error.stack) { - console.error(error.stack); - } - - process.exit(1); - } -} - -void main(); diff --git a/apps/control-panel-app/src/main.ts b/apps/control-panel-app/src/main.ts index 15da980..bf223bb 100644 --- a/apps/control-panel-app/src/main.ts +++ b/apps/control-panel-app/src/main.ts @@ -137,6 +137,8 @@ async function bootstrap(): Promise { const port = Number(configService.get("PORT")); + app.setGlobalPrefix("api"); + app.enableCors({ origin: true, credentials: true, diff --git a/apps/control-panel-app/src/modules/server-connections/controllers/server-connections.controller.ts b/apps/control-panel-app/src/modules/server-connections/controllers/server-connections.controller.ts deleted file mode 100644 index aa18ad9..0000000 --- a/apps/control-panel-app/src/modules/server-connections/controllers/server-connections.controller.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - Logger, - Param, - Post, -} from "@nestjs/common"; -import { ServerConnectionsService } from "../services/server-connections.service"; -import { ExecuteCommandDto } from "@shared/ssh"; - -@Controller("server-connections") -export class ServerConnectionsController { - constructor(private readonly connectionsService: ServerConnectionsService) {} - - private readonly logger = new Logger(ServerConnectionsController.name); - - // Removed: POST /server-connections (server creation moved to POST /servers/onboard) - - @Get() - async list() { - return this.connectionsService.list(); - } - - @Get(":id") - async get(@Param("id") id: string): Promise { - return this.connectionsService.get(id); - } - - async patch( - @Param("id") id: string, - @Body() body: Record, - ): Promise { - return await this.connectionsService.patch(id, body); - } - - @Delete(":id") - async remove(@Param("id") id: string): Promise { - await this.connectionsService.remove(id); - return { success: true }; - } - - @Post(":id/test") - async test(@Param("id") id: string): Promise { - return this.connectionsService.test(id); - } - - @Post(":id/credentials") - // Removed: endpoint for adding SSH credentials. Use POST /servers/onboard instead. - @Post(":id/execute") - async execute( - @Param("id") id: string, - @Body() body: ExecuteCommandDto, - ): Promise { - return await this.connectionsService.execute(id, body); - } -} diff --git a/apps/control-panel-app/src/modules/server-connections/controllers/servers.controller.ts b/apps/control-panel-app/src/modules/server-connections/controllers/servers.controller.ts index e535dfe..0d98795 100644 --- a/apps/control-panel-app/src/modules/server-connections/controllers/servers.controller.ts +++ b/apps/control-panel-app/src/modules/server-connections/controllers/servers.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Post } from "@nestjs/common"; +import { Body, Controller, Param, Post } from "@nestjs/common"; import { ServerConnectionsService } from "../services/server-connections.service"; import { CreateServerOnboardRequestDto } from "../dto/create-server-onboard.request.dto"; @@ -6,19 +6,43 @@ import { CreateServerOnboardRequestDto } from "../dto/create-server-onboard.requ export class ServersController { constructor(private readonly connectionsService: ServerConnectionsService) {} + /** + * create server + * @param body + * @returns + */ @Post("onboard") async onboard(@Body() body: CreateServerOnboardRequestDto): Promise { - // Debug only — avoid logging credentials in production - const ssh = body.ssh; + return await this.connectionsService.onboardServer(body); + } - console.log("ONBOARD REQUEST RECEIVED:", { - server: body.server, - }); + /** + * connect with the server + * @param id + * @returns + */ + @Post(":id/connect") + async connect(@Param("id") id: string) { + return await this.connectionsService.connectServer(id); + } - if (ssh) { - console.log("FULL SSH PAYLOAD:", ssh); - } + /** + * disconnect server + * @param id + * @returns + */ + @Post(":id/disconnect") + async disconnect(@Param("id") id: string) { + return await this.connectionsService.disconnectServer(id); + } - return await this.connectionsService.onboardServer(body); + /** + * soft delete server + * @param id + * @returns + */ + @Post(":id/delete") + deleteServer(@Param("id") id: string) { + return this.connectionsService.deleteServer(id); } } diff --git a/apps/control-panel-app/src/modules/server-connections/dto/onboard-response.dto.ts b/apps/control-panel-app/src/modules/server-connections/dto/onboard-response.dto.ts index 33b8ddc..afa6f45 100644 --- a/apps/control-panel-app/src/modules/server-connections/dto/onboard-response.dto.ts +++ b/apps/control-panel-app/src/modules/server-connections/dto/onboard-response.dto.ts @@ -3,7 +3,7 @@ export interface OnboardSuccessResponse { serverId: string; sshCredentialId: string; sshTest: { success: true }; - logs: string[]; + message: string; } export interface OnboardFailureResponse { diff --git a/apps/control-panel-app/src/modules/server-connections/entities/server-ssh-credential.entity.ts b/apps/control-panel-app/src/modules/server-connections/entities/server-ssh-credential.entity.ts index 25c55b1..c454cb4 100644 --- a/apps/control-panel-app/src/modules/server-connections/entities/server-ssh-credential.entity.ts +++ b/apps/control-panel-app/src/modules/server-connections/entities/server-ssh-credential.entity.ts @@ -1,16 +1,8 @@ -import { - IsEnum, - IsNotEmpty, - IsOptional, - IsString, - IsUUID, -} from "class-validator"; +import { IsEnum, IsOptional, IsString, IsUUID } from "class-validator"; import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; import { BaseEntity } from "../../../common/entity/base.entity"; import { ServerSshAuthType } from "../enums/server-ssh-auth-type.enum"; -import { - SSH_FINGERPRINT_MAX_LENGTH, -} from "../server-connections.constants"; +import { SSH_FINGERPRINT_MAX_LENGTH } from "../server-connections.constants"; import { ServerEntity } from "./server.entity"; @Entity({ name: "serverSshCredentials" }) @@ -45,7 +37,6 @@ export class ServerSshCredentialEntity extends BaseEntity { @Column({ type: "text", nullable: true, - select: false, comment: "Encrypted SSH private key material. Integrate with Vault/KMS before storing production secrets.", }) @@ -67,7 +58,6 @@ export class ServerSshCredentialEntity extends BaseEntity { @Column({ type: "text", nullable: true, - select: false, comment: "Encrypted SSH password. Do not store plaintext passwords.", }) encryptedPassword!: string | null; diff --git a/apps/control-panel-app/src/modules/server-connections/entities/server.entity.ts b/apps/control-panel-app/src/modules/server-connections/entities/server.entity.ts index 642c192..387d7ee 100644 --- a/apps/control-panel-app/src/modules/server-connections/entities/server.entity.ts +++ b/apps/control-panel-app/src/modules/server-connections/entities/server.entity.ts @@ -45,7 +45,7 @@ export class ServerEntity extends BaseEntity { @IsString() @IsNotEmpty() - @Column({ type: "varchar", length: SSH_USERNAME_MAX_LENGTH}) + @Column({ type: "varchar", length: SSH_USERNAME_MAX_LENGTH }) username!: string; @IsEnum(ServerProvider) diff --git a/apps/control-panel-app/src/modules/server-connections/server-connections.module.ts b/apps/control-panel-app/src/modules/server-connections/server-connections.module.ts index a5e4919..56b3007 100644 --- a/apps/control-panel-app/src/modules/server-connections/server-connections.module.ts +++ b/apps/control-panel-app/src/modules/server-connections/server-connections.module.ts @@ -1,7 +1,6 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; -import { ServerConnectionsController } from "./controllers/server-connections.controller"; import { ServersController } from "./controllers/servers.controller"; import { ServerEntity } from "./entities/server.entity"; @@ -15,7 +14,7 @@ import { SshModule } from "@shared/ssh"; TypeOrmModule.forFeature([ServerEntity, ServerSshCredentialEntity]), SshModule, ], - controllers: [ServerConnectionsController, ServersController], + controllers: [ServersController], providers: [ServerConnectionsService], exports: [ServerConnectionsService], }) diff --git a/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts b/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts index 65e8999..4d390f4 100644 --- a/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts +++ b/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts @@ -1,5 +1,5 @@ import { ConflictException, Injectable } from "@nestjs/common"; -import { DataSource, Repository } from "typeorm"; +import { DataSource, FindOneOptions, Repository, IsNull } from "typeorm"; import { InjectRepository } from "@nestjs/typeorm"; import { CreateServerDto, @@ -16,8 +16,13 @@ import { SshCommandExecutorService, ExecuteCommandDto, ExecuteResult, + SshConnectionManager, } from "@shared/ssh"; import { DEFAULT_SSH_PORT } from "../server-connections.constants"; +import { EntityStatus } from "@control-panel/common/entity/base.entity"; +import dayjs from "dayjs"; +import { ERROR_MESSAGES } from "@control-panel/constants/error"; +import { SUCCESS_MESSAGES } from "@control-panel/constants/success"; export interface ExistingServerCheck { host: string; @@ -35,31 +40,187 @@ export class ServerConnectionsService { private readonly encryptionService: EncryptionService, private readonly health: SshHealthCheckService, private readonly executor: SshCommandExecutorService, + private readonly sshManager: SshConnectionManager, ) {} - async assertServerNotDuplicate(input: ExistingServerCheck): Promise { - const exists = await this.serverRepository.findOne({ - where: { host: input.host, username: input.username }, + private async getServerConnectionOptions(id: string) { + const server = await this.serverRepository.findOne({ + where: { id, status: EntityStatus.ACTIVE, deletedAt: IsNull() }, }); - if (exists) { - throw new ConflictException( - "Server with this host and username already exists", - ); + + if (!server) { + throw new Error(ERROR_MESSAGES.SERVER.NOT_FOUND); } + + const credential = await this.credentialRepository.findOne({ + where: { serverId: id }, + }); + + if (!credential) { + throw new Error(ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND); + } + + return { + serverId: id, + host: server.host, + port: server.port, + username: server.username, + authType: credential.authType, + encryptedPassword: credential.encryptedPassword, + encryptedPrivateKey: credential.encryptedPrivateKey, + privateKeyPassphrase: credential.privateKeyPassphrase, + }; + } + + /** + * find exisiting server + * @param input + * @returns + */ + private async findExistingServer( + input: ExistingServerCheck, + ): Promise { + return this.serverRepository.findOne({ + where: { + host: input.host, + username: input.username, + }, + }); } /** - * Onboard server: create server + credentials and validate SSH connection - * Atomically: if SSH test fails the transaction is rolled back and nothing is persisted + * restore from soft delete + * @param serverId + * @returns + */ + private async restoreServer( + serverId: string, + ): Promise { + await this.serverRepository.update( + { id: serverId }, + { + status: EntityStatus.ACTIVE, + deletedAt: null, + }, + ); + + await this.credentialRepository.update( + { serverId }, + { + status: EntityStatus.ACTIVE, + deletedAt: null, + }, + ); + + return this.credentialRepository.findOne({ + where: { serverId, status: EntityStatus.ACTIVE }, + }); + } + + /** + * create the server + * @param input + * @returns */ async onboardServer( input: CreateServerOnboardRequestDto, ): Promise { - await this.assertServerNotDuplicate({ + const existingServer = await this.findExistingServer({ host: input.server.host, - username: input.server.username + username: input.server.username, }); + if (existingServer) { + // Active server already exists + if ( + existingServer.status === EntityStatus.ACTIVE && + !existingServer.deletedAt + ) { + throw new ConflictException(ERROR_MESSAGES.SERVER.ALREADY_EXIST); + } + + // Previously deleted -> restore it + if ( + existingServer.status === EntityStatus.INACTIVE && + existingServer.deletedAt + ) { + const credential = await this.credentialRepository.findOne({ + where: { + serverId: existingServer.id, + status: EntityStatus.INACTIVE, + }, + }); + + if (!credential) { + return { + success: false, + step: "SSH_TEST", + error: ERROR_MESSAGES.SERVER.CREDENTIALS_NOT_FOUND, + code: "CREDENTIALS_NOT_FOUND", + logs: ["SSH credentials not found for deleted server"], + }; + } + + const testTimeoutMs = 10_000; + + type SshTestResult = { + success: boolean; + latency: number; + username: string | null; + hostname: string | null; + platform: string | null; + message: string; + code?: string; + }; + + const testPromise = this.validateServerConnection( + existingServer, + credential, + ); + + const result = (await Promise.race([ + testPromise, + new Promise((resolve) => + setTimeout( + () => + resolve({ + success: false, + latency: 0, + username: null, + hostname: null, + platform: null, + message: "Connection timed out", + code: "CONNECTION_TIMEOUT", + }), + testTimeoutMs, + ), + ), + ])) as SshTestResult; + + if (!result.success) { + return { + success: false, + step: "SSH_TEST", + error: result.message, + code: result.code ?? this.mapTestErrorCode(result.message), + logs: ["Deleted server found", "SSH validation failed"], + }; + } + + const restoredCredential = await this.restoreServer(existingServer.id); + + return { + success: true, + serverId: existingServer.id, + sshCredentialId: restoredCredential?.id ?? "", + sshTest: { + success: true, + }, + message: SUCCESS_MESSAGES.SERVER.CREATED, + }; + } + } + const logs: string[] = []; const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); @@ -191,7 +352,7 @@ export class ServerConnectionsService { serverId: savedServer.id, sshCredentialId: savedCredential.id, sshTest: { success: true }, - logs, + message: SUCCESS_MESSAGES.SERVER.CREATED, }; } @@ -241,31 +402,177 @@ export class ServerConnectionsService { } } - // Controller-facing helpers moved into service so controllers remain thin - async list(): Promise { - return this.serverRepository.find({ - where: {}, - order: { createdAt: "DESC" }, + /** + * Test connection + * @param server + * @param credential + * @returns + */ + private async validateServerConnection( + server: ServerEntity, + credential: ServerSshCredentialEntity, + ) { + return this.health.testConnection({ + serverId: server.id, + host: server.host, + port: server.port, + username: server.username, + authType: credential.authType, + encryptedPassword: credential.encryptedPassword ?? null, + encryptedPrivateKey: credential.encryptedPrivateKey ?? null, + privateKeyPassphrase: credential.privateKeyPassphrase ?? null, }); } - async get(id: string): Promise { - return this.serverRepository.findOne({ where: { id } }); + /** + * connec with the server + * @param id + * @returns + */ + async connectServer(id: string) { + try { + const serverOptions = await this.getServerConnectionOptions(id); + + const existing = this.sshManager.getConnection(id); + + if (existing) { + throw new Error("Server already connected"); + } + + await this.sshManager.connect(serverOptions); + + return { + success: true, + connected: true, + message: "Server connected successfully", + }; + } catch (error) { + console.log(error); + return { + success: false, + connected: false, + message: + error instanceof Error ? error.message : "Failed to connect server", + }; + } + } + + /** + * disconnect with the server + * @param id + * @returns + */ + async disconnectServer(id: string) { + try { + await this.getServerConnectionOptions(id); + this.sshManager.disconnect(id); + return { + success: true, + connected: false, + message: "Server disconnected successfully", + }; + } catch (error) { + return { + success: false, + connected: false, + message: + error instanceof Error ? error.message : "Failed to connect server", + }; + } } - async patch(id: string, patch: Partial): Promise { - const entity = await this.serverRepository.findOne({ where: { id } }); - if (!entity) throw new Error("Server not found"); - Object.assign(entity, patch); - return this.serverRepository.save(entity); + /** + * soft delete server + * @param id + * @returns + */ + async deleteServer(id: string) { + const queryRunner = this.dataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const serverRepo = queryRunner.manager.getRepository(ServerEntity); + const credentialRepo = queryRunner.manager.getRepository( + ServerSshCredentialEntity, + ); + + const server = await serverRepo.findOne({ + where: { id }, + }); + + if (!server) { + throw new Error("Server not found"); + } + + // Disconnect active SSH session + if (this.sshManager.isConnected(id)) { + this.sshManager.disconnect(id); + } + + const currentTime = dayjs().unix(); + + await serverRepo.update( + { id }, + { + status: EntityStatus.INACTIVE, + deletedAt: currentTime, + }, + ); + + await credentialRepo.update( + { serverId: id }, + { + status: EntityStatus.INACTIVE, + deletedAt: currentTime, + }, + ); + + await queryRunner.commitTransaction(); + + return { + success: true, + message: "Server deleted successfully", + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + + return { + success: false, + message: + error instanceof Error ? error.message : "Failed to delete server", + }; + } finally { + await queryRunner.release(); + } } - async remove(id: string): Promise { - await this.serverRepository.softDelete({ id }); + /** + * List servers + * @returns + */ + async list(): Promise { + return await this.serverRepository.find({ + where: { + status: EntityStatus.ACTIVE, + deletedAt: IsNull(), + }, + order: { + createdAt: "DESC", + }, + }); } + /** + * Test connection + * @param id + * @returns + */ async test(id: string): Promise { - const server = await this.serverRepository.findOne({ where: { id } }); + const server = await this.serverRepository.findOne({ + where: { id, status: EntityStatus.ACTIVE, deletedAt: IsNull() }, + }); if (!server) return { success: false, message: "Server not found" }; const creds = await this.credentialRepository.find({ where: { serverId: id }, @@ -287,11 +594,19 @@ export class ServerConnectionsService { }); } + /** + * execute commands + * @param id + * @param body + * @returns + */ async execute( id: string, body: ExecuteCommandDto, ): Promise { - const server = await this.serverRepository.findOne({ where: { id } }); + const server = await this.serverRepository.findOne({ + where: { id, status: EntityStatus.ACTIVE, deletedAt: IsNull() }, + }); if (!server) return { success: false, message: "Server not found" }; const creds = await this.credentialRepository.find({ where: { serverId: id }, @@ -322,7 +637,6 @@ export class ServerConnectionsService { return result; } - // Map textual error to one of the UI codes private mapTestErrorCode(message: string | undefined): string { const msg = (message ?? "").toLowerCase(); if ( @@ -343,4 +657,13 @@ export class ServerConnectionsService { return "HOST_UNREACHABLE"; return "UNKNOWN_ERROR"; } + + /** + * find one + * @param options + * @returns + */ + async findOne(options: FindOneOptions) { + return await this.serverRepository.findOne(options); + } } diff --git a/apps/control-panel-app/templates/postgresql/template.config.json b/apps/control-panel-app/templates/postgresql/template.config.json deleted file mode 100644 index 553e16b..0000000 --- a/apps/control-panel-app/templates/postgresql/template.config.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "env_schema": { - "POSTGRES_DB": { - "type": "string", - "required": false, - "default": "postgres", - "description": "Database name" - }, - "POSTGRES_IMAGE": { - "type": "string", - "required": false, - "default": "postgres:16", - "description": "Docker image tag" - }, - "POSTGRES_RESTART_POLICY": { - "type": "string", - "required": false, - "default": "unless-stopped" - }, - "POSTGRES_DATA_PATH": { - "type": "string", - "required": false, - "default": "/var/lib/postgresql/data" - }, - "SERVICE_USER_POSTGRES": { - "type": "string", - "required": false, - "description": "Auto-generated if omitted (maps to POSTGRES_USER)" - }, - "SERVICE_PASSWORD_POSTGRES": { - "type": "string", - "required": false, - "description": "Auto-generated if omitted (maps to POSTGRES_PASSWORD)" - } - }, - "port_schema": { - "SERVICE_PORT_POSTGRES": { - "type": "number", - "required": true, - "description": "Host port mapped to container port 5432" - } - } -} diff --git a/apps/control-panel-app/templates/redis/template.config.json b/apps/control-panel-app/templates/redis/template.config.json deleted file mode 100644 index f3604ba..0000000 --- a/apps/control-panel-app/templates/redis/template.config.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "env_schema": { - "REDIS_PASSWORD": { - "type": "string", - "required": false, - "default": "", - "description": "Password for Redis (empty = no password)" - }, - "CONTAINER_NAME": { - "type": "string", - "required": false, - "default": "redis", - "description": "Name for the Redis container" - } - }, - "port_schema": { - "REDIS_PORT": { - "type": "number", - "required": true, - "description": "Port Redis will listen on" - } - } -} diff --git a/apps/control-panel-app/tsconfig.json b/apps/control-panel-app/tsconfig.json index dc2fe93..dffbbed 100644 --- a/apps/control-panel-app/tsconfig.json +++ b/apps/control-panel-app/tsconfig.json @@ -5,22 +5,43 @@ "rootDir": "../../", "baseUrl": "../../", "paths": { - "@shared/common": ["libs/common/src"], - "@shared/common/*": ["libs/common/src/*"], - "@shared/socket-events": ["libs/socket-events/src"], - "@shared/socket-events/*": ["libs/socket-events/src/*"], - "@shared/types": ["libs/shared-types/src"], - "@shared/types/*": ["libs/shared-types/src/*"], - "@shared/ssh": ["libs/ssh/src"], - "@shared/ssh/*": ["libs/ssh/src/*"], - "@control-panel/*": ["apps/control-panel-app/src/*"], - "@control-panel": ["apps/control-panel-app/src"] + "@shared/common": [ + "libs/common/src" + ], + "@shared/common/*": [ + "libs/common/src/*" + ], + "@shared/socket-events": [ + "libs/socket-events/src" + ], + "@shared/socket-events/*": [ + "libs/socket-events/src/*" + ], + "@shared/types": [ + "libs/shared-types/src" + ], + "@shared/types/*": [ + "libs/shared-types/src/*" + ], + "@shared/ssh": [ + "libs/ssh/src" + ], + "@shared/ssh/*": [ + "libs/ssh/src/*" + ], + "@control-panel/*": [ + "apps/control-panel-app/src/*" + ], + "@control-panel": [ + "apps/control-panel-app/src" + ] } }, "include": [ "../../apps/control-panel-app/*", "src/**/*", - "../../libs/**/*" + "../../libs/**/*", + "seeders/templates.seed.ts" ], "exclude": [ "../../apps/agent-app/**/*", diff --git a/libs/ssh/src/constants/ssh.constants.ts b/libs/ssh/src/constants/ssh.constants.ts index 047e4bf..b2e7a04 100644 --- a/libs/ssh/src/constants/ssh.constants.ts +++ b/libs/ssh/src/constants/ssh.constants.ts @@ -4,3 +4,8 @@ export const SSH_DEFAULTS = { READY_TIMEOUT: 20000, // ms COMMAND_TIMEOUT: 30000, // ms }; + +export const AUTH_TYPE = { + PRIVATE_KEY: "PRIVATE_KEY", + PASSWORD: "PASSWORD", +}; diff --git a/libs/ssh/src/errors/error-messages.ts b/libs/ssh/src/errors/error-messages.ts new file mode 100644 index 0000000..8fcb96e --- /dev/null +++ b/libs/ssh/src/errors/error-messages.ts @@ -0,0 +1,3 @@ +export const errors = { + MISSING_PRIVATE_KEY: "missing private key", +}; diff --git a/libs/ssh/src/managers/ssh-connection-manager.service.ts b/libs/ssh/src/managers/ssh-connection-manager.service.ts index 947e63e..698bc3d 100644 --- a/libs/ssh/src/managers/ssh-connection-manager.service.ts +++ b/libs/ssh/src/managers/ssh-connection-manager.service.ts @@ -2,9 +2,10 @@ import { Injectable, Logger } from "@nestjs/common"; import { Client, ConnectConfig } from "ssh2"; import { SshConnectionOptions } from "../interfaces/ssh-connection-options.interface"; import { EncryptionService } from "@shared/common"; -import { SSH_DEFAULTS } from "../constants/ssh.constants"; +import { SSH_DEFAULTS, AUTH_TYPE } from "../constants/ssh.constants"; import { SshConnectionError } from "../errors/ssh-connection.error"; import { SshAuthenticationError } from "../errors/ssh-authentication.error"; +import { errors } from "../errors/error-messages"; @Injectable() export class SshConnectionManager { @@ -33,14 +34,14 @@ export class SshConnectionManager { // Debug: show full options received (for debugging only — avoid logging secrets in production) console.log("SSH OPTIONS RECEIVED:", options); - if (options.authType === "PASSWORD" || options.encryptedPassword) { + if (options.authType === AUTH_TYPE.PASSWORD || options.encryptedPassword) { const pwd = options.encryptedPassword ? this.encryptionService.decrypt(options.encryptedPassword) : undefined; connectConfig.password = pwd; } - if (options.authType === "PRIVATE_KEY") { + if (options.authType === AUTH_TYPE.PRIVATE_KEY) { const key = options.privateKey ?? (options.encryptedPrivateKey @@ -48,7 +49,7 @@ export class SshConnectionManager { : undefined); if (!key) { - throw new SshAuthenticationError("Missing private key"); + throw new SshAuthenticationError(errors.MISSING_PRIVATE_KEY); } connectConfig.privateKey = key; @@ -110,7 +111,11 @@ export class SshConnectionManager { return c ?? null; } - disconnect(serverId: string): void { + isConnected(serverId: string): boolean { + return this.clients.has(serverId); + } + + disconnect(serverId: string) { const client = this.clients.get(serverId); if (!client) return; try { From 99ac7b808d9b1f56c694b08171c3c4933601d4b5 Mon Sep 17 00:00:00 2001 From: dhavalpiqud Date: Fri, 22 May 2026 17:09:13 +0530 Subject: [PATCH 2/3] fix: Removed sensitive logs from ssh credentials --- .../services/server-connections.service.ts | 7 ------- libs/ssh/src/managers/ssh-connection-manager.service.ts | 7 ------- 2 files changed, 14 deletions(-) diff --git a/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts b/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts index 4d390f4..8b65ff4 100644 --- a/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts +++ b/apps/control-panel-app/src/modules/server-connections/services/server-connections.service.ts @@ -254,13 +254,6 @@ export class ServerConnectionsService { const ssh: CreateServerSshCredentialRequestDto | undefined = input.ssh; if (!ssh) throw new Error("ssh payload required"); - // Debug: show full incoming ssh payload (for debugging only — avoid in production) - console.log("ONBOARD SSH PAYLOAD:", { - authType: ssh.authType, - hasPrivateKey: !!ssh.privateKey, - }); - console.log("FULL SSH PAYLOAD:", ssh); - if (ssh.authType === ServerSshAuthType.PASSWORD && !ssh.password) { throw new Error("password required for PASSWORD authType"); } diff --git a/libs/ssh/src/managers/ssh-connection-manager.service.ts b/libs/ssh/src/managers/ssh-connection-manager.service.ts index 698bc3d..7f97801 100644 --- a/libs/ssh/src/managers/ssh-connection-manager.service.ts +++ b/libs/ssh/src/managers/ssh-connection-manager.service.ts @@ -32,7 +32,6 @@ export class SshConnectionManager { }; // Debug: show full options received (for debugging only — avoid logging secrets in production) - console.log("SSH OPTIONS RECEIVED:", options); if (options.authType === AUTH_TYPE.PASSWORD || options.encryptedPassword) { const pwd = options.encryptedPassword @@ -93,12 +92,6 @@ export class SshConnectionManager { }); try { - // Debug: show sanitized config (do not print privateKey contents) - const safeConfig = { - ...connectConfig, - privateKey: connectConfig.privateKey ? "[REDACTED]" : undefined, - }; - console.log("SSH CONFIG:", safeConfig); client.connect(connectConfig); } catch (err) { reject(new SshConnectionError(String((err as Error).message))); From f36d519161057294383210362e94699a17bed436 Mon Sep 17 00:00:00 2001 From: dhavalpiqud Date: Fri, 22 May 2026 17:47:46 +0530 Subject: [PATCH 3/3] fix: template failing for redis --- apps/control-panel-app/templates/redis/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/control-panel-app/templates/redis/docker-compose.yml b/apps/control-panel-app/templates/redis/docker-compose.yml index 61882ed..52bc3bb 100644 --- a/apps/control-panel-app/templates/redis/docker-compose.yml +++ b/apps/control-panel-app/templates/redis/docker-compose.yml @@ -10,7 +10,7 @@ services: restart: unless-stopped ports: - - '${REDIS_PORT:-6379}:6379' + - '${SERVICE_PORT_REDIS:-6379}:6379' command: > sh -c "if [ -n \"$$REDIS_PASSWORD\" ]; then @@ -18,7 +18,7 @@ services: else exec redis-server; fi" environment: - REDIS_PASSWORD: ${REDIS_PASSWORD:-} + REDIS_PASSWORD: ${SERVICE_PASSWORD_REDIS} healthcheck: test: ['CMD', 'redis-cli', 'ping']