diff --git a/app/components/tags/TailscaleSSH.tsx b/app/components/tags/TailscaleSSH.tsx new file mode 100644 index 0000000..96262b9 --- /dev/null +++ b/app/components/tags/TailscaleSSH.tsx @@ -0,0 +1,21 @@ +import cn from '~/utils/cn'; +import Chip from '../Chip'; +import Tooltip from '../Tooltip'; + +export function TailscaleSSHTag() { + return ( + + + + This machine advertises Tailscale SSH, which allows you to authenticate + SSH credentials using your Tailscale account and via the Headplane web + UI. + + + ); +} diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index 285e7fb..0dba2d9 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -12,6 +12,7 @@ import { ExitNodeTag } from '~/components/tags/ExitNode'; import { ExpiryTag } from '~/components/tags/Expiry'; import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent'; import { SubnetTag } from '~/components/tags/Subnet'; +import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH'; import { PopulatedNode } from '~/utils/node-info'; import toast from '~/utils/toast'; import MenuOptions from './menu'; @@ -177,6 +178,10 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) { uiTags.push('subnet-approved'); } + if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) { + uiTags.push('tailscale-ssh'); + } + if (isAgent === true) { uiTags.push('headplane-agent'); } @@ -205,6 +210,9 @@ export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) { /> ); + case 'tailscale-ssh': + return ; + case 'headplane-agent': return ; diff --git a/app/routes/machines/components/menu.tsx b/app/routes/machines/components/menu.tsx index 167f92d..c3acf16 100644 --- a/app/routes/machines/components/menu.tsx +++ b/app/routes/machines/components/menu.tsx @@ -1,4 +1,4 @@ -import { Cog, Ellipsis } from 'lucide-react'; +import { Cog, Ellipsis, SquareTerminal } from 'lucide-react'; import { useState } from 'react'; import Menu from '~/components/Menu'; import type { User } from '~/types'; @@ -10,7 +10,6 @@ import Move from '../dialogs/move'; import Rename from '../dialogs/rename'; import Routes from '../dialogs/routes'; import Tags from '../dialogs/tags'; - interface MenuProps { node: PopulatedNode; users: User[]; @@ -29,6 +28,9 @@ export default function MachineMenu({ isDisabled, }: MenuProps) { const [modal, setModal] = useState(null); + const supportsTailscaleSSH = + node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0; + return ( <> {modal === 'remove' && ( @@ -90,21 +92,69 @@ export default function MachineMenu({ {isFullButton ? ( - - -

Machine Settings

-
+
+ {supportsTailscaleSSH ? ( + { + // We need to use JS to open the SSH URL + // in a new WINDOW since href can only + // do a new TAB. + window.open( + // TODO: Use the actual real username lol + `${__PREFIX__}/ssh?hostname=${node.name}&username=tale`, + '_blank', + 'noopener,noreferrer,width=800,height=600', + ); + }} + > + +

SSH

+
+ ) : undefined} + + +

Machine Settings

+
+
) : ( - - - +
+ {supportsTailscaleSSH ? ( + { + // We need to use JS to open the SSH URL + // in a new WINDOW since href can only + // do a new TAB. + window.open( + // TODO: Use the actual real username lol + `${__PREFIX__}/ssh?hostname=${node.name}&username=tale`, + '_blank', + 'noopener,noreferrer,width=800,height=600', + ); + }} + className={cn( + 'py-0.5 w-fit bg-transparent border-transparent', + 'border group-hover:border-headplane-200', + 'dark:group-hover:border-headplane-700', + 'opacity-0 pointer-events-none group-hover:opacity-100', + 'group-hover:pointer-events-auto', + )} + > + SSH + + ) : undefined} + + + +
)} setModal(key as Modal)} diff --git a/app/routes/machines/machine.tsx b/app/routes/machines/machine.tsx index ae3f892..962ff45 100644 --- a/app/routes/machines/machine.tsx +++ b/app/routes/machines/machine.tsx @@ -2,7 +2,6 @@ import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react'; import { useMemo, useState } from 'react'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import { Link as RemixLink, useLoaderData } from 'react-router'; -import { mapTag } from 'yaml/util'; import Attribute from '~/components/Attribute'; import Button from '~/components/Button'; import Card from '~/components/Card'; diff --git a/app/routes/ssh/console.tsx b/app/routes/ssh/console.tsx index 0741224..9588f9d 100644 --- a/app/routes/ssh/console.tsx +++ b/app/routes/ssh/console.tsx @@ -71,11 +71,8 @@ export async function loader({ const qp = new URL(request.url).searchParams; const username = qp.get('username') || undefined; const hostname = qp.get('hostname') || undefined; - const port = qp.get('port') - ? Number.parseInt(qp.get('port')!, 10) - : undefined; - if (!username || !hostname || !port) { - throw data('Missing required parameters: username, hostname, port', 400); + if (!username || !hostname) { + throw data('Missing required parameters: username, hostname', 400); } // TODO: Verify the Host headers to ensure CORS friendly // TODO: Check if the URL actually resolves correctly