docs/connecting.md
Ref: Size: 13.0 KiB History
# 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](quickstart.md) shows—one binary, no
installer—and run the guided setup:
```sh
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`):
```sh
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:
```sh
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:
```sh
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:
```sh
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](mcp.md) work with nothing but a
PAT, where the same two steps are the `delegate_begin` and `delegate_complete`
tools.
## One-liner
```sh
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:
```sh
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>`:
```sh
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](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:
```sh
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`:
```sh
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.
## Related
- [networking](networking.md)—reaching a service a guest serves, past the gate
- [credential-revocation.md](credential-revocation.md)—revoking a leaked user
cert or host credential
- [cert-rotation.md](cert-rotation.md)—rotating the server's QUIC identity