improve launcher auth and remote connection feedback
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: studio-launcher-be
|
||||||
|
namespace: studio-system
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: studio-launcher-be
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 4300
|
||||||
|
targetPort: 4300
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: studio-launcher-be
|
||||||
|
namespace: studio-system
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: studio-launcher-be
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: studio-launcher-be
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: studio-launcher-be
|
||||||
|
image: localhost:5000/studio-launcher-be:0.1.0
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 4300
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: studio-launcher-be-env
|
||||||
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: studio-launcher-db-secret
|
||||||
|
namespace: studio-system
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
POSTGRES_USER: launcher
|
||||||
|
POSTGRES_PASSWORD: future1234!@
|
||||||
|
POSTGRES_DB: launcher
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: studio-launcher-db
|
||||||
|
namespace: studio-system
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: studio-launcher-db
|
||||||
|
ports:
|
||||||
|
- name: postgres
|
||||||
|
port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: studio-launcher-db
|
||||||
|
namespace: studio-system
|
||||||
|
spec:
|
||||||
|
serviceName: studio-launcher-db
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: studio-launcher-db
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: studio-launcher-db
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:17
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: studio-launcher-db-secret
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
subPath: postgres
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: data
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 3Gi
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: studio-launcher-ingress
|
||||||
|
namespace: studio-system
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: portal-api.autonomystudio.net
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: studio-launcher-be
|
||||||
|
port:
|
||||||
|
number: 4300
|
||||||
|
|
||||||
|
|
||||||
@@ -24,6 +24,21 @@ model LauncherNotice {
|
|||||||
@@map("launcher_notices")
|
@@map("launcher_notices")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model LauncherTerm {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
key String @unique
|
||||||
|
body String
|
||||||
|
isRequired Boolean @default(true) @map("is_required")
|
||||||
|
createdById String? @map("created_by_id") @db.Uuid
|
||||||
|
createdByName String? @map("created_by_name")
|
||||||
|
createdByEmail String? @map("created_by_email")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@index([createdByEmail])
|
||||||
|
@@map("launcher_terms")
|
||||||
|
}
|
||||||
|
|
||||||
model LauncherAppVersion {
|
model LauncherAppVersion {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
appKey String @unique @map("app_key")
|
appKey String @unique @map("app_key")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { NoticesModule } from './notices/notices.module';
|
|||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { DownloadsModule } from './downloads/downloads.module';
|
import { DownloadsModule } from './downloads/downloads.module';
|
||||||
import { ProvisioningModule } from './provisioning/provisioning.module';
|
import { ProvisioningModule } from './provisioning/provisioning.module';
|
||||||
|
import { TermsModule } from './terms/terms.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -25,6 +26,7 @@ import { ProvisioningModule } from './provisioning/provisioning.module';
|
|||||||
DownloadsModule,
|
DownloadsModule,
|
||||||
ProvisioningModule,
|
ProvisioningModule,
|
||||||
AppsModule,
|
AppsModule,
|
||||||
|
TermsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export class AppsService {
|
|||||||
|
|
||||||
async getApps(accessToken?: string) {
|
async getApps(accessToken?: string) {
|
||||||
const authUserId = await this.getAuthUserId(accessToken);
|
const authUserId = await this.getAuthUserId(accessToken);
|
||||||
|
if (authUserId) {
|
||||||
|
await this.ensureDefaultAppViewPermissions(authUserId);
|
||||||
|
}
|
||||||
|
|
||||||
const apps = await this.prisma.launcherApp.findMany({
|
const apps = await this.prisma.launcherApp.findMany({
|
||||||
where: { isEnabled: true },
|
where: { isEnabled: true },
|
||||||
orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }],
|
orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }],
|
||||||
@@ -296,4 +300,22 @@ export class AppsService {
|
|||||||
|
|
||||||
return Boolean(permission);
|
return Boolean(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ensureDefaultAppViewPermissions(authUserId: string) {
|
||||||
|
const apps = await this.prisma.launcherApp.findMany({
|
||||||
|
where: { isEnabled: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!apps.length) return;
|
||||||
|
|
||||||
|
await this.prisma.launcherUserApp.createMany({
|
||||||
|
data: apps.map((app) => ({
|
||||||
|
authUserId,
|
||||||
|
appId: app.id,
|
||||||
|
canView: true,
|
||||||
|
canLaunch: false,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { ProvisioningDeviceDto } from './create-provisioning.dto';
|
||||||
|
|
||||||
|
export class CreateSessionProvisioningDto {
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
hostname?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
os?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
arch?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
kernel?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
distro?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
usageType?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
sshUser?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
workspaceRoot?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
publicSshKey?: string;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => ProvisioningDeviceDto)
|
||||||
|
@IsOptional()
|
||||||
|
device?: ProvisioningDeviceDto;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Body, Controller, Headers, Post, UnauthorizedException } from '@nestjs/
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||||
|
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
|
||||||
import { ProvisioningService } from './provisioning.service';
|
import { ProvisioningService } from './provisioning.service';
|
||||||
import type { ProvisioningResponse } from './provisioning.service';
|
import type { ProvisioningResponse } from './provisioning.service';
|
||||||
|
|
||||||
@@ -19,6 +20,17 @@ export class ProvisioningController {
|
|||||||
return this.provisioningService.createProvisioning(createProvisioningDto);
|
return this.provisioningService.createProvisioning(createProvisioningDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('session')
|
||||||
|
createSessionProvisioning(
|
||||||
|
@Headers('authorization') authorization: string | undefined,
|
||||||
|
@Body() createProvisioningDto: CreateSessionProvisioningDto,
|
||||||
|
): Promise<ProvisioningResponse> {
|
||||||
|
return this.provisioningService.createProvisioningFromAccessToken(
|
||||||
|
this.getBearerToken(authorization),
|
||||||
|
createProvisioningDto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('ssh-keys/lookup')
|
@Post('ssh-keys/lookup')
|
||||||
listSshKeys(
|
listSshKeys(
|
||||||
@Headers('x-lms-gateway-token') token: string | undefined,
|
@Headers('x-lms-gateway-token') token: string | undefined,
|
||||||
@@ -33,4 +45,9 @@ export class ProvisioningController {
|
|||||||
}
|
}
|
||||||
return this.provisioningService.listLauncherSshKeys(body.emails ?? []);
|
return this.provisioningService.listLauncherSshKeys(body.emails ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getBearerToken(authorization: string | undefined): string | undefined {
|
||||||
|
const [type, token] = authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createHash } from 'crypto';
|
|||||||
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
import { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||||
|
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
|
||||||
|
|
||||||
export type ProvisioningResponse = {
|
export type ProvisioningResponse = {
|
||||||
installTailscale: boolean;
|
installTailscale: boolean;
|
||||||
@@ -31,6 +32,8 @@ type AuthLoginResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ProvisioningInput = CreateProvisioningDto | CreateSessionProvisioningDto;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProvisioningService {
|
export class ProvisioningService {
|
||||||
private readonly authServerUrl: string;
|
private readonly authServerUrl: string;
|
||||||
@@ -61,6 +64,21 @@ export class ProvisioningService {
|
|||||||
provisioningDto.account,
|
provisioningDto.account,
|
||||||
provisioningDto.password,
|
provisioningDto.password,
|
||||||
);
|
);
|
||||||
|
return this.createProvisioningForUser(provisioningDto, authUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProvisioningFromAccessToken(
|
||||||
|
accessToken: string | undefined,
|
||||||
|
provisioningDto: CreateSessionProvisioningDto,
|
||||||
|
): Promise<ProvisioningResponse> {
|
||||||
|
const authUser = await this.authenticateAccessToken(accessToken);
|
||||||
|
return this.createProvisioningForUser(provisioningDto, authUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createProvisioningForUser(
|
||||||
|
provisioningDto: ProvisioningInput,
|
||||||
|
authUser: NonNullable<AuthLoginResponse['user']>,
|
||||||
|
): Promise<ProvisioningResponse> {
|
||||||
const headscaleUser = this.toHeadscaleUserName(authUser.email);
|
const headscaleUser = this.toHeadscaleUserName(authUser.email);
|
||||||
const provisioningMode = this.normalizeProvisioningMode(
|
const provisioningMode = this.normalizeProvisioningMode(
|
||||||
provisioningDto.usageType ?? provisioningDto.device?.usageType,
|
provisioningDto.usageType ?? provisioningDto.device?.usageType,
|
||||||
@@ -147,7 +165,7 @@ export class ProvisioningService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async recordLauncherSshKey(
|
private async recordLauncherSshKey(
|
||||||
provisioningDto: CreateProvisioningDto,
|
provisioningDto: ProvisioningInput,
|
||||||
authUser: NonNullable<AuthLoginResponse['user']>,
|
authUser: NonNullable<AuthLoginResponse['user']>,
|
||||||
) {
|
) {
|
||||||
const publicKey = this.normalizePublicSshKey(
|
const publicKey = this.normalizePublicSshKey(
|
||||||
@@ -184,7 +202,7 @@ export class ProvisioningService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async recordProvisionedDevice(
|
private async recordProvisionedDevice(
|
||||||
provisioningDto: CreateProvisioningDto,
|
provisioningDto: ProvisioningInput,
|
||||||
authUser: NonNullable<AuthLoginResponse['user']>,
|
authUser: NonNullable<AuthLoginResponse['user']>,
|
||||||
tailscaleHostname: string,
|
tailscaleHostname: string,
|
||||||
provisioningMode: 'personal' | 'class',
|
provisioningMode: 'personal' | 'class',
|
||||||
@@ -303,6 +321,42 @@ export class ProvisioningService {
|
|||||||
return payload.user;
|
return payload.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async authenticateAccessToken(
|
||||||
|
accessToken: string | undefined,
|
||||||
|
): Promise<NonNullable<AuthLoginResponse['user']>> {
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new UnauthorizedException('Invalid bearer token');
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: globalThis.Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${this.authServerUrl}/auth/me`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { authorization: `Bearer ${accessToken}` },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new BadGatewayException('Auth server is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json().catch(() => null)) as
|
||||||
|
| AuthLoginResponse
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new UnauthorizedException('Invalid bearer token');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload?.user?.email) {
|
||||||
|
throw new BadGatewayException({
|
||||||
|
message: 'Auth server request failed',
|
||||||
|
statusCode: response.status,
|
||||||
|
error: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.user;
|
||||||
|
}
|
||||||
|
|
||||||
private async createTailscaleAuthKey(
|
private async createTailscaleAuthKey(
|
||||||
headscaleUser: string,
|
headscaleUser: string,
|
||||||
aclTags: string[],
|
aclTags: string[],
|
||||||
@@ -438,7 +492,7 @@ export class ProvisioningService {
|
|||||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new BadGatewayException('Headscale server is unavailable');
|
throw new BadGatewayException('Server is unavailable');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get, Param } from '@nestjs/common';
|
||||||
|
import { TermsService } from './terms.service';
|
||||||
|
|
||||||
|
@Controller('api/terms')
|
||||||
|
export class TermsController {
|
||||||
|
constructor(private readonly termsService: TermsService) {}
|
||||||
|
|
||||||
|
@Get(':key')
|
||||||
|
getTerm(@Param('key') key: string) {
|
||||||
|
return this.termsService.getTerm(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { TermsController } from './terms.controller';
|
||||||
|
import { TermsService } from './terms.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [TermsController],
|
||||||
|
providers: [TermsService],
|
||||||
|
})
|
||||||
|
export class TermsModule {}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TermsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getTerm(key: string) {
|
||||||
|
const term = await this.prisma.launcherTerm.findUnique({
|
||||||
|
where: { key },
|
||||||
|
select: {
|
||||||
|
key: true,
|
||||||
|
body: true,
|
||||||
|
isRequired: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!term) {
|
||||||
|
throw new NotFoundException('약관을 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { term };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user