connect tailscale node status through platform gateway
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-08 16:39:20 +09:00
parent ec17bba92d
commit 52d53b0462
4 changed files with 262 additions and 0 deletions
+2
View File
@@ -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,
],
+13
View File
@@ -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();
}
}
+12
View File
@@ -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 {}
+235
View File
@@ -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<string, unknown>;
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<string>('PLATFORM_GATEWAY_URL', 'http://platform-gateway.studio-system.svc.cluster.local:4500')
.replace(/\/$/, '');
this.gatewayToken = this.configService.get<string>('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<unknown>('/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<T>(path: string): Promise<T> {
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<TailscaleNodeDto[]> {
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<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}