Connecting to a VM

Your CA, the client, and the jump gate in front of every guest

eitri runs an SSH jump gate: a bastion that accepts an ssh -J hop and forwards you to a VM's SSHd. You authenticate to the gate with a short-lived SSH user certificate that you sign yourself, using your tenant's own user CA. Every VM in your tenant trusts your tenant's user CAs—seeded at VM create, so there's no per-VM key to manage—and the cert carries the principal ubuntu, the login user on the VM.

Verification runs both ways. Just as the VM trusts your user cert, you verify what you connect to: the gate and every VM present a host certificate signed by eitri's host CA. You pin that CA once (@cert-authority) and both hops are then verified by certificate—no blind trust-on-first-use, and no host-key-changed warnings when VM names or IPs are recycled.

Two CAs, two directions: your tenant's user CA (private key on your machine) signs what you present; eitri's host CA (private key on the server) signs what the gate and VMs present. eitri deliberately holds no user signing key — a server compromise cannot mint user credentials.

Start a client

SSH access uses certificates signed by your CA—eitri never holds a user key that can enter your VMs. A guest trusts the CA set it is created with, so this comes before your first VM.

Install the client as the quickstart shows—one binary, no installer—and run the guided setup:

eitri init

It asks for a personal access token—mint one in the console under Settings → Personal access tokens—and walks three steps, printing what each will do and doing it only on a y:

  1. Identity. Calls /api/v1/me with the token to learn your tenant and which SSH gate this plane runs. The token is used once and never stored; entered at the prompt it is not echoed.
  2. CA. Lists the CAs your tenant has already registered. If the signing key on this laptop is one of them, there is nothing to do. If you have a key it does not know—here or at a path you give it—init offers to register the public half. Only when there is neither does it offer to generate an ed25519 pair, naming both files and their modes before writing anything.
  3. Config. Writes ~/.eitri/config.json—plane, gate, tenant, CA and key paths—after showing the current values beside what would change.

Run it again whenever: each step reports what is already settled and touches nothing. It is also how you find out that the key on this laptop signs certs your tenant never registered—the failure that otherwise arrives as a bare "permission denied" from a guest that was never going to trust you.

After init, eitri ssh needs no environment at all: the config names the plane and the tenant, the certificate is signed locally, and the host-CA pin comes from a public endpoint. Every EITRI_* variable still overrides the file, and the file still overrides the hosted defaults.

Bring your own CA (once per tenant)

Generate a user CA and register its public key with your tenant. The token names the tenant (POST /api/v1/user-cas), so no handle is needed; a token that can act for more than one tenant pins one with eitri ca upload <tenant> <key> (POST /api/v1/tenants/<tenant>/user-cas):

ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my tenant user CA"

export EITRI_URL=https://eitri.example.com
export EITRI_TOKEN=<personal-access-token>   # mint one in the console → Settings
eitri ca upload ~/.ssh/eitri_user_ca.pub

The CA's private key never leaves your machine; the server stores only the public key. Upload the CA before creating VMs—a VM trusts the tenant user CAs present at its creation. The gate authorizes each connection against the tenant the signing CA was uploaded to.

Registering a CA later does not reach a VM that already exists. The set is copied onto the VM as it is created and nothing rewrites it, so a certificate signed by a CA registered afterwards is refused by that guest's sshd. Recreating the guest is the only way to change what it trusts.

The console does the same under Settings, and so does the MCP ca_upload tool. All three register into the same set.

Which CAs does a guest trust?

A VM's page lists them under Trusted CAs, by the label you gave the CA and its fingerprint. Match those against Settings → SSH Access to tell whether a certificate you are about to sign will open that guest.

On the fleet page, a VM missing any CA your tenant now has is marked stale trust. It still works with the CAs it was created against; it just cannot be opened by every CA you hold.

Delegating access to eitri

A caller holding only a token has no CA and no private key, so it cannot sign anything — and eitri holds no signing key for anyone, so it cannot sign on their behalf either. Instead, you lend eitri a credential.

eitri generates an ephemeral keypair for your tenant, in memory only, and hands you the public half:

curl -X POST -H "Authorization: Bearer $EITRI_TOKEN" \
  https://eitri.example.com/api/v1/delegations

Sign it with your own CA, on your own terms:

printf '%s\n' "<public_key from the response>" > eitri-delegation.pub
ssh-keygen -s ~/.ssh/eitri_user_ca -I eitri-delegation -n ubuntu -V +8h eitri-delegation.pub

-n ubuntu is not optional. A guest matches the certificate's principals against the login user, so a certificate naming anything else is refused by every guest. Post the result back:

curl -X PUT -H "Authorization: Bearer $EITRI_TOKEN" \
  -H 'Content-Type: application/json' \
  -d "{\"certificate\": \"$(cat eitri-delegation-cert.pub)\"}" \
  https://eitri.example.com/api/v1/delegations

eitri now authenticates to your guests as that key plus that certificate, until the certificate expires. GET /api/v1/delegations reports the expiry; DELETE ends it immediately, and so does restarting the control plane.

Guests created before you delegated accept it too—unlike registering a new CA, there is no ordering constraint here.

This is what makes the remote MCP endpoint work with nothing but a PAT, where the same two steps are the delegate_begin and delegate_complete tools.

One-liner

export EITRI_URL=https://eitri.example.com
export EITRI_GATE=eitri.example.com:2222   # the gate's ssh_listen address

eitri ssh <vm-name>                    # opens a shell on the VM
eitri ssh <vm-name> uptime             # runs a command and exits

You pass the bare <vm-name>, but the name that reaches the wire is always the gate connect name <tenant>.<vm-name>—a VM's host cert carries exactly that one principal, and eitri ssh verifies the dialed name against it under strict checking, so a bare name would fail host verification. (The gate itself also resolves a bare name within the connection's tenant, but the VM's cert does not, so the client sends the namespaced form.) You never need to know your tenant: eitri ssh derives it from your credential via /me, or takes it from EITRI_TENANT when set (offline, and the escape hatch for a CA registered in more than one tenant).

Environment variables. EITRI_URL, EITRI_GATE, EITRI_CA, EITRI_TENANT and EITRI_KEY are per-invocation overrides above the config file eitri init writes, so the chain is variable, then ~/.eitri/config.json, then the default below. EITRI_TOKEN and EITRI_KNOWN_HOSTS have no config-file rung—no credential is ever written to disk, and the pin file is a cache rather than a setting.

Var Meaning
EITRI_CONFIG Path to that config file (default ~/.eitri/config.json)
EITRI_URL Base URL of the eitri server (default the hosted https://console.eitri.sh)
EITRI_GATE Jump gate address for the hop (host:port, ssh_listen); otherwise the plane's own /me answer, and the hosted gate.eitri.sh:2222 only when EITRI_URL is the hosted plane
EITRI_CA Your tenant user-CA private key (default ~/.ssh/eitri_user_ca)
EITRI_TOKEN Personal access token, used only to look up your tenant for the connect name (skipped when EITRI_TENANT is set)
EITRI_TENANT Optional: pins the tenant (offline, and the escape hatch when your CA is registered in more than one); otherwise derived from the token
EITRI_KEY SSH private key path (default ~/.ssh/id_ed25519)
EITRI_KNOWN_HOSTS eitri-managed known_hosts for the CA pin (default ~/.ssh/eitri_known_hosts)

The SSH session authenticates with no API credential—your signing CA is the credential. eitri ssh generates ~/.ssh/id_ed25519 if missing, self-signs a 30-minute cert to <key>-cert.pub (which OpenSSH auto-offers), fetches the eitri host CA and pins it as @cert-authority * in a dedicated known_hosts file, and execs ssh with both hops verified.

The host EITRI_GATE points at must match the gate's host-cert principal, i.e. the server's ssh_gate_domain (which defaults to the host part of ssh_listen). A mismatch is a hard host-verification failure, by design. A server whose ssh_listen binds every interface (:2222, 0.0.0.0:2222) has no host part to default to, so ssh_gate_domain is required there and the server refuses to start without it.

Against your own server, EITRI_URL must name it—unset, the client defaults to the hosted service—and the gate comes from that server's /api/v1/me, which serves whatever ssh_gate_domain and ssh_listen say. A /me that names no gate is an error naming those two settings, never a silent hop through eitri.sh.

Manual flow

The client is a thin wrapper over three steps you can run by hand:

  1. Self-sign a cert for your public key with your tenant CA—no server involved:

    ssh-keygen -s ~/.ssh/eitri_user_ca -I "$(whoami)@$(hostname)" \
      -n ubuntu -V +30m ~/.ssh/id_ed25519.pub
    
  2. Place the cert beside the key. ssh-keygen -s writes id_ed25519-cert.pub next to the key, and OpenSSH auto-offers a cert named <key>-cert.pub—nothing further needed, no ssh-add.

  3. Hop through the gate to ubuntu@<tenant>.<vm-name>:

    ssh -J "$EITRI_GATE" ubuntu@<tenant>.<vm-name>
    

    The inner user must be ubuntu (the cert principal). The outer gate hop accepts any username. The gate derives your tenant from the CA that signed your cert, resolves names within that tenant, and rejects a foreign-prefixed name.

The gate honours a source-address critical option (-O source-address=…) against the address it sees you connect from. The guest does not see that address—the hop to it leaves from the host—so a cert that carries the option is refused when offered through delegation, where only the plane would ever present it.

Certs are short-lived

Self-signed certs should carry a short validity (-V +30m above). When one expires, ssh is simply rejected—re-run eitri ssh (or the signing step) to refresh. A specific cert can also be revoked at the gate by serial before it expires; see credential-revocation.md.

Host verification (via the CA)

You pin eitri's host CA once and let certificate verification stand in for trust-on-first-use. Fetch the CA (public material, no token needed) and pin it in a dedicated known_hosts file—never your main ~/.ssh/known_hosts, where a * wildcard CA would be trusted for every host you ssh to:

curl -sS "$EITRI_URL/api/v1/ssh-ca" | jq -r .ca \
  | sed 's/^/@cert-authority * /' > ~/.ssh/eitri_known_hosts

Then both hops are verified against the CA with StrictHostKeyChecking=yes. A command-line -o reaches only the final hop, so thread the same options to the jump hop with an explicit ProxyCommand instead of -J:

GATE_HOST=${EITRI_GATE%%:*}; GATE_PORT=${EITRI_GATE##*:}
[ "$GATE_PORT" = "$EITRI_GATE" ] && GATE_PORT=22
KH=~/.ssh/eitri_known_hosts
ssh \
  -o "ProxyCommand=ssh -W %h:%p -o StrictHostKeyChecking=yes -o UserKnownHostsFile='$KH' -p $GATE_PORT ubuntu@$GATE_HOST" \
  -o StrictHostKeyChecking=yes \
  -o "UserKnownHostsFile=$KH" \
  ubuntu@<tenant>.<vm-name>

Keep the inner quoting if you edit this: ssh runs the ProxyCommand through a shell of its own, and the single quotes are what keep a $HOME with a space in it one word.

The gate's cert principal is ssh_gate_domain (so $GATE_HOST must match it), and each VM's cert principal is its <tenant>.<vm-name> connect name (so the inner ubuntu@<tenant>.<vm-name> host must match). Because verification is by CA, recycling a VM name or IP never produces a host-key-changed warning—the new VM simply presents a fresh CA-signed cert for that name. eitri ssh does all of this for you.