feat: support 0.26+ routes only

This commit is contained in:
Aarnav Tale
2025-05-16 11:05:20 -04:00
parent 59b32c0842
commit eda8ed0b3e
7 changed files with 78 additions and 62 deletions
+5 -5
View File
@@ -46,16 +46,16 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
</TableList.Item>
) : undefined}
{subnets.map((route) => (
<TableList.Item key={route.id}>
<p>{route.prefix}</p>
<TableList.Item key={route}>
<p>{route}</p>
<Switch
defaultSelected={route.enabled}
defaultSelected={node.approvedRoutes.includes(route)}
label="Enabled"
onChange={(checked) => {
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(','),
);
+32 -6
View File
@@ -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' };
}
+6 -10
View File
@@ -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() {
) : (
<ul className="leading-normal">
{node.customRouting.subnetApprovedRoutes.map((route) => (
<li key={route.id}>{route.prefix}</li>
<li key={route}>{route}</li>
))}
</ul>
)}
@@ -192,7 +188,7 @@ export default function Page() {
) : (
<ul className="leading-normal">
{node.customRouting.subnetWaitingRoutes.map((route) => (
<li key={route.id}>{route.prefix}</li>
<li key={route}>{route}</li>
))}
</ul>
)}
+3 -9
View File
@@ -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,
+5
View File
@@ -26,4 +26,9 @@ export interface Machine {
validTags: string[];
givenName: string;
online: boolean;
// Added in Headscale 0.26+
approvedRoutes: string[];
availableRoutes: string[];
subnetRoutes: string[];
}
+25 -31
View File
@@ -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<string, HostInfo> | undefined,
): PopulatedNode[] {
return nodes.map((node) => {
const nodeRoutes = routes.filter((route) => route.node.id === node.id);
const customRouting = nodeRoutes.reduce<PopulatedNode['customRouting']>(
(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:
+2 -1
View File
@@ -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"