@@ -23,6 +23,10 @@ export const config = {
|
|||||||
lmsGatewayToken: process.env.LMS_GATEWAY_TOKEN ?? process.env.PLATFORM_GATEWAY_TOKEN ?? '',
|
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 ?? '',
|
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 ?? '',
|
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')
|
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(',')
|
.split(',')
|
||||||
.map((origin) => origin.trim())
|
.map((origin) => origin.trim())
|
||||||
|
|||||||
+17
-2
@@ -1,12 +1,13 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { config } from '../config'
|
import { config } from '../config'
|
||||||
|
import { headscaleAdapter } from '../services/headscale-adapter'
|
||||||
import { headscaleNodeDiscovery } from '../services/headscale-node-discovery'
|
import { headscaleNodeDiscovery } from '../services/headscale-node-discovery'
|
||||||
|
|
||||||
export const adminRoutes = new Hono()
|
export const adminRoutes = new Hono()
|
||||||
|
|
||||||
adminRoutes.use('/admin/*', async (c, next) => {
|
adminRoutes.use('/admin/*', async (c, next) => {
|
||||||
const token = c.req.header('x-platform-gateway-token') ?? ''
|
const token = c.req.header('x-platform-gateway-token')?.trim() ?? ''
|
||||||
if (!config.adminGatewayToken || token !== config.adminGatewayToken) {
|
if (!config.adminGatewayTokens.length || !config.adminGatewayTokens.includes(token)) {
|
||||||
return c.json({ error: 'invalid_platform_gateway_token' }, 401)
|
return c.json({ error: 'invalid_platform_gateway_token' }, 401)
|
||||||
}
|
}
|
||||||
return next()
|
return next()
|
||||||
@@ -18,3 +19,17 @@ adminRoutes.get('/admin/tailscale/nodes', async (c) => {
|
|||||||
fetchedAt: new Date().toISOString(),
|
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-'
|
const generatedPrefix = 'group:lms-platform-'
|
||||||
|
|
||||||
export class HeadscaleAdapter {
|
export class HeadscaleAdapter {
|
||||||
@@ -24,6 +38,35 @@ export class HeadscaleAdapter {
|
|||||||
return { revoked: true, mode: 'noop' }
|
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<{
|
async syncAssignments(assignments: AccessSyncAssignment[]): Promise<{
|
||||||
applied: boolean
|
applied: boolean
|
||||||
mode: 'headscale'
|
mode: 'headscale'
|
||||||
@@ -106,6 +149,65 @@ export class HeadscaleAdapter {
|
|||||||
return { applied: false, error: lastError || 'policy_apply_failed' }
|
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[]> {
|
private async listNodes(): Promise<HeadscaleNode[]> {
|
||||||
if (!config.headscaleApiKey) return []
|
if (!config.headscaleApiKey) return []
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ export type HeadscaleNodeStatus = {
|
|||||||
lastSeenAt: string | null
|
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 {
|
class HeadscaleNodeDiscovery {
|
||||||
async listNodesStatus(): Promise<HeadscaleNodeStatus[]> {
|
async listNodesStatus(): Promise<HeadscaleNodeStatus[]> {
|
||||||
const nodes = await this.listNodes()
|
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[]> {
|
async listTeachingAgents(accountEmail?: string): Promise<TeachingAgentNode[]> {
|
||||||
const nodes = await this.listNodes().catch(() => [])
|
const nodes = await this.listNodes().catch(() => [])
|
||||||
const agents = nodes
|
const agents = nodes
|
||||||
@@ -247,11 +285,29 @@ class HeadscaleNodeDiscovery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractRoutes(node: HeadscaleNode): string[] {
|
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.advertisedRoutes,
|
||||||
node.approvedRoutes,
|
node.availableRoutes,
|
||||||
node.subnetRoutes,
|
node.subnetRoutes,
|
||||||
node.routes,
|
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) {
|
for (const value of direct) {
|
||||||
|
|||||||
Reference in New Issue
Block a user