@@ -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
@@ -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(),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user