@@ -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