agentmux_srv/server/
agent_self_keys.rs1use axum::extract::State;
20use axum::http::{header, StatusCode};
21use axum::response::{IntoResponse, Response};
22use axum::Json;
23use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
24use serde::Serialize;
25
26use super::caller::Caller;
27use super::AppState;
28
29#[derive(Serialize, Debug, PartialEq, Eq)]
30pub(crate) struct SelfKeys {
31 pub slug: String,
33 pub jekt_key: Option<String>,
35 pub jekt_key_expires_at: Option<i64>,
38 pub lan_key: Option<String>,
40 pub wan_key: Option<String>,
42 pub uid: String,
43 pub uid_lan_key: Option<String>,
45 pub uid_wan_key: Option<String>,
46}
47
48pub(crate) fn self_keys_for(mstore: &crate::backend::storage::store::Store, uid: &str) -> Option<SelfKeys> {
50 let def = mstore.agent_def_get(uid).ok().flatten()?;
51 let slug = def.slug.trim().to_string();
52 let named = !slug.is_empty();
53 let jekt_key = named.then(|| mstore.agent_jekt_key_ensure(&slug).ok()).flatten().map(|k| BASE64.encode(k));
54 let jekt_key_expires_at = named.then(|| mstore.agent_jekt_key_expires_at(&slug).ok().flatten()).flatten();
55 let lan_key = named.then(|| mstore.agent_lan_key_ensure(&slug).ok()).flatten().map(|k| k.private_key);
56 let wan_key = named.then(|| mstore.agent_wan_key_ensure(&slug).ok()).flatten().map(|k| k.private_key);
57 let uid_keys = mstore.agent_uid_keys_ensure(uid).ok().flatten().map(|(keys, _)| keys);
58 Some(SelfKeys {
59 slug,
60 jekt_key,
61 jekt_key_expires_at,
62 lan_key,
63 wan_key,
64 uid: uid.to_string(),
65 uid_lan_key: uid_keys.as_ref().map(|k| k.lan.private_key.clone()),
66 uid_wan_key: uid_keys.map(|k| k.wan.private_key),
67 })
68}
69
70pub(crate) async fn handle_agent_self_keys(State(state): State<AppState>, caller: Option<axum::Extension<Caller>>) -> Response {
71 let Some(uid) = caller.as_deref().and_then(Caller::uid).map(str::to_string) else {
72 return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "agents/self/keys: this request carries no agent identity (X-Agent-Token)" }))).into_response();
73 };
74 let mstore = state.mstore.clone();
75 let keys = tokio::task::spawn_blocking(move || self_keys_for(&mstore, &uid)).await.ok().flatten();
76 match keys {
77 Some(keys) => ([(header::CACHE_CONTROL, "no-store")], Json(keys)).into_response(),
78 None => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "agents/self/keys: no agent row for this token" }))).into_response(),
79 }
80}
81
82pub(crate) fn record_config_id_drift(site: &'static str, written_id: &str, row_slug: Option<&str>) {
90 if row_slug.map(str::trim) != Some(written_id) {
91 crate::backend::agent_resolve::record_uid_fallback(site);
92 }
93}
94
95pub(crate) fn single_row_slug_folded(mstore: &crate::backend::storage::store::Store, name: &str) -> Option<String> {
99 let mut slugs = mstore.agent_slugs_folded(name).ok()?;
100 (slugs.len() == 1).then(|| slugs.remove(0))
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106 use crate::backend::storage::agents::test_agent_def;
107 use crate::backend::storage::store::Store;
108
109 fn store_with(uid: &str, slug: &str) -> Store {
110 let store = Store::open_in_memory().unwrap();
111 let mut def = test_agent_def(uid, slug, "claude", "agent", agentmux_common::time::now_ms(), "");
112 def.slug = slug.to_string();
113 store.agent_def_insert(&mut def).unwrap();
114 store
115 }
116
117 #[test]
118 fn serves_the_slug_s_keys_the_same_ones_injection_writes() {
119 let store = store_with("uid-aria", "aria");
120 let keys = self_keys_for(&store, "uid-aria").unwrap();
121 assert_eq!(keys.slug, "aria");
122 assert_eq!(keys.jekt_key, Some(BASE64.encode(store.agent_jekt_key_ensure("aria").unwrap())));
123 assert_eq!(keys.lan_key, Some(store.agent_lan_key_ensure("aria").unwrap().private_key));
124 assert_eq!(keys.wan_key, Some(store.agent_wan_key_ensure("aria").unwrap().private_key));
125 assert!(keys.uid_lan_key.is_some() && keys.uid_wan_key.is_some());
126 let expires = keys.jekt_key_expires_at.expect("an expiry with the key");
127 let ttl = crate::backend::storage::agent_jekt_keys::JEKT_KEY_TTL_SECS;
128 assert!((expires - agentmux_common::time::now_secs() - ttl).abs() <= 5, "a fresh key expires a TTL from now");
129 assert_eq!(self_keys_for(&store, "uid-aria").unwrap(), keys, "stable across fetches");
130 }
131
132 #[test]
133 fn an_unknown_uid_gets_nothing() {
134 let store = Store::open_in_memory().unwrap();
135 assert_eq!(self_keys_for(&store, "uid-nobody"), None);
136 }
137
138 #[test]
139 fn a_config_s_id_matches_one_row_only_when_one_slug_folds_to_it() {
140 let store = store_with("uid-a", "Aria");
141 assert_eq!(super::single_row_slug_folded(&store, "aria").as_deref(), Some("Aria"));
142 let mut def = test_agent_def("uid-b", "ä", "claude", "agent", 1, "");
143 def.slug = "Ä".into();
144 store.agent_def_insert(&mut def).unwrap();
145 let mut def = test_agent_def("uid-c", "ä", "claude", "agent", 1, "");
146 def.slug = "ä".into();
147 store.agent_def_insert(&mut def).unwrap();
148 assert_eq!(super::single_row_slug_folded(&store, "ä"), None, "Ä and ä fold together: ambiguous");
149 assert_eq!(super::single_row_slug_folded(&store, "nobody"), None);
150 }
151
152 #[test]
153 fn drift_is_counted_when_the_written_id_is_not_exactly_the_row_s_slug() {
154 let count = || {
155 crate::backend::agent_resolve::uid_fallback_counts()
156 .into_iter()
157 .find(|(site, _)| *site == "m4d.config_id_drift.test")
158 .map_or(0, |(_, n)| n)
159 };
160 let before = count();
161 super::record_config_id_drift("m4d.config_id_drift.test", "aria", Some("aria"));
162 assert_eq!(count(), before, "exact match: no drift");
163 super::record_config_id_drift("m4d.config_id_drift.test", "aria", Some("aria-2"));
164 super::record_config_id_drift("m4d.config_id_drift.test", "Aria", Some("aria"));
165 super::record_config_id_drift("m4d.config_id_drift.test", "aria", None);
166 assert_eq!(count(), before + 3);
167 }
168}