1use std::collections::{HashMap, HashSet};
6use std::sync::Arc;
7
8use async_trait::async_trait;
9use ed25519_dalek::SigningKey;
10
11use super::forward::{
12 FORWARD_DOMAIN, FORWARD_RESPONSE_DOMAIN, FORWARD_VERSION, ForwardOutcome, ForwardRefusal,
13 ForwardRequest, ForwardResponse, MEM_SCHEME, STORE_INGEST_METHOD, STORE_SERVICE, now_ms,
14 owner_target,
15};
16use super::store::{Provenance, StoreDirectory, StoreError};
17use super::types::MAX_VECTORS_PER_BATCH;
18use crate::ipc::{KernelMessage, MessagePayload, MessageTarget};
19use crate::mesh::{MeshError, MeshStream, TransportListener};
20use crate::mesh_ipc::MeshIpcEnvelope;
21use crate::node_registry::node_id_from_pubkey;
22use crate::workload_ctl::msg::{
23 MAX_CTL_BYTES, MAX_SKEW_MS, MAX_TTL_MS, NonceGuard, SignedCtl, open, sign,
24};
25use crate::workload_ctl::session::NoiseStream;
26use crate::workload_ctl::transport::CtlConnector;
27use crate::workload_pkg::codec::is_lower_hex;
28
29
30pub trait ForwardPolicy: Send + Sync {
32 fn allows(&self, key: &[u8; 32], project_id: Option<&str>) -> bool;
34}
35
36#[derive(Default)]
39pub struct KeyPolicy {
40 keys: HashMap<[u8; 32], Option<HashSet<String>>>,
41}
42
43impl KeyPolicy {
44 pub fn new() -> Self {
46 Self::default()
47 }
48
49 pub fn allow_any(mut self, key: [u8; 32]) -> Self {
51 self.keys.insert(key, None);
52 self
53 }
54
55 pub fn allow_projects(mut self, key: [u8; 32], projects: &[&str]) -> Self {
57 self.keys
58 .insert(key, Some(projects.iter().map(|p| p.to_string()).collect()));
59 self
60 }
61}
62
63impl ForwardPolicy for KeyPolicy {
64 fn allows(&self, key: &[u8; 32], project_id: Option<&str>) -> bool {
65 match self.keys.get(key) {
66 None => false,
67 Some(None) => true,
68 Some(Some(set)) => project_id.is_some_and(|p| set.contains(p)),
69 }
70 }
71}
72
73pub struct StoreOwnerService {
76 node_id: String,
77 key: SigningKey,
78 policy: Arc<dyn ForwardPolicy>,
79 directory: Arc<dyn StoreDirectory>,
80 nonces: NonceGuard,
81}
82
83impl StoreOwnerService {
84 pub fn new(
86 key: SigningKey,
87 policy: Arc<dyn ForwardPolicy>,
88 directory: Arc<dyn StoreDirectory>,
89 ) -> Self {
90 Self {
91 node_id: node_id_from_pubkey(&key.verifying_key().to_bytes()),
92 key,
93 policy,
94 directory,
95 nonces: NonceGuard::new(),
96 }
97 }
98
99 pub fn node_id(&self) -> &str {
101 &self.node_id
102 }
103
104 pub fn handle_at(&self, signed: &SignedCtl, now_ms: u64) -> (SignedCtl, bool) {
107 let (nonce, outcome, authed) = match self.check(signed, now_ms) {
108 Ok(req) => {
109 let nonce = req.nonce.clone();
110 (nonce, self.apply(req), true)
111 }
112 Err((nonce, code, reason, authed)) => {
113 (nonce, ForwardOutcome::Refused { code, reason }, authed)
114 }
115 };
116 let resp = ForwardResponse {
117 version: FORWARD_VERSION,
118 responder: self.node_id.clone(),
119 request_nonce: nonce,
120 outcome,
121 };
122 let payload = serde_json::to_string(&resp).unwrap_or_else(|_| "{}".into());
123 (sign(FORWARD_RESPONSE_DOMAIN, payload, &self.key), authed)
124 }
125
126 #[allow(clippy::type_complexity)]
127 fn check(
128 &self,
129 s: &SignedCtl,
130 now: u64,
131 ) -> Result<ForwardRequest, (String, ForwardRefusal, String, bool)> {
132 use ForwardRefusal as R;
133 let fail = |nonce: &str, c: R, m: &str, authed: bool| {
134 (nonce.to_string(), c, m.to_string(), authed)
135 };
136 let pk = open(FORWARD_DOMAIN, s).map_err(|r| fail("", R::Signature, &r.reason, false))?;
137 let req: ForwardRequest = serde_json::from_str(&s.payload)
138 .map_err(|_| fail("", R::Signature, "malformed request", false))?;
139 let n = req.nonce.clone();
140 if req.version != FORWARD_VERSION {
141 return Err(fail(&n, R::Invalid, "unsupported version", false));
142 }
143 if req.requester != node_id_from_pubkey(&pk) {
144 return Err(fail(&n, R::Signature, "requester is not the signing key's node", false));
145 }
146 if req.target != self.node_id {
147 return Err(fail(&n, R::NotForMe, "addressed to another node", false));
148 }
149 if req.expires_at_ms <= now {
150 return Err(fail(&n, R::Expired, "request expired", false));
151 }
152 if req.issued_at_ms > now.saturating_add(MAX_SKEW_MS)
153 || req.expires_at_ms < req.issued_at_ms
154 || req.expires_at_ms - req.issued_at_ms > MAX_TTL_MS
155 {
156 return Err(fail(&n, R::Expired, "request lifetime out of bounds", false));
157 }
158 if !is_lower_hex(&req.nonce, 32) {
159 return Err(fail(&n, R::Invalid, "nonce must be 32 hex", false));
160 }
161 if !self.policy.allows(&pk, req.project_id.as_deref()) {
162 return Err(fail(&n, R::Unauthorized, "key may not forward for this project", false));
163 }
164 if req.vectors.is_empty() || req.vectors.len() > MAX_VECTORS_PER_BATCH {
165 return Err(fail(&n, R::Invalid, "vector count out of bounds", true));
166 }
167 if req.vectors.iter().any(|v| v.values.iter().any(|f| !f.is_finite())) {
168 return Err(fail(&n, R::Invalid, "non-finite value", true));
169 }
170 if !self.nonces.admit(&req.nonce, req.expires_at_ms, now) {
171 return Err(fail(&n, R::Replay, "nonce already used", true));
172 }
173 Ok(req)
174 }
175
176 fn apply(&self, req: ForwardRequest) -> ForwardOutcome {
177 let refuse = |code, reason: &str| ForwardOutcome::Refused {
178 code,
179 reason: reason.into(),
180 };
181 let Some(store) = self.directory.store_for(req.project_id.as_deref()) else {
182 return refuse(ForwardRefusal::NoStore, "no store for this project here");
183 };
184 let from = Provenance {
185 instance_id: req.instance_id,
186 source_node: req.requester,
187 };
188 match store.ingest(&from, &req.vectors, req.dedup) {
189 Ok(result) => ForwardOutcome::Ok { result },
190 Err(StoreError::Full(_)) => refuse(ForwardRefusal::StoreFull, "store full"),
191 Err(StoreError::Backend(_)) => refuse(ForwardRefusal::Backend, "store backend error"),
192 }
193 }
194}
195
196pub async fn serve_connection(
198 mut stream: Box<dyn MeshStream>,
199 svc: Arc<StoreOwnerService>,
200) -> Result<(), MeshError> {
201 loop {
202 let raw = match stream.recv().await {
203 Ok(r) => r,
204 Err(MeshError::ConnectionClosed) => return Ok(()),
205 Err(e) => return Err(e),
206 };
207 if raw.len() > MAX_CTL_BYTES + 4096 {
208 let _ = stream.close().await;
209 return Err(MeshError::Transport("oversize ingest frame".into()));
210 }
211 let env = MeshIpcEnvelope::from_bytes(&raw)
212 .map_err(|e| MeshError::Transport(format!("bad envelope: {e}")))?;
213 let signed = match (&env.message.target, &env.message.payload) {
214 (MessageTarget::ServiceMethod { service, method }, MessagePayload::Json(v))
215 if service == STORE_SERVICE && method == STORE_INGEST_METHOD =>
216 {
217 serde_json::from_value::<SignedCtl>(v.clone())
218 .map_err(|e| MeshError::Transport(format!("bad signed payload: {e}")))?
219 }
220 _ => {
221 let _ = stream.close().await;
222 return Err(MeshError::Transport("not a cog-store request".into()));
223 }
224 };
225 let (resp, authed) = svc.handle_at(&signed, now_ms());
226 let mut msg = KernelMessage::new(
227 0,
228 owner_target(),
229 MessagePayload::Json(serde_json::to_value(&resp).unwrap_or_default()),
230 );
231 msg.correlation_id = env.message.correlation_id.clone();
232 let out = MeshIpcEnvelope::new(svc.node_id().to_string(), env.source_node, msg);
233 let bytes = out
234 .to_bytes()
235 .map_err(|e| MeshError::Transport(e.to_string()))?;
236 stream.send(&bytes).await?;
237 if !authed {
238 let _ = stream.close().await;
239 return Ok(());
240 }
241 }
242}
243
244pub const MAX_SESSIONS: usize = 32;
246
247pub async fn serve_listener(
250 mut listener: Box<dyn TransportListener>,
251 svc: Arc<StoreOwnerService>,
252 noise: bool,
253) -> Result<(), MeshError> {
254 let slots = Arc::new(tokio::sync::Semaphore::new(MAX_SESSIONS));
255 loop {
256 let (mut stream, peer) = listener.accept().await?;
257 let Ok(slot) = slots.clone().try_acquire_owned() else {
258 let _ = stream.close().await;
259 continue;
260 };
261 let svc = svc.clone();
262 tokio::spawn(async move {
263 let _slot = slot;
264 let stream: Box<dyn MeshStream> = if noise {
265 let cfg = crate::mesh_noise::NoiseConfig {
266 pattern: crate::mesh_noise::NoisePattern::XX,
267 local_private_key: rand::random(),
268 remote_static_key: None,
269 };
270 match crate::mesh_noise::NoiseChannel::respond(stream, &cfg).await {
271 Ok(ch) => Box::new(NoiseStream::new(Box::new(ch))),
272 Err(e) => {
273 tracing::warn!(%peer, error = %e, "cog-store noise handshake failed");
274 return;
275 }
276 }
277 } else {
278 stream
279 };
280 if let Err(e) = serve_connection(stream, svc).await {
281 tracing::debug!(%peer, error = %e, "cog-store session ended");
282 }
283 });
284 }
285}
286
287#[derive(Default)]
290pub struct OwnerConnector {
291 noise: bool,
292 local: std::sync::RwLock<HashMap<String, Arc<StoreOwnerService>>>,
293}
294
295impl OwnerConnector {
296 pub fn new(noise: bool) -> Self {
298 Self {
299 noise,
300 local: Default::default(),
301 }
302 }
303
304 pub fn register_local(&self, name: &str, svc: Arc<StoreOwnerService>) -> String {
306 let addr = format!("{MEM_SCHEME}{name}");
307 if let Ok(mut m) = self.local.write() {
308 m.insert(addr.clone(), svc);
309 }
310 addr
311 }
312}
313
314#[async_trait]
315impl CtlConnector for OwnerConnector {
316 async fn connect(&self, addr: &str) -> Result<Box<dyn MeshStream>, MeshError> {
317 use crate::mesh::MeshTransport;
318 if addr.starts_with(MEM_SCHEME) {
319 let svc = self
320 .local
321 .read()
322 .ok()
323 .and_then(|m| m.get(addr).cloned())
324 .ok_or_else(|| MeshError::PeerNotConnected(addr.to_string()))?;
325 let (client, server) = crate::mesh_test_support::connected_pair().await?;
326 tokio::spawn(async move {
327 let _ = serve_connection(Box::new(server), svc).await;
328 });
329 return Ok(Box::new(client));
330 }
331 let stream = crate::mesh_tcp::TcpTransport.connect(addr).await?;
332 if !self.noise {
333 return Ok(stream);
334 }
335 let cfg = crate::mesh_noise::NoiseConfig {
336 pattern: crate::mesh_noise::NoisePattern::XX,
337 local_private_key: rand::random(),
338 remote_static_key: None,
339 };
340 let ch = crate::mesh_noise::NoiseChannel::initiate(stream, &cfg).await?;
341 Ok(Box::new(NoiseStream::new(Box::new(ch))))
342 }
343}
344