crates/gunnar-ui-server/proto/gunnar/v1/bootstrap.proto
syntax = "proto3";
package gunnar.v1;
import "gunnar/v1/common.proto";
// `GrowStatusRequest` / `GrowStatusResponse` — the runtime growth's status,
// declared beside `Admin.GrowStatus`, served here WITHOUT a credential.
import "gunnar/v1/admin.proto";
// `LogChunk` — the transaction log's wire piece, for `Restore`.
import "gunnar/v1/mirror.proto";
// **Trust on first use, and closed for ever after.**
//
// This is the ONE service in gunnar's surface that can be served without a
// credential, and it can be served without one for exactly as long as nothing
// has ever been enrolled. `Enroll` creates the first identity and, in the SAME
// compare-and-swap that creates it, writes a record saying the window has been
// used. After that every call here is refused, and every other RPC in the
// surface is authenticated exactly as it was before.
//
// ⚠ The record is written BESIDE the identity table, not in it. Deleting every
// principal and every key does NOT reopen the window. That is the obvious
// attack on a design like this one and it is refused explicitly, not by luck.
//
// # Why this exists rather than two public keys baked into the image
//
// The alternative on the table was to bake the operators' public keys into
// gunnar's container and ISO. A key baked into an image is a credential that
// cannot be revoked and cannot be rotated: every copy of the image carries it,
// including copies nobody remembers taking, and the only way to change the
// answer is to build and redeploy an image. The bootstrap window is a
// credential nobody has yet, granted to whoever reaches the box first, and shut
// permanently the moment it is used.
//
// # What it does NOT do
//
// It does not serve git. It does not create a second identity model. The
// principal it creates is an ordinary identity-table principal with an ordinary
// `Server`/`Admin` grant, so every later user is created through
// `Access.AddPrincipal` by an authenticated gunnar-admin, and there is no
// second path (one implementation).
service Bootstrap {
// Create the first identity. **Unauthenticated, once, ever.**
//
// FAILED_PRECONDITION once it has succeeded, or if the identity table already
// holds anything. UNAVAILABLE — never OK — if the identity table cannot be
// read: "cannot read" must not be served as "nothing is enrolled".
rpc Enroll(EnrollRequest) returns (EnrollResponse);
// Is the window open? **Unauthenticated, always.**
//
// An operator must never have to guess whether an unenrolled server's door is
// open, and on an unenrolled server there is by definition no credential with
// which to ask `Admin.Health`. This is the only thing it can be asked.
rpc Status(BootstrapStatusRequest) returns (BootstrapStatusResponse);
// **The runtime data-set growth's phase. Unauthenticated, always** (additive,
// 2026-09-14). The same answer as `Admin.GrowStatus`, from the same reader.
//
// Here because of T6 (`DATA-SET-GROWTH-FLOW.md`): while `/data` is
// unmounted the identity table is served from RAM, and after a crash in that
// window it is not served at all — the box halts at boot on a missing member.
// The actor OUTSIDE the box that owns the volumes (the bench under KVM, a
// front watchdog on UpCloud) has to read "waiting-members" / "failed" with
// nothing in hand to decide to re-attach. What it reveals: a phase word, the
// member disks' kernel names and sizes, and the journal — nothing a caller on
// the utility network could not learn from the console band.
rpc GrowStatus(GrowStatusRequest) returns (GrowStatusResponse);
// **Make this FRESH box a twin of `primary`. Unauthenticated, once**
// (additive, 2026-09-15; `.nornir/twin-transaction-log.md`). The appliance
// has no shell, so the door that turns a new box into a twin is here, beside
// `Enroll`, under the same rule: refused (FAILED_PRECONDITION) unless nothing
// is enrolled and no repository exists. The box mints its own key, gives up
// its own log numbers, freezes, and starts following. The answer is the
// public key to enrol on the primary as a member of group `twin`.
rpc BecomeTwin(BecomeTwinRequest) returns (BecomeTwinResponse);
// **Restore a FRESH primary from its twin's log. Unauthenticated, while
// fresh** (additive, 2026-09-15). The caller streams the twin's
// `Mirror.LogFetch` chunks in order; the box takes them at the twin's own
// numbers and applies each. Refused unless the box is fresh or a restore is
// already in progress (a broken stream resumes; entries below the box's
// head + 1 are skipped). After it the box must restart: it opens as the
// primary it was, sealed secrets under the replayed key.
rpc Restore(stream LogChunk) returns (RestoreResponse);
// **This box's WireGuard tunnel address, handed over at bootstrap.
// Unauthenticated, while fresh** (additive, 2026-09-21, T17). The medium
// carries the SITE only (hub key, endpoint, AllowedIPs, keepalive): an
// address on it was the same address on every box installed from it. An
// empty `address` is a read — the box's minted public key and what it holds.
// A non-empty one is written while the box is fresh (nothing enrolled, the
// rule `Enroll` keeps) or when it repeats the address already held; another
// address is refused (FAILED_PRECONDITION), a tunnel address is never
// re-pointed. The box raises `wg0` at once, no restart.
rpc AssignTunnelAddress(AssignTunnelAddressRequest) returns (AssignTunnelAddressResponse);
// **This twin's replication certificate signing request. Unauthenticated**
// (additive, 2026-09-21, lane T9). Refused unless the box is a twin. Mints the
// twin's TLS key ON THE BOX on first call and answers the request for it —
// public key and proof of possession, nothing secret. Idempotent. The first of
// the three pairing steps `gunnar control twin certify` runs; the certificate
// cannot be on the medium (one medium, nothing per box), so it is minted here.
rpc TwinCsr(TwinCsrRequest) returns (TwinCsrResponse);
// **Install the certificate the primary's issuer signed. Unauthenticated**
// (additive, 2026-09-21, lane T9). Refused unless the box is a twin, the
// certificate is for THIS box's key, the CA named with it is its issuer, the
// expiry rule does not already refuse it, and — once pinned — the issuer is the
// SAME one (renewal yes, re-pointing never).
rpc InstallTwinCertificate(InstallTwinCertificateRequest) returns (InstallTwinCertificateResponse);
}
// AssignTunnelAddressRequest / AssignTunnelAddressResponse live in admin.proto
// (bootstrap.proto imports admin.proto; Admin.SetTunnelAddress shares them).
message BecomeTwinRequest {
// The primary's control plane, `host:port`.
string primary = 1;
}
message BecomeTwinResponse {
// One authorized_keys line: enrol it on the primary in group `twin`.
string twin_public_key = 1;
string detail = 2;
// **True: this box's GIT LISTENER is still bound where a primary's was**
// (additive, 2026-09-21, lane T4).
//
// `serve::start` picks the git bind once, at start-up: a twin gets
// `127.0.0.1:0` and everything else gets the operator's address. A box
// conferred the twin role over the wire is past that point — and under the
// one-medium ruling that is the only way a twin is ever made, since a twin is
// installed from the same image as a main. MEASURED under KVM 2026-09-21: a
// runtime-conferred twin kept `0.0.0.0:2222` bound for the rest of its life,
// and its volume grew five `objects.exploded` cache files.
//
// It is NOT an "it is serving git" flag. From the moment this call returns,
// every git service on this box is refused by name at the door, read and
// write, keyed and anonymous. This says only that the SOCKET is still on an
// address a twin's socket does not belong on, and that a restart is what
// moves it.
bool restart_required = 3;
}
message RestoreResponse {
// The box's head after the stream.
uint64 head_seq = 1;
// Entries applied by this call (skipped ones not counted).
uint64 applied = 2;
// Replayed CAS claims that lost, as they lost on the primary.
uint64 lost = 3;
// True: restart the box now.
bool restart_required = 4;
string detail = 5;
}
message EnrollRequest {
// The user's name. It is also the account: this principal is intrinsically
// admin of `<host>/<name>/…`, and the namespace `<name>` is created with it.
// Same character rules as a namespace.
string name = 1;
// One line in OpenSSH authorized_keys format: "ssh-ed25519 AAAA... comment".
// The server parses it and derives the sha256 fingerprint itself, exactly as
// `Access.AddKey` does.
string openssh_public_key = 2;
}
message EnrollResponse {
// The principal that was created. It is gunnar-admin.
Principal principal = 1;
// The key it was created with, as the server stored it.
PublicKey key = 2;
// The account it owns: `<host>/<account>/<repo>`. Equal to the principal id.
string account = 3;
// When the window closed.
int64 bootstrapped_at_unix_ms = 4;
// A client certificate for this principal, minted by the server's issuer and
// registered to the principal in the same enrolment — the bootstrap admin
// leaves with a working mTLS credential, not just an SSH key. All three
// fields are EMPTY when the server has no issuer (it normally mints one at
// first start, so this is the degraded case, not the default). Enrolment
// itself never fails on their account: the window is already shut when
// minting runs, and a closed door with no certificate beats an open one.
string client_cert_pem = 5;
// Its private key. Generated server-side for this one bootstrap handshake —
// there is no authenticated channel yet for a CSR — and not retained: the
// server seals no copy, so losing this response means asking gunnar-admin
// (yourself) to issue a new certificate.
string client_key_pem = 6;
// The issuer's own certificate, for the client to pin.
string issuer_cert_pem = 7;
}
message BootstrapStatusRequest {}
message BootstrapStatusResponse {
// TRUE while `Enroll` will be served without a credential. This is the field
// an operator, a UI or a boot script looks at.
bool window_open = 1;
// TRUE once `Enroll` has succeeded. `window_open` and this are NOT simply
// each other's negation: a data path holding identities written before the
// bootstrap record existed reports both false, and that is a real state.
bool bootstrapped = 2;
int64 bootstrapped_at_unix_ms = 3;
// Who used the window, and with which key. Named so an operator can tell an
// expected enrolment from somebody else having got there first.
string bootstrapped_principal = 4;
string bootstrapped_fingerprint = 5;
// How many principals and keys the identity table holds now. Zero with
// `bootstrapped` true is the "every identity was deleted" state: the window
// stays shut and nobody can reach this server.
uint64 principals = 6;
uint64 keys = 7;
// One sentence naming the state, so a human reading raw output does not have
// to reconstruct it from the booleans.
string detail = 8;
// **Is the git listener serving yet?** FALSE during the boot window: after a
// restart the control plane answers ~0.6-0.7 s before the stores have
// finished their post-crash work (measured on the appliance), and
// in that window every git transfer is refused with EX_TEMPFAIL and a
// sentence containing "starting". This is the field a boot script waits on,
// and it is on THIS call because this is the one call a server nobody holds a
// key for can answer.
bool serving_git = 9;
// One sentence about `serving_git`, for a human.
string readiness = 10;
// **The clock, without a credential** (additive, 2026-09-14). A server whose
// clock is wrong refuses every credential as stale, `Admin.Health` included,
// so the one place its clock can be read from is the one call that needs no
// credential. Neither field is a secret: `server_unix_ms` is what every HTTP
// server publishes in `Date`, and the sync report names the configured
// sources and why each failed — the facts an operator needs and nothing an
// attacker on the utility network could not measure for himself.
int64 server_unix_ms = 11;
ClockSync clock_last = 12;
// Skew-refusal syncs dropped by the rate limit since boot (see Admin.Health).
uint64 clock_skew_syncs_suppressed = 13;
// **Who is answering, without a credential** (additive, 2026-09-15). A box
// that serves no git — `gunnar serve --role twin` binds its forge listener to
// loopback — has no SSH banner to name its build, so an installer door and a
// re-image verify that must prove WHICH medium came up read it here instead.
// `build` is the same stamp the banner's comments field carries
// (`gunnar_common::BUILD_REF`, e.g. `v1.4.0-167-ga5db99c`); `role` is
// `primary` or `twin` (the `--role` it was started with, or a box that has
// become a twin since). Empty on a server older than these fields.
string build = 14;
string role = 15;
// **On a twin: when its primary last answered** (additive, 2026-09-15).
// Without a credential because a twin has no enrolled operator of its own
// to sign `Admin.Health` with. Reveals the primary's address and two
// sequence numbers. Absent on a primary.
PrimaryHeartbeat primary = 16;
// **The machine, for the twin election, while the box is FRESH** (additive, 2026-09-21,
// T17 for T15's TD8). The election (`private-gunnar-ops` `cargo xtask elect twin`) ranks two
// boxes by these facts before either is enrolled — and it must, because `BecomeTwin`
// requires a fresh box, so an operator cannot enrol first to be allowed `Admin.Capacity`.
// PRESENT exactly while `window_open` is true; ABSENT once anybody has enrolled (ask the
// authenticated `Admin.Capacity`, which carries the same numbers and more). Read-only, and
// nothing in it names a tenant: cores, RAM, the data root's size and what its disk did at
// start-up — facts a stranger on the utility network could measure by renting the box.
// Filled by the SAME code that fills `Admin.Capacity` (one source).
BoxFacts facts = 17;
// One sentence: why `facts` is present, or why it is absent (enrolled; or the box could
// not be measured, with the reason). Never empty on a server that has these fields.
string facts_detail = 18;
// **A RECHARGED main, without a credential** (additive, 2026-09-29). The dead main of a closed
// estate, laid again from the same medium and re-admitted by the installer as the primary it was
// (Skidbladnir nordisk-pairing `Door::readmit`, fed `recharge_from`), comes up FRESH and holds
// its admin back so a restore can land. `recharge_from` names the twin's control plane it awaits
// (`<utility>:50051`) while the box is fresh or restoring; empty on every other box and once the
// box is restored. What the restore lane (monetize's gunnar plugin) reads to say `awaiting`.
string recharge_from = 19;
// A restore began here and is not whole (`txlog-restoring`): the next stream resumes at
// `log_head_seq + 1`, never at the twin's first sequence again.
bool restoring = 20;
// This box's transaction-log head, read at answer time.
uint64 log_head_seq = 21;
// **The entry a restoring box holds PART of** (additive, 2026-10-07, restore-large-entry).
// An entry larger than one capped call of the restore lane can carry (monetize's doors cut a
// stream 30 s after it opens) is staged on the box's disk as it arrives, durably, and applied
// only once it is whole and its crc checks out. `restore_partial_seq` is that entry
// (`log_head_seq + 1`) and `restore_partial_bytes` how many bytes of its blob the box holds
// durably: the next stream asks the twin for `Mirror.LogFetch{from: seq, from_offset: bytes}`
// and loses at most the piece that was in flight, never the entry. 0 / 0 = nothing staged
// (and from a box older than this field): the next stream starts the entry at byte 0.
uint64 restore_partial_seq = 22;
uint64 restore_partial_bytes = 23;
// **The twin's snapshot, staged across calls** (additive, 2026-10-07, restore-after-snapshot).
// A restore from a twin whose log starts after a snapshot of its own (a twin that took a full
// resync) streams that snapshot first; a 600 MB base needs many capped calls of monetize's
// restore lane, so the box stages it durably and says where the next call resumes it.
//
// `restore_snapshot_resumable`: this box takes a snapshot across several streams. False from a
// box older than this field — the lane then refuses by name ("re-lay the main from the
// current medium") instead of streaming a snapshot that cannot finish.
bool restore_snapshot_resumable = 24;
// The snapshot staged (its `LogChunk.snapshot_id`); empty = none.
string restore_snapshot_id = 25;
// Items of it staged whole, durably — the next stream asks `from_item` = this.
uint64 restore_snapshot_items = 26;
// Bytes of the next item staged durably — the next stream asks `from_offset` = this.
uint64 restore_snapshot_bytes = 27;
// Every item, terminator included, is staged: the box installs it when the next stream
// opens, and the log follows from the snapshot's `at + 1`.
bool restore_snapshot_whole = 28;
// **Is the installer's break-glass token still the first admin's way in?** (additive,
// 2026-10-28, RA5-14). TRUE while no server admin holds a passkey here: nobody exists who
// could let the first admin in. FALSE once one does, however that passkey came (the token's
// first invite, a claim-operator and an invite, a re-laid console with a new store): the
// bootstrap is spent, and a console retires the token on THIS answer, never on a mark of its
// own. Absent from a box older than this field, or a plane that keeps no passkeys.
optional bool break_glass_open = 29;
}
// **What a fresh box is made of**, as the twin election ranks it. Every field has the same
// name, type and ABSENT-NEVER-ZERO contract as its `CapacityResponse` twin in admin.proto:
// an absent optional was not measured — it is never a zero, and a zero is never "unknown".
message BoxFacts {
// = CapacityResponse.cpu_millicores_total: CPUs gunnar may run on × 1000.
optional uint64 cpu_millicores_total = 1;
// = CapacityResponse.ram_bytes_total.
optional uint64 ram_bytes_total = 2;
// = CapacityResponse.total_bytes of the data root — but ABSENT (not 0) when the
// `statvfs` failed (Capacity says `CAPACITY_UNMEASURED` and a 0 there).
optional uint64 data_total_bytes = 3;
// = CapacityResponse.disk_test: the honesty flag for the three speeds below.
// `DISKTEST_UNSPECIFIED` never appears here (a server with this message has the test).
DiskTest disk_test = 4;
// = CapacityResponse.disk_seq_write_bps. Absent unless `DISKTEST_MEASURED`.
optional uint64 disk_seq_write_bps = 5;
// = CapacityResponse.disk_seq_read_bps. Absent unless `DISKTEST_MEASURED`, and ALSO
// absent when the read came back cached (over 5 GB/s: the device was never touched).
optional uint64 disk_seq_read_bps = 6;
// = CapacityResponse.disk_sync_milliops_per_s. Absent unless `DISKTEST_MEASURED`.
optional uint64 disk_sync_milliops_per_s = 7;
// = CapacityResponse.disk_read_how (`direct` | `dropped`, empty when no read leg ran).
string disk_read_how = 8;
// = CapacityResponse.disk_reason. Never empty.
string disk_reason = 9;
// = CapacityResponse.machine_reason. Never empty.
string machine_reason = 10;
}
message TwinCsrRequest {}
message TwinCsrResponse {
// PEM. The key it is for never leaves the twin.
string csr_pem = 1;
}
message InstallTwinCertificateRequest {
// The certificate the primary's issuer signed for this twin's request.
string cert_pem = 1;
// That issuer, which this twin then PINS.
string ca_pem = 2;
// The primary's mutual replication listener port.
uint32 replication_port = 3;
// The host to dial that port at, when the primary reported one (its tunnel
// address). Empty = the host this twin was joined to, as before.
string replication_host = 4;
}
message InstallTwinCertificateResponse {
// Whole days until the certificate lapses.
int64 days_left = 1;
string detail = 2;
}