From 2303985d0cce27f37b990d9b95adb5e3f951dbad Mon Sep 17 00:00:00 2001 From: keyhan Date: Fri, 15 May 2026 13:58:08 +0330 Subject: [PATCH] Add time-limited external access for optional services and database. Users can open temporary NodePort access with auto-revoke via Bull jobs and a dashboard UI to manage active grants. Co-authored-by: Cursor --- .../migrations/001_service_access_grants.sql | 40 +++ backend/src/access/access.module.ts | 20 ++ backend/src/access/access.processor.ts | 17 + backend/src/access/access.service.ts | 334 ++++++++++++++++++ backend/src/access/dto/service-access.dto.ts | 61 ++++ .../entities/service-access-grant.entity.ts | 58 +++ .../applications/applications.controller.ts | 60 +++- .../src/applications/applications.module.ts | 2 + backend/src/common/enums.ts | 14 + backend/src/kubernetes/kubernetes.service.ts | 197 ++++++++++- backend/src/seed.ts | 1 + frontend/src/app/dashboard/apps/[id]/page.tsx | 204 ++++++++++- frontend/src/types/index.ts | 26 ++ 13 files changed, 1015 insertions(+), 19 deletions(-) create mode 100644 backend/migrations/001_service_access_grants.sql create mode 100644 backend/src/access/access.module.ts create mode 100644 backend/src/access/access.processor.ts create mode 100644 backend/src/access/access.service.ts create mode 100644 backend/src/access/dto/service-access.dto.ts create mode 100644 backend/src/access/entities/service-access-grant.entity.ts diff --git a/backend/migrations/001_service_access_grants.sql b/backend/migrations/001_service_access_grants.sql new file mode 100644 index 0000000..d2c6585 --- /dev/null +++ b/backend/migrations/001_service_access_grants.sql @@ -0,0 +1,40 @@ +-- Temporary external access grants (Redis, RabbitMQ, database) +CREATE TYPE service_access_target AS ENUM ( + 'database', + 'redis', + 'rabbitmq_amqp', + 'rabbitmq_management' +); + +CREATE TYPE service_access_grant_status AS ENUM ( + 'active', + 'expired', + 'revoked' +); + +CREATE TABLE IF NOT EXISTS service_access_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + "applicationId" UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE, + "userId" UUID NOT NULL, + "clusterId" UUID NOT NULL, + namespace VARCHAR(255) NOT NULL, + target service_access_target NOT NULL, + "nodePort" INTEGER NOT NULL, + "targetPort" INTEGER NOT NULL, + host VARCHAR(255) NOT NULL, + "k8sServiceName" VARCHAR(255) NOT NULL, + status service_access_grant_status NOT NULL DEFAULT 'active', + "expiresAt" TIMESTAMPTZ NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_service_access_grants_app_target_status + ON service_access_grants ("applicationId", target, status); + +INSERT INTO platform_settings (id, key, value, description, "createdAt", "updatedAt") +SELECT gen_random_uuid(), 'access_max_duration_minutes', '240', + 'Maximum duration (minutes) for temporary external service access', + NOW(), NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM platform_settings WHERE key = 'access_max_duration_minutes' +); diff --git a/backend/src/access/access.module.ts b/backend/src/access/access.module.ts new file mode 100644 index 0000000..8b228a7 --- /dev/null +++ b/backend/src/access/access.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bull'; +import { AccessService } from './access.service'; +import { AccessProcessor } from './access.processor'; +import { ServiceAccessGrant } from './entities/service-access-grant.entity'; +import { Application } from '../applications/entities/application.entity'; +import { PlatformSetting } from '../billing/entities/platform-setting.entity'; +import { KubernetesModule } from '../kubernetes/kubernetes.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ServiceAccessGrant, Application, PlatformSetting]), + BullModule.registerQueue({ name: 'access-revoke' }), + KubernetesModule, + ], + providers: [AccessService, AccessProcessor], + exports: [AccessService], +}) +export class AccessModule {} diff --git a/backend/src/access/access.processor.ts b/backend/src/access/access.processor.ts new file mode 100644 index 0000000..cf570bb --- /dev/null +++ b/backend/src/access/access.processor.ts @@ -0,0 +1,17 @@ +import { Process, Processor } from '@nestjs/bull'; +import { Logger } from '@nestjs/common'; +import { Job } from 'bull'; +import { AccessService, RevokeAccessJobData } from './access.service'; + +@Processor('access-revoke') +export class AccessProcessor { + private readonly logger = new Logger(AccessProcessor.name); + + constructor(private readonly accessService: AccessService) {} + + @Process('revoke') + async handleRevoke(job: Job) { + this.logger.log(`Revoking access grant ${job.data.grantId}`); + await this.accessService.revokeGrant(job.data.grantId, undefined, true); + } +} diff --git a/backend/src/access/access.service.ts b/backend/src/access/access.service.ts new file mode 100644 index 0000000..1091d34 --- /dev/null +++ b/backend/src/access/access.service.ts @@ -0,0 +1,334 @@ +import { + Injectable, + Logger, + OnModuleInit, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { InjectQueue } from '@nestjs/bull'; +import { Repository, LessThan } from 'typeorm'; +import { Queue } from 'bull'; +import { v4 as uuidv4 } from 'uuid'; +import { ServiceAccessGrant } from './entities/service-access-grant.entity'; +import { Application } from '../applications/entities/application.entity'; +import { PlatformSetting } from '../billing/entities/platform-setting.entity'; +import { KubernetesService } from '../kubernetes/kubernetes.service'; +import { + ServiceAccessTarget, + ServiceAccessGrantStatus, + DatabaseType, + AppLifecycleStatus, +} from '../common/enums'; +import { CreateServiceAccessDto } from './dto/service-access.dto'; + +export interface RevokeAccessJobData { + grantId: string; +} + +const MIN_DURATION_MINUTES = 15; +const DEFAULT_MAX_DURATION_MINUTES = 240; + +@Injectable() +export class AccessService implements OnModuleInit { + private readonly logger = new Logger(AccessService.name); + + constructor( + @InjectRepository(ServiceAccessGrant) + private readonly grantsRepo: Repository, + @InjectRepository(Application) + private readonly appsRepo: Repository, + @InjectRepository(PlatformSetting) + private readonly settingsRepo: Repository, + private readonly kubernetesService: KubernetesService, + @InjectQueue('access-revoke') + private readonly revokeQueue: Queue, + ) {} + + async onModuleInit(): Promise { + const expired = await this.grantsRepo.find({ + where: { + status: ServiceAccessGrantStatus.ACTIVE, + expiresAt: LessThan(new Date()), + }, + }); + for (const grant of expired) { + try { + await this.revokeGrant(grant.id, grant.userId, true); + } catch (e: any) { + this.logger.warn(`Startup expiry failed for grant ${grant.id}: ${e.message}`); + } + } + if (expired.length > 0) { + this.logger.log(`Expired ${expired.length} stale access grant(s) on startup`); + } + } + + private async getMaxDurationMinutes(): Promise { + try { + const setting = await this.settingsRepo.findOne({ + where: { key: 'access_max_duration_minutes' }, + }); + if (setting) { + const parsed = parseInt(setting.value, 10); + if (!Number.isNaN(parsed) && parsed >= MIN_DURATION_MINUTES) return parsed; + } + } catch {} + return DEFAULT_MAX_DURATION_MINUTES; + } + + private async validateApplicationForAccess(app: Application): Promise { + if (!app.clusterId) { + throw new BadRequestException('Application must be deployed to a cluster first'); + } + if (!app.latestImageTag) { + throw new BadRequestException('Application must be deployed before requesting external access'); + } + if (app.lifecycleStatus === AppLifecycleStatus.SUSPENDED) { + throw new BadRequestException('Application is suspended'); + } + if (app.lifecycleStatus === AppLifecycleStatus.PENDING_DELETION) { + throw new BadRequestException('Application is pending deletion'); + } + } + + private validateTargetEnabled(app: Application, target: ServiceAccessTarget): void { + switch (target) { + case ServiceAccessTarget.DATABASE: + if (!app.databaseType || app.databaseType === DatabaseType.NONE) { + throw new BadRequestException('Application has no database'); + } + break; + case ServiceAccessTarget.REDIS: + if (!app.enableRedis) throw new BadRequestException('Redis is not enabled'); + break; + case ServiceAccessTarget.RABBITMQ_AMQP: + case ServiceAccessTarget.RABBITMQ_MANAGEMENT: + if (!app.enableRabbitmq) throw new BadRequestException('RabbitMQ is not enabled'); + break; + } + } + + private buildConnection( + target: ServiceAccessTarget, + host: string, + port: number, + creds: Record, + app: Application, + ): Record { + const connection: Record = { + host, + port, + }; + + if (creds.username) connection.username = creds.username as string; + if (creds.password) connection.password = creds.password as string; + if (creds.database) connection.database = creds.database as string; + + switch (target) { + case ServiceAccessTarget.DATABASE: { + const dbType = app.databaseType; + const dbName = app.name.replace(/-/g, '_'); + const user = (creds.username as string) || 'appuser'; + const pass = creds.password as string; + if (dbType === DatabaseType.POSTGRESQL) { + connection.url = `postgresql://${user}:${pass}@${host}:${port}/${dbName}`; + } else if (dbType === DatabaseType.MONGODB) { + connection.url = `mongodb://${user}:${pass}@${host}:${port}/${dbName}?authSource=admin`; + } else { + connection.url = `mysql://${user}:${pass}@${host}:${port}/${dbName}`; + } + break; + } + case ServiceAccessTarget.REDIS: + connection.url = creds.password + ? `redis://:${creds.password}@${host}:${port}` + : `redis://${host}:${port}`; + break; + case ServiceAccessTarget.RABBITMQ_AMQP: + connection.url = `amqp://${creds.username || 'appuser'}:${creds.password}@${host}:${port}`; + break; + case ServiceAccessTarget.RABBITMQ_MANAGEMENT: + connection.url = `http://${host}:${port}`; + break; + } + + return connection; + } + + private async findApp(applicationId: string, userId?: string): Promise { + const where: { id: string; userId?: string } = { id: applicationId }; + if (userId) where.userId = userId; + const app = await this.appsRepo.findOne({ where }); + if (!app) throw new NotFoundException('Application not found'); + return app; + } + + async createGrant( + applicationId: string, + userId: string | undefined, + dto: CreateServiceAccessDto, + ) { + const app = await this.findApp(applicationId, userId); + + await this.validateApplicationForAccess(app); + this.validateTargetEnabled(app, dto.target); + + const maxDuration = await this.getMaxDurationMinutes(); + if (dto.durationMinutes > maxDuration) { + throw new BadRequestException(`Duration cannot exceed ${maxDuration} minutes`); + } + + const existing = await this.grantsRepo.findOne({ + where: { + applicationId, + target: dto.target, + status: ServiceAccessGrantStatus.ACTIVE, + }, + }); + if (existing) { + throw new ConflictException( + `Active access already exists for ${dto.target}. Revoke it first or wait until it expires.`, + ); + } + + const grantId = uuidv4(); + const namespace = this.kubernetesService.getUserNamespace(app.userId); + const expiresAt = new Date(Date.now() + dto.durationMinutes * 60 * 1000); + + let k8sResult: { host: string; nodePort: number; k8sServiceName: string; targetPort: number }; + try { + k8sResult = await this.kubernetesService.createTemporaryAccess(app, dto.target, grantId); + } catch (e: any) { + throw new BadRequestException(e.message || 'Failed to open external access'); + } + + const grant = this.grantsRepo.create({ + id: grantId, + applicationId, + userId: app.userId, + clusterId: app.clusterId!, + namespace, + target: dto.target, + nodePort: k8sResult.nodePort, + targetPort: k8sResult.targetPort, + host: k8sResult.host, + k8sServiceName: k8sResult.k8sServiceName, + status: ServiceAccessGrantStatus.ACTIVE, + expiresAt, + }); + + await this.grantsRepo.save(grant); + + const delayMs = Math.max(0, expiresAt.getTime() - Date.now()); + await this.revokeQueue.add('revoke', { grantId }, { delay: delayMs, jobId: grantId }); + + const creds = await this.kubernetesService.readAccessCredentials(app, dto.target); + const connection = this.buildConnection( + dto.target, + k8sResult.host, + k8sResult.nodePort, + creds, + app, + ); + + return { + id: grant.id, + target: grant.target, + host: grant.host, + port: grant.nodePort, + targetPort: grant.targetPort, + expiresAt: grant.expiresAt, + status: grant.status, + connection, + }; + } + + async listGrants(applicationId: string, userId: string | undefined, includeRecent = false) { + const app = await this.findApp(applicationId, userId); + + const where: any = { applicationId }; + if (!includeRecent) { + where.status = ServiceAccessGrantStatus.ACTIVE; + } + + const grants = await this.grantsRepo.find({ + where, + order: { createdAt: 'DESC' }, + }); + + const filtered = includeRecent + ? grants + : grants.filter((g) => g.status === ServiceAccessGrantStatus.ACTIVE); + + return Promise.all( + filtered.map(async (grant) => { + const creds = await this.kubernetesService.readAccessCredentials(app, grant.target); + const connection = this.buildConnection( + grant.target, + grant.host, + grant.nodePort, + creds, + app, + ); + return { + id: grant.id, + target: grant.target, + host: grant.host, + port: grant.nodePort, + targetPort: grant.targetPort, + expiresAt: grant.expiresAt, + status: grant.status, + connection, + }; + }), + ); + } + + async revokeGrant(grantId: string, userId: string | undefined, system = false): Promise { + const grant = await this.grantsRepo.findOne({ + where: { id: grantId }, + relations: ['application'], + }); + if (!grant) throw new NotFoundException('Access grant not found'); + if (!system && userId !== undefined && grant.userId !== userId) { + throw new NotFoundException('Access grant not found'); + } + if (grant.status !== ServiceAccessGrantStatus.ACTIVE) return; + + await this.kubernetesService.revokeTemporaryAccess( + grant.clusterId, + grant.namespace, + grant.k8sServiceName, + ); + + grant.status = + system && grant.expiresAt.getTime() <= Date.now() + ? ServiceAccessGrantStatus.EXPIRED + : ServiceAccessGrantStatus.REVOKED; + await this.grantsRepo.save(grant); + + try { + const job = await this.revokeQueue.getJob(grantId); + if (job) await job.remove(); + } catch {} + } + + async revokeAllForApplication(applicationId: string): Promise { + const grants = await this.grantsRepo.find({ + where: { applicationId, status: ServiceAccessGrantStatus.ACTIVE }, + }); + for (const grant of grants) { + await this.revokeGrant(grant.id, undefined, true); + } + } + + async findGrantForApp(grantId: string, applicationId: string, userId?: string) { + const where: any = { id: grantId, applicationId }; + if (userId) where.userId = userId; + const grant = await this.grantsRepo.findOne({ where }); + if (!grant) throw new NotFoundException('Access grant not found'); + return grant; + } +} diff --git a/backend/src/access/dto/service-access.dto.ts b/backend/src/access/dto/service-access.dto.ts new file mode 100644 index 0000000..3735bf9 --- /dev/null +++ b/backend/src/access/dto/service-access.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsInt, Min, Max } from 'class-validator'; +import { ServiceAccessTarget } from '../../common/enums'; + +export class CreateServiceAccessDto { + @ApiProperty({ enum: ServiceAccessTarget }) + @IsEnum(ServiceAccessTarget) + target: ServiceAccessTarget; + + @ApiProperty({ example: 60, description: 'Access duration in minutes (min 15)' }) + @IsInt() + @Min(15) + @Max(1440) + durationMinutes: number; +} + +export class ServiceAccessConnectionDto { + @ApiPropertyOptional() + host?: string; + + @ApiPropertyOptional() + port?: number; + + @ApiPropertyOptional() + username?: string; + + @ApiPropertyOptional() + password?: string; + + @ApiPropertyOptional() + database?: string; + + @ApiPropertyOptional() + url?: string; +} + +export class ServiceAccessGrantResponseDto { + @ApiProperty() + id: string; + + @ApiProperty({ enum: ServiceAccessTarget }) + target: ServiceAccessTarget; + + @ApiProperty() + host: string; + + @ApiProperty() + port: number; + + @ApiProperty() + targetPort: number; + + @ApiProperty() + expiresAt: Date; + + @ApiProperty() + status: string; + + @ApiProperty({ type: ServiceAccessConnectionDto }) + connection: ServiceAccessConnectionDto; +} diff --git a/backend/src/access/entities/service-access-grant.entity.ts b/backend/src/access/entities/service-access-grant.entity.ts new file mode 100644 index 0000000..6eb5ad2 --- /dev/null +++ b/backend/src/access/entities/service-access-grant.entity.ts @@ -0,0 +1,58 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ServiceAccessTarget, ServiceAccessGrantStatus } from '../../common/enums'; +import { Application } from '../../applications/entities/application.entity'; + +@Entity('service_access_grants') +@Index(['applicationId', 'target', 'status']) +export class ServiceAccessGrant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + applicationId: string; + + @ManyToOne(() => Application, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'applicationId' }) + application: Application; + + @Column() + userId: string; + + @Column() + clusterId: string; + + @Column() + namespace: string; + + @Column({ type: 'enum', enum: ServiceAccessTarget }) + target: ServiceAccessTarget; + + @Column() + nodePort: number; + + @Column() + targetPort: number; + + @Column() + host: string; + + @Column() + k8sServiceName: string; + + @Column({ type: 'enum', enum: ServiceAccessGrantStatus, default: ServiceAccessGrantStatus.ACTIVE }) + status: ServiceAccessGrantStatus; + + @Column({ type: 'timestamptz' }) + expiresAt: Date; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index 0a34ebf..3664f30 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -27,6 +27,8 @@ import { Roles } from '../common/decorators/roles.decorator'; import { UserRole, DatabaseType } from '../common/enums'; import { KubernetesService } from '../kubernetes/kubernetes.service'; import { DeploymentsService } from '../deployments/deployments.service'; +import { AccessService } from '../access/access.service'; +import { CreateServiceAccessDto } from '../access/dto/service-access.dto'; @ApiTags('Applications') @ApiBearerAuth() @@ -41,8 +43,17 @@ export class ApplicationsController { private readonly kubernetesService: KubernetesService, @Inject(forwardRef(() => DeploymentsService)) private readonly deploymentsService: DeploymentsService, + private readonly accessService: AccessService, ) {} + private isStaff(role: string): boolean { + return role === UserRole.ADMIN || role === UserRole.TECHNICAL; + } + + private staffUserIdFilter(req: any): string | undefined { + return this.isStaff(req.user.role) ? undefined : req.user.id; + } + @Post() @ApiOperation({ summary: 'Create a new application' }) async create(@Request() req: any, @Body() dto: CreateApplicationDto) { @@ -268,13 +279,41 @@ export class ApplicationsController { @Get(':id/preview') @ApiOperation({ summary: 'Get preview URL for the deployed application' }) async getPreview(@Param('id') id: string, @Request() req: any) { - const app = await this.applicationsService.findOne( - id, - (req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL) ? undefined : req.user.id, - ); + const app = await this.applicationsService.findOne(id, this.staffUserIdFilter(req)); return this.kubernetesService.getPreviewInfo(app); } + @Post(':id/access') + @ApiOperation({ summary: 'Open temporary external access to database, Redis, or RabbitMQ' }) + async createAccess( + @Param('id') id: string, + @Request() req: any, + @Body() dto: CreateServiceAccessDto, + ) { + await this.applicationsService.findOne(id, this.staffUserIdFilter(req)); + return this.accessService.createGrant(id, this.staffUserIdFilter(req), dto); + } + + @Get(':id/access') + @ApiOperation({ summary: 'List active temporary access grants for an application' }) + async listAccess(@Param('id') id: string, @Request() req: any) { + await this.applicationsService.findOne(id, this.staffUserIdFilter(req)); + return this.accessService.listGrants(id, this.staffUserIdFilter(req)); + } + + @Delete(':id/access/:grantId') + @ApiOperation({ summary: 'Revoke temporary external access' }) + async revokeAccess( + @Param('id') id: string, + @Param('grantId') grantId: string, + @Request() req: any, + ) { + const app = await this.applicationsService.findOne(id, this.staffUserIdFilter(req)); + await this.accessService.findGrantForApp(grantId, id, this.staffUserIdFilter(req)); + await this.accessService.revokeGrant(grantId, this.staffUserIdFilter(req)); + return { message: 'Access revoked' }; + } + // ── Custom Domain ───────────────────────────────────────────── @Post('domain/check-dns') @@ -351,7 +390,14 @@ export class ApplicationsController { // 1. Get the app first const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); - // 2. Delete K8s resources (deployment, service, ingress, db, secrets, PVCs) + // 2. Revoke temporary access grants + try { + await this.accessService.revokeAllForApplication(app.id); + } catch (e: any) { + this.logger.warn(`Access grant cleanup failed for ${app.name}: ${e.message}`); + } + + // 3. Delete K8s resources (deployment, service, ingress, db, secrets, PVCs) try { await this.kubernetesService.deleteApplication(app); this.logger.log(`Deleted K8s resources for ${app.name}`); @@ -359,14 +405,14 @@ export class ApplicationsController { this.logger.warn(`K8s cleanup failed for ${app.name}: ${e.message}`); } - // 3. Delete deployment records from DB + // 4. Delete deployment records from DB try { await this.deploymentsService.deleteAllForApplication(app.id); } catch (e: any) { this.logger.warn(`Deployment records cleanup failed for ${app.name}: ${e.message}`); } - // 4. Delete app (also deletes uploaded files) + // 5. Delete app (also deletes uploaded files) await this.applicationsService.delete(id, isStaff ? app.userId : req.user.id); return { message: `Application "${app.name}" and all resources deleted` }; diff --git a/backend/src/applications/applications.module.ts b/backend/src/applications/applications.module.ts index cc14ee8..bb44270 100644 --- a/backend/src/applications/applications.module.ts +++ b/backend/src/applications/applications.module.ts @@ -8,12 +8,14 @@ import { PlatformSetting } from '../billing/entities/platform-setting.entity'; import { ClustersModule } from '../clusters/clusters.module'; import { KubernetesModule } from '../kubernetes/kubernetes.module'; import { DeploymentsModule } from '../deployments/deployments.module'; +import { AccessModule } from '../access/access.module'; @Module({ imports: [ TypeOrmModule.forFeature([Application, PlatformSetting]), ClustersModule, KubernetesModule, + AccessModule, forwardRef(() => DeploymentsModule), ], controllers: [ApplicationsController], diff --git a/backend/src/common/enums.ts b/backend/src/common/enums.ts index 7caf00a..440e074 100644 --- a/backend/src/common/enums.ts +++ b/backend/src/common/enums.ts @@ -51,6 +51,20 @@ export enum OptionalService { ELASTICSEARCH = 'elasticsearch', } +/** Targets for time-limited external access via temporary NodePort */ +export enum ServiceAccessTarget { + DATABASE = 'database', + REDIS = 'redis', + RABBITMQ_AMQP = 'rabbitmq_amqp', + RABBITMQ_MANAGEMENT = 'rabbitmq_management', +} + +export enum ServiceAccessGrantStatus { + ACTIVE = 'active', + EXPIRED = 'expired', + REVOKED = 'revoked', +} + export enum DeploymentStatus { PENDING = 'pending', BUILDING = 'building', diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 22c947e..742bd0a 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -9,7 +9,7 @@ import { PassThrough } from 'stream'; import { ClustersService } from '../clusters/clusters.service'; import { Application } from '../applications/entities/application.entity'; import { ensureAppUrlEnv } from '../applications/app-url.util'; -import { AppRuntime, DatabaseType, CustomDomainStatus } from '../common/enums'; +import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget } from '../common/enums'; import { HelmService } from './helm.service'; const execFileAsync = promisify(execFile); @@ -1823,6 +1823,187 @@ export class KubernetesService implements OnModuleInit { this.logger.log(`Updated resources for ${target.deploymentName} (${workload}): ${JSON.stringify(resources)}`); } + getUserNamespace(userId: string): string { + return `user-${userId.split('-')[0]}`; + } + + private getClusterHostIp(kc: k8s.KubeConfig): string { + const clusterServer = kc.getCurrentCluster()?.server || ''; + try { + return new URL(clusterServer).hostname; + } catch { + return '127.0.0.1'; + } + } + + resolveAccessTarget( + app: Application, + target: ServiceAccessTarget, + ): { selector: Record; targetPort: number; portName?: string } { + switch (target) { + case ServiceAccessTarget.DATABASE: { + if (!app.databaseType || app.databaseType === DatabaseType.NONE) { + throw new BadRequestException('Application has no database'); + } + let targetPort = 3306; + if (app.databaseType === DatabaseType.POSTGRESQL) targetPort = 5432; + else if (app.databaseType === DatabaseType.MONGODB) targetPort = 27017; + return { selector: { app: `${app.name}-db` }, targetPort }; + } + case ServiceAccessTarget.REDIS: + if (!app.enableRedis) throw new BadRequestException('Redis is not enabled for this application'); + return { selector: { app: `${app.name}-redis` }, targetPort: 6379 }; + case ServiceAccessTarget.RABBITMQ_AMQP: + if (!app.enableRabbitmq) throw new BadRequestException('RabbitMQ is not enabled for this application'); + return { selector: { app: `${app.name}-rabbitmq` }, targetPort: 5672, portName: 'amqp' }; + case ServiceAccessTarget.RABBITMQ_MANAGEMENT: + if (!app.enableRabbitmq) throw new BadRequestException('RabbitMQ is not enabled for this application'); + return { selector: { app: `${app.name}-rabbitmq` }, targetPort: 15672, portName: 'management' }; + default: + throw new BadRequestException(`Unknown access target: ${target}`); + } + } + + async createTemporaryAccess( + app: Application, + target: ServiceAccessTarget, + grantId: string, + ): Promise<{ host: string; nodePort: number; k8sServiceName: string; targetPort: number }> { + if (!app.clusterId) { + throw new BadRequestException('Application is not assigned to a cluster'); + } + + const { coreApi, kc } = await this.getK8sClient(app.clusterId); + const namespace = this.getUserNamespace(app.userId); + const { selector, targetPort, portName } = this.resolveAccessTarget(app, target); + const shortId = grantId.split('-')[0]; + const k8sServiceName = `${app.name}-${target}-access-${shortId}`.slice(0, 63); + + const portSpec: k8s.V1ServicePort = { + port: targetPort, + targetPort: targetPort, + protocol: 'TCP', + }; + if (portName) portSpec.name = portName; + + const service: k8s.V1Service = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + name: k8sServiceName, + namespace, + labels: { + app: selector.app, + 'cloudhost.io/access-grant': 'true', + 'cloudhost.io/grant-id': grantId, + 'cloudhost.io/application-id': app.id, + }, + }, + spec: { + type: 'NodePort', + selector, + ports: [portSpec], + }, + }; + + const created = await coreApi.createNamespacedService(namespace, service); + const nodePort = created.body.spec?.ports?.[0]?.nodePort; + if (!nodePort) { + try { + await coreApi.deleteNamespacedService(k8sServiceName, namespace); + } catch {} + throw new Error('Failed to allocate NodePort for temporary access'); + } + + const host = this.getClusterHostIp(kc); + this.logger.log( + `Temporary access for ${app.name} target=${target}: ${host}:${nodePort} (service ${k8sServiceName})`, + ); + + return { host, nodePort, k8sServiceName, targetPort }; + } + + async revokeTemporaryAccess( + clusterId: string, + namespace: string, + k8sServiceName: string, + ): Promise { + const { coreApi } = await this.getK8sClient(clusterId); + try { + await coreApi.deleteNamespacedService(k8sServiceName, namespace); + this.logger.log(`Revoked temporary access service ${k8sServiceName} in ${namespace}`); + } catch (e: any) { + if (e.statusCode !== 404 && e.response?.statusCode !== 404) { + this.logger.warn(`Failed to delete access service ${k8sServiceName}: ${e.message}`); + } + } + } + + async deleteTemporaryAccessServicesForApp(app: Application): Promise { + if (!app.clusterId) return; + const namespace = this.getUserNamespace(app.userId); + const { coreApi } = await this.getK8sClient(app.clusterId); + + try { + const services = await coreApi.listNamespacedService( + namespace, + undefined, + undefined, + undefined, + undefined, + 'cloudhost.io/access-grant=true', + ); + for (const svc of services.body.items) { + const appId = svc.metadata?.labels?.['cloudhost.io/application-id']; + if (appId === app.id && svc.metadata?.name) { + await this.revokeTemporaryAccess(app.clusterId, namespace, svc.metadata.name); + } + } + } catch (e: any) { + this.logger.warn(`Failed to list temporary access services for ${app.name}: ${e.message}`); + } + } + + async readAccessCredentials( + app: Application, + target: ServiceAccessTarget, + ): Promise> { + const namespace = this.getUserNamespace(app.userId); + + switch (target) { + case ServiceAccessTarget.DATABASE: + return { + username: app.dbUsername || 'appuser', + password: app.dbPassword || undefined, + database: app.name.replace(/-/g, '_'), + }; + case ServiceAccessTarget.REDIS: { + if (!app.clusterId) return {}; + const { coreApi } = await this.getK8sClient(app.clusterId); + const secret = await coreApi.readNamespacedSecret(`${app.name}-redis-secret`, namespace); + const password = secret.body.data?.password + ? Buffer.from(secret.body.data.password, 'base64').toString('utf8') + : undefined; + return { password }; + } + case ServiceAccessTarget.RABBITMQ_AMQP: + case ServiceAccessTarget.RABBITMQ_MANAGEMENT: { + if (!app.clusterId) return {}; + const { coreApi } = await this.getK8sClient(app.clusterId); + const secret = await coreApi.readNamespacedSecret(`${app.name}-rabbitmq-secret`, namespace); + const username = secret.body.data?.username + ? Buffer.from(secret.body.data.username, 'base64').toString('utf8') + : 'appuser'; + const password = secret.body.data?.password + ? Buffer.from(secret.body.data.password, 'base64').toString('utf8') + : undefined; + return { username, password }; + } + default: + return {}; + } + } + /** * Get preview info for a deployed application. * Patches the service to NodePort if needed, and returns the access URL. @@ -1834,15 +2015,9 @@ export class KubernetesService implements OnModuleInit { ingressUrl?: string; }> { const { coreApi, networkingApi, kc } = await this.getK8sClient(app.clusterId); - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const domain = this.configService.get('platform.domain'); - const clusterServer = kc.getCurrentCluster()?.server || ''; - // Extract host IP from cluster API server URL (e.g., https://217.197.107.252:6443 → 217.197.107.252) - let hostIp = '127.0.0.1'; - try { - const serverUrl = new URL(clusterServer); - hostIp = serverUrl.hostname; - } catch {} + const hostIp = this.getClusterHostIp(kc); // Read current service let nodePort = 0; @@ -1900,9 +2075,11 @@ export class KubernetesService implements OnModuleInit { } async deleteApplication(app: Application): Promise { - const namespace = `user-${app.userId.split('-')[0]}`; + const namespace = this.getUserNamespace(app.userId); const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId); + await this.deleteTemporaryAccessServicesForApp(app); + // Step 1: Try Helm uninstall (handles most resources) try { const kubeconfig = await this.getKubeconfig(app.clusterId); diff --git a/backend/src/seed.ts b/backend/src/seed.ts index 2882ebd..38bf3fd 100644 --- a/backend/src/seed.ts +++ b/backend/src/seed.ts @@ -56,6 +56,7 @@ async function bootstrap() { const defaults = [ { key: 'custom_domain_monthly_price_toman', value: '50000', description: 'Monthly price for custom domain addon (Toman)' }, { key: 'platform_cname_target', value: 'apps.cloudhost.ir', description: 'CNAME target shown to users for custom domain setup' }, + { key: 'access_max_duration_minutes', value: '240', description: 'Maximum duration (minutes) for temporary external service access' }, ]; for (const d of defaults) { diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index 8b1b975..6f001aa 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -4,9 +4,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useParams, useRouter } from 'next/navigation'; import api from '@/lib/api'; import { toast } from 'react-toastify'; -import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision } from '@/types'; +import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPoolPublic, AppSnapshot, K8sRevisionData, K8sRevision, ServiceAccessGrant, ServiceAccessTarget } from '@/types'; import { useState, useRef, useCallback, useEffect } from 'react'; -import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle } from 'lucide-react'; +import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle, ShieldAlert, ExternalLink } from 'lucide-react'; import { useConfirm } from '@/components/confirm-modal'; import { BuildProgressModal } from '@/components/build-progress-modal'; @@ -91,6 +91,11 @@ export default function AppDetailPage() { const [showDomainSetup, setShowDomainSetup] = useState(false); const [customDomainInput, setCustomDomainInput] = useState(''); + const [accessTarget, setAccessTarget] = useState('database'); + const [accessDuration, setAccessDuration] = useState(60); + const [showAccessSecret, setShowAccessSecret] = useState(false); + const [accessNow, setAccessNow] = useState(() => Date.now()); + const { data: app, isLoading } = useQuery({ queryKey: ['application', appId], queryFn: () => api.get(`/applications/${appId}`).then((r) => r.data), @@ -646,6 +651,77 @@ export default function AppDetailPage() { onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'), }); + const accessTargetOptions: { value: ServiceAccessTarget; label: string }[] = []; + if (app?.databaseType && app.databaseType !== 'none') { + accessTargetOptions.push({ value: 'database', label: 'Database' }); + } + if (app?.enableRedis) accessTargetOptions.push({ value: 'redis', label: 'Redis' }); + if (app?.enableRabbitmq) { + accessTargetOptions.push({ value: 'rabbitmq_amqp', label: 'RabbitMQ (AMQP)' }); + accessTargetOptions.push({ value: 'rabbitmq_management', label: 'RabbitMQ Management UI' }); + } + const hasAccessTargets = accessTargetOptions.length > 0; + + useEffect(() => { + if (!hasAccessTargets) return; + if (!accessTargetOptions.some((o) => o.value === accessTarget)) { + setAccessTarget(accessTargetOptions[0].value); + } + }, [app?.databaseType, app?.enableRedis, app?.enableRabbitmq]); + + const { data: accessGrants = [], refetch: refetchAccessGrants } = useQuery({ + queryKey: ['access-grants', appId], + queryFn: () => api.get(`/applications/${appId}/access`).then((r) => r.data), + enabled: hasAccessTargets && !!app?.latestImageTag, + refetchInterval: 30000, + }); + + useEffect(() => { + if (!accessGrants.some((g) => g.status === 'active')) return; + const t = setInterval(() => setAccessNow(Date.now()), 1000); + return () => clearInterval(t); + }, [accessGrants]); + + const createAccessMutation = useMutation({ + mutationFn: () => + api.post(`/applications/${appId}/access`, { + target: accessTarget, + durationMinutes: accessDuration, + }).then((r) => r.data), + onSuccess: () => { + refetchAccessGrants(); + toast.success('Temporary external access enabled'); + }, + onError: (err: any) => { + toast.error(err.response?.data?.message || 'Failed to enable access'); + }, + }); + + const revokeAccessMutation = useMutation({ + mutationFn: (grantId: string) => + api.delete(`/applications/${appId}/access/${grantId}`).then((r) => r.data), + onSuccess: () => { + refetchAccessGrants(); + toast.success('Access revoked'); + }, + onError: () => toast.error('Failed to revoke access'), + }); + + const accessTargetLabel = (target: ServiceAccessTarget) => + accessTargetOptions.find((o) => o.value === target)?.label || target; + + const formatAccessCountdown = (expiresAt: string) => { + const ms = new Date(expiresAt).getTime() - accessNow; + if (ms <= 0) return 'Expired'; + const totalSec = Math.floor(ms / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + if (h > 0) return `${h}h ${m}m ${s}s`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; + }; + const uploadMutation = useMutation({ mutationFn: (file: File) => { const formData = new FormData(); @@ -1688,6 +1764,130 @@ export default function AppDetailPage() { )} + {/* Temporary External Access */} + {hasAccessTargets && ( +
+

+ Temporary External Access +

+

+ + Opens a NodePort on the cluster node IP. Anyone who can reach that IP may connect until the timer ends. Use short durations only. +

+ + {!app?.latestImageTag ? ( +

Deploy the application first to enable external access.

+ ) : ( + <> +
+
+ + +
+
+ +
+ {[30, 60, 240].map((mins) => ( + + ))} +
+
+ +
+ + {accessGrants.filter((g) => g.status === 'active').length === 0 ? ( +

No active external access sessions.

+ ) : ( +
+ {accessGrants + .filter((g) => g.status === 'active') + .map((grant) => ( +
+
+
+ {accessTargetLabel(grant.target)} + ends in {formatAccessCountdown(grant.expiresAt)} +
+ +
+
+
+ Endpoint +
+ {grant.host}:{grant.port} + +
+
+ {grant.connection.url && ( +
+ URL +
+ + {showAccessSecret ? grant.connection.url : '••••••••••••'} + + + +
+
+ )} +
+
+ ))} +
+ )} + + )} +
+ )} + {/* Resource Monitoring & Scaling */}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2865bc2..8c6bf28 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -62,6 +62,32 @@ export interface Application { updatedAt: string; } +export type ServiceAccessTarget = + | 'database' + | 'redis' + | 'rabbitmq_amqp' + | 'rabbitmq_management'; + +export interface ServiceAccessConnection { + host?: string; + port?: number; + username?: string; + password?: string; + database?: string; + url?: string; +} + +export interface ServiceAccessGrant { + id: string; + target: ServiceAccessTarget; + host: string; + port: number; + targetPort: number; + expiresAt: string; + status: 'active' | 'expired' | 'revoked'; + connection: ServiceAccessConnection; +} + export interface Deployment { id: string; status: DeploymentStatus;