Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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;
}