crates/gunnar-ui-server/src/api/server_key.rs
//! **`GET /api/server-key` — the forge's SSH host key, as gunnar holds it.**
//!
//! Owner, 2026-10-02: the Overview's second line is "Server key SHA256:…", so a
//! user can check the fingerprint their ssh client shows on first connect
//! against the console before typing `yes`. It is the value the installer pins
//! in `forge-host-keys.toml` (gunnar.rs: `SHA256:e4I/Cns…`), and it is READ
//! LIVE, never written into the console: a re-imaged box mints a new key, and a
//! hard-coded fingerprint would then vouch for a key nobody serves.
//!
//! # Where gunnar says it
//!
//! `Mirror.LogHead`, asked WITH a nonce, answers `answer_key`: "the answering
//! box's public key, one OpenSSH `authorized_keys` line — its SSH host key, the
//! same one it proves itself with on :2222" (gunnar `mirror.proto` field 13).
//! A twin pins exactly this. No other call on gunnar's control plane carries
//! the host key, and this one needs no change to gunnar: the console's key is a
//! server reader there (`require_log_reader`, `Access::Server` Read). The nonce
//! is a fresh random one per ask; without it gunnar signs nothing and sends no
//! key. The call has no side effect: a heartbeat is recorded only for a
//! `report`, which this never sends.
//!
//! The fingerprint is computed HERE from the key gunnar sent, with `ssh-key`'s
//! own SHA-256 fingerprint — the `SHA256:<base64, no padding>` ssh prints —
//! never taken as a string from anybody.
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use crate::api::{ApiError, ApiResult};
use crate::app::AppState;
use crate::grpc::Fail;
use crate::pb;
/// The forge's SSH host key.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ServerKey {
/// `SHA256:…`, as `ssh` and `ssh-keygen -lf` print it.
pub fingerprint: String,
/// The public key, one OpenSSH line (`ssh-ed25519 AAAA…`), comment dropped.
pub public_key: String,
}
fn refuse(code: &str, message: impl Into<String>) -> ApiError {
ApiError(Fail { code: code.to_owned(), message: message.into() })
}
/// A fresh nonce: 16 random bytes, hex.
fn nonce() -> String {
let mut b = [0u8; 16];
rand_bytes(&mut b);
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn rand_bytes(out: &mut [u8]) {
use rand::Rng as _;
rand::rand_core::UnwrapErr(rand::rngs::SysRng).fill_bytes(out);
}
/// The key line gunnar sent → what the page shows. `None` for anything that is
/// not one OpenSSH public key.
pub fn server_key_of(line: &str) -> Option<ServerKey> {
let key = ssh_key::PublicKey::from_openssh(line.trim()).ok()?;
let mut bare = key.clone();
bare.set_comment("");
Some(ServerKey {
fingerprint: key.fingerprint(ssh_key::HashAlg::Sha256).to_string(),
public_key: bare.to_openssh().ok()?.trim().to_owned(),
})
}
/// `GET /api/server-key`
pub async fn server_key(State(state): State<AppState>) -> ApiResult<ServerKey> {
let head = state
.gunnar
.mirror()
.log_head(pb::LogHeadRequest { nonce: nonce(), ..Default::default() })
.await
.map_err(ApiError::from)?
.into_inner();
server_key_of(&head.answer_key).map(Json).ok_or_else(|| {
refuse(
"unavailable",
"Gunnar did not name its SSH host key (Mirror.LogHead sent no answer_key): the server key cannot be shown",
)
})
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use tonic::{Request, Response, Status};
use tower::ServiceExt as _;
use super::*;
use crate::grpc::Gunnar;
fn host_key() -> ssh_key::PrivateKey {
ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[42u8; 32]))
}
/// gunnar's `log_head_signed`: the key only for a caller that sent a nonce.
#[derive(Clone, Default)]
struct FakeForge {
nonces: Arc<Mutex<Vec<String>>>,
silent: bool,
}
type Chunks = std::pin::Pin<Box<dyn tokio_stream::Stream<Item = Result<pb::LogChunk, Status>> + Send + 'static>>;
#[tonic::async_trait]
impl pb::mirror_server::Mirror for FakeForge {
async fn log_head(&self, r: Request<pb::LogHeadRequest>) -> Result<Response<pb::LogHeadResponse>, Status> {
let r = r.into_inner();
assert!(r.report.is_none(), "the console sent a heartbeat report: it would be recorded as a twin");
self.nonces.lock().unwrap().push(r.nonce.clone());
let mut head = pb::LogHeadResponse { head_seq: 7, first_seq: 1, ..Default::default() };
if !r.nonce.trim().is_empty() && !self.silent {
head.answer_key = host_key().public_key().to_openssh().unwrap();
head.answer_sig = "sig".into();
}
Ok(Response::new(head))
}
async fn log_fetch(&self, _: Request<pb::LogFetchRequest>) -> Result<Response<Chunks>, Status> {
Err(Status::unimplemented("not here"))
}
async fn snapshot_fetch(&self, _: Request<pb::SnapshotFetchRequest>) -> Result<Response<Chunks>, Status> {
Err(Status::unimplemented("not here"))
}
}
async fn serve(f: FakeForge) -> String {
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = format!("http://{}", l.local_addr().unwrap());
tokio::spawn(tonic::transport::Server::builder().add_service(pb::mirror_server::MirrorServer::new(f)).serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(l)));
at
}
fn router(at: &str) -> axum::Router {
let signer = crate::signer::Signer::new(ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[9u8; 32]))).unwrap();
let gunnar = Gunnar::connect_lazy(at, Arc::new(signer)).unwrap();
let store = crate::state::StateStore::open(tempfile::Builder::new().suffix(".redb").tempfile().unwrap().into_temp_path().keep().unwrap()).unwrap();
crate::serve::router(Arc::new(crate::app::App {
webauthn: None,
#[cfg(feature = "metered")]
monetize: None,
admin_token: None,
events: crate::sse::EventHub::detached(),
gunnar,
twin: None,
guest: None,
reviewers: Arc::new(crate::reviewer::ReviewerKeys::new(at.to_owned(), store.clone())),
tenants: Arc::new(crate::tenant::TenantKeys::new(at.to_owned(), store)),
forges: Default::default(),
forge_apps: Default::default(),
ui_dir: None,
caches: crate::api::Caches::default(),
objects: Default::default(),
readmes: Default::default(),
imports: Default::default(),
}))
}
async fn get(app: &axum::Router) -> (http::StatusCode, serde_json::Value) {
let request = http::Request::builder().uri("/api/server-key").body(axum::body::Body::empty()).unwrap();
let response = app.clone().oneshot(request).await.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), 1 << 20).await.unwrap();
(status, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
}
/// ★ **The Overview's server key is the forge's own SSH host key, read
/// live from gunnar**: the fingerprint ssh prints for it, computed here from
/// the key gunnar sent, asked with a fresh nonce each time.
#[tokio::test]
async fn the_server_key_is_the_forges_ssh_host_key_read_live_from_gunnar() {
let forge = FakeForge::default();
let app = router(&serve(forge.clone()).await);
let (status, json) = get(&app).await;
assert_eq!(status, http::StatusCode::OK, "{json}");
let want = host_key().public_key().fingerprint(ssh_key::HashAlg::Sha256).to_string();
assert!(want.starts_with("SHA256:"), "{want}");
assert_eq!(json["fingerprint"], want.as_str(), "{json}");
assert!(json["public_key"].as_str().unwrap().starts_with("ssh-ed25519 AAAA"), "{json}");
let (_, again) = get(&app).await;
assert_eq!(again["fingerprint"], want.as_str());
let nonces = forge.nonces.lock().unwrap().clone();
assert_eq!(nonces.len(), 2);
assert!(nonces.iter().all(|n| n.len() == 32), "a 16-byte nonce per ask: {nonces:?}");
assert_ne!(nonces[0], nonces[1], "the nonce is fresh per ask");
}
/// A gunnar that names no key: a refusal that says so, never a made-up line.
#[tokio::test]
async fn a_gunnar_that_names_no_key_is_a_refusal_not_a_blank_fingerprint() {
let app = router(&serve(FakeForge { silent: true, ..FakeForge::default() }).await);
let (status, json) = get(&app).await;
assert_ne!(status, http::StatusCode::OK, "{json}");
assert!(json.to_string().contains("answer_key"), "{json}");
assert_eq!(server_key_of("not a key"), None);
assert_eq!(server_key_of(""), None);
}
}