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
+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);