geth/crates/geth-control/src/lib.rs

704 lines
17 KiB
Rust
Raw Normal View History

2026-05-16 16:32:03 +02:00
use geth_auth::{AuthExplanation, AuthOp};
2026-05-18 03:57:26 +02:00
use geth_cas::{FileConflict, FileRoot, FileRootScan};
2026-05-17 20:32:36 +02:00
use geth_db::{CrSqliteChangeBatch, DbResource};
2026-05-18 04:03:52 +02:00
use geth_discovery::{DiscoveredPeer, PeerCard};
2026-05-17 19:59:03 +02:00
use geth_document::{DocumentResource, DocumentState};
use geth_keychain::KeychainOp;
2026-05-16 21:52:35 +02:00
use geth_kv::{KvEntry, KvResource};
2026-05-17 20:17:26 +02:00
use geth_pipe::{PipeConnection, PipeListener};
2026-05-17 18:23:51 +02:00
use geth_pubsub::PubsubMessage;
2026-05-15 15:08:20 +02:00
use geth_resource::ResourceDescriptor;
use geth_secrets::{BearerAccess, ResourceMasterSecret};
use geth_ssh_identity::{
SshCertApproval, SshCertRequest, SshCertificateRecord, SshRevocationEntry,
};
2026-05-15 15:08:20 +02:00
use geth_types::BlobHash;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ControlRequest {
Status,
NodeId,
2026-05-18 04:03:52 +02:00
PeerCardExport {
out: Option<PathBuf>,
},
PeerCardImport {
path: PathBuf,
},
PeerCardList,
2026-05-18 12:09:50 +02:00
PeerPing {
node: String,
},
2026-05-18 17:01:50 +02:00
PeerAuthCheck {
node: String,
resource: String,
capability: String,
},
2026-05-15 15:08:20 +02:00
ResourceList,
ResourceCreate {
kind: String,
name: String,
},
CasAdd {
path: PathBuf,
},
CasGet {
hash: BlobHash,
out: PathBuf,
},
CasHash {
path: PathBuf,
},
CasHas {
hash: BlobHash,
},
2026-05-16 16:36:35 +02:00
CasPin {
hash: BlobHash,
},
CasUnpin {
hash: BlobHash,
},
2026-05-16 21:10:25 +02:00
CasCleanup {
dry_run: bool,
},
2026-05-15 15:08:20 +02:00
CasList,
2026-05-18 03:50:09 +02:00
CasRootAdd {
name: String,
path: PathBuf,
},
CasRootList,
CasRootScan {
name: String,
},
2026-05-18 03:57:26 +02:00
CasConflictRecord {
root: String,
path: String,
kind: String,
detail: String,
base_tree: Option<BlobHash>,
local_tree: Option<BlobHash>,
remote_tree: Option<BlobHash>,
},
CasConflictList {
root: Option<String>,
},
CasConflictResolve {
conflict_id: String,
resolution: String,
note: Option<String>,
},
KeychainInit {
admin_key_path: Option<PathBuf>,
},
2026-05-15 15:08:20 +02:00
KeychainStatus,
2026-05-16 22:18:49 +02:00
SecretStatus,
SecretCreate {
resource: String,
},
SecretRotate {
resource: String,
},
SecretBearerCreate {
resource: String,
capabilities: Vec<String>,
expires_at_ms: Option<i64>,
},
SecretBearerList,
SecretBearerRevoke {
resource: String,
secret: String,
},
2026-05-15 15:08:20 +02:00
AuthExplain {
subject: String,
resource: String,
capability: String,
},
2026-05-16 16:32:03 +02:00
AuthGrant {
subject: String,
resource: String,
capability: String,
grant_id: Option<String>,
},
AuthRevoke {
resource: String,
grant_id: String,
},
SshCertRequest {
public_key_path: PathBuf,
cert_kind: String,
principals: Vec<String>,
requested_validity: Option<String>,
renewal_of: Option<String>,
reason: Option<String>,
},
SshCertRequests,
SshCertApprove {
request_id: String,
ca_key_path: PathBuf,
valid_for: Option<String>,
serial: Option<u64>,
out: Option<PathBuf>,
},
SshCertImport {
request_id: String,
cert_path: PathBuf,
},
SshCertList,
SshRevocationAdd {
kind: String,
target: String,
reason: Option<String>,
},
SshRevocationList,
SshRevocationExport {
out: PathBuf,
2026-05-17 18:29:47 +02:00
format: String,
2026-05-18 11:51:12 +02:00
ca_public: Option<PathBuf>,
},
2026-05-18 11:56:42 +02:00
SshRevocationImport {
path: PathBuf,
format: String,
},
2026-05-16 21:13:33 +02:00
DbAdd {
name: String,
path: PathBuf,
},
DbStatus {
name: String,
},
2026-05-17 20:32:36 +02:00
DbChanges {
name: String,
after_db_version: Option<i64>,
limit: u32,
},
2026-05-16 21:52:35 +02:00
KvCreate {
name: String,
},
KvSet {
name: String,
key: String,
value: String,
2026-05-18 04:06:41 +02:00
subject: Option<String>,
2026-05-16 21:52:35 +02:00
},
KvGet {
name: String,
key: String,
},
2026-05-16 22:15:18 +02:00
DocumentCreate {
name: String,
},
DocumentStatus {
name: String,
},
2026-05-17 19:59:03 +02:00
DocumentSet {
name: String,
state_json: String,
},
DocumentGet {
name: String,
},
2026-05-17 18:23:51 +02:00
PubsubPub {
topic: String,
message: String,
},
PubsubSub {
topic: String,
},
2026-05-17 20:17:26 +02:00
PipeListen {
name: String,
},
PipeConnect {
target: String,
},
2026-05-15 15:08:20 +02:00
ModuleStub {
module: String,
command: String,
},
}
2026-05-17 20:32:36 +02:00
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
2026-05-15 15:08:20 +02:00
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ControlResponse {
Status(StatusResponse),
NodeId(NodeIdResponse),
2026-05-18 04:03:52 +02:00
PeerCardExported {
card: PeerCard,
out: Option<PathBuf>,
note: String,
},
PeerCardImported {
peer: DiscoveredPeer,
note: String,
},
PeerCardList {
peers: Vec<DiscoveredPeer>,
note: String,
},
2026-05-18 12:09:50 +02:00
PeerPinged {
peer_node_id: String,
peer_agent_id: String,
endpoint_id: String,
alpn: String,
note: String,
},
2026-05-18 17:01:50 +02:00
PeerAuthChecked {
peer_node_id: String,
peer_agent_id: String,
endpoint_id: String,
resource: String,
capability: String,
allowed: bool,
reason: String,
evaluated_ops: usize,
note: String,
},
2026-05-15 15:08:20 +02:00
ResourceList {
resources: Vec<ResourceDescriptor>,
},
ResourceCreated {
resource: ResourceDescriptor,
},
CasAdded {
hash: BlobHash,
size_bytes: u64,
},
CasGot {
hash: BlobHash,
out: PathBuf,
size_bytes: u64,
},
CasHash {
hash: BlobHash,
},
CasHas {
hash: BlobHash,
present: bool,
},
2026-05-16 16:36:35 +02:00
CasPinned {
hash: BlobHash,
pinned: bool,
},
2026-05-16 21:10:25 +02:00
CasCleanup {
removed: Vec<BlobHash>,
retained_pinned: Vec<BlobHash>,
dry_run: bool,
},
2026-05-15 15:08:20 +02:00
CasList {
blobs: Vec<CasBlob>,
},
2026-05-18 03:50:09 +02:00
CasRootAdded {
root: FileRoot,
},
CasRootList {
roots: Vec<FileRoot>,
},
CasRootScanned {
scan: FileRootScan,
},
2026-05-18 03:57:26 +02:00
CasConflictRecorded {
conflict: FileConflict,
},
CasConflictList {
conflicts: Vec<FileConflict>,
},
CasConflictResolved {
conflict: FileConflict,
},
2026-05-15 15:08:20 +02:00
KeychainStatus(KeychainStatusResponse),
KeychainInitialized {
ops: Vec<KeychainOp>,
},
2026-05-16 22:18:49 +02:00
SecretStatus {
secrets: Vec<ResourceMasterSecret>,
},
SecretCreated {
secret: ResourceMasterSecret,
},
SecretBearerCreated {
access: BearerAccess,
},
SecretBearerList {
access: Vec<BearerAccess>,
},
SecretBearerRevoked {
resource: String,
secret: String,
},
2026-05-15 15:08:20 +02:00
AuthExplain(AuthExplanation),
2026-05-16 16:32:03 +02:00
AuthOpRecorded {
op: AuthOp,
},
SshCertRequested {
request: SshCertRequest,
},
SshCertRequests {
requests: Vec<SshCertRequest>,
},
SshCertApproved {
approval: SshCertApproval,
},
SshCertImported {
certificate: SshCertificateRecord,
},
SshCertList {
requests: Vec<SshCertRequest>,
certificates: Vec<SshCertificateRecord>,
},
SshRevocationAdded {
revocation: SshRevocationEntry,
},
SshRevocationList {
revocations: Vec<SshRevocationEntry>,
},
SshRevocationExported {
out: PathBuf,
2026-05-17 18:29:47 +02:00
format: String,
count: usize,
2026-05-17 18:29:47 +02:00
note: String,
},
2026-05-18 11:56:42 +02:00
SshRevocationImported {
revocations: Vec<SshRevocationEntry>,
format: String,
count: usize,
note: String,
},
2026-05-16 21:13:33 +02:00
DbAdded {
db: DbResource,
},
DbStatus {
db: DbResource,
},
2026-05-17 20:32:36 +02:00
DbChanges {
db: DbResource,
batch: CrSqliteChangeBatch,
},
2026-05-16 21:52:35 +02:00
KvCreated {
kv: KvResource,
},
KvSet {
entry: KvEntry,
},
KvGet {
entry: Option<KvEntry>,
},
2026-05-16 22:15:18 +02:00
DocumentCreated {
document: DocumentResource,
},
DocumentStatus {
document: DocumentResource,
},
2026-05-17 19:59:03 +02:00
DocumentSet {
state: DocumentState,
},
DocumentGet {
state: DocumentState,
},
2026-05-17 18:23:51 +02:00
PubsubPublished {
message: PubsubMessage,
},
PubsubMessages {
topic: String,
messages: Vec<PubsubMessage>,
note: String,
},
2026-05-17 20:17:26 +02:00
PipeListening {
listener: PipeListener,
},
PipeConnected {
connection: PipeConnection,
},
2026-05-15 15:08:20 +02:00
NotImplemented {
module: String,
command: String,
},
Error {
message: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatusResponse {
pub home: PathBuf,
pub socket: PathBuf,
pub agent_id: String,
pub node_id: String,
2026-05-16 01:54:00 +02:00
pub iroh_enabled: bool,
pub endpoint_id: Option<String>,
2026-05-16 03:17:45 +02:00
pub iroh_relay_mode: String,
2026-05-16 14:33:45 +02:00
pub iroh_local_discovery: bool,
2026-05-15 15:08:20 +02:00
pub iroh: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NodeIdResponse {
pub agent_id: String,
pub node_id: String,
pub endpoint_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeychainStatusResponse {
pub initialized: bool,
pub admin_keys: usize,
pub users: usize,
pub devices: usize,
pub nodes: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CasBlob {
pub hash: BlobHash,
pub size_bytes: u64,
2026-05-16 16:36:35 +02:00
pub pinned: bool,
2026-05-15 15:08:20 +02:00
}
2026-05-18 12:09:50 +02:00
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum PeerControlRequest {
2026-05-18 17:01:50 +02:00
Ping {
peer_card: PeerCard,
nonce: String,
},
AuthCheck {
peer_card: PeerCard,
resource: String,
capability: String,
nonce: String,
},
2026-05-18 12:09:50 +02:00
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum PeerControlResponse {
Pong {
node_id: String,
agent_id: String,
endpoint_id: String,
remote_endpoint_id: String,
alpn: String,
nonce: String,
note: String,
},
2026-05-18 17:01:50 +02:00
AuthChecked {
node_id: String,
agent_id: String,
endpoint_id: String,
remote_endpoint_id: String,
resource: String,
capability: String,
allowed: bool,
reason: String,
evaluated_ops: usize,
nonce: String,
note: String,
},
2026-05-18 12:09:50 +02:00
Error {
message: String,
},
}
2026-05-15 15:08:20 +02:00
#[derive(Debug, thiserror::Error)]
pub enum ControlError {
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
pub fn encode_request(request: &ControlRequest) -> Result<String, ControlError> {
let mut line = serde_json::to_string(request)?;
line.push('\n');
Ok(line)
}
pub fn decode_request(line: &str) -> Result<ControlRequest, ControlError> {
serde_json::from_str(line).map_err(ControlError::from)
}
pub fn encode_response(response: &ControlResponse) -> Result<String, ControlError> {
let mut line = serde_json::to_string(response)?;
line.push('\n');
Ok(line)
}
pub fn decode_response(line: &str) -> Result<ControlResponse, ControlError> {
serde_json::from_str(line).map_err(ControlError::from)
}
2026-05-18 12:09:50 +02:00
pub fn encode_peer_request(request: &PeerControlRequest) -> Result<String, ControlError> {
let mut line = serde_json::to_string(request)?;
line.push('\n');
Ok(line)
}
pub fn decode_peer_request(line: &str) -> Result<PeerControlRequest, ControlError> {
serde_json::from_str(line).map_err(ControlError::from)
}
pub fn encode_peer_response(response: &PeerControlResponse) -> Result<String, ControlError> {
let mut line = serde_json::to_string(response)?;
line.push('\n');
Ok(line)
}
pub fn decode_peer_response(line: &str) -> Result<PeerControlResponse, ControlError> {
serde_json::from_str(line).map_err(ControlError::from)
}
2026-05-15 15:08:20 +02:00
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn control_request_response_serialization_roundtrip() {
let request = ControlRequest::CasHas {
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
let response = ControlResponse::CasHas {
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
present: true,
};
assert_eq!(
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
response
);
2026-05-17 18:29:47 +02:00
let request = ControlRequest::SshRevocationExport {
out: PathBuf::from("revocations.krl-spec"),
format: "openssh-krl-spec".to_owned(),
2026-05-18 11:51:12 +02:00
ca_public: None,
2026-05-17 18:29:47 +02:00
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
2026-05-18 11:56:42 +02:00
let request = ControlRequest::SshRevocationImport {
path: PathBuf::from("revocations.jsonl"),
format: "jsonl".to_owned(),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
2026-05-17 18:29:47 +02:00
let response = ControlResponse::SshRevocationExported {
out: PathBuf::from("revocations.krl-spec"),
format: "openssh-krl-spec".to_owned(),
count: 2,
note: "OpenSSH KRL specification".to_owned(),
};
assert_eq!(
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
response
);
2026-05-17 19:59:03 +02:00
let request = ControlRequest::DocumentSet {
name: "notes".to_owned(),
state_json: r#"{"title":"notes"}"#.to_owned(),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
2026-05-17 20:17:26 +02:00
let request = ControlRequest::PipeListen {
name: "inbox".to_owned(),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
2026-05-17 20:32:36 +02:00
let request = ControlRequest::DbChanges {
name: "notes".to_owned(),
after_db_version: Some(7),
limit: 10,
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
2026-05-18 03:50:09 +02:00
let request = ControlRequest::CasRootScan {
name: "notes".to_owned(),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
2026-05-18 03:57:26 +02:00
let request = ControlRequest::CasConflictResolve {
conflict_id: "file-conflict:notes:1".to_owned(),
resolution: "keep-local".to_owned(),
note: Some("local file is authoritative".to_owned()),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
2026-05-18 04:03:52 +02:00
let request = ControlRequest::PeerCardExport { out: None };
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
2026-05-18 12:09:50 +02:00
let request = ControlRequest::PeerPing {
node: "node:peer".to_owned(),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
2026-05-18 17:01:50 +02:00
let request = ControlRequest::PeerAuthCheck {
node: "node:peer".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
2026-05-18 12:09:50 +02:00
let response = PeerControlResponse::Pong {
node_id: "node:peer".to_owned(),
agent_id: "agent:peer".to_owned(),
endpoint_id: "endpoint:peer".to_owned(),
remote_endpoint_id: "endpoint:caller".to_owned(),
alpn: "/geth/control/1".to_owned(),
nonce: "nonce".to_owned(),
note: "candidate only".to_owned(),
};
assert_eq!(
decode_peer_response(&encode_peer_response(&response).expect("encode"))
.expect("decode"),
response
);
2026-05-18 17:01:50 +02:00
let response = PeerControlResponse::AuthChecked {
node_id: "node:peer".to_owned(),
agent_id: "agent:peer".to_owned(),
endpoint_id: "endpoint:peer".to_owned(),
remote_endpoint_id: "endpoint:caller".to_owned(),
resource: "resource:cas:local".to_owned(),
capability: "cas.fetch".to_owned(),
allowed: false,
reason: "no grant".to_owned(),
evaluated_ops: 0,
nonce: "nonce".to_owned(),
note: "protected".to_owned(),
};
assert_eq!(
decode_peer_response(&encode_peer_response(&response).expect("encode"))
.expect("decode"),
response
);
2026-05-15 15:08:20 +02:00
}
}