token modify
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-08 17:19:16 +09:00
parent a252397023
commit 8973598977
4 changed files with 181 additions and 4 deletions
+4
View File
@@ -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())
+17 -2
View File
@@ -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(),
})
})
+102
View File
@@ -13,6 +13,20 @@ type HeadscalePolicy = {
}>
}
export type HeadscaleAclStatus = {
id: string
action: string
sources: string[]
destinations: string[]
}
export type HeadscalePolicyStatus = {
groups: Record<string, string[]>
tagOwners: Record<string, string[]>
acls: HeadscaleAclStatus[]
raw: HeadscalePolicy | Record<string, unknown> | null
}
const generatedPrefix = 'group:lms-platform-'
export class HeadscaleAdapter {
@@ -24,6 +38,35 @@ export class HeadscaleAdapter {
return { revoked: true, mode: 'noop' }
}
async getPolicyStatus(): Promise<HeadscalePolicyStatus> {
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<string, unknown>)
: {}
const tagOwners = parsed && typeof parsed.tagOwners === 'object' && !Array.isArray(parsed.tagOwners)
? this.normalizeStringArrayRecord(parsed.tagOwners as Record<string, unknown>)
: {}
const aclPayload = Array.isArray(parsed?.acls) ? parsed.acls : []
return {
groups,
tagOwners,
acls: aclPayload.map((acl) => {
const record = acl && typeof acl === 'object' ? acl as Record<string, unknown> : {}
return {
action: this.readString(record, 'action') === 'accept' ? 'accept' : 'accept',
src: this.normalizeStringArray(record.src),
dst: this.normalizeStringArray(record.dst),
}
}),
}
}
private parsePolicyPayload(payload: unknown): Record<string, unknown> | null {
if (!payload) return null
if (typeof payload === 'string') {
return this.parsePolicyText(payload)
}
if (typeof payload !== 'object') return null
const record = payload as Record<string, unknown>
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<string, unknown>
}
return record
}
private parsePolicyText(value: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' ? parsed as Record<string, unknown> : null
} catch {
return null
}
}
private normalizeStringArrayRecord(record: Record<string, unknown>): Record<string, string[]> {
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<HeadscaleNode[]> {
if (!config.headscaleApiKey) return []
+58 -2
View File
@@ -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<HeadscaleNodeStatus[]> {
const nodes = await this.listNodes()
@@ -59,6 +71,32 @@ class HeadscaleNodeDiscovery {
})
}
async listRouteStatus(): Promise<HeadscaleRouteStatus[]> {
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<TeachingAgentNode[]> {
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) {