improve launcher auth and remote connection feedback

This commit is contained in:
2026-07-10 15:09:46 +09:00
parent 8461adfb3d
commit e36b0ca528
12 changed files with 334 additions and 3 deletions
+38
View File
@@ -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
+62
View File
@@ -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
+20
View File
@@ -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
+15
View File
@@ -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")
+2
View File
@@ -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],
+22
View File
@@ -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;
}
}
+57 -3
View File
@@ -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');
}
}
+12
View File
@@ -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);
}
}
+11
View File
@@ -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 {}
+25
View File
@@ -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 };
}
}