1use std::collections::{BTreeMap, HashMap, VecDeque};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex, OnceLock, RwLock};
6use std::time::{Duration, Instant};
7
8use jiff::Timestamp;
9use okbot_core::{
10 Clock, ConversationKey, ConversationStore, InboundSink, PeerCaller, PeerError, Prompt, SentRef,
11};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use sha2::Digest as _;
15use tokio::io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader};
16
17use crate::key::{PublicKey, SigningKey, bot_public_key};
18use crate::manifest::{self, Manifest, Member};
19use crate::token::{self, Claims, Replays};
20use crate::{Error, a2a};
21
22pub fn socket_dir() -> PathBuf {
26 if let Some(dir) = std::env::var_os("OKBOT_PEER_DIR") {
27 return PathBuf::from(dir);
28 }
29 if let Some(dir) = std::env::var_os("XDG_RUNTIME_DIR") {
30 return PathBuf::from(dir).join("okbot");
31 }
32 let user = std::env::var("USER").unwrap_or_else(|_| "user".into());
33 std::env::temp_dir().join(format!("okbot-{user}"))
34}
35
36pub fn socket_path(sockets: &Path, bot_dir: &Path) -> PathBuf {
39 let dir = std::fs::canonicalize(bot_dir).unwrap_or_else(|_| bot_dir.to_path_buf());
40 let digest = sha2::Sha256::digest(dir.as_os_str().as_encoded_bytes());
41 sockets.join(format!("{}.sock", &hex::encode(digest)[..20]))
42}
43pub const SIGNATURE_HEADER: &str = "okbot-peer-signature";
45pub(crate) const SCHEME: &str = "OkbotPeer ";
47pub(crate) const REFRESH: Duration = Duration::from_secs(600);
49const DOWN_FOR: Duration = Duration::from_secs(60);
51pub(crate) const MAX_BODY: usize = 4 << 20;
53const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum ManifestSource {
59 File(PathBuf),
61 Builtin,
63 Url {
65 url: String,
67 team_key: PublicKey,
69 },
70}
71
72#[derive(Debug, Clone)]
74pub struct TeamConfig {
75 pub id: String,
77 pub member: String,
79 pub source: ManifestSource,
81 pub accept: bool,
83 pub max_depth: u32,
85 pub max_concurrent: u32,
87 pub task_timeout: Duration,
89}
90
91#[derive(Debug, Clone)]
93pub struct NodeConfig {
94 pub key: SigningKey,
96 pub box_key: crate::seal::BoxKey,
98 pub state_dir: PathBuf,
100 pub sockets: PathBuf,
102 pub teams: Vec<TeamConfig>,
104 pub external: Option<(String, crate::external::ExternalConfig)>,
106}
107
108pub(crate) struct Team {
110 pub(crate) config: TeamConfig,
111 manifest: RwLock<Arc<Manifest>>,
112 forced: Mutex<Option<Instant>>,
114}
115
116impl Team {
117 pub(crate) fn manifest(&self) -> Arc<Manifest> {
118 self.manifest
119 .read()
120 .unwrap_or_else(std::sync::PoisonError::into_inner)
121 .clone()
122 }
123
124 pub(crate) fn signed(&self) -> bool {
125 matches!(self.config.source, ManifestSource::Url { .. })
126 }
127
128 pub(crate) fn may_force_refresh(&self) -> bool {
131 let mut last = lock(&self.forced);
132 if last.is_some_and(|at| at.elapsed() < FORCED_REFRESH) {
133 return false;
134 }
135 *last = Some(Instant::now());
136 true
137 }
138}
139
140const FORCED_REFRESH: Duration = Duration::from_secs(30);
142
143#[derive(Debug, Clone)]
145pub(crate) struct Running {
146 pub(crate) team: String,
147 pub(crate) caller: String,
148 pub(crate) task: String,
149 pub(crate) deadline: Timestamp,
150}
151
152#[derive(Debug, Clone)]
154pub(crate) struct OpenPrompt {
155 pub(crate) prompt: Prompt,
156 pub(crate) sent: SentRef,
157}
158
159pub(crate) struct Inner {
161 pub(crate) key: SigningKey,
162 pub(crate) box_key: crate::seal::BoxKey,
163 pub(crate) sockets: PathBuf,
164 pub(crate) state_dir: PathBuf,
165 pub(crate) teams: BTreeMap<String, Team>,
166 pub(crate) store: Arc<dyn ConversationStore>,
167 pub(crate) clock: Arc<dyn Clock>,
168 pub(crate) replays: Replays,
169 pub(crate) http: reqwest::Client,
170 pub(crate) sink: OnceLock<Arc<dyn InboundSink>>,
171 pub(crate) local_server: std::sync::atomic::AtomicBool,
172 pub(crate) inbound: Mutex<HashMap<ConversationKey, VecDeque<Running>>>,
174 pub(crate) callers: Mutex<HashMap<ConversationKey, PeerCaller>>,
175 pub(crate) prompts: Mutex<HashMap<String, OpenPrompt>>,
177 down: Mutex<HashMap<(String, String), Instant>>,
179 pub(crate) relay: crate::relay::Relay,
181 pub(crate) usage: Mutex<HashMap<String, u64>>,
183 pub(crate) external: Option<crate::external::ExternalConfig>,
185 pub(crate) progress: Mutex<HashMap<String, String>>,
187}
188
189pub(crate) fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
190 m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
191}
192
193impl Inner {
194 pub(crate) fn new(
195 config: NodeConfig,
196 store: Arc<dyn ConversationStore>,
197 clock: Arc<dyn Clock>,
198 ) -> Result<Self, Error> {
199 let mut teams = BTreeMap::new();
200 for team in config.teams {
201 let manifest = match &team.source {
202 ManifestSource::File(path) => load_file(path, &team.id)?,
203 ManifestSource::Url { .. } | ManifestSource::Builtin => Manifest::new(&team.id),
205 };
206 teams.insert(
207 team.id.clone(),
208 Team {
209 config: team,
210 manifest: RwLock::new(Arc::new(manifest)),
211 forced: Mutex::new(None),
212 },
213 );
214 }
215 let external = config.external.map(|(me, external)| {
216 teams.insert(
217 crate::external::TEAM.to_string(),
218 Team {
219 config: TeamConfig {
220 id: crate::external::TEAM.into(),
221 member: me.clone(),
222 source: ManifestSource::Builtin,
223 accept: !external.clients.is_empty(),
224 max_depth: 1,
225 max_concurrent: external.max_concurrent,
226 task_timeout: external.task_timeout,
227 },
228 manifest: RwLock::new(Arc::new(external.manifest(&me))),
229 forced: Mutex::new(None),
230 },
231 );
232 external
233 });
234 let _ = rustls::crypto::ring::default_provider().install_default();
235 Ok(Self {
236 key: config.key,
237 box_key: config.box_key,
238 sockets: config.sockets,
239 state_dir: config.state_dir,
240 teams,
241 store,
242 clock,
243 replays: Replays::default(),
244 http: reqwest::Client::builder()
245 .timeout(REQUEST_TIMEOUT)
246 .build()
247 .map_err(|e| Error::Key(format!("HTTP client: {e}")))?,
248 sink: OnceLock::new(),
249 local_server: std::sync::atomic::AtomicBool::new(false),
250 inbound: Mutex::default(),
251 callers: Mutex::default(),
252 prompts: Mutex::default(),
253 down: Mutex::default(),
254 relay: crate::relay::Relay::default(),
255 usage: Mutex::default(),
256 external,
257 progress: Mutex::default(),
258 })
259 }
260
261 pub(crate) fn now(&self) -> Timestamp {
262 self.clock.now()
263 }
264
265 pub(crate) fn check_membership(&self) -> Vec<String> {
267 let mut problems = Vec::new();
268 for team in self.teams.values() {
269 let manifest = team.manifest();
270 if (team.signed() && manifest.team.version == 0)
271 || team.config.source == ManifestSource::Builtin
272 {
273 continue;
274 }
275 match manifest.members.get(&team.config.member) {
276 None => problems.push(format!(
277 "team `{}`: this bot is not a member named `{}`",
278 team.config.id, team.config.member
279 )),
280 Some(m) => {
281 if member_key(m).ok().as_ref() != Some(&self.key.public()) {
282 problems.push(format!(
283 "team `{}`: member `{}` has another key than this bot",
284 team.config.id, team.config.member
285 ));
286 }
287 }
288 }
289 }
290 problems
291 }
292
293 pub(crate) fn install(&self, team: &Team, manifest: Manifest) -> Result<bool, Error> {
295 manifest.check_fresh(self.now(), team.signed())?;
296 let current = team.manifest();
297 if current.team.version > 0 {
298 manifest.may_replace(¤t)?;
299 }
300 if manifest == *current {
301 return Ok(false);
302 }
303 tracing::info!(team = %team.config.id, version = manifest.team.version, "team manifest installed");
304 *team
305 .manifest
306 .write()
307 .unwrap_or_else(std::sync::PoisonError::into_inner) = Arc::new(manifest);
308 Ok(true)
309 }
310
311 pub(crate) async fn refresh(&self, team: &Team) -> Result<bool, Error> {
313 let manifest = match &team.config.source {
314 ManifestSource::Builtin => return Ok(false),
315 ManifestSource::File(path) => load_file(path, &team.config.id)?,
316 ManifestSource::Url { url, team_key } => {
317 let get = |u: String| async move {
318 let reply = self
319 .http
320 .get(&u)
321 .send()
322 .await
323 .and_then(reqwest::Response::error_for_status)
324 .map_err(|e| Error::Manifest(format!("cannot fetch {u}: {e}")))?;
325 reply
326 .text()
327 .await
328 .map_err(|e| Error::Manifest(format!("cannot read {u}: {e}")))
329 };
330 let text = get(url.clone()).await?;
331 let sig = get(format!("{url}.sig")).await?;
332 let manifest = manifest::verify(&text, &sig, team_key)?;
333 if manifest.team.id != team.config.id {
334 return Err(Error::Manifest(format!(
335 "{url} is the manifest of team `{}`",
336 manifest.team.id
337 )));
338 }
339 manifest
340 }
341 };
342 self.install(team, manifest)
343 }
344
345 pub(crate) fn key_of(manifest: &Manifest, name: &str) -> Result<PublicKey, Error> {
347 manifest
348 .members
349 .get(name)
350 .ok_or_else(|| Error::Token(format!("`{name}` is not a member")))
351 .and_then(member_key)
352 }
353
354 pub(crate) fn version_of(&self, team: &Team, member: &str) -> a2a::Version {
356 if team.config.source == ManifestSource::Builtin {
357 return self
358 .external
359 .as_ref()
360 .and_then(|e| e.agent(member))
361 .map_or(a2a::Version::V10, |a| a.version);
362 }
363 a2a::Version::V10
364 }
365
366 pub(crate) fn is_down(&self, team: &str, member: &str) -> bool {
367 lock(&self.down)
368 .get(&(team.to_string(), member.to_string()))
369 .is_some_and(|until| *until > Instant::now())
370 }
371
372 fn mark(&self, team: &str, member: &str, up: bool) {
373 let key = (team.to_string(), member.to_string());
374 let mut down = lock(&self.down);
375 if up {
376 down.remove(&key);
377 } else {
378 down.insert(key, Instant::now() + DOWN_FOR);
379 }
380 }
381
382 pub(crate) async fn call(
384 &self,
385 team: &Team,
386 member: &str,
387 request: &a2a::Request,
388 ) -> Result<Value, PeerError> {
389 let result = self.exchange(team, member, request).await;
390 self.mark(
391 &team.config.id,
392 member,
393 !matches!(result, Err(PeerError::Unreachable(_))),
394 );
395 let response = result?;
396 if let Some(error) = response.get("error") {
397 let message = error
398 .get("message")
399 .and_then(Value::as_str)
400 .unwrap_or("refused")
401 .to_string();
402 return Err(PeerError::Refused(message));
403 }
404 response
405 .get("result")
406 .cloned()
407 .ok_or_else(|| PeerError::Refused("an empty response".into()))
408 }
409
410 async fn exchange(
411 &self,
412 team: &Team,
413 member: &str,
414 request: &a2a::Request,
415 ) -> Result<Value, PeerError> {
416 if team.config.source == ManifestSource::Builtin {
417 let agent = self
418 .external
419 .as_ref()
420 .and_then(|e| e.agent(member))
421 .ok_or_else(|| PeerError::NoRoute(member.to_string()))?;
422 return crate::external::call(&self.http, agent, request).await;
423 }
424 let manifest = team.manifest();
425 let target = manifest
426 .members
427 .get(member)
428 .ok_or_else(|| PeerError::NoRoute(member.to_string()))?;
429 let callee_key = member_key(target).map_err(|e| PeerError::Unreachable(e.to_string()))?;
430 let body = serde_json::to_vec(request).expect("requests serialize");
431 let claims = Claims::new(
432 &team.config.id,
433 &team.config.member,
434 member,
435 manifest.team.version,
436 &body,
437 self.now(),
438 );
439 let authorization = format!("{SCHEME}{}", token::sign(&self.key, &claims));
440 let (status, signature, reply) = match (&target.dir, &target.url, &target.relay) {
441 (Some(dir), _, _) => {
442 let socket = socket_path(&self.sockets, dir);
443 local_request(&socket, &team.config.id, &authorization, &body).await?
444 }
445 (_, Some(url), _) => self.http_request(url, &authorization, body).await?,
446 (_, _, Some(relay)) => {
447 Box::pin(crate::relay::send(
448 self,
449 team,
450 relay,
451 member,
452 &authorization,
453 &body,
454 ))
455 .await?
456 }
457 _ => return Err(PeerError::NoRoute(member.to_string())),
458 };
459 let Some(signature) = signature else {
460 return Err(PeerError::Refused(format!(
461 "`{member}` answered HTTP {status} without a signature"
462 )));
463 };
464 let claims = token::verify(&signature, &callee_key, &reply, self.now())
465 .map_err(|e| PeerError::Refused(format!("`{member}`'s answer: {e}")))?;
466 if claims.iss != member || claims.aud != team.config.member || claims.team != team.config.id
467 {
468 return Err(PeerError::Refused(format!(
469 "`{member}`'s answer is signed for someone else"
470 )));
471 }
472 serde_json::from_slice(&reply)
473 .map_err(|_| PeerError::Refused(format!("`{member}` sent invalid JSON")))
474 }
475
476 async fn http_request(
477 &self,
478 url: &str,
479 authorization: &str,
480 body: Vec<u8>,
481 ) -> Result<(u16, Option<String>, Vec<u8>), PeerError> {
482 let reply = self
483 .http
484 .post(url)
485 .header("authorization", authorization)
486 .header("content-type", "application/json")
487 .header(
488 crate::a2a::VERSION_HEADER,
489 crate::a2a::Version::V10.header(),
490 )
491 .header(crate::a2a::EXTENSIONS_HEADER, crate::a2a::EXTENSION)
492 .body(body)
493 .send()
494 .await
495 .map_err(|e| PeerError::Unreachable(without_url(e)))?;
496 let status = reply.status().as_u16();
497 let signature = reply
498 .headers()
499 .get(SIGNATURE_HEADER)
500 .and_then(|v| v.to_str().ok())
501 .map(str::to_string);
502 let bytes = reply
503 .bytes()
504 .await
505 .map_err(|e| PeerError::Unreachable(without_url(e)))?;
506 if bytes.len() > MAX_BODY {
507 return Err(PeerError::Refused("the answer is too large".into()));
508 }
509 Ok((status, signature, bytes.to_vec()))
510 }
511
512 pub(crate) fn sign_response(&self, team: &Team, caller: &str, body: &[u8]) -> String {
514 let claims = Claims::new(
515 &team.config.id,
516 &team.config.member,
517 caller,
518 team.manifest().team.version,
519 body,
520 self.now(),
521 );
522 token::sign(&self.key, &claims)
523 }
524}
525
526fn without_url(error: reqwest::Error) -> String {
527 if error.is_timeout() {
528 return "timed out".into();
529 }
530 error.without_url().to_string()
531}
532
533pub(crate) fn member_box_key(member: &Member) -> Result<crate::seal::BoxPublic, Error> {
535 match (&member.box_key, &member.dir) {
536 (Some(key), _) => Ok(*key),
537 (None, Some(dir)) => crate::seal::bot_box_key(dir),
538 (None, None) => Err(Error::Key(
539 "the member has no `box_key` (`okbot peer id` prints it)".into(),
540 )),
541 }
542}
543
544pub(crate) fn member_key(member: &Member) -> Result<PublicKey, Error> {
546 match (&member.key, &member.dir) {
547 (Some(key), _) => Ok(key.clone()),
548 (None, Some(dir)) => bot_public_key(dir),
549 (None, None) => Err(Error::Key("the member has no key".into())),
550 }
551}
552
553fn load_file(path: &Path, team: &str) -> Result<Manifest, Error> {
554 let text =
555 std::fs::read_to_string(path).map_err(|e| Error::Io(path.display().to_string(), e))?;
556 let manifest = Manifest::parse(&text)?;
557 if manifest.team.id != team {
558 return Err(Error::Manifest(format!(
559 "{} is the manifest of team `{}`, not `{team}`",
560 path.display(),
561 manifest.team.id
562 )));
563 }
564 Ok(manifest)
565}
566
567#[derive(Debug, Serialize, Deserialize)]
569pub(crate) struct LocalRequest {
570 pub(crate) team: String,
571 pub(crate) authorization: String,
572 pub(crate) body: String,
573}
574
575#[derive(Debug, Serialize, Deserialize)]
577pub(crate) struct LocalResponse {
578 pub(crate) status: u16,
579 #[serde(default)]
580 pub(crate) signature: Option<String>,
581 pub(crate) body: String,
582}
583
584async fn local_request(
585 socket: &Path,
586 team: &str,
587 authorization: &str,
588 body: &[u8],
589) -> Result<(u16, Option<String>, Vec<u8>), PeerError> {
590 let unreachable = |e: std::io::Error| {
591 PeerError::Unreachable(format!("{}: {e} (is that bot running?)", socket.display()))
592 };
593 let exchange = async {
594 let mut stream = tokio::net::UnixStream::connect(socket)
595 .await
596 .map_err(unreachable)?;
597 let request = LocalRequest {
598 team: team.to_string(),
599 authorization: authorization.to_string(),
600 body: String::from_utf8_lossy(body).into_owned(),
601 };
602 let mut line = serde_json::to_vec(&request).expect("requests serialize");
603 line.push(b'\n');
604 stream.write_all(&line).await.map_err(unreachable)?;
605 let mut reader = BufReader::new(stream).take(MAX_BODY as u64 * 2);
606 let mut answer = String::new();
607 reader.read_line(&mut answer).await.map_err(unreachable)?;
608 let response: LocalResponse = serde_json::from_str(&answer)
609 .map_err(|_| PeerError::Refused("an invalid local answer".into()))?;
610 Ok((
611 response.status,
612 response.signature,
613 response.body.into_bytes(),
614 ))
615 };
616 tokio::time::timeout(REQUEST_TIMEOUT, exchange)
617 .await
618 .map_err(|_| PeerError::Unreachable(format!("{}: timed out", socket.display())))?
619}