web/src/lib/SshConnect.svelte
Ref: Size: 9.5 KiB History
<script lang="ts">
// The connect recipes for one VM, with this plane's real values in them.
// The one-shot command is the one docs/connecting.md prints — the gate hop
// rides an explicit ProxyCommand rather than -J because command-line -o
// options reach only the final hop, and BOTH hops must verify the host
// certificate they are presented. The same argv shape is what
// internal/cli/sshcmd.go execs. The ~/.ssh/config block below is this
// component's own: the same two hops and the same verification, spelled as
// stanzas.
import { copyText } from '$lib/fleet.svelte';
let { vmName, tenant, gate }: { vmName: string; tenant: string; gate: string } = $props();
// splitGate parses the published gate address — host:port, IPv6 bracketed,
// which is what net.JoinHostPort emits — into the two pieces the recipes
// need apart: the ProxyCommand wants the port as -p, and ~/.ssh/config
// wants it on its own Port line. A bare IPv6 literal (colons, no brackets)
// is a host, not a host:port, so the last colon is not a port separator.
function splitGate(addr: string): { host: string; port: string } {
if (addr.startsWith('[')) {
const end = addr.indexOf(']');
if (end !== -1) {
const rest = addr.slice(end + 1);
return { host: addr.slice(1, end), port: rest.slice(1) || '22' };
}
}
const i = addr.indexOf(':');
if (i === -1 || addr.indexOf(':', i + 1) !== -1) return { host: addr, port: '22' };
return { host: addr.slice(0, i), port: addr.slice(i + 1) || '22' };
}
const gateHost = $derived(splitGate(gate).host);
const gatePort = $derived(splitGate(gate).port);
// ProxyJump takes one word, so an IPv6 gate keeps its brackets there —
// without them the last hextet reads as a port.
const jumpTarget = $derived(
gateHost.includes(':') ? `[${gateHost}]:${gatePort}` : `${gateHost}:${gatePort}`
);
// The connect name is the VM host certificate's one principal, so it is
// what the final hop must dial — a bare name would fail verification.
const connectName = $derived(`${tenant}.${vmName}`);
// The console is served same-origin with the API it fronts, so the origin
// in the browser's address bar is the URL the CA pin is fetched from.
const apiOrigin = $derived(typeof window === 'undefined' ? '' : window.location.origin);
// $KH is quoted twice over. The outer shell expands it into the
// ProxyCommand string, and ssh runs that string through a shell of its own
// — so the single quotes are what reach the inner shell and keep a $HOME
// with a space in it one word. Same reasoning as shq() in
// internal/cli/sshcmd.go, which single-quotes the path for the same hop.
const oneShot = $derived(`KH=~/.ssh/eitri_known_hosts
ssh \\
-o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile='$KH' -p ${gatePort} ubuntu@${gateHost}" \\
-o StrictHostKeyChecking=yes \\
-o "UserKnownHostsFile=$KH" \\
ubuntu@${connectName}`);
// Two exact names, no patterns. The first IS the gate's own name, because
// ssh verifies the host certificate against the name it dialed and the
// gate's certificate carries exactly that one principal. The second is this
// VM and only this VM: a `${tenant}.*` pattern would quietly claim every
// name in the tenant, including the gate's were it ever named one, and
// every VM's page prints its own stanza anyway.
const sshConfig = $derived(`Host ${gateHost}
Port ${gatePort}
User ubuntu
StrictHostKeyChecking yes
UserKnownHostsFile ~/.ssh/eitri_known_hosts
Host ${connectName}
ProxyJump ${jumpTarget}
User ubuntu
StrictHostKeyChecking yes
UserKnownHostsFile ~/.ssh/eitri_known_hosts`);
const pinCA = $derived(`curl -sS "${apiOrigin}/api/v1/ssh-ca" | jq -r .ca \\
| sed 's/^/@cert-authority * /' > ~/.ssh/eitri_known_hosts`);
const signKey = `ssh-keygen -s ~/.ssh/eitri_user_ca -I "$(whoami)@$(hostname)" \\
-n ubuntu -V +30m ~/.ssh/id_ed25519.pub`;
// copied names the block whose button last succeeded, so each button
// reports for itself.
let copied = $state('');
let blocks = $state<Record<string, HTMLPreElement | null>>({});
async function copy(key: string, text: string) {
copied = (await copyText(text, blocks[key], 'command')) ? key : '';
}
// The recipe is one word on the page until it is wanted: a VM's own facts
// come first, and the way in is three tabs behind them. Closed is the
// default — anyone already set up needs none of this.
let open = $state(false);
// The one-shot command leads: it is the shortest path from this page to a
// shell, and the other two are what you reach for once or not at all.
const tabs = [
{ id: 'oneshot', label: 'ssh' },
{ id: 'config', label: '~/.ssh/config' },
{ id: 'prereq', label: 'prerequisites' }
];
let tab = $state('oneshot');
let tabEls = $state<Record<string, HTMLButtonElement | null>>({});
// A tab strip is one stop on the tab key, and the arrows move within it —
// which is why only the selected tab is tabbable. Home and End go to the
// ends, as they do in every other tab strip.
function onTabKey(e: KeyboardEvent) {
const i = tabs.findIndex((t) => t.id === tab);
let j = i;
if (e.key === 'ArrowRight') j = (i + 1) % tabs.length;
else if (e.key === 'ArrowLeft') j = (i - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') j = 0;
else if (e.key === 'End') j = tabs.length - 1;
else return;
e.preventDefault();
tab = tabs[j].id;
tabEls[tabs[j].id]?.focus();
}
</script>
{#snippet block(key: string, text: string)}
<div class="block">
<pre bind:this={blocks[key]}>{text}</pre>
<button type="button" class="ghost" onclick={() => copy(key, text)}>
{copied === key ? 'Copied' : 'Copy'}
</button>
</div>
{/snippet}
<div class="connect">
<h3>
<button
type="button"
class="ghost"
aria-expanded={open}
aria-controls="connect-panel"
onclick={() => (open = !open)}
>
{open ? '▾' : '▸'} Connect
</button>
</h3>
<div id="connect-panel" hidden={!open}>
<p class="hint">
You log in as <code>ubuntu</code>. Both hops present a certificate signed by eitri's host CA,
and both are verified against it.
</p>
<div class="tabs" role="tablist" aria-label="connect recipes">
{#each tabs as t (t.id)}
<button
type="button"
role="tab"
id="tab-{t.id}"
class:on={tab === t.id}
aria-selected={tab === t.id}
aria-controls="panel-{t.id}"
tabindex={tab === t.id ? 0 : -1}
bind:this={tabEls[t.id]}
onclick={() => (tab = t.id)}
onkeydown={onTabKey}
>
{t.label}
</button>
{/each}
</div>
<div id="panel-oneshot" role="tabpanel" aria-labelledby="tab-oneshot" hidden={tab !== 'oneshot'}>
{@render block('oneshot', oneShot)}
</div>
<div id="panel-config" role="tabpanel" aria-labelledby="tab-config" hidden={tab !== 'config'}>
<p class="hint">
With this in <code>~/.ssh/config</code>, the whole thing collapses to
<code>ssh {connectName}</code>.
</p>
{@render block('config', sshConfig)}
</div>
<div id="panel-prereq" role="tabpanel" aria-labelledby="tab-prereq" hidden={tab !== 'prereq'}>
<h4>Both of them need</h4>
<ol>
<li>
<p>
eitri's host CA pinned, once per machine. It goes in a file of its own—a
<code>@cert-authority *</code> line in your main
<code>known_hosts</code> would trust that CA for every host you ssh to.
</p>
{@render block('pin', pinCA)}
</li>
<li>
<p>
A certificate on your key, signed by a CA registered for this tenant (add one in
<a href="/settings">settings</a>). <code>ssh-keygen -s</code> writes it beside the key as
<code>id_ed25519-cert.pub</code>, which OpenSSH offers on its own. The principal must be
<code>ubuntu</code>: a guest matches it against the login user and refuses anything else.
</p>
{@render block('sign', signKey)}
</li>
</ol>
</div>
<p class="hint">
<code>eitri ssh {vmName}</code> does all of the above—it signs a 30-minute certificate, pins the
CA, and execs the command above.
</p>
</div>
</div>
<style>
/* Closed, this is one button on an otherwise empty line — no box around a
box. The measure is the prose one either way. */
.connect {
margin-top: 1.2em;
max-width: 92ch;
}
.connect h3 {
margin: 0;
font-size: inherit;
font-weight: normal;
}
.connect h4 {
margin: 0.9em 0 0.3em;
font-size: inherit;
}
#connect-panel {
margin-top: 0.6em;
}
/* A tab is its label and a rule under it: ink for the one you are reading,
faint for the others, and the strip's own hairline joining them. Reaching
for one firms its rule to ink, as every other control here does. */
.tabs {
display: flex;
gap: 1.5em;
margin-bottom: 0.6em;
border-bottom: 1px solid var(--hairline);
}
.tabs button {
border: 0;
border-bottom: 1px solid transparent;
border-radius: 0;
margin-bottom: -1px;
padding: 0.1em 0;
color: var(--faint);
}
.tabs button:hover {
color: var(--ink);
}
.tabs button.on {
color: var(--ink);
border-bottom-color: var(--ink);
}
[role='tabpanel'][hidden],
#connect-panel[hidden] {
display: none;
}
.connect p {
margin: 0 0 0.4em;
}
.connect ol {
margin: 0;
padding-left: 2em;
}
.connect li {
margin-bottom: 0.6em;
}
/* A command and the button that takes it: the command keeps its own line
breaks and scrolls sideways rather than reflowing, since a wrapped
continuation backslash reads as a different command. */
.block {
display: flex;
align-items: flex-start;
gap: 0.5em;
margin-bottom: 0.4em;
}
.block pre {
flex: 1;
min-width: 0;
border: 1px solid var(--hairline);
padding: 0.3em 0.6em;
overflow-x: auto;
}
</style>