diff --git a/app/routes/_data.machines._index/action.tsx b/app/routes/_data.machines._index/action.tsx index 90ba1db..2053760 100644 --- a/app/routes/_data.machines._index/action.tsx +++ b/app/routes/_data.machines._index/action.tsx @@ -62,6 +62,24 @@ export async function menuAction(request: ActionFunctionArgs['request']) { return json({ message: 'Route updated' }) } + case 'exit-node': { + if (!data.has('routes') || !data.has('enabled')) { + return json({ message: 'No route or enabled provided' }, { + status: 400, + }) + } + + const routes = data.get('routes')?.toString().split(',') ?? [] + const enabled = data.get('enabled') === 'true' + const postfix = enabled ? 'enable' : 'disable' + + await Promise.all(routes.map(async (route) => { + await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!) + })) + + return json({ message: 'Exit node updated' }) + } + case 'move': { if (!data.has('to')) { return json({ message: 'No destination provided' }, { diff --git a/app/routes/_data.machines._index/dialogs/routes.tsx b/app/routes/_data.machines._index/dialogs/routes.tsx index 08ecdf2..a0f7fe1 100644 --- a/app/routes/_data.machines._index/dialogs/routes.tsx +++ b/app/routes/_data.machines._index/dialogs/routes.tsx @@ -1,9 +1,10 @@ import { useFetcher } from '@remix-run/react' -import { type Dispatch, type SetStateAction } from 'react' +import { Dispatch, SetStateAction, useMemo } from 'react' import Dialog from '~/components/Dialog' import Switch from '~/components/Switch' -import { type Machine, type Route } from '~/types' +import Link from '~/components/Link' +import { Machine, Route } from '~/types' import { cn } from '~/utils/cn' interface RoutesProps { @@ -16,6 +17,22 @@ interface RoutesProps { export default function Routes({ machine, routes, state }: RoutesProps) { const fetcher = useFetcher() + // This is much easier with Object.groupBy but it's too new for us + const { exit, subnet } = routes.reduce((acc, route) => { + if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') { + acc.exit.push(route) + return acc + } + + acc.subnet.push(route) + return acc + }, { exit: [], subnet: [] }) + + const exitEnabled = useMemo(() => { + if (exit.length !== 2) return false + return exit[0].enabled && exit[1].enabled + }, [exit]) + return ( @@ -26,9 +43,19 @@ export default function Routes({ machine, routes, state }: RoutesProps) { {' '} {machine.givenName} + + Subnet routes + Connect to devices you can't install Tailscale on by advertising IP ranges as subnet routes. + {' '} + + Learn More +
- {routes.length === 0 + {subnet.length === 0 ? (
) : undefined} - {routes.map(route => ( + {subnet.map(route => (
))}
+ + Exit nodes + + + Allow your network to route internet traffic through this machine. + {' '} + + Learn More + + +
+ {exit.length === 0 + ? ( +
+

+ This machine is not an exit node. +

+
+ ) : ( +
+

+ Use as exit node +

+ { + const form = new FormData() + form.set('id', machine.id) + form.set('_method', 'exit-node') + form.set('routes', exit.map(route => route.id).join(',')) + + form.set('enabled', String(checked)) + fetcher.submit(form, { + method: 'POST', + }) + }} + /> +
+ )} +