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 요청이 실패했습니다.', }; } } async listRoutes() { if (!this.gatewayUrl || !this.gatewayToken) { return { routes: [], source: 'platform-gateway', error: 'PLATFORM_GATEWAY_URL 또는 PLATFORM_GATEWAY_TOKEN이 설정되어 있지 않습니다.', }; } try { const body = await this.request('/api/admin/tailscale/routes'); const routes = isRecord(body) && Array.isArray(body.routes) ? body.routes : []; return { routes, source: 'platform-gateway', fetchedAt: new Date().toISOString(), }; } catch (error) { return { routes: [], source: 'platform-gateway', error: error instanceof Error ? error.message : 'Platform Gateway Tailscale Route API 요청이 실패했습니다.', }; } } async getAcl() { if (!this.gatewayUrl || !this.gatewayToken) { return { policy: { groups: {}, tagOwners: {}, acls: [] }, source: 'platform-gateway', error: 'PLATFORM_GATEWAY_URL 또는 PLATFORM_GATEWAY_TOKEN이 설정되어 있지 않습니다.', }; } try { const body = await this.request('/api/admin/tailscale/acl'); const policy = isRecord(body) && isRecord(body.policy) ? body.policy : { groups: {}, tagOwners: {}, acls: [] }; return { policy, source: 'platform-gateway', fetchedAt: new Date().toISOString(), }; } catch (error) { return { policy: { groups: {}, tagOwners: {}, acls: [] }, source: 'platform-gateway', error: error instanceof Error ? error.message : 'Platform Gateway Tailscale ACL 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); }