Skip to content

Commit

Permalink
Revamp SSH command generator (#2543)
Browse files Browse the repository at this point in the history
Revamp SSH command generator using a Graph-based approach.

Also pilot dynamically-generated MDX. This is useful in #2017 

Closes #1751 (replaced)
  • Loading branch information
ben-z authored Mar 30, 2024
1 parent 8989921 commit eb7c527
Show file tree
Hide file tree
Showing 11 changed files with 391 additions and 177 deletions.
24 changes: 9 additions & 15 deletions components/machine-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,26 +168,20 @@ export function MachineCard({
<PopoverTrigger><HelpCircle className="ml-1 mr-1 h-3 w-3 text-muted-foreground" /></PopoverTrigger>
<PopoverContent side="top">
<p><Code>*.cluster.watonomous.ca</Code> hostnames resolve to internal IP addresses in the cluster. They are accessible only from within the cluster.</p>
<p><Code>*.watonomous.ca</Code> hostnames resolve to external IP addresses. They are accessible from anywhere. However, they may be behind the UWaterloo firewall. To access them, you may need to use a VPN or a bastion server.</p>
<p><Code>*.ext.watonomous.ca</Code> hostnames resolve to external IP addresses. They are accessible from anywhere. However, they may be behind the UWaterloo firewall. To access them, you may need to use a VPN or a bastion server.</p>
<p>There may be other hostnames that resolve to this machine. For example, mesh networks may have additional hostnames.</p>
</PopoverContent>
</Popover>
</dt>
<dd className="font-medium">
<ul className="list-none">
<TooltipProvider>
{machine.hostnames.length ? machine.hostnames.sort(hostnameSorter).map((hostname, index) => {
return (
<li key={index} className="my-0">
<Tooltip key={index}>
<TooltipTrigger className='text-start'><Link className="text-inherit decoration-dashed" href={`/docs/compute-cluster/ssh?hostname=${hostname}#command-generator`}>{hostname}</Link></TooltipTrigger>
<TooltipContent side="top">
<p className='font-normal'>Click to see SSH instructions for accessing <Code>{machine.name}</Code> via <Code>{hostname}</Code></p>
</TooltipContent>
</Tooltip>
</li>
)
}) : "None"}
</TooltipProvider>
{machine.hostnames.length ? machine.hostnames.sort(hostnameSorter).map((hostname, index) => {
return (
<li key={index} className="my-0">
{hostname}
</li>
)
}) : "None"}
</ul>
</dd>
</div>
Expand Down
213 changes: 61 additions & 152 deletions components/ssh-command-generator.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { useRouter } from 'next/router'
import { Pre, Code } from 'nextra/components'
import stripIndent from 'strip-indent';
Expand All @@ -12,81 +12,37 @@ import {
} from "@/components/ui/popover"
import { ComboBox } from '@/components/ui/combo-box'
import { Input } from "@/components/ui/input"
import { machineInfo } from '@/lib/data'
import { hostnameSorter } from '@/lib/wato-utils'
import { lookupStringMDX, sshInfo } from '@/lib/data'
import { htmlEncode } from '@/lib/wato-utils'

const DEFAULT_MACHINE_NAME = machineInfo.dev_vms[0].name
const ACCESSIBLE_MACHINES = Object.fromEntries([...machineInfo.dev_vms, ...machineInfo.bastions].map(m => [m.name, m]))
const BASTION_NAMES = machineInfo.bastions.map(m => m.name)
const ACCESSIBLE_MACHINE_LIST = Object.keys(ACCESSIBLE_MACHINES).map(m => ({value: m, label: m}))
const HOSTNAME_TO_MACHINE_NAME = Object.fromEntries(Object.entries(ACCESSIBLE_MACHINES).flatMap(([machineName, machine]) => machine.hostnames?.map(hostname => [hostname, machineName]) || []))
const ALL_ENTRYPOINTS: Map<string,string> = new Map(Object.entries({
"direct": "Direct",
"uw-vpn": "UW VPN",
"uw-campus": "UW Campus",
...Object.fromEntries(machineInfo.bastions.map(b => [b.name, b.name])),
}))

function getEntrypointToHostnamesMap(machineName: string, hostnames: string[]): Map<string, Set<string>> {
const ret: Map<string, Set<string>> = new Map()

// All bastions are to be accessed directly
if (BASTION_NAMES.includes(machineName)) {
ret.set('direct', new Set(hostnames))
return ret
}

for (const hostname of hostnames) {
if (hostname.endsWith(".ext.watonomous.ca")) {
ret.set('uw-vpn', ret.get('uw-vpn') || new Set())
ret.get('uw-vpn')!.add(hostname)
ret.set('uw-campus', ret.get('uw-campus') || new Set())
ret.get('uw-campus')!.add(hostname)
for (const bastion of machineInfo.bastions) {
ret.set(bastion.name, ret.get(bastion.name) || new Set())
ret.get(bastion.name)!.add(hostname)
}
} else if (hostname.endsWith(".cluster.watonomous.ca")) {
for (const bastion of machineInfo.bastions) {
ret.set(bastion.name, ret.get(bastion.name) || new Set())
ret.get(bastion.name)!.add(hostname)
}
const STRING_TO_MDX: Record<string, React.ReactElement> = {}
for (const { paths } of Object.values(sshInfo)) {
for (const { instructions } of paths) {
for (const instruction of instructions) {
STRING_TO_MDX[instruction] = React.createElement((await lookupStringMDX(instruction)).default)
}
}

return ret
}

export function SSHCommandGenerator() {
const router = useRouter()
const queryHostname = Array.isArray(router.query.hostname) ? router.query.hostname[0] : router.query.hostname || ""
const queryMachineName = Array.isArray(router.query.machinename)
? router.query.machinename[0]
: router.query.machinename || "";

const instructionsRef = useRef<HTMLDivElement>(null)
const machineNames = Object.keys(sshInfo) as (keyof typeof sshInfo)[]

const [_machineName, _setMachineName] = useState("")
const [_hostname, _setHostname] = useState("")
const [_entrypoint, _setEntrypoint] = useState("")
const [_machineName, setMachineName] = useState<keyof typeof sshInfo | "">("")
const [username, _setUsername] = useState("")
const [sshKeyPath, _setSSHKeyPath] = useState("")

const machineName = _machineName || HOSTNAME_TO_MACHINE_NAME[queryHostname] || DEFAULT_MACHINE_NAME
const machineHostnames = useMemo(() => ACCESSIBLE_MACHINES[machineName]?.hostnames?.toSorted(hostnameSorter) || [], [machineName])
const entrypointToHostnamesMap = useMemo(() => getEntrypointToHostnamesMap(machineName, machineHostnames), [machineName, machineHostnames])
const entrypoint = _entrypoint || entrypointToHostnamesMap.keys().next()?.value || ""
const hostname = entrypointToHostnamesMap.get(entrypoint)?.has(_hostname || queryHostname) ? (_hostname || queryHostname) : (entrypointToHostnamesMap.get(entrypoint)?.values().next()?.value || "")
const machineName: keyof typeof sshInfo =
_machineName ||
(machineNames.includes(queryMachineName as keyof typeof sshInfo)
? queryMachineName
: machineNames[0]) as keyof typeof sshInfo;

const entrypointOptions = [...entrypointToHostnamesMap.keys()].map(k => ({value: k, label: ALL_ENTRYPOINTS.get(k)!}))
const hostnameOptions = [...entrypointToHostnamesMap.get(entrypoint) || []].map(h => ({value: h, label: h}))

function setHostname(h: string) {
_setHostname(h)
}
function setEntrypoint(e: string) {
_setEntrypoint(e)
setHostname("")
}
function setMachineName(n: string) {
_setMachineName(n)
setEntrypoint("")
}
function setUsername(u: string) {
_setUsername(u)
localStorage.setItem("wato_ssh_command_generator_username", u)
Expand Down Expand Up @@ -121,104 +77,40 @@ export function SSHCommandGenerator() {
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps

const displaySSHKeyPath = sshKeyPath || "<ssh_key_path>"
const displayUsername = (username || "<username>").replace(/\$$/, "\\$")
const displayHostname = hostname || "<hostname>"
const displayUsername = (username || htmlEncode("<username>")).replace(/\$$/, "\\$")
const displaySSHKeyPath = sshKeyPath || htmlEncode("<ssh_key_path>")

let sshCommand = "";
if (["direct", "uw-vpn", "uw-campus"].includes(entrypoint)) {
let preamble = "";
if (entrypoint === "uw-vpn") {
preamble = stripIndent(`
# 1. Connect to the UW VPN:
# https://uwaterloo.ca/web-resources/resources/virtual-private-network-vpn
#
# 2. Run the following command to connect to ${machineName}:
`).trim() + "\n"
} else if (entrypoint === "uw-campus") {
preamble = stripIndent(`
# 1. Connect to the UW Campus network (e.g. using Eduroam)
#
# 2. Run the following command to connect to ${machineName}:
`).trim() + "\n"
}
sshCommand = preamble + stripIndent(`
ssh -v -i "${displaySSHKeyPath}" "${displayUsername}@${displayHostname}"
`).trim()
} else if (BASTION_NAMES.includes(entrypoint)) {
const jump_host = ACCESSIBLE_MACHINES[entrypoint]
const jump_host_hostname = jump_host.hostnames?.[0]
if (!jump_host_hostname) {
console.error(`Error generating SSH command! Jump host ${entrypoint} has no hostnames.`)
// Replace placeholders in instructions
useEffect(() => {
if (instructionsRef.current) {
const instructions = instructionsRef.current.querySelectorAll("code > span")
instructions.forEach((instruction) => {
if (!instruction.getAttribute("data-original-inner-html")) {
instruction.setAttribute("data-original-inner-html", instruction.innerHTML)
}
const originalInnerHTML = instruction.getAttribute("data-original-inner-html") || ''
instruction.innerHTML = originalInnerHTML
.replace(/__SSH_USER__/g, displayUsername)
.replace(/__SSH_KEY_PATH__/g, displaySSHKeyPath)
})
}
sshCommand = stripIndent(`
# Connect to ${machineName} via ${jump_host.name} (${jump_host_hostname})
ssh -v -o ProxyCommand="ssh -W %h:%p -i \\"${displaySSHKeyPath}\\" \\"${displayUsername}@${jump_host_hostname}\\"" -i "${displaySSHKeyPath}" "${displayUsername}@${displayHostname}"
`).trim()
} else {
sshCommand = stripIndent(`
# Error generating SSH command! Please report this issue to the WATO team.
# Debug info:
# machineName: ${machineName}
# hostname: ${hostname}
# entrypoint: ${entrypoint}
`).trim()
}
}, [displayUsername, displaySSHKeyPath, machineName])

return (
<>
<dl className="text-gray-900 divide-y divide-gray-200 dark:text-white dark:divide-gray-700 overflow-hidden">
<dt className="mb-1 mt-2 text-gray-500 dark:text-gray-400 border-none">Machine</dt>
<dd className='border-none'>
<ComboBox
options={ACCESSIBLE_MACHINE_LIST}
options={machineNames.map(m => ({value: m, label: m}))}
value={machineName}
setValue={setMachineName}
setValue={setMachineName as any}
selectPlaceholder="Select machine"
searchPlaceholder="Find machine..."
emptySearchResultText="No machines found"
allowDeselect={false}
/>
</dd>
<dt className="mb-1 mt-2 text-gray-500 dark:text-gray-400 border-none">
<Popover>
Entrypoint{<PopoverTrigger><HelpCircle className="ml-1 mr-1 h-3 w-3 text-muted-foreground" /></PopoverTrigger>}
<PopoverContent side="top">
The entrypoint determines how you connect to the machine.
</PopoverContent>
</Popover>
</dt>
<dd className='border-none'>
<ComboBox
options={entrypointOptions}
value={entrypoint}
setValue={setEntrypoint}
selectPlaceholder="Select entrypoint"
searchPlaceholder="Find entrypoint..."
emptySearchResultText="No entrypoints found"
allowDeselect={false}
/>
</dd>
<dt className="mb-1 mt-2 text-gray-500 dark:text-gray-400 border-none">
<Popover>
Hostname{<PopoverTrigger><HelpCircle className="ml-1 mr-1 h-3 w-3 text-muted-foreground" /></PopoverTrigger>}
<PopoverContent side="top">
A machine may be reachable via multiple hostnames.
</PopoverContent>
</Popover>
</dt>
<dd className='border-none'>
<ComboBox
options={hostnameOptions}
value={hostname}
setValue={setHostname}
selectPlaceholder="Select hostname"
searchPlaceholder="Find hostname..."
emptySearchResultText="No hostnames found"
popoverContentClassName="w-120"
allowDeselect={false}
/>
</dd>
<dt className="mb-1 mt-2 text-gray-500 dark:text-gray-400 border-none">
<Popover>
Compute Cluster Username{<PopoverTrigger><HelpCircle className="ml-1 mr-1 h-3 w-3 text-muted-foreground" /></PopoverTrigger>}
Expand Down Expand Up @@ -255,13 +147,30 @@ export function SSHCommandGenerator() {
autoComplete='off'
/>
</dd>
<dt className="mb-1 mt-2 text-gray-500 dark:text-gray-400 border-none">Generated SSH Command</dt>
<dd className='border-none'>
<Pre hasCopyCode>
<Code>{sshCommand}</Code>
</Pre>
</dd>
</dl>
<div className="mt-8">
<h4 className="text-xl font-semibold">Results</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Below are options for connecting to <Code>{machineName}</Code>.
Please choose the option that best fits your use case.
</p>
</div>
<div ref={instructionsRef}>
{
sshInfo[machineName].paths.map(({hops, instructions}) => (
<div key={hops.join(" -> ")} className="mt-8">
<h4 className="text-lg font-semibold">{hops.length === 1 ? "Direct Connection" : hops.join(" -> ")}</h4>
<ol className='list-decimal ltr:ml-6 rtl:mr-6 mt-6'>
{instructions.map((instruction, i) => (
<li key={i} className="my-2">
{STRING_TO_MDX[instruction]}
</li>
))}
</ol>
</div>
))
}
</div>
</>
)
}
11 changes: 11 additions & 0 deletions lib/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,18 @@ import { Convert as MachineInfoConvert } from '@/build/fixtures/machine-info'
export type { MachineInfo } from '@/build/fixtures/machine-info'
export const machineInfo = MachineInfoConvert.toMachineInfo(JSON.stringify(machineInfoJSON))

import sshInfoJSON from '@/build/fixtures/ssh-info.json'
import { Convert as SshInfoConvert } from '@/build/fixtures/ssh-info'
export type { SSHInfo } from '@/build/fixtures/ssh-info'
export const sshInfo = SshInfoConvert.toSSHInfo(JSON.stringify(sshInfoJSON))

import websiteConfigJSON from '@/build/fixtures/website-config.json'
import { Convert as WebsiteConfigConvert } from '@/build/fixtures/website-config'
export type { WebsiteConfig } from '@/build/fixtures/website-config'
export const websiteConfig = WebsiteConfigConvert.toWebsiteConfig(JSON.stringify(websiteConfigJSON))

import { hashCode } from './wato-utils'
export function lookupStringMDX(str: string) {
const basename = `${hashCode(str)}.mdx`
return import(`@/build/fixtures/strings/${basename}`)
}
26 changes: 26 additions & 0 deletions lib/wato-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,30 @@ export function hostnameSorter(a: string, b: string) {
const aIsClusterHostname = a.endsWith(".cluster.watonomous.ca")
const bIsClusterHostname = b.endsWith(".cluster.watonomous.ca")
return +bIsClusterHostname - +aIsClusterHostname
}

/**
* Returns a hash code from a string
* @param {String} str The string to hash.
* @return {Number} A 32bit integer
* @see http://werxltd.com/wp/2010/05/13/javascript-implementation-of-javas-string-hashcode-method/
* @see https://stackoverflow.com/a/8831937
*/
export function hashCode(str: string) {
let hash = 0;
for (let i = 0, len = str.length; i < len; i++) {
let chr = str.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0; // Convert to 32bit integer
}

// Convert to unsigned integer
// https://stackoverflow.com/a/47612303
return hash >>> 0;
}


// Derived from https://stackoverflow.com/a/18750001
export function htmlEncode(str: string) {
return str.replace(/[\u00A0-\u9999<>\&]/g, (i) => "&#" + i.charCodeAt(0) + ";");
}
9 changes: 5 additions & 4 deletions pages/docs/compute-cluster/ssh.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@ Choose your preferred machine and entrypoint[^entrypoint] below. Your personaliz
because the cluster is behind a [firewall](./firewall) and you cannot connect to it directly.

import { SSHCommandGenerator } from '@/components/ssh-command-generator'
import { Separator } from "@/components/ui/separator"

<Separator className="mt-6" />

<div className="mt-2">
<SSHCommandGenerator />
</div>

The above is your personalized SSH command. Copy and paste it into your terminal to connect to the cluster.
<Separator className="mt-6" />

The generated command does *not* require setting up ssh agent[^ssh-agent] or ssh config[^ssh-config].
The generated commands do *not* require setting up ssh agent[^ssh-agent] or ssh config[^ssh-config].
However, you may soon find that setting them up will make your life easier. If you are interested in learning more about these
tools, please check out [Tips and Tricks](#tips-and-tricks) and the official documentation linked in the footnotes.

Expand Down Expand Up @@ -143,6 +146,4 @@ ssh -v trpro-ubuntu2
{
// Separate footnotes from the main content
}
import { Separator } from "@/components/ui/separator"

<Separator className="mt-6" />
Loading

0 comments on commit eb7c527

Please sign in to comment.