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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user