use cyberbrain_core::{Error, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const TOKEN_ENV: &str = "CYBERBRAIN_HUB_TOKEN";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invitation {
pub kind: String,
pub version: u32,
pub device: String,
pub name: String,
pub token: String,
pub hub_url: Option<String>,
pub inference_url: Option<String>,
#[serde(default)]
pub hub_cert_sha256: Option<String>,
}
pub fn parse_invitation(text: &str) -> Result<Invitation> {
let inv: Invitation = serde_json::from_str(text)
.map_err(|e| Error::Config(format!("not an invitation file: {e}")))?;
if inv.kind != "cyberbrain.hub.invitation" {
return Err(Error::Config(format!(
"file says it is {:?}, not an invitation",
inv.kind
)));
}
if inv.version == 0 || inv.version > 2 {
return Err(Error::Config(format!(
"invitation version {} is newer than this program understands; upgrade it",
inv.version
)));
}
if inv.hub_url.is_none() {
return Err(Error::Config(
"the invitation names no hub address; ask for one issued with --hub-url".into(),
));
}
Ok(inv)
}
pub const FLEET_INVITATION_KIND: &str = "cyberbrain.hub.fleet-invitation";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FleetInvitation {
pub kind: String,
pub version: u32,
pub code: String,
pub label: String,
pub hub_url: String,
#[serde(default)]
pub inference_url: Option<String>,
#[serde(default)]
pub hub_cert_sha256: Option<String>,
pub expires_at: String,
}
pub enum AnyInvitation {
Device(Invitation),
Fleet(FleetInvitation),
}
pub fn parse_any_invitation(text: &str) -> Result<AnyInvitation> {
let v: serde_json::Value = serde_json::from_str(text)
.map_err(|e| Error::Config(format!("not an invitation file: {e}")))?;
if v.get("kind").and_then(|k| k.as_str()) == Some(FLEET_INVITATION_KIND) {
let inv: FleetInvitation = serde_json::from_value(v)
.map_err(|e| Error::Config(format!("not a readable fleet invitation: {e}")))?;
if inv.version != 1 {
return Err(Error::Config(format!(
"fleet invitation version {} is newer than this program understands; upgrade it",
inv.version
)));
}
return Ok(AnyInvitation::Fleet(inv));
}
parse_invitation(text).map(AnyInvitation::Device)
}
#[derive(Debug, Clone, Deserialize)]
pub struct Enrolled {
pub device: String,
pub name: String,
pub token: String,
}
pub async fn enrol_at_hub(
egress: &cyberbrain_policy::Egress,
actor: &cyberbrain_policy::Actor,
inv: &FleetInvitation,
machine: &str,
project: &str,
) -> Result<Enrolled> {
let url = format!("{}/api/v1/enrol", inv.hub_url.trim_end_matches('/'));
let pin = inv
.hub_cert_sha256
.as_deref()
.map(cyberbrain_policy::egress::transport::CertificatePin::parse)
.transpose()?;
let ticket = egress.open(actor, cyberbrain_core::EgressPurpose::HubEnrolment, &url)?;
let body =
serde_json::json!({ "code": inv.code, "machine": machine, "project": project }).to_string();
let resp = cyberbrain_policy::egress::transport::post_json(
&ticket,
&url,
&[("x-cyberbrain-version", env!("CARGO_PKG_VERSION"))],
body,
pin,
)
.await?;
let text = String::from_utf8_lossy(&resp.body).to_string();
if resp.status == 200 {
return serde_json::from_str(&text).map_err(|e| {
Error::Config(format!(
"the hub's answer to the enrolment is not readable: {e}"
))
});
}
let json: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
let message = json
.get("error")
.and_then(|v| v.as_str())
.unwrap_or(text.trim())
.to_string();
Err(Error::Config(format!(
"the hub did not enrol this project ({}): {message}",
resp.status
)))
}
fn config_base() -> Option<PathBuf> {
if cfg!(windows) {
std::env::var_os("APPDATA").map(PathBuf::from)
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
}
}
fn hub_file_in(base: &Path, hub_url: &str, device: Option<&str>, ext: &str) -> PathBuf {
let key = match device {
Some(d) => format!("{hub_url}\n{d}"),
None => hub_url.to_string(),
};
let name = blake3::hash(key.as_bytes()).to_hex().to_string();
base.join("cyberbrain")
.join("hub-tokens")
.join(format!("{}.{ext}", &name[..32]))
}
fn existing_in(base: &Path, hub_url: &str, device: Option<&str>, ext: &str) -> PathBuf {
let own = hub_file_in(base, hub_url, device, ext);
if device.is_none() || own.exists() {
own
} else {
hub_file_in(base, hub_url, None, ext)
}
}
fn write_in(
base: &Path,
hub_url: &str,
device: Option<&str>,
ext: &str,
text: &str,
) -> Result<PathBuf> {
let path = hub_file_in(base, hub_url, device, ext);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
std::fs::write(&path, text).map_err(|e| Error::Io {
path: path.clone(),
source: e,
})?;
Ok(path)
}
pub fn token_for(hub_url: &str, device: Option<&str>) -> Result<String> {
if let Ok(t) = std::env::var(TOKEN_ENV) {
let t = t.trim().to_string();
if !t.is_empty() {
return Ok(t);
}
}
let base = config_base()
.ok_or_else(|| Error::Config("no configuration directory to read a token from".into()))?;
token_in(&base, hub_url, device)
}
fn token_in(base: &Path, hub_url: &str, device: Option<&str>) -> Result<String> {
let path = existing_in(base, hub_url, device, "token");
let text = std::fs::read_to_string(&path).map_err(|e| {
Error::Config(format!(
"no token for {hub_url}: {} ({e}). Enrol with `cyberbrain hub enrol <invitation>`, \
or set {TOKEN_ENV}",
path.display()
))
})?;
Ok(text.trim().to_string())
}
pub fn pin_path(hub_url: &str) -> Option<PathBuf> {
Some(hub_file_in(&config_base()?, hub_url, None, "pin"))
}
pub fn pin_for(hub_url: &str) -> Option<String> {
pin_at(&pin_path(hub_url)?)
}
pub fn pin_at(path: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let text = text.trim().to_string();
(!text.is_empty()).then_some(text)
}
pub fn save_pin(hub_url: &str, pin: &str) -> Result<PathBuf> {
let path = pin_path(hub_url)
.ok_or_else(|| Error::Config("no configuration directory to write a pin to".into()))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
std::fs::write(&path, format!("{pin}\n")).map_err(|e| Error::Io {
path: path.clone(),
source: e,
})?;
Ok(path)
}
pub fn forget_pin(hub_url: &str) -> Result<()> {
if let Some(path) = pin_path(hub_url)
&& path.exists()
{
std::fs::remove_file(&path).map_err(|e| Error::Io { path, source: e })?;
}
Ok(())
}
pub fn save_token(hub_url: &str, device: &str, token: &str) -> Result<PathBuf> {
let base = config_base()
.ok_or_else(|| Error::Config("no configuration directory to write a token to".into()))?;
save_token_in(&base, hub_url, device, token)
}
fn save_token_in(base: &Path, hub_url: &str, device: &str, token: &str) -> Result<PathBuf> {
let path = write_in(base, hub_url, Some(device), "token", &format!("{token}\n"))?;
restrict(&path);
Ok(path)
}
fn restrict(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
let _ = path;
}
pub fn machine_name() -> Option<String> {
let raw = std::env::var("COMPUTERNAME")
.ok()
.or_else(|| std::fs::read_to_string("/proc/sys/kernel/hostname").ok())
.or_else(|| std::env::var("HOSTNAME").ok())?;
super::normalise_machine(&raw)
}
fn machine_headers<'a>(version: &'a str, machine: Option<&'a str>) -> Vec<(&'static str, &'a str)> {
let mut headers = vec![("x-cyberbrain-version", version)];
if let Some(m) = machine {
headers.push(("x-cyberbrain-machine", m));
}
headers
}
#[derive(Debug, Clone, Serialize)]
pub struct Delivered {
pub accepted: usize,
pub total_rows: i64,
pub hub: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub conflicts: Vec<serde_json::Value>,
}
#[derive(Debug)]
pub enum Reply {
Ok(Delivered),
NotCollecting(String),
Gap {
expected: String,
},
Refused {
status: u16,
message: String,
},
}
pub fn read_cursor(hub_url: &str, device: Option<&str>) -> Option<String> {
let base = config_base()?;
std::fs::read_to_string(existing_in(&base, hub_url, device, "cursor"))
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn write_cursor(hub_url: &str, device: Option<&str>, cursor: &str) -> Result<()> {
let Some(base) = config_base() else {
return Ok(());
};
write_in(&base, hub_url, device, "cursor", cursor)
.map(|_| ())
.map_err(|e| Error::Config(format!("cannot record the pull position: {e}")))
}
pub fn known_path(hub_url: &str, device: Option<&str>) -> Option<PathBuf> {
Some(hub_file_in(&config_base()?, hub_url, device, "known.json"))
}
pub fn read_known(
hub_url: &str,
device: Option<&str>,
) -> std::collections::BTreeMap<String, String> {
config_base()
.and_then(|base| {
std::fs::read_to_string(existing_in(&base, hub_url, device, "known.json")).ok()
})
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
pub fn write_known(
hub_url: &str,
device: Option<&str>,
map: &std::collections::BTreeMap<String, String>,
) -> Result<()> {
let Some(p) = known_path(hub_url, device) else {
return Ok(());
};
if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir);
}
std::fs::write(&p, serde_json::to_string(map).unwrap_or_default())
.map_err(|e| Error::Config(format!("cannot record what the hub holds: {e}")))
}
pub async fn fetch_from_hub(
egress: &cyberbrain_policy::Egress,
actor: &cyberbrain_policy::Actor,
hub_url: &str,
token: &str,
pin: Option<&str>,
since: Option<&str>,
) -> Result<serde_json::Value> {
let url = format!("{}/api/v1/fetch", hub_url.trim_end_matches('/'));
let pin = pin
.map(cyberbrain_policy::egress::transport::CertificatePin::parse)
.transpose()?;
let ticket = egress.open(actor, cyberbrain_core::EgressPurpose::NoteSync, &url)?;
let payload = serde_json::json!({ "since": since }).to_string();
let resp =
cyberbrain_policy::egress::transport::post_bearer(&ticket, &url, token, &[], payload, pin)
.await?;
let body = String::from_utf8_lossy(&resp.body).to_string();
if resp.status != 200 {
return Err(Error::Config(format!(
"the hub refused the fetch ({}): {}",
resp.status,
body.trim()
)));
}
serde_json::from_str(&body)
.map_err(|e| Error::Config(format!("the hub's answer was not a fetch result: {e}")))
}
pub async fn erase_at_hub(
egress: &cyberbrain_policy::Egress,
actor: &cyberbrain_policy::Actor,
hub_url: &str,
token: &str,
pin: Option<&str>,
bereich: &str,
name: &str,
) -> Result<Reply> {
let url = format!("{}/api/v1/erase", hub_url.trim_end_matches('/'));
let pin = pin
.map(cyberbrain_policy::egress::transport::CertificatePin::parse)
.transpose()?;
let ticket = egress.open(actor, cyberbrain_core::EgressPurpose::NoteErasure, &url)?;
let payload = serde_json::json!({ "bereich": bereich, "name": name }).to_string();
let resp =
cyberbrain_policy::egress::transport::post_bearer(&ticket, &url, token, &[], payload, pin)
.await?;
let body = String::from_utf8_lossy(&resp.body).to_string();
let json: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null);
let message = json
.get("error")
.and_then(|v| v.as_str())
.unwrap_or(body.trim())
.to_string();
Ok(match resp.status {
200 => Reply::Ok(Delivered {
accepted: json.get("notes").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
total_rows: json.get("conflicts").and_then(|v| v.as_i64()).unwrap_or(0),
hub: hub_url.to_string(),
conflicts: Vec::new(),
}),
503 => Reply::NotCollecting(message),
status => Reply::Refused { status, message },
})
}
pub async fn deliver_notes(
egress: &cyberbrain_policy::Egress,
actor: &cyberbrain_policy::Actor,
hub_url: &str,
token: &str,
pin: Option<&str>,
version: &str,
batch: String,
) -> Result<Reply> {
let url = format!("{}/api/v1/notes", hub_url.trim_end_matches('/'));
let pin = pin
.map(cyberbrain_policy::egress::transport::CertificatePin::parse)
.transpose()?;
let ticket = egress.open(actor, cyberbrain_core::EgressPurpose::NoteSync, &url)?;
let resp = cyberbrain_policy::egress::transport::post_bearer(
&ticket,
&url,
token,
&[("x-cyberbrain-version", version)],
batch,
pin,
)
.await?;
let body = String::from_utf8_lossy(&resp.body).to_string();
let json: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null);
let message = json
.get("error")
.and_then(|v| v.as_str())
.unwrap_or(body.trim())
.to_string();
Ok(match resp.status {
200 => Reply::Ok(Delivered {
accepted: json
.get("accepted")
.and_then(|v| v.as_u64())
.unwrap_or_default() as usize,
total_rows: json
.get("stored")
.and_then(|v| v.as_i64())
.unwrap_or_default(),
hub: hub_url.to_string(),
conflicts: json
.get("conflicts")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default(),
}),
503 => Reply::NotCollecting(message),
status => Reply::Refused { status, message },
})
}
pub async fn deliver(
egress: &cyberbrain_policy::Egress,
actor: &cyberbrain_policy::Actor,
hub_url: &str,
token: &str,
pin: Option<&str>,
version: &str,
bundle: String,
) -> Result<Reply> {
let url = format!("{}/api/v1/ingest", hub_url.trim_end_matches('/'));
let pin = pin
.map(cyberbrain_policy::egress::transport::CertificatePin::parse)
.transpose()?;
let ticket = egress.open(actor, cyberbrain_core::EgressPurpose::AuditSync, &url)?;
let machine = machine_name();
let resp = cyberbrain_policy::egress::transport::post_bearer(
&ticket,
&url,
token,
&machine_headers(version, machine.as_deref()),
bundle,
pin,
)
.await?;
let body = String::from_utf8_lossy(&resp.body).to_string();
let json: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null);
let message = json
.get("error")
.and_then(|v| v.as_str())
.unwrap_or(body.trim())
.to_string();
Ok(match resp.status {
200 => Reply::Ok(Delivered {
accepted: json
.get("accepted")
.and_then(|v| v.as_u64())
.unwrap_or_default() as usize,
total_rows: json
.get("total_rows")
.and_then(|v| v.as_i64())
.unwrap_or_default(),
hub: hub_url.to_string(),
conflicts: Vec::new(),
}),
503 => Reply::NotCollecting(message),
409 => Reply::Gap {
expected: json
.get("expected_anchor")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
},
status => Reply::Refused { status, message },
})
}
pub fn set_hub_in_config(text: &str, url: &str, device: &str) -> String {
let mut out = String::with_capacity(text.len() + 128);
let mut in_hub = false;
let mut wrote_url = false;
let mut wrote_device = false;
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
if in_hub {
if !wrote_url {
out.push_str(&format!("url = \"{url}\"\n"));
wrote_url = true;
}
if !wrote_device {
out.push_str(&format!("device = \"{device}\"\n"));
wrote_device = true;
}
out.push('\n');
}
in_hub = trimmed.starts_with("[hub]");
out.push_str(line);
out.push('\n');
continue;
}
if in_hub {
let key = trimmed.trim_start_matches('#').trim_start();
if key.starts_with("url") && key.contains('=') {
out.push_str(&format!("url = \"{url}\"\n"));
wrote_url = true;
continue;
}
if key.starts_with("device") && key.contains('=') {
out.push_str(&format!("device = \"{device}\"\n"));
wrote_device = true;
continue;
}
}
out.push_str(line);
out.push('\n');
}
if in_hub {
if !wrote_url {
out.push_str(&format!("url = \"{url}\"\n"));
wrote_url = true;
}
if !wrote_device {
out.push_str(&format!("device = \"{device}\"\n"));
wrote_device = true;
}
}
if !wrote_url || !wrote_device {
out.push_str(&format!(
"\n[hub]\nurl = \"{url}\"\ndevice = \"{device}\"\n"
));
}
out
}
pub fn set_inference_url(text: &str, url: &str) -> String {
let mut out = String::with_capacity(text.len() + 64);
let mut in_inference = false;
let mut wrote = false;
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('[') {
if in_inference && !wrote {
out.push_str(&format!("base_url = \"{url}\"\n"));
wrote = true;
out.push('\n');
}
in_inference = trimmed.starts_with("[inference]");
out.push_str(line);
out.push('\n');
continue;
}
if in_inference {
let key = trimmed.trim_start_matches('#').trim_start();
if key.starts_with("base_url") && key.contains('=') {
out.push_str(&format!("base_url = \"{url}\"\n"));
wrote = true;
continue;
}
}
out.push_str(line);
out.push('\n');
}
if in_inference && !wrote {
out.push_str(&format!("base_url = \"{url}\"\n"));
wrote = true;
}
if !wrote {
out.push_str(&format!("\n[inference]\nbase_url = \"{url}\"\n"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = "\
# A comment somebody wrote.
[rings]
resident_cap_tokens = 8192
[hub]
# Set by `cyberbrain hub enrol <invitation>`.
# url = \"https://example.invalid\"
allow_public_hub = false
[policy]
profile = \"eu\"
";
#[test]
fn enrolling_sets_the_url_and_keeps_every_comment() {
let out = set_hub_in_config(SAMPLE, "https://hub.internal:7788", "dev_1");
assert!(out.contains("# A comment somebody wrote."));
assert!(out.contains("# Set by `cyberbrain hub enrol <invitation>`."));
assert!(out.contains("url = \"https://hub.internal:7788\""));
assert!(out.contains("device = \"dev_1\""));
assert!(
out.contains("allow_public_hub = false"),
"other keys survive"
);
assert!(out.contains("profile = \"eu\""), "later sections survive");
assert!(!out.contains("https://example.invalid"));
let cfg: toml::Value = toml::from_str(&out).expect("valid toml");
assert_eq!(
cfg["hub"]["url"].as_str(),
Some("https://hub.internal:7788")
);
}
#[test]
fn enrolling_twice_does_not_duplicate_the_key() {
let once = set_hub_in_config(SAMPLE, "https://a.internal", "dev_1");
let twice = set_hub_in_config(&once, "https://b.internal", "dev_2");
assert_eq!(twice.matches("url = ").count(), 1);
assert_eq!(twice.matches("device = ").count(), 1);
assert!(twice.contains("https://b.internal"));
assert!(!twice.contains("https://a.internal"));
toml::from_str::<toml::Value>(&twice).expect("valid toml");
}
#[test]
fn a_config_without_a_hub_section_gets_one() {
let plain = "[rings]\nresident_cap_tokens = 8192\n";
let out = set_hub_in_config(plain, "https://hub.internal", "dev_9");
let cfg: toml::Value = toml::from_str(&out).expect("valid toml");
assert_eq!(cfg["hub"]["url"].as_str(), Some("https://hub.internal"));
assert_eq!(cfg["rings"]["resident_cap_tokens"].as_integer(), Some(8192));
}
#[test]
fn the_inference_endpoint_from_an_invitation_replaces_the_default() {
let text = "[inference]\nbase_url = \"http://127.0.0.1:11434/v1\"\ntimeout_ms = 30000\n";
let out = set_inference_url(text, "http://192.168.1.50:11434/v1");
let cfg: toml::Value = toml::from_str(&out).expect("valid toml");
assert_eq!(
cfg["inference"]["base_url"].as_str(),
Some("http://192.168.1.50:11434/v1")
);
assert_eq!(cfg["inference"]["timeout_ms"].as_integer(), Some(30000));
}
#[test]
fn an_invitation_must_say_which_hub() {
let without = r#"{"kind":"cyberbrain.hub.invitation","version":1,"device":"d","name":"n","token":"t","hub_url":null,"inference_url":null}"#;
let err = parse_invitation(without).unwrap_err().to_string();
assert!(err.contains("names no hub address"), "{err}");
}
#[test]
fn two_stores_enrolled_with_one_hub_keep_their_own_token_and_position() {
let dir = tempfile::tempdir().unwrap();
let (base, hub) = (dir.path(), "https://hub.internal:7788");
save_token_in(base, hub, "dev_a", "token-a").unwrap();
save_token_in(base, hub, "dev_b", "token-b").unwrap();
assert_eq!(token_in(base, hub, Some("dev_a")).unwrap(), "token-a");
assert_eq!(token_in(base, hub, Some("dev_b")).unwrap(), "token-b");
write_in(base, hub, Some("dev_a"), "cursor", "cursor-a").unwrap();
write_in(base, hub, Some("dev_b"), "cursor", "cursor-b").unwrap();
let read = |d| std::fs::read_to_string(existing_in(base, hub, Some(d), "cursor")).unwrap();
assert_eq!(
(read("dev_a"), read("dev_b")),
("cursor-a".into(), "cursor-b".into())
);
}
#[test]
fn a_store_enrolled_before_keeps_its_token_until_it_enrols_again() {
let dir = tempfile::tempdir().unwrap();
let (base, hub) = (dir.path(), "https://hub.internal:7788");
write_in(base, hub, None, "token", "old-token\n").unwrap();
assert_eq!(token_in(base, hub, Some("dev_a")).unwrap(), "old-token");
save_token_in(base, hub, "dev_a", "new-token").unwrap();
assert_eq!(token_in(base, hub, Some("dev_a")).unwrap(), "new-token");
}
#[test]
fn a_token_file_is_one_per_hub() {
let base = std::path::Path::new("/cfg");
let a = hub_file_in(base, "https://a.internal:7788", None, "token");
let b = hub_file_in(base, "https://b.internal:7788", None, "token");
assert_ne!(a, b, "two hubs, two tokens");
let name = a.file_name().unwrap().to_string_lossy().to_string();
assert!(
!name.contains('/') && !name.contains(':'),
"the file name is a hash, not the URL: {name}"
);
}
}