Quickstart

From nothing to a VM you can SSH into

The hosted way (console.eitri.sh)

eitri.sh runs a control plane so you don't have to. You bring the hardware; the console, sync, and SSH gate are already up. No server install at all.

Create an account

Open https://console.eitri.sh and sign in with Google. Your first sign-in creates your tenant.

Join a host

On the machine that will serve VMs (Linux with KVM—see "What you need" under Self-hosting, or a Mac, see "Join a Mac"), download and verify the host bundle:

V=v0.0.1
curl -fsSLO "https://eitri.sh/dl/$V/eitri_${V}_linux_amd64.tar.gz"
curl -fsSLO "https://eitri.sh/dl/$V/SHA256SUMS"
sha256sum -c SHA256SUMS --ignore-missing
tar xzf "eitri_${V}_linux_amd64.tar.gz" && cd "eitri_${V}_linux_amd64"

Then click + Add host in the console and run the command it prints from the unpacked bundle—it installs the agent and joins this machine to your tenant:

sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent
sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service
sudo eitri-agent --state-dir /var/lib/eitri-agent join eitri_join_<blob-from-console>
sudo systemctl daemon-reload
sudo systemctl enable --now eitri-agent

The agent dials out—a machine behind NAT needs no open ports. It goes online in the console within seconds.

Join a Mac

A Mac joins the same way, with two differences. It runs guests through vfkit on Apple's Virtualization.framework, and you install that yourself:

brew install vfkit

The agent installs cloud-hypervisor on a Linux host but cannot do the same here: vfkit only works carrying Apple's virtualization entitlement, and an entitlement lives in a code signature. Homebrew's copy is signed. Without it, VMs placed on this host fail at once, saying so.

Take the darwin bundle instead of the linux one. Nothing here needs sudo, and nothing goes in a system directory:

mkdir -p ~/.local/bin && cp eitri-agent ~/.local/bin/
~/.local/bin/eitri-agent join eitri_join_<blob-from-console>
./eitri-agent-launchagent.sh install ~/.local/bin/eitri-agent

State lands in ~/.eitri/agent, logs in ~/Library/Logs/eitri-agent.log, and the agent starts with your session.

Keep the binary somewhere this account owns. The agent replaces its own binary when it upgrades, so a root-owned prefix like /usr/local/bin would look tidy and quietly disable every future upgrade—the installer refuses rather than let that happen.

Unprivileged throughout, because on macOS eitri owns none of the host's networking: Virtualization.framework owns the guest network and vfkit carries the entitlement for it. Contrast the Linux agent, which is root because it owns the bridge, the taps, /dev/kvm and the nftables rules.

A LaunchAgent, not a LaunchDaemon, because a Mac host is nearly always somebody's Mac and an agent runs where that person already is. For a dedicated machine that must run guests with nobody logged in, move the generated plist to /Library/LaunchDaemons and add a UserName key naming this account; leave the binary and state where they are.

Apple Silicon runs arm64 guests only: the framework cannot emulate another architecture. You do not have to remember that—default_images is keyed by host architecture, so a VM created without an explicit image gets the arm64 one on a Mac. If you do name an image, name an arm64 image; nothing downstream checks, and one built for the wrong architecture boots into nothing.

Guests get their addresses from macOS's own NAT rather than from the agent, so a Mac's guests sit on vmnet's subnet. The host reports that subnet to the fleet, so it is what the console shows—no allocation is made for a Mac and none is used. Everything above that—eitri ssh, the console, reconcile—is the same.

Boot a VM

Console → + Create VM, pick your host, create. Watch it boot in the browser serial console.

SSH in

SSH access uses certificates signed by your CA—eitri never holds a user key that can enter your VMs. On your laptop, download the client CLI. It targets the hosted service by default, so the only thing to set is a personal access token:

V=v0.0.1
curl -fsSLO "https://eitri.sh/dl/$V/eitri-cli_${V}_$(uname -s | tr A-Z a-z)_amd64.tar.gz"   # arm64 laptop → _arm64
tar xzf eitri-cli_${V}_*.tar.gz
sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri

export EITRI_TOKEN=<pat>          # mint one in the console → Settings → Personal access tokens

Register a user CA once—eitri gets the public key, never the private one:

ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA"
eitri ca upload ~/.ssh/eitri_user_ca.pub

Your token names the tenant, so neither command needs one. In more than one tenant? EITRI_TENANT and eitri ca upload <tenant> <key> pin one explicitly.

Then SSH in. The console and gate are hosted defaults, and eitri ssh uses your token to look up your tenant for the connect name—nothing else to set:

eitri ssh <vm-name>
eitri ssh <vm-name> uptime

Expose a service

The gate reaches a guest over SSH. Anything else a guest serves needs a published port: the fleet binds one on the VM's host and pipes it to the guest.

Open the VM's page in the console, find Exposures, and enter the port your service listens on inside the guest—8080, say. Leave the host port blank and eitri allocates one from 30000–32767, the range it reserves on every host. The row then reads:

guest :8080  →  192.168.0.190:30080   ● active

That address is the host's. Anything that can reach the host on that port reaches the service—there is no authentication in front of a published port, so publish what you are content to serve to everything on that network, and leave the rest to the gate.

active means the host's listener is bound. Whether anything answers on the guest's 8080 is the guest's business.

Remove the row to take the port down. Deleting the VM takes its exposures with it.

Self-hosting

Run the whole control plane yourself—everything below is your own box. eitri is three pieces: a server, an agent on every box that runs VMs, and your laptop. 192.0.2.10 is the server below. Substitute yours.

What you need

Every Linux VM host needs KVM (ls -l /dev/kvm); a Mac needs brew install vfkit (see "Join a Mac"). Guest images are decoded in the agent, so there is no image toolchain to install. On Linux the agent fetches cloud-hypervisor and the guest firmware itself on first start, sha-verified against the release. To manage them by hand instead, disable it in /etc/default/eitri-agent: EITRI_AGENT_FLAGS="--bootstrap-url=".

Tarballs live at https://eitri.sh/dl/latest/. The host bundle (eitri_<version>_linux_amd64.tar.gz) has eitri-server, eitri-agent, and their systemd units. The issuer bundle (eitri-oidc_<version>_linux_amd64.tar.gz) has eitri-oidc—the bundled sign-in provider—and its unit. The client bundle (eitri-cli_<version>_<os>_<arch>.tar.gz) is the single eitri binary for your laptop, built for linux and macOS. arm64 boxes take the arm64 bundle.

Download and verify—set V to the current release (shown at /dl):

V=v0.0.1
curl -fsSLO "https://eitri.sh/dl/$V/eitri_${V}_linux_amd64.tar.gz"
curl -fsSLO "https://eitri.sh/dl/$V/eitri-oidc_${V}_linux_amd64.tar.gz"
curl -fsSLO "https://eitri.sh/dl/$V/eitri-cli_${V}_$(uname -s | tr A-Z a-z)_amd64.tar.gz"
curl -fsSLO "https://eitri.sh/dl/$V/SHA256SUMS"
sha256sum -c SHA256SUMS --ignore-missing

Plain files at stable URLs—no install script. sha256sum -c must say OK for every tarball before you unpack anything.

The server

tar xzf eitri_*_linux_amd64.tar.gz && cd eitri_*_linux_amd64
sudo install -m 0755 eitri-server /usr/local/bin/eitri-server
sudo install -m 0644 eitri-server.service /etc/systemd/system/eitri-server.service
sudo useradd --system --home-dir /var/lib/eitri --shell /usr/sbin/nologin eitri
sudo mkdir -p /etc/eitri

The server runs as the dedicated eitri user—it needs no privilege (every listener is on an unprivileged port); its state directory /var/lib/eitri is created and owned for it by the unit.

Set SERVER_ADDR, paste the rest:

SERVER_ADDR=192.0.2.10
IMAGE_DIR=https://cloud-images.ubuntu.com/resolute/current
HOST_SECRET=$(openssl rand -hex 32)
sha() { curl -fsSL "$IMAGE_DIR/SHA256SUMS" | awk -v f="resolute-server-cloudimg-$1.img" '$2 == "*" f {print $1}'; }
AMD64_SHA=$(sha amd64)
ARM64_SHA=$(sha arm64)

sudo tee /etc/eitri/server.json >/dev/null <<EOF
{
  "http_listen": ":8080",
  "quic_listen": ":8443",
  "advertise_http": "http://$SERVER_ADDR:8080",
  "advertise_quic": "$SERVER_ADDR:8443",
  "db_path": "/var/lib/eitri/eitri.db",
  "cidr_pool": "10.100.0.0/16",
  "oidc": {
    "issuer": "http://127.0.0.1:9111",
    "client_id": "eitri-console",
    "public_url": "http://$SERVER_ADDR:8080"
  },
  "host_secret": "$HOST_SECRET",
  "default_images": {
    "amd64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-amd64.img", "sha256": "$AMD64_SHA"},
    "arm64": {"url": "$IMAGE_DIR/resolute-server-cloudimg-arm64.img", "sha256": "$ARM64_SHA"}
  },
  "ssh_listen": ":2222",
  "ssh_gate_domain": "$SERVER_ADDR",
  "ssh_ca_key": "/var/lib/eitri/ssh_ca",
  "ssh_host_key": "/var/lib/eitri/ssh_host_key"
}
EOF
sudo chgrp eitri /etc/eitri/server.json && sudo chmod 0640 /etc/eitri/server.json

The chmod matters: server.json carries host_secret, so it is root-owned and readable only via the eitri group—not world-readable.

advertise_* is what hosts and your laptop dial. Not 127.0.0.1. Any cloud-init disk image works as a default image; the Ubuntu one boots out of the box.

default_images is keyed by the architecture of the host a VM lands on, and a VM created without an explicit image takes the entry for its host. Configure every architecture in your fleet: a host can only run a guest built for its own CPU, and a create for an architecture you have not listed is refused rather than served an image that cannot boot. One entry is plenty for a single-architecture fleet—both are shown because an Apple silicon host takes arm64.

The oidc block points the console's sign-in at the bundled issuer you start next. public_url is where browsers reach the console (the callback lands at $public_url/auth/callback), so keep it equal to advertise_http. Bringing your own IdP instead of the bundled issuer: see byo-idp.md.

Sign-in

The console always signs in through OIDC. The bundled eitri-oidc issuer runs next to the server on loopback. Install it, write its config, and add yourself:

tar xzf eitri-oidc_*_linux_amd64.tar.gz && cd eitri-oidc_*_linux_amd64
sudo install -m 0755 eitri-oidc /usr/local/bin/eitri-oidc
sudo install -m 0644 eitri-oidc.service /etc/systemd/system/eitri-oidc.service
sudo useradd --system --home-dir /var/lib/eitri-oidc --shell /usr/sbin/nologin eitri-oidc

sudo tee /etc/eitri/eitri-oidc.json >/dev/null <<EOF
{
  "listen": "127.0.0.1:9111",
  "issuer": "http://127.0.0.1:9111",
  "users_file": "/var/lib/eitri-oidc/users.json",
  "signing_key": "/var/lib/eitri-oidc/signing.key",
  "clients": [
    {"id": "eitri-console", "redirect_url": "http://$SERVER_ADDR:8080/auth/callback"}
  ]
}
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now eitri-oidc
sudo -u eitri-oidc eitri-oidc user add you@example.com    # prompts for a password

The issuer handles passwords and holds the sign-in signing key, so it runs as its own eitri-oidc user—separate even from the server's eitri user, keeping the key material unreadable by any other service. Its mutable state (user file, autogenerated signing key) lives in /var/lib/eitri-oidc, created and owned for it by the unit; that's also why user add runs via sudo -u eitri-oidc—files it writes stay readable by the service.

redirect_url must equal the server's oidc.public_url + /auth/callback. On this single box the loopback issuer/public_url work because your browser is on the same machine; anything multi-machine needs a routable issuer.

Now start the server:

sudo systemctl enable --now eitri-server
journalctl -u eitri-server -f    # watch it come up

It speaks plain HTTP, so keep it on your LAN or put TLS in front. Open 8080/tcp (console, enroll), 8443/udp (sync), 2222/tcp (SSH gate). The issuer stays on loopback.

Sign in at http://192.0.2.10:8080 with the user you added. Your first sign-in creates your tenant.

Join a host

Once per box that runs VMs. The server's box counts.

+ Add host in the console prints a one-shot join command. On the box, from the unpacked host bundle:

sudo install -m 0755 eitri-agent /usr/local/bin/eitri-agent
sudo install -m 0644 eitri-agent.service /etc/systemd/system/eitri-agent.service
sudo eitri-agent --state-dir /var/lib/eitri-agent join eitri_join_<blob-from-console>
sudo systemctl daemon-reload
sudo systemctl enable --now eitri-agent

The host goes online in the console. Logs: journalctl -u eitri-agent -f.

A Mac joins the same fleet from the darwin_arm64 bundle—brew install vfkit first, then eitri-agent join, then eitri-agent-launchagent.sh install in place of the systemd unit. The differences are the same ones the hosted section covers above, and the one extra thing self-hosting asks of you is an arm64 entry in the server's default_images.

Boot a VM

VMs trust your SSH CA from birth, so register one first. eitri gets the public key, never the private one. On your laptop:

tar xzf eitri-cli_*_$(uname -s | tr A-Z a-z)_*.tar.gz
sudo install -m 0755 eitri-cli_*/eitri /usr/local/bin/eitri

export EITRI_URL=http://192.0.2.10:8080
export EITRI_TOKEN=<pat>          # mint one in the console → Settings

ssh-keygen -t ed25519 -N '' -f ~/.ssh/eitri_user_ca -C "my eitri user CA"
eitri ca upload ~/.ssh/eitri_user_ca.pub

Your token names the tenant, so ca upload needs no handle. In more than one tenant? EITRI_TENANT and eitri ca upload <tenant> <key> pin one explicitly.

+ Create VM, pick a host, create. Defaults: 2 vCPUs, 2048 MB, 10 GB, the default image. Status reads creating while the image downloads and the guest boots, then ready. Power reads running, an IP appears, you're on.

SSH in

export EITRI_URL=http://192.0.2.10:8080   # set earlier, when you booted the VM
export EITRI_GATE=192.0.2.10:2222         # must match ssh_gate_domain

eitri ssh <vm-name>
eitri ssh <vm-name> uptime

Unset, EITRI_URL and EITRI_GATE default to the hosted service at eitri.sh, so self-hosting always sets them to your own box.

eitri ssh is plain ssh in a trenchcoat: it signs a short-lived cert with your CA, pins eitri's host CA, and jumps the gate to ubuntu@<tenant>.<vm-name> — the VM's host cert carries that one namespaced principal, so that is the name verified. You still type only <vm-name>; eitri ssh looks the tenant up from your token (or EITRI_TENANT). ssh-access.md shows it done by hand.

More