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")
|
||||
}
|
||||
|
||||
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 {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
appKey String @unique @map("app_key")
|
||||
|
||||
@@ -10,6 +10,7 @@ import { NoticesModule } from './notices/notices.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { DownloadsModule } from './downloads/downloads.module';
|
||||
import { ProvisioningModule } from './provisioning/provisioning.module';
|
||||
import { TermsModule } from './terms/terms.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -25,6 +26,7 @@ import { ProvisioningModule } from './provisioning/provisioning.module';
|
||||
DownloadsModule,
|
||||
ProvisioningModule,
|
||||
AppsModule,
|
||||
TermsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -61,6 +61,10 @@ export class AppsService {
|
||||
|
||||
async getApps(accessToken?: string) {
|
||||
const authUserId = await this.getAuthUserId(accessToken);
|
||||
if (authUserId) {
|
||||
await this.ensureDefaultAppViewPermissions(authUserId);
|
||||
}
|
||||
|
||||
const apps = await this.prisma.launcherApp.findMany({
|
||||
where: { isEnabled: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { displayName: 'asc' }],
|
||||
@@ -296,4 +300,22 @@ export class AppsService {
|
||||
|
||||
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 { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
|
||||
import { ProvisioningService } from './provisioning.service';
|
||||
import type { ProvisioningResponse } from './provisioning.service';
|
||||
|
||||
@@ -19,6 +20,17 @@ export class ProvisioningController {
|
||||
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')
|
||||
listSshKeys(
|
||||
@Headers('x-lms-gateway-token') token: string | undefined,
|
||||
@@ -33,4 +45,9 @@ export class ProvisioningController {
|
||||
}
|
||||
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 { CreateProvisioningDto } from './dto/create-provisioning.dto';
|
||||
import { CreateSessionProvisioningDto } from './dto/create-session-provisioning.dto';
|
||||
|
||||
export type ProvisioningResponse = {
|
||||
installTailscale: boolean;
|
||||
@@ -31,6 +32,8 @@ type AuthLoginResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
type ProvisioningInput = CreateProvisioningDto | CreateSessionProvisioningDto;
|
||||
|
||||
@Injectable()
|
||||
export class ProvisioningService {
|
||||
private readonly authServerUrl: string;
|
||||
@@ -61,6 +64,21 @@ export class ProvisioningService {
|
||||
provisioningDto.account,
|
||||
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 provisioningMode = this.normalizeProvisioningMode(
|
||||
provisioningDto.usageType ?? provisioningDto.device?.usageType,
|
||||
@@ -147,7 +165,7 @@ export class ProvisioningService {
|
||||
}
|
||||
|
||||
private async recordLauncherSshKey(
|
||||
provisioningDto: CreateProvisioningDto,
|
||||
provisioningDto: ProvisioningInput,
|
||||
authUser: NonNullable<AuthLoginResponse['user']>,
|
||||
) {
|
||||
const publicKey = this.normalizePublicSshKey(
|
||||
@@ -184,7 +202,7 @@ export class ProvisioningService {
|
||||
}
|
||||
|
||||
private async recordProvisionedDevice(
|
||||
provisioningDto: CreateProvisioningDto,
|
||||
provisioningDto: ProvisioningInput,
|
||||
authUser: NonNullable<AuthLoginResponse['user']>,
|
||||
tailscaleHostname: string,
|
||||
provisioningMode: 'personal' | 'class',
|
||||
@@ -303,6 +321,42 @@ export class ProvisioningService {
|
||||
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(
|
||||
headscaleUser: string,
|
||||
aclTags: string[],
|
||||
@@ -438,7 +492,7 @@ export class ProvisioningService {
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
} 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