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 <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 13:58:08 +03:30
parent c7981074d4
commit 2303985d0c
13 changed files with 1015 additions and 19 deletions
@@ -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'
);
+20
View File
@@ -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 {}
+17
View File
@@ -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<RevokeAccessJobData>) {
this.logger.log(`Revoking access grant ${job.data.grantId}`);
await this.accessService.revokeGrant(job.data.grantId, undefined, true);
}
}
+334
View File
@@ -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<ServiceAccessGrant>,
@InjectRepository(Application)
private readonly appsRepo: Repository<Application>,
@InjectRepository(PlatformSetting)
private readonly settingsRepo: Repository<PlatformSetting>,
private readonly kubernetesService: KubernetesService,
@InjectQueue('access-revoke')
private readonly revokeQueue: Queue<RevokeAccessJobData>,
) {}
async onModuleInit(): Promise<void> {
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<number> {
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<void> {
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<string, string | number | undefined>,
app: Application,
): Record<string, string | number | undefined> {
const connection: Record<string, string | number | undefined> = {
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<Application> {
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<void> {
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<void> {
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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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` };
@@ -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],
+14
View File
@@ -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',
+187 -10
View File
@@ -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<string, string>; 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<void> {
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<void> {
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<Record<string, string | number | undefined>> {
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<void> {
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);
+1
View File
@@ -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) {