bsdkrund — the bsdkrun daemon #
A token-authenticated server that drives the bsdkrun CLI, so a machine that
can actually run VMs — a Linux/KVM box, bare metal, a VPS — can be controlled
from somewhere else. It speaks two protocols over the same operations:
| API | Port | For |
|---|---|---|
| gRPC | 50051 | The CLI, the desktop app, scripts, anything typed |
| GraphQL | 50052 | The web frontend — queries, mutations and subscriptions |
Both are backed by one shared operations layer, so they cannot drift in what commands they actually run.
The daemon owns no VM logic. It resolves the bsdkrun binary installed beside
it and runs it as a subprocess, so a daemon always exposes exactly the feature
set of that binary and can never drift from it.
Using a daemon is opt-in. The CLI and the desktop app keep running bsdkrun
directly on the local machine by default, and always will; pointing them at a
daemon is a choice, not a migration.
Quick start #
On the server:
$ bsdkrund --bind 0.0.0.0:50051 --tls-cert cert.pem --tls-key key.pem
bsdkrun daemon listening on https://0.0.0.0:50051
access token (generated — shown only once):
9f2c...64 hex chars...
connect with:
export BSDKRUN_HOST=https://<this-host>:50051
export BSDKRUN_TOKEN=9f2c...
Set BSDKRUN_TOKEN (or --token) to pin a token across restarts instead of
generating a fresh one each boot.
Security #
Every RPC requires a bearer token. There is no anonymous or read-only tier — the daemon can boot a VM, open a root shell in it, and read any file its host user can, so a single credential guards all of it.
| Concern | Behaviour |
|---|---|
| Default bind | 127.0.0.1:50051 — exposing it to a network is a deliberate act |
| Token | --token / BSDKRUN_TOKEN, else 32 random bytes from /dev/urandom, hex-encoded |
| Header | authorization: Bearer <token>, or x-bsdkrun-token: <token> |
| Comparison | Constant-time, so a wrong token leaks nothing through timing |
| TLS | --tls-cert / --tls-key; a non-loopback bind without TLS logs a warning |
| Reflection, health | Served anonymously — schema and liveness only, never machine state |
A bearer token on a plaintext socket is readable by anyone on the path. Over the public internet, use TLS or an encrypted tunnel (SSH, WireGuard, tailscale).
The API #
Two layers, because neither alone is enough:
- Typed RPCs for the surface clients program against — machines, images,
volumes, networks, flavors, lifecycle, exec, logs. These parse the CLI's
--jsonoutput into real protobuf messages. - A generic
Runpassthrough that forwards an argv to the CLI and streams the result. It covers the long tail (probe,kernel,firmware,grow,store) and any subcommand added to the CLI after the proto was written, so a client is never blocked waiting for the daemon to catch up.
Schema: proto/bsdkrun.proto. Reflection is enabled, so
grpcurl needs no local copy of it:
$ grpcurl -plaintext localhost:50051 list
$ grpcurl -plaintext -H "authorization: Bearer $BSDKRUN_TOKEN" \
-d '{"all":true}' localhost:50051 bsdkrun.v1.Bsdkrun/ListMachines
The GraphQL API #
For the web frontend. Served on --graphql-bind (default 127.0.0.1:50052),
or turned off with --no-graphql.
| Endpoint | What |
|---|---|
POST /graphql |
Queries and mutations. Authorization: Bearer <token> |
GET /graphql |
GraphiQL IDE — anonymous, like gRPC reflection |
/graphql/ws |
Subscriptions over graphql-transport-ws |
GET /health |
Liveness |
$ curl -X POST localhost:50052/graphql \
-H "authorization: Bearer $BSDKRUN_TOKEN" \
-H 'content-type: application/json' \
-d '{"query":"{ machines(all:true){ id name status netIp } }"}'
The browser cannot set headers on a WebSocket handshake, so subscriptions carry
the same token in the connection_init payload instead:
{ type: 'connection_init', payload: { authorization: `Bearer ${token}` } }
CORS is permissive. The API is gated by a bearer token that a browser will not attach on its own — there is no cookie or session for a hostile page to ride on — so same-origin policy is not what protects it, and being strict would only break a dev server on another port.
Interactive shells over GraphQL #
A GraphQL subscription only flows server→client, so there is nowhere to put keystrokes. A terminal is therefore assembled from three operations over one socket:
mutation { openShell(machineId: "abc", rows: 40, cols: 120) { id } }
subscription { shellOutput(sessionId: "…") { dataBase64 exitCode } }
mutation { sendShellInput(sessionId: "…", dataBase64: "bHMK") }
mutation { resizeShell(sessionId: "…", rows: 50, cols: 100) }
mutation { closeShell(sessionId: "…") }
Bytes are base64 because a terminal emits arbitrary binary — escape sequences,
UTF-8 split across chunk boundaries — that a GraphQL String would not survive.
Feed dataBase64 straight into xterm.js and send its onData back.
Output is buffered from the moment the session opens. That is a correctness requirement, not an optimisation: the subscription is necessarily a separate operation from the mutation that opened the shell, so a prompt written in between would otherwise be lost before anyone was listening.
Two other subscriptions exist: machineLogs (a machine's console, logs -f)
and machinesChanged (a polled machine list, so a dashboard needs no timer of
its own — the CLI has no change feed, so this genuinely polls).
Machines are always detached #
The daemon outlives any single RPC, so a foreground VM would have nowhere to
live. RunLinux / RunBsd / RunUnikraft / RunNanos / RunOsv /
RunSolo5 / RunFlavor return a machine id;
use Logs to watch it boot and Exec to get a shell.
RunUnikraft boots a Unikraft unikernel — the
application linked into the kernel. It takes neither a volume nor a command,
because such a guest has no disk and no in-guest agent; Exec and Commit
are rejected for the machines it creates, and Logs is how you read their
output.
RunNanos and RunOsv boot two more unikernels. They share the "no
agent, so no Exec and no Commit" contract, but both have a root
filesystem, so unlike RunUnikraft they do take the disk options — RunOsv
in particular takes a disk of its own, which is how an x86_64 guest is
given a filesystem (its loader ELF is kernel only).
RunSolo5 boots a Solo5 unikernel —
MirageOS, mostly. It is the one guest that does not run under libkrun: the
daemon's supervisor spawns the embedded solo5-hvt tender, which is itself
the hypervisor front end. Over the wire it is just another unikernel (no
disk options, no agent); the unikernel declares its own network and block
devices in its MFT1 manifest note, so the request carries only what the
host alone can know — memory, port forwards, NAME=FILE block backings, and
the arguments handed to the unikernel itself.
How a remote interactive shell works #
Exec is bidirectional, and the daemon allocates a real pty on its own host:
your terminal (raw mode) daemon host (VPS / bare metal)
│ keystrokes ┌────────────────────────┐
├─► ExecStart{id, tty, rows, cols} ──►│ pty master │
├─► stdin bytes ─────────────────────►│ ↕ │
├─► Resize{rows, cols} (SIGWINCH) ──►│ `bsdkrun shell <id>` │ ← sees a real tty
│ │ ↕ guest agent │
◄── stdout bytes ─────────────────────┤ guest shell │
◄── exit_code ────────────────────────└────────────────────────┘
one HTTP/2 bidi stream, TLS + bearer token
Plain pipes cannot do this: the CLI and the guest shell both check isatty, so
without a terminal there is no prompt, no line editing, no job control and no
window size to honour. Running the CLI under a pty makes it see a genuine
terminal; the daemon then only moves bytes between the pty master and the
stream. TCP_NODELAY is set on both ends so a keystroke is not delayed by
Nagle's algorithm.
Two rules make the lifetime work, and both were bugs before they were rules:
- The client half-closing its request stream means "no more stdin", not
"cancel". A non-interactive client sends
startand half-closes immediately; treating that as cancellation cut every such call off before its first byte. - Cleanup is driven by the client dropping the response stream. It cannot be a background task holding a channel sender, because a live sender keeps the channel open and the stream could then never end.
Clients #
bsdkrun-daemon is also a library, so the CLI and desktop app can link it and
talk to a daemon. Configuration follows the DOCKER_HOST convention:
export BSDKRUN_HOST=https://vps.example.com:50051
export BSDKRUN_TOKEN=<token>
RemoteConfig::from_env() returns Ok(None) when BSDKRUN_HOST is unset — the
signal to run locally. A host set without a token is an error rather than a
silent fallback: quietly running a command on the wrong machine is worse than
refusing.
Development #
$ cargo build --release # needs protoc on PATH
$ cargo test # unit + end-to-end
$ cargo clippy --all-targets -- -D warnings
The suites (tests/e2e.rs for gRPC, tests/graphql.rs for GraphQL) run against
a stub bsdkrun. That keeps them hermetic — no hypervisor, no VM boots, no
downloads — while letting each test assert the exact argv produced, which is the
part most likely to break: the whole daemon is a translation layer from wire
messages to command lines.
CI: e2e-daemon.yml runs both suites on
macOS and Linux, plus an over-the-wire job — grpcurl for gRPC, curl for
GraphQL, and a real WebSocket client driving an interactive shell through a
subscription;
release-daemon.yml builds
macOS arm64 and static musl Linux binaries for amd64 and arm64.