diff --git a/src/app.module.ts b/src/app.module.ts index 7360cea..6899a9d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { OrganizationsModule } from './organizations/organizations.module'; import { PageAgentModule } from './page-agent/page-agent.module'; import { PrismaModule } from './prisma/prisma.module'; import { RuntimeModule } from './runtime/runtime.module'; +import { TailscaleModule } from './tailscale/tailscale.module'; import { UsersModule } from './users/users.module'; import { WoodpeckerModule } from './woodpecker/woodpecker.module'; @@ -43,6 +44,7 @@ import { WoodpeckerModule } from './woodpecker/woodpecker.module'; PageAgentModule, PrismaModule, RuntimeModule, + TailscaleModule, UsersModule, WoodpeckerModule, ], diff --git a/src/tailscale/tailscale.controller.ts b/src/tailscale/tailscale.controller.ts new file mode 100644 index 0000000..3d860dd --- /dev/null +++ b/src/tailscale/tailscale.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +import { TailscaleService } from './tailscale.service'; + +@Controller('tailscale') +export class TailscaleController { + constructor(private readonly tailscaleService: TailscaleService) {} + + @Get('nodes') + listNodes() { + return this.tailscaleService.listNodes(); + } +} diff --git a/src/tailscale/tailscale.module.ts b/src/tailscale/tailscale.module.ts new file mode 100644 index 0000000..91ec39d --- /dev/null +++ b/src/tailscale/tailscale.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { PrismaModule } from '../prisma/prisma.module'; +import { TailscaleController } from './tailscale.controller'; +import { TailscaleService } from './tailscale.service'; + +@Module({ + imports: [PrismaModule], + controllers: [TailscaleController], + providers: [TailscaleService], +}) +export class TailscaleModule {} diff --git a/src/tailscale/tailscale.service.ts b/src/tailscale/tailscale.service.ts new file mode 100644 index 0000000..ecbb207 --- /dev/null +++ b/src/tailscale/tailscale.service.ts @@ -0,0 +1,235 @@ +import { BadGatewayException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Prisma } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; + +type HeadscaleRecord = Record; + +type TailscaleNodeDto = { + id: string; + name: string; + ip: string; + ips: string[]; + role: string; + user: string; + routes: string; + lastSeen: string | null; + status: string; + online: boolean; +}; + +@Injectable() +export class TailscaleService { + private readonly gatewayUrl: string; + private readonly gatewayToken?: string; + + constructor( + private readonly configService: ConfigService, + private readonly prisma: PrismaService, + ) { + this.gatewayUrl = this.configService + .get('PLATFORM_GATEWAY_URL', 'http://platform-gateway.studio-system.svc.cluster.local:4500') + .replace(/\/$/, ''); + this.gatewayToken = this.configService.get('PLATFORM_GATEWAY_TOKEN') || undefined; + } + + async listNodes() { + if (!this.gatewayUrl || !this.gatewayToken) { + const nodes = await this.listSnapshotNodes(); + return { + nodes, + source: 'snapshot', + error: 'PLATFORM_GATEWAY_URL 또는 PLATFORM_GATEWAY_TOKEN이 설정되어 있지 않습니다.', + }; + } + + try { + const body = await this.request('/api/admin/tailscale/nodes'); + const nodes = extractNodeRecords(body).map(normalizeHeadscaleNode); + await this.saveSnapshot(nodes); + return { + nodes, + source: 'platform-gateway', + fetchedAt: new Date().toISOString(), + }; + } catch (error) { + const nodes = await this.listSnapshotNodes(); + return { + nodes, + source: 'snapshot', + error: error instanceof Error ? error.message : 'Headscale API 요청이 실패했습니다.', + }; + } + } + + private async request(path: string): Promise { + const response = await fetch(`${this.gatewayUrl}${path}`, { + headers: { + Accept: 'application/json', + 'x-platform-gateway-token': this.gatewayToken ?? '', + }, + }); + + if (response.status === 401 || response.status === 403) { + throw new UnauthorizedException('PLATFORM_GATEWAY_TOKEN이 유효하지 않습니다.'); + } + + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new BadGatewayException({ + message: 'Platform Gateway Tailscale API 요청이 실패했습니다.', + status: response.status, + detail, + }); + } + + return (await response.json()) as T; + } + + private async saveSnapshot(nodes: TailscaleNodeDto[]) { + await Promise.all(nodes.map((node) => this.prisma.tailscaleNode.upsert({ + where: { name: node.name }, + update: { + ip: node.ip, + role: node.role, + user: node.user, + routes: node.routes, + status: node.status, + lastSeen: node.lastSeen ? new Date(node.lastSeen) : null, + metadata: node as unknown as Prisma.InputJsonValue, + }, + create: { + name: node.name, + ip: node.ip, + role: node.role, + user: node.user, + routes: node.routes, + status: node.status, + lastSeen: node.lastSeen ? new Date(node.lastSeen) : null, + metadata: node as unknown as Prisma.InputJsonValue, + }, + }))); + } + + private async listSnapshotNodes(): Promise { + const nodes = await this.prisma.tailscaleNode.findMany({ + orderBy: [{ status: 'asc' }, { name: 'asc' }], + }); + + return nodes.map((node) => ({ + id: node.id, + name: node.name, + ip: node.ip, + ips: getJsonStringArray(node.metadata, 'ips', node.ip ? [node.ip] : []), + role: node.role ?? '-', + user: node.user ?? '-', + routes: node.routes ?? '-', + status: node.status, + online: node.status === '활성', + lastSeen: node.lastSeen?.toISOString() ?? null, + })); + } +} + +function extractNodeRecords(body: unknown): HeadscaleRecord[] { + if (Array.isArray(body)) return body.filter(isRecord); + if (!isRecord(body)) return []; + + const candidates = [body.nodes, body.node, body.machines, body.machine]; + for (const candidate of candidates) { + if (Array.isArray(candidate)) return candidate.filter(isRecord); + } + return []; +} + +function normalizeHeadscaleNode(node: HeadscaleRecord): TailscaleNodeDto { + const id = getString(node.id) || getString(node.ID) || getString(node.nodeId) || getString(node.machineKey) || getString(node.name); + const name = getString(node.name) || getString(node.givenName) || getString(node.hostname) || `node-${id}`; + const ips = getStringArray(node.ipAddresses ?? node.ip_addresses ?? node.ips ?? node.IPAddresses); + const user = getUserName(node.user); + const routes = getRoutes(node); + const lastSeen = getDateString(node.lastSeen ?? node.last_seen ?? node.LastSeen); + const online = getBoolean(node.online ?? node.connected ?? node.isOnline ?? node.Connected); + + return { + id, + name, + ip: ips[0] ?? '-', + ips, + role: getRole(node), + user: user || '-', + routes: routes || '-', + lastSeen, + status: online ? '활성' : '오프라인', + online, + }; +} + +function getRole(node: HeadscaleRecord) { + const tags = getStringArray(node.forcedTags ?? node.validTags ?? node.tags ?? node.Tags); + if (tags.length) return tags.join(', '); + const routes = getRoutes(node); + return routes ? 'subnet-router' : 'node'; +} + +function getRoutes(node: HeadscaleRecord) { + const advertised = getStringArray(node.advertisedRoutes ?? node.approvedRoutes ?? node.subnetRoutes); + if (advertised.length) return advertised.join(', '); + + const routes = node.routes; + if (!Array.isArray(routes)) return ''; + + return routes + .map((route) => { + if (typeof route === 'string') return route; + if (!isRecord(route)) return ''; + return getString(route.prefix) || getString(route.route) || getString(route.ipPrefix); + }) + .filter(Boolean) + .join(', '); +} + +function getUserName(value: unknown) { + if (isRecord(value)) { + return getString(value.name) || getString(value.email) || getString(value.displayName); + } + return getString(value); +} + +function getJsonStringArray(value: Prisma.JsonValue | null, key: string, fallback: string[]) { + if (!isRecord(value)) return fallback; + const candidate = value[key]; + const values = getStringArray(candidate); + return values.length ? values : fallback; +} + +function getStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.map((item) => getString(item)).filter(Boolean); +} + +function getString(value: unknown) { + return typeof value === 'string' || typeof value === 'number' ? String(value) : ''; +} + +function getBoolean(value: unknown) { + return value === true || value === 'true' || value === 'online' || value === 'connected'; +} + +function getDateString(value: unknown) { + if (!value) return null; + if (typeof value === 'string') { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + if (typeof value === 'number') { + const date = new Date(value > 10_000_000_000 ? value : value * 1000); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); + } + return null; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +}