From 8973598977e6f0fa29e608b19e3a445f80d59078 Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 8 Jul 2026 17:19:16 +0900 Subject: [PATCH] token modify --- src/config.ts | 4 + src/routes/admin.ts | 19 ++++- src/services/headscale-adapter.ts | 102 +++++++++++++++++++++++ src/services/headscale-node-discovery.ts | 60 ++++++++++++- 4 files changed, 181 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index a536d14..6d665c4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,10 @@ export const config = { lmsGatewayToken: process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '', launcherGatewayToken: process.env.LAUNCHER_GATEWAY_TOKEN ?? process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '', adminGatewayToken: process.env.ADMIN_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '', + adminGatewayTokens: [ + process.env.ADMIN_GATEWAY_TOKEN, + process.env.PLATFORM_GATEWAY_TOKEN, + ].map((token) => token?.trim()).filter((token): token is string => Boolean(token)), corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:4200,http://localhost:4300,http://localhost:4400') .split(',') .map((origin) => origin.trim()) diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 85789c6..aad2653 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,12 +1,13 @@ import { Hono } from 'hono' import { config } from '../config' +import { headscaleAdapter } from '../services/headscale-adapter' import { headscaleNodeDiscovery } from '../services/headscale-node-discovery' export const adminRoutes = new Hono() adminRoutes.use('/admin/*', async (c, next) => { - const token = c.req.header('x-platform-gateway-token') ?? '' - if (!config.adminGatewayToken || token !== config.adminGatewayToken) { + const token = c.req.header('x-platform-gateway-token')?.trim() ?? '' + if (!config.adminGatewayTokens.length || !config.adminGatewayTokens.includes(token)) { return c.json({ error: 'invalid_platform_gateway_token' }, 401) } return next() @@ -18,3 +19,17 @@ adminRoutes.get('/admin/tailscale/nodes', async (c) => { fetchedAt: new Date().toISOString(), }) }) + +adminRoutes.get('/admin/tailscale/routes', async (c) => { + return c.json({ + routes: await headscaleNodeDiscovery.listRouteStatus(), + fetchedAt: new Date().toISOString(), + }) +}) + +adminRoutes.get('/admin/tailscale/acl', async (c) => { + return c.json({ + policy: await headscaleAdapter.getPolicyStatus(), + fetchedAt: new Date().toISOString(), + }) +}) diff --git a/src/services/headscale-adapter.ts b/src/services/headscale-adapter.ts index 2061779..5cb9964 100644 --- a/src/services/headscale-adapter.ts +++ b/src/services/headscale-adapter.ts @@ -13,6 +13,20 @@ type HeadscalePolicy = { }> } +export type HeadscaleAclStatus = { + id: string + action: string + sources: string[] + destinations: string[] +} + +export type HeadscalePolicyStatus = { + groups: Record + tagOwners: Record + acls: HeadscaleAclStatus[] + raw: HeadscalePolicy | Record | null +} + const generatedPrefix = 'group:lms-platform-' export class HeadscaleAdapter { @@ -24,6 +38,35 @@ export class HeadscaleAdapter { return { revoked: true, mode: 'noop' } } + async getPolicyStatus(): Promise { + if (!config.headscaleApiKey) { + return { + groups: {}, + tagOwners: {}, + acls: [], + raw: null, + } + } + + const response = await this.fetchHeadscale('/api/v1/policy', { method: 'GET' }) + if (!response.ok) { + throw new Error(await response.text().catch(() => `policy_read_failed:${response.status}`)) + } + + const policy = this.normalizePolicy(await response.text().catch(() => null)) + return { + groups: policy.groups, + tagOwners: policy.tagOwners, + acls: policy.acls.map((acl, index) => ({ + id: `acl-${index}`, + action: acl.action, + sources: acl.src, + destinations: acl.dst, + })), + raw: policy, + } + } + async syncAssignments(assignments: AccessSyncAssignment[]): Promise<{ applied: boolean mode: 'headscale' @@ -106,6 +149,65 @@ export class HeadscaleAdapter { return { applied: false, error: lastError || 'policy_apply_failed' } } + private normalizePolicy(payload: unknown): HeadscalePolicy { + const parsed = this.parsePolicyPayload(payload) + const groups = parsed && typeof parsed.groups === 'object' && !Array.isArray(parsed.groups) + ? this.normalizeStringArrayRecord(parsed.groups as Record) + : {} + const tagOwners = parsed && typeof parsed.tagOwners === 'object' && !Array.isArray(parsed.tagOwners) + ? this.normalizeStringArrayRecord(parsed.tagOwners as Record) + : {} + const aclPayload = Array.isArray(parsed?.acls) ? parsed.acls : [] + + return { + groups, + tagOwners, + acls: aclPayload.map((acl) => { + const record = acl && typeof acl === 'object' ? acl as Record : {} + return { + action: this.readString(record, 'action') === 'accept' ? 'accept' : 'accept', + src: this.normalizeStringArray(record.src), + dst: this.normalizeStringArray(record.dst), + } + }), + } + } + + private parsePolicyPayload(payload: unknown): Record | null { + if (!payload) return null + if (typeof payload === 'string') { + return this.parsePolicyText(payload) + } + if (typeof payload !== 'object') return null + + const record = payload as Record + for (const key of ['policy', 'aclPolicy', 'policyText']) { + const value = record[key] + if (typeof value === 'string') return this.parsePolicyText(value) + if (value && typeof value === 'object') return value as Record + } + return record + } + + private parsePolicyText(value: string): Record | null { + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' ? parsed as Record : null + } catch { + return null + } + } + + private normalizeStringArrayRecord(record: Record): Record { + return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, this.normalizeStringArray(value)])) + } + + private normalizeStringArray(value: unknown): string[] { + if (typeof value === 'string') return [value] + if (!Array.isArray(value)) return [] + return value.map((entry) => String(entry)).filter(Boolean) + } + private async listNodes(): Promise { if (!config.headscaleApiKey) return [] diff --git a/src/services/headscale-node-discovery.ts b/src/services/headscale-node-discovery.ts index 8ec40a1..c8ea43b 100644 --- a/src/services/headscale-node-discovery.ts +++ b/src/services/headscale-node-discovery.ts @@ -38,6 +38,18 @@ export type HeadscaleNodeStatus = { lastSeenAt: string | null } +export type HeadscaleRouteStatus = { + id: string + nodeId: string + nodeName: string + user: string + route: string + approved: boolean + advertised: boolean + connected: boolean + lastSeenAt: string | null +} + class HeadscaleNodeDiscovery { async listNodesStatus(): Promise { const nodes = await this.listNodes() @@ -59,6 +71,32 @@ class HeadscaleNodeDiscovery { }) } + async listRouteStatus(): Promise { + const nodes = await this.listNodes() + return nodes.flatMap((node) => { + const nodeId = this.readString(node, 'id') || this.readString(node, 'ID') || this.readString(node, 'nodeId') || this.readString(node, 'node_id') + const nodeName = this.readString(node, 'name') || this.readString(node, 'givenName') || this.readString(node, 'given_name') || this.readString(node, 'hostname') || nodeId || '-' + const user = this.extractUserName(node) || '-' + const connected = this.readBoolean(node, 'online') || this.readBoolean(node, 'connected') || this.readString(node, 'connected').toLowerCase() === 'online' + const lastSeenAt = this.extractLastSeenAt(node) + const approved = new Set(this.extractApprovedRoutes(node)) + const advertised = new Set(this.extractAdvertisedRoutes(node)) + const routes = [...new Set([...approved, ...advertised])] + + return routes.map((route) => ({ + id: `${nodeId || nodeName}-${route}`, + nodeId, + nodeName, + user, + route, + approved: approved.has(route), + advertised: advertised.has(route) || !advertised.size, + connected, + lastSeenAt, + })) + }) + } + async listTeachingAgents(accountEmail?: string): Promise { const nodes = await this.listNodes().catch(() => []) const agents = nodes @@ -247,11 +285,29 @@ class HeadscaleNodeDiscovery { } private extractRoutes(node: HeadscaleNode): string[] { - const direct = [ + return [...new Set([...this.extractAdvertisedRoutes(node), ...this.extractApprovedRoutes(node)])] + } + + private extractAdvertisedRoutes(node: HeadscaleNode): string[] { + return this.extractRouteValues([ node.advertisedRoutes, - node.approvedRoutes, + node.availableRoutes, node.subnetRoutes, node.routes, + ]) + } + + private extractApprovedRoutes(node: HeadscaleNode): string[] { + return this.extractRouteValues([ + node.approvedRoutes, + node.enabledRoutes, + node.allowedRoutes, + ]) + } + + private extractRouteValues(values: unknown[]): string[] { + const direct = [ + ...values, ] for (const value of direct) {