chore: pre-migration snapshot
Fleet monitoring/control plane: UI, node agent, ops wiring
This commit is contained in:
@@ -8,6 +8,7 @@ axum = { version = "0.7", features = ["macros", "json"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_jcs = "0.1"
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["trace"] }
|
||||
tracing = "0.1"
|
||||
@@ -21,3 +22,6 @@ anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
async-stream = "0.3"
|
||||
futures-core = "0.3"
|
||||
blake3 = "1"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
79
command-center/src/bin/cc-receipt.rs
Normal file
79
command-center/src/bin/cc-receipt.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use vaultmesh_command_center::contracts::map_event_envelope_to_receipt::map_event_envelope_to_receipt;
|
||||
use vaultmesh_command_center::contracts::receipt_v1::ReceiptV1;
|
||||
use vaultmesh_command_center::state::EventEnvelope;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "cc-receipt", about = "Export and verify VaultMesh Receipt v1 from EventEnvelope")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Export a Receipt v1 JSON from an EventEnvelope JSON or JSONL line.
|
||||
Export {
|
||||
/// Path to EventEnvelope JSON (or JSONL if --line is provided).
|
||||
#[arg(long)]
|
||||
event_json: PathBuf,
|
||||
/// If set, treat event_json as JSONL and read this 1-based line.
|
||||
#[arg(long)]
|
||||
line: Option<usize>,
|
||||
/// Output file for Receipt v1 JSON.
|
||||
#[arg(long)]
|
||||
out: PathBuf,
|
||||
},
|
||||
|
||||
/// Verify hashes of a Receipt v1 JSON.
|
||||
Verify {
|
||||
#[arg(long)]
|
||||
receipt: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.cmd {
|
||||
Command::Export {
|
||||
event_json,
|
||||
line,
|
||||
out,
|
||||
} => {
|
||||
let env = load_event_envelope(&event_json, line)?;
|
||||
let receipt = map_event_envelope_to_receipt(&env);
|
||||
fs::write(&out, serde_json::to_vec_pretty(&receipt)?)
|
||||
.with_context(|| format!("write {}", out.display()))?;
|
||||
println!("wrote {}", out.display());
|
||||
}
|
||||
Command::Verify { receipt } => {
|
||||
let bytes = fs::read(&receipt).with_context(|| format!("read {}", receipt.display()))?;
|
||||
let r: ReceiptV1 = serde_json::from_slice(&bytes)?;
|
||||
r.verify_hashes()?;
|
||||
println!("OK: hashes verify");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_event_envelope(path: &PathBuf, line: Option<usize>) -> Result<EventEnvelope> {
|
||||
let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
||||
|
||||
if let Some(n) = line {
|
||||
let s = std::str::from_utf8(&bytes)?;
|
||||
let line_str = s
|
||||
.lines()
|
||||
.nth(n.saturating_sub(1))
|
||||
.ok_or_else(|| anyhow!("JSONL line {} not found", n))?;
|
||||
let env: EventEnvelope = serde_json::from_str(line_str)?;
|
||||
Ok(env)
|
||||
} else {
|
||||
let env: EventEnvelope = serde_json::from_slice(&bytes)?;
|
||||
Ok(env)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use serde_json::Value;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
use crate::state::EventEnvelope;
|
||||
|
||||
use super::receipt_v1::{
|
||||
genesis_prev_blake3, ReceiptRequest, ReceiptResponse, ReceiptTarget, ReceiptV1,
|
||||
};
|
||||
|
||||
/// Map an existing canonical EventEnvelope into a schema-valid Receipt V1.
|
||||
pub fn map_event_envelope_to_receipt(envelope: &EventEnvelope) -> ReceiptV1 {
|
||||
let mut canon = envelope.clone();
|
||||
canon.canonicalize_in_place();
|
||||
|
||||
let created_at = canon
|
||||
.ts
|
||||
.format(&Rfc3339)
|
||||
.expect("EventEnvelope ts should format to RFC3339");
|
||||
|
||||
// Payload is already canonicalized by EventEnvelope rules (sorted object keys, arrays preserved).
|
||||
let payload: Value = canon.payload.clone();
|
||||
|
||||
let target = canon
|
||||
.node_id
|
||||
.as_ref()
|
||||
.map(|id| ReceiptTarget {
|
||||
id: id.to_string(),
|
||||
name: None,
|
||||
ip: None,
|
||||
});
|
||||
|
||||
let mut receipt = ReceiptV1 {
|
||||
receipt_version: "1".to_string(),
|
||||
created_at,
|
||||
source: "command-center".to_string(),
|
||||
action: canon.kind.clone(),
|
||||
reason: None,
|
||||
target,
|
||||
request: ReceiptRequest {
|
||||
method: "POST".to_string(),
|
||||
path: "/api/events".to_string(),
|
||||
body: payload.clone(),
|
||||
},
|
||||
response: ReceiptResponse {
|
||||
status: 200,
|
||||
ok: true,
|
||||
data: payload,
|
||||
raw: String::new(),
|
||||
},
|
||||
prev_blake3: genesis_prev_blake3(),
|
||||
hash_alg: String::new(),
|
||||
blake3: String::new(),
|
||||
sha256: String::new(),
|
||||
plan_file: None,
|
||||
plan_blake3: None,
|
||||
plan_sha256: None,
|
||||
lock_file: None,
|
||||
lock_started_at: None,
|
||||
force: None,
|
||||
cwd: None,
|
||||
user: None,
|
||||
hostname: None,
|
||||
argv: None,
|
||||
sig_alg: None,
|
||||
signer_pub: None,
|
||||
signature: None,
|
||||
signed_at: None,
|
||||
};
|
||||
|
||||
receipt.compute_hashes().expect("hash computation must succeed");
|
||||
receipt
|
||||
}
|
||||
2
command-center/src/contracts/mod.rs
Normal file
2
command-center/src/contracts/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod map_event_envelope_to_receipt;
|
||||
pub mod receipt_v1;
|
||||
146
command-center/src/contracts/receipt_v1.rs
Normal file
146
command-center/src/contracts/receipt_v1.rs
Normal file
@@ -0,0 +1,146 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use blake3::Hasher as Blake3Hasher;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReceiptTarget {
|
||||
pub id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReceiptRequest {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReceiptResponse {
|
||||
pub status: u16,
|
||||
pub ok: bool,
|
||||
pub data: Value,
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReceiptV1 {
|
||||
pub receipt_version: String,
|
||||
pub created_at: String,
|
||||
pub source: String,
|
||||
pub action: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub target: Option<ReceiptTarget>,
|
||||
|
||||
pub request: ReceiptRequest,
|
||||
pub response: ReceiptResponse,
|
||||
|
||||
pub prev_blake3: String,
|
||||
|
||||
pub hash_alg: String,
|
||||
pub blake3: String,
|
||||
pub sha256: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plan_file: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plan_blake3: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plan_sha256: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lock_file: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lock_started_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub force: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cwd: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hostname: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub argv: Option<Vec<String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sig_alg: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub signer_pub: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub signature: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub signed_at: Option<String>,
|
||||
}
|
||||
|
||||
pub fn genesis_prev_blake3() -> String {
|
||||
"0".repeat(64)
|
||||
}
|
||||
|
||||
impl ReceiptV1 {
|
||||
fn strip_field(v: &mut Value, key: &str) {
|
||||
if let Value::Object(map) = v {
|
||||
map.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce canonical JCS bytes without self-referential hash/signature fields.
|
||||
pub fn canonical_body_bytes(&self) -> Result<Vec<u8>> {
|
||||
let mut v = serde_json::to_value(self)?;
|
||||
Self::strip_field(&mut v, "hash_alg");
|
||||
Self::strip_field(&mut v, "blake3");
|
||||
Self::strip_field(&mut v, "sha256");
|
||||
Self::strip_field(&mut v, "sig_alg");
|
||||
Self::strip_field(&mut v, "signer_pub");
|
||||
Self::strip_field(&mut v, "signature");
|
||||
Self::strip_field(&mut v, "signed_at");
|
||||
|
||||
let bytes = serde_jcs::to_vec(&v).map_err(|e| anyhow!("serde_jcs: {e}"))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
pub fn compute_hashes(&mut self) -> Result<()> {
|
||||
let bytes = self.canonical_body_bytes()?;
|
||||
|
||||
let mut b3 = Blake3Hasher::new();
|
||||
b3.update(&bytes);
|
||||
let b3_hex = b3.finalize().to_hex().to_string();
|
||||
|
||||
let mut s = Sha256::new();
|
||||
s.update(&bytes);
|
||||
let sha_hex = hex::encode(s.finalize());
|
||||
|
||||
self.hash_alg = "blake3+sha256".to_string();
|
||||
self.blake3 = b3_hex;
|
||||
self.sha256 = sha_hex;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_hashes(&self) -> Result<()> {
|
||||
let mut tmp = self.clone();
|
||||
tmp.compute_hashes()?;
|
||||
if tmp.blake3 != self.blake3 {
|
||||
return Err(anyhow!(
|
||||
"blake3 mismatch: expected {}, got {}",
|
||||
self.blake3, tmp.blake3
|
||||
));
|
||||
}
|
||||
if tmp.sha256 != self.sha256 {
|
||||
return Err(anyhow!(
|
||||
"sha256 mismatch: expected {}, got {}",
|
||||
self.sha256, tmp.sha256
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
5
command-center/src/lib.rs
Normal file
5
command-center/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod cli;
|
||||
pub mod contracts;
|
||||
pub mod logs;
|
||||
pub mod routes;
|
||||
pub mod state;
|
||||
@@ -1,11 +1,3 @@
|
||||
mod cli;
|
||||
mod logs;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use crate::cli::{Cli, Commands, LogsAction};
|
||||
use crate::routes::app;
|
||||
use crate::state::{now_utc_seconds, AppState, CommandPayload, SignedCommand};
|
||||
use base64::Engine;
|
||||
use clap::Parser;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
@@ -15,6 +7,10 @@ use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
use uuid::Uuid;
|
||||
use vaultmesh_command_center::cli::{Cli, Commands, LogsAction};
|
||||
use vaultmesh_command_center::logs;
|
||||
use vaultmesh_command_center::routes::app;
|
||||
use vaultmesh_command_center::state::{now_utc_seconds, AppState, CommandPayload, SignedCommand};
|
||||
|
||||
/// Load an existing Ed25519 signing key or generate a new one.
|
||||
fn load_or_init_signing_key() -> SigningKey {
|
||||
|
||||
41
command-center/tests/receipt_adapter.rs
Normal file
41
command-center/tests/receipt_adapter.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
use vaultmesh_command_center::contracts::map_event_envelope_to_receipt::map_event_envelope_to_receipt;
|
||||
use vaultmesh_command_center::contracts::receipt_v1::{genesis_prev_blake3, ReceiptV1};
|
||||
use vaultmesh_command_center::state::EventEnvelope;
|
||||
|
||||
#[test]
|
||||
fn maps_envelope_to_receipt_and_verifies_hashes() {
|
||||
let id = Uuid::parse_str("00000000-0000-0000-0000-0000000000aa").unwrap();
|
||||
let ts = OffsetDateTime::parse(
|
||||
"2025-12-17T23:07:10Z",
|
||||
&time::format_description::well_known::Rfc3339,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"note": "hello",
|
||||
"details": {"x": 2, "a": 1},
|
||||
});
|
||||
|
||||
let env = EventEnvelope::new(
|
||||
id,
|
||||
ts,
|
||||
"note".to_string(),
|
||||
None,
|
||||
"operator".to_string(),
|
||||
payload,
|
||||
);
|
||||
|
||||
let receipt: ReceiptV1 = map_event_envelope_to_receipt(&env);
|
||||
|
||||
assert_eq!(receipt.receipt_version, "1");
|
||||
assert_eq!(receipt.source, "command-center");
|
||||
assert_eq!(receipt.action, "note");
|
||||
assert_eq!(receipt.prev_blake3, genesis_prev_blake3());
|
||||
assert!(!receipt.blake3.is_empty());
|
||||
assert!(!receipt.sha256.is_empty());
|
||||
|
||||
// Hashes must verify against canonical recomputation.
|
||||
receipt.verify_hashes().expect("hashes should verify");
|
||||
}
|
||||
Reference in New Issue
Block a user