use std::path::{Path, PathBuf};
use clawft_types::secret::SecretString;
use serde_json::{Value, json};
use super::seed::{API_TIMEOUT, SeedApiRuntime};
use super::seed_http::Method;
use super::types::RuntimeError;
pub const KIND_SEED_FIRMWARE: &str = "seed-firmware";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeedBackup {
dir: PathBuf,
node_id: String,
device_id: String,
public_key: String,
firmware: String,
files: Vec<(String, String)>,
manifest_blake3: String,
}
pub const REQUIRED_BACKUP_FILES: [&str; 4] = [
"status.json",
"identity.json",
"apps.json",
"witness-chain.json",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpgradeOutcome {
UpToDate {
version: String,
},
Applied {
from: String,
},
}
fn gated(status: &Value) -> bool {
status
.pointer("/integrity/writes_gated")
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn write_private(path: &Path, bytes: &[u8]) -> Result<(), RuntimeError> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(path)
.map_err(|e| RuntimeError::Backend(format!("backup write {}: {e}", path.display())))?;
f.write_all(bytes)
.map_err(|e| RuntimeError::Backend(format!("backup write: {e}")))
}
impl SeedBackup {
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn node_id(&self) -> &str {
&self.node_id
}
pub fn device_id(&self) -> &str {
&self.device_id
}
pub fn firmware(&self) -> &str {
&self.firmware
}
pub fn files(&self) -> &[(String, String)] {
&self.files
}
pub fn verify(&self) -> Result<(), RuntimeError> {
let refuse = |m: String| RuntimeError::AdmissionRefused(m);
let m = std::fs::read(self.dir.join("manifest.json"))
.map_err(|e| refuse(format!("backup manifest: {e}")))?;
if blake3::hash(&m).to_hex().as_str() != self.manifest_blake3 {
return Err(refuse("backup manifest changed".into()));
}
let doc: Value =
serde_json::from_slice(&m).map_err(|e| refuse(format!("backup manifest: {e}")))?;
if doc != self.manifest_doc() {
return Err(refuse("backup manifest does not match this backup".into()));
}
for req in REQUIRED_BACKUP_FILES {
if !self.files.iter().any(|(n, _)| n == req) {
return Err(refuse(format!("backup lacks {req}")));
}
}
for (name, hash) in &self.files {
let b = std::fs::read(self.dir.join(name))
.map_err(|e| refuse(format!("backup file {name}: {e}")))?;
if blake3::hash(&b).to_hex().as_str() != hash {
return Err(refuse(format!("backup file {name} changed")));
}
}
Ok(())
}
fn manifest_doc(&self) -> Value {
json!({
"node_id": self.node_id,
"device_id": self.device_id,
"public_key": self.public_key,
"firmware": self.firmware,
"files": self.files,
})
}
pub fn audit(&self) -> Value {
json!({
"node_id": self.node_id,
"device_id": self.device_id,
"firmware": self.firmware,
"files": self.files.len(),
"manifest_blake3": self.manifest_blake3,
})
}
}
fn identity_field(v: &Value, key: &str) -> Result<String, RuntimeError> {
v.get(key)
.and_then(Value::as_str)
.filter(|s| !s.is_empty() && s.len() <= 256)
.map(str::to_string)
.ok_or_else(|| RuntimeError::Backend(format!("seed /api/v1/identity: no {key}")))
}
impl SeedApiRuntime {
pub async fn pair(
&self,
client_name: &str,
bootstrap: &SecretString,
) -> Result<(), RuntimeError> {
if !crate::workload_pkg::manifest::valid_token(client_name, 64) {
return Err(RuntimeError::InvalidConfig(
"client name must be a plain token".into(),
));
}
let t = &self.transport;
let (s, _) = t
.request(
Method::Post,
"/api/v1/pair/window",
None,
bootstrap,
API_TIMEOUT,
)
.await?;
if !(200..300).contains(&s) {
return Err(RuntimeError::Backend(format!("seed pair window: HTTP {s}")));
}
let body = json!({ "client_name": client_name });
let (s, v) = t
.request(
Method::Post,
"/api/v1/pair",
Some(&body),
bootstrap,
API_TIMEOUT,
)
.await?;
if !(200..300).contains(&s) {
return Err(RuntimeError::Backend(format!("seed pair: HTTP {s}")));
}
let token = v
.get("token")
.and_then(Value::as_str)
.filter(|t| t.len() >= 16 && t.len() <= 512 && t.bytes().all(|b| b.is_ascii_graphic()))
.ok_or_else(|| {
RuntimeError::Backend("seed pair: no usable token in response".into())
})?;
self.creds.put(&self.cfg.node_id, SecretString::new(token))
}
pub async fn writes_gated(&self) -> Result<bool, RuntimeError> {
let s = self
.api(Method::Get, "/api/v1/status", None, API_TIMEOUT)
.await?;
Ok(gated(&s))
}
pub async fn backup(&self, dir: &Path) -> Result<SeedBackup, RuntimeError> {
let mut docs: Vec<(String, Value)> = Vec::new();
for (name, path) in [
("status.json", "/api/v1/status"),
("identity.json", "/api/v1/identity"),
("apps.json", "/api/v1/apps"),
("witness-chain.json", "/api/v1/witness/chain"),
] {
docs.push((
name.into(),
self.api(Method::Get, path, None, API_TIMEOUT).await?,
));
}
for c in self.installed().await? {
let v = self
.api(
Method::Get,
&format!("/api/v1/apps/{}/config", c.id),
None,
API_TIMEOUT,
)
.await?;
docs.push((format!("config-{}.json", c.id), v));
}
let identity = docs
.iter()
.find(|(n, _)| n == "identity.json")
.map(|(_, v)| v.clone())
.unwrap_or(Value::Null);
let device_id = identity_field(&identity, "device_id")?;
let public_key = identity_field(&identity, "public_key")?;
let firmware = identity_field(&identity, "firmware_version")?;
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.mode(0o700)
.create(dir)
.map_err(|e| RuntimeError::Backend(format!("backup dir {}: {e}", dir.display())))?;
}
let mut files = Vec::new();
for (name, v) in &docs {
let bytes = serde_json::to_vec_pretty(v).unwrap_or_default();
write_private(&dir.join(name), &bytes)?;
files.push((name.clone(), blake3::hash(&bytes).to_hex().to_string()));
}
let mut backup = SeedBackup {
dir: dir.to_path_buf(),
node_id: self.cfg.node_id.clone(),
device_id,
public_key,
firmware,
files,
manifest_blake3: String::new(),
};
let manifest = serde_json::to_vec_pretty(&backup.manifest_doc()).unwrap_or_default();
write_private(&dir.join("manifest.json"), &manifest)?;
backup.manifest_blake3 = blake3::hash(&manifest).to_hex().to_string();
Ok(backup)
}
pub async fn check_backup(
&self,
backup: &SeedBackup,
same_firmware: bool,
) -> Result<String, RuntimeError> {
backup.verify()?;
let refuse = |m: &str| Err(RuntimeError::AdmissionRefused(m.into()));
if backup.node_id != self.cfg.node_id {
return refuse("backup was taken from a different Seed node");
}
let id = self
.api(Method::Get, "/api/v1/identity", None, API_TIMEOUT)
.await?;
if identity_field(&id, "device_id")? != backup.device_id
|| identity_field(&id, "public_key")? != backup.public_key
{
return refuse("backup device identity does not match this Seed");
}
let firmware = identity_field(&id, "firmware_version")?;
if same_firmware && firmware != backup.firmware {
return refuse("backup predates the current firmware; take a new backup");
}
Ok(firmware)
}
pub async fn upgrade_firmware(
&self,
backup: &SeedBackup,
target_version: &str,
) -> Result<UpgradeOutcome, RuntimeError> {
if !super::cog_spec::valid_value(target_version) {
return Err(RuntimeError::InvalidConfig(
"firmware target version must be a plain version string".into(),
));
}
self.check_backup(backup, true).await?;
if self.writes_gated().await? {
return Err(RuntimeError::InvalidState(
"writes are gated; recover (truncate-confirm after backup) before upgrading".into(),
));
}
let check = self
.api(Method::Get, "/api/v1/upgrade/check", None, API_TIMEOUT)
.await?;
let current = check
.get("current_version")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if !check
.get("pending_update")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return Ok(UpgradeOutcome::UpToDate { version: current });
}
match check.get("target_version").and_then(Value::as_str) {
Some(t) if t == target_version => {}
Some(t) => {
return Err(RuntimeError::AdmissionRefused(format!(
"pending firmware is {t}, the operator pinned {target_version}"
)));
}
None => {
return Err(RuntimeError::AdmissionRefused(
"the Seed does not say which firmware is pending; refusing to apply it".into(),
));
}
}
self.api(Method::Post, "/api/v1/upgrade/apply", None, API_TIMEOUT * 6)
.await?;
Ok(UpgradeOutcome::Applied { from: current })
}
pub async fn recover_writes_gated(&self, backup: &SeedBackup) -> Result<(), RuntimeError> {
self.check_backup(backup, false).await?;
if !self.writes_gated().await? {
return Err(RuntimeError::InvalidState(
"writes are not gated; nothing to recover".into(),
));
}
self.api(
Method::Post,
"/api/v1/store/truncate-confirm",
Some(&json!({ "confirm": true })),
API_TIMEOUT,
)
.await
.map(|_| ())
}
}