From eda8ed0b3e921dfcf2265eaa47765931bfa860d0 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Fri, 16 May 2025 11:05:20 -0400 Subject: [PATCH] feat: support 0.26+ routes only --- app/routes/machines/dialogs/routes.tsx | 10 ++--- app/routes/machines/machine-actions.ts | 38 ++++++++++++++--- app/routes/machines/machine.tsx | 16 +++----- app/routes/machines/overview.tsx | 12 ++---- app/types/Machine.ts | 5 +++ app/utils/node-info.ts | 56 ++++++++++++-------------- compose.yaml | 3 +- 7 files changed, 78 insertions(+), 62 deletions(-) diff --git a/app/routes/machines/dialogs/routes.tsx b/app/routes/machines/dialogs/routes.tsx index 7376475..0fbfa29 100644 --- a/app/routes/machines/dialogs/routes.tsx +++ b/app/routes/machines/dialogs/routes.tsx @@ -46,16 +46,16 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) { ) : undefined} {subnets.map((route) => ( - -

{route.prefix}

+ +

{route}

{ const form = new FormData(); form.set('action_id', 'update_routes'); form.set('node_id', node.id); - form.set('routes', [route.id].join(',')); + form.set('routes', [route].join(',')); form.set('enabled', String(checked)); fetcher.submit(form, { @@ -95,7 +95,7 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) { form.set( 'routes', node.customRouting.exitRoutes - .map((route) => route.id) + .map((route) => route) .join(','), ); diff --git a/app/routes/machines/machine-actions.ts b/app/routes/machines/machine-actions.ts index 1387bff..b0d3377 100644 --- a/app/routes/machines/machine-actions.ts +++ b/app/routes/machines/machine-actions.ts @@ -188,6 +188,12 @@ async function updateRoutes( nodeId: string, context: LoadContext, ) { + const { node } = await context.client.get<{ node: Machine }>( + `v1/node/${nodeId}`, + apiKey, + ); + + const newApproved = node.approvedRoutes; const routes = formData.get('routes')?.toString(); if (!routes) { throw data('Missing `routes` in the form data.', { @@ -209,12 +215,32 @@ async function updateRoutes( }); } - const postfix = enabled === 'true' ? 'enable' : 'disable'; - await Promise.all( - allRoutes.map(async (route) => { - await context.client.post(`v1/routes/${route}/${postfix}`, apiKey); - }), - ); + if (enabled === 'true') { + for (const route of allRoutes) { + // If already approved, skip, otherwise add to approved + if (newApproved.includes(route)) { + continue; + } + + newApproved.push(route); + } + } else { + for (const route of allRoutes) { + // If not approved, skip, otherwise remove from approved + if (!newApproved.includes(route)) { + continue; + } + + const index = newApproved.indexOf(route); + if (index > -1) { + newApproved.splice(index, 1); + } + } + } + + await context.client.post(`v1/node/${nodeId}/approve_routes`, apiKey, { + routes: newApproved, + }); return { message: 'Routes updated' }; } diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index d37537e..9ef6cb8 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -11,7 +11,7 @@ import Link from '~/components/Link'; import StatusCircle from '~/components/StatusCircle'; import Tooltip from '~/components/Tooltip'; import type { LoadContext } from '~/server'; -import type { Machine, Route, User } from '~/types'; +import type { Machine, User } from '~/types'; import cn from '~/utils/cn'; import { getOSInfo, getTSVersion } from '~/utils/host-info'; import { mapNodes } from '~/utils/node-info'; @@ -37,20 +37,16 @@ export async function loader({ } } - const [machine, { routes }, { users }] = await Promise.all([ + const [machine, { users }] = await Promise.all([ context.client.get<{ node: Machine }>( `v1/node/${params.id}`, session.get('api_key')!, ), - context.client.get<{ routes: Route[] }>( - 'v1/routes', - session.get('api_key')!, - ), context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!), ]); - const [node] = mapNodes([machine.node], routes); - const lookup = await context.agents?.lookup([node.nodeKey]); + const lookup = await context.agents?.lookup([machine.node.nodeKey]); + const [node] = mapNodes([machine.node], lookup); return { node, @@ -160,7 +156,7 @@ export default function Page() { ) : (
    {node.customRouting.subnetApprovedRoutes.map((route) => ( -
  • {route.prefix}
  • +
  • {route}
  • ))}
)} @@ -192,7 +188,7 @@ export default function Page() { ) : (
    {node.customRouting.subnetWaitingRoutes.map((route) => ( -
  • {route.prefix}
  • +
  • {route}
  • ))}
)} diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index c12ab8e..d412b72 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -2,12 +2,11 @@ import { InfoIcon } from '@primer/octicons-react'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import { useLoaderData } from 'react-router'; import Code from '~/components/Code'; -import { ErrorPopup } from '~/components/Error'; import Link from '~/components/Link'; import Tooltip from '~/components/Tooltip'; import type { LoadContext } from '~/server'; import { Capabilities } from '~/server/web/roles'; -import type { Machine, Route, User } from '~/types'; +import type { Machine, User } from '~/types'; import cn from '~/utils/cn'; import { mapNodes } from '~/utils/node-info'; import MachineRow from './components/machine-row'; @@ -41,15 +40,11 @@ export async function loader({ Capabilities.write_machines, ); - const [{ nodes }, { routes }, { users }] = await Promise.all([ + const [{ nodes }, { users }] = await Promise.all([ context.client.get<{ nodes: Machine[] }>( 'v1/node', session.get('api_key')!, ), - context.client.get<{ routes: Route[] }>( - 'v1/routes', - session.get('api_key')!, - ), context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!), ]); @@ -61,12 +56,11 @@ export async function loader({ } const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey)); - const populatedNodes = mapNodes(nodes, routes, stats); + const populatedNodes = mapNodes(nodes, stats); return { populatedNodes, nodes, - routes, users, magic, server: context.config.headscale.url, diff --git a/app/types/Machine.ts b/app/types/Machine.ts index e1d05eb..de9b929 100644 --- a/app/types/Machine.ts +++ b/app/types/Machine.ts @@ -26,4 +26,9 @@ export interface Machine { validTags: string[]; givenName: string; online: boolean; + + // Added in Headscale 0.26+ + approvedRoutes: string[]; + availableRoutes: string[]; + subnetRoutes: string[]; } diff --git a/app/utils/node-info.ts b/app/utils/node-info.ts index 2e60279..01be3aa 100644 --- a/app/utils/node-info.ts +++ b/app/utils/node-info.ts @@ -1,52 +1,46 @@ import { HostInfo, Machine, Route } from '~/types'; export interface PopulatedNode extends Machine { - routes: Route[]; + routes: string[]; hostInfo?: HostInfo; expired: boolean; customRouting: { - exitRoutes: Route[]; + exitRoutes: string[]; exitApproved: boolean; - subnetApprovedRoutes: Route[]; - subnetWaitingRoutes: Route[]; + subnetApprovedRoutes: string[]; + subnetWaitingRoutes: string[]; }; } export function mapNodes( nodes: Machine[], - routes: Route[], stats?: Record | undefined, ): PopulatedNode[] { return nodes.map((node) => { - const nodeRoutes = routes.filter((route) => route.node.id === node.id); - const customRouting = nodeRoutes.reduce( - (acc, route) => { - if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') { - acc.exitRoutes.push(route); - if (route.enabled) { - acc.exitApproved = true; - } - } else { - if (route.enabled) { - acc.subnetApprovedRoutes.push(route); - } else { - acc.subnetWaitingRoutes.push(route); - } - } - - return acc; - }, - { - exitRoutes: [], - exitApproved: false, - subnetApprovedRoutes: [], - subnetWaitingRoutes: [], - }, - ); + const customRouting = { + exitRoutes: node.availableRoutes.filter( + (route) => route === '::/0' || route === '0.0.0.0/0', + ), + exitApproved: node.approvedRoutes.some( + (route) => route === '::/0' || route === '0.0.0.0/0', + ), + subnetApprovedRoutes: node.approvedRoutes.filter( + (route) => + route !== '::/0' && + route !== '0.0.0.0/0' && + node.availableRoutes.includes(route), + ), + subnetWaitingRoutes: node.availableRoutes.filter( + (route) => + route !== '::/0' && + route !== '0.0.0.0/0' && + !node.approvedRoutes.includes(route), + ), + } satisfies PopulatedNode['customRouting']; return { ...node, - routes: nodeRoutes, + routes: Array.from(new Set(node.availableRoutes)), hostInfo: stats?.[node.nodeKey], customRouting, expired: diff --git a/compose.yaml b/compose.yaml index 8314de0..439515b 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,7 +8,7 @@ networks: driver: "bridge" services: headscale: - image: "headscale/headscale:0.25.1" + image: "headscale/headscale:0.26.0-debug" container_name: "headscale" labels: me.tale.headplane.target: headscale @@ -21,5 +21,6 @@ services: - "./test:/etc/headscale" ports: - "8080:8080" + - "9090:9090" environment: TZ: "America/New_York"