connect tailscale node status through platform gateway
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user