1pub mod actions;
15pub mod api;
17
18use std::convert::Infallible;
19use std::net::SocketAddr;
20use std::sync::Arc;
21
22use bytes::Bytes;
23use http_body_util::{BodyExt, Full};
24use hyper::body::Incoming;
25use hyper::header::{HeaderValue, CACHE_CONTROL, CONTENT_TYPE, RETRY_AFTER};
26use hyper::server::conn::http1;
27use hyper::service::service_fn;
28use hyper::{Request, Response, StatusCode};
29use hyper_util::rt::TokioIo;
30use serde_json::{json, Value};
31use tokio::sync::mpsc;
32use tracing::{debug, error, info};
33
34use crate::llm::action_helper::call_llm;
35use crate::llm::ollama_client::OllamaClient;
36use crate::protocol::{Event, EventType};
37use crate::server::connection::ConnectionId;
38use crate::state::app_state::AppState;
39use crate::state::ServerId;
40use crate::{console_debug, console_error, console_info};
41
42use api::Route;
43
44pub const MAX_REQUEST_BODY_BYTES: usize = 1024 * 1024;
50
51const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
53
54const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
58
59const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
61
62const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
64 Content-Type: application/json\r\nContent-Length: 44\r\nRetry-After: 5\r\n\
65 Connection: close\r\n\r\n{\"errors\":[\"netget: too many connections\"]}\n";
66
67pub const DEFAULT_VAULT_VERSION: &str = "1.18.3";
69
70#[derive(Clone, Debug)]
72pub struct VaultConfig {
73 pub identity: api::VaultIdentity,
74 pub kv_mounts: Vec<String>,
76 pub token: Option<String>,
79}
80
81pub struct VaultServer;
83
84impl VaultServer {
85 pub async fn spawn_with_llm_actions(
87 listen_addr: SocketAddr,
88 llm_client: OllamaClient,
89 app_state: Arc<AppState>,
90 status_tx: mpsc::UnboundedSender<String>,
91 server_id: ServerId,
92 config: VaultConfig,
93 ) -> anyhow::Result<SocketAddr> {
94 let listener =
95 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
96 let local_addr = listener.local_addr()?;
97
98 console_info!(
99 status_tx,
100 "Vault listening on http://{} (KV v2 mounts: {}; token {})",
101 local_addr,
102 config.kv_mounts.join(", "),
103 if config.token.is_some() {
104 "configured"
105 } else {
106 "not configured - any token accepted"
107 }
108 );
109
110 let protocol = Arc::new(actions::VaultProtocol::new());
111 let config = Arc::new(config);
112 let task_registrar = app_state.clone();
113 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
114 let accept_handle = tokio::spawn(async move {
115 loop {
116 match crate::server::accept_bounded::accept_bounded(
117 &listener,
118 &limiter,
119 CONNECTION_CAP_REFUSAL,
120 "Vault",
121 Some(&status_tx),
122 )
123 .await
124 {
125 Ok((stream, remote_addr, permit)) => {
126 let connection_id =
127 ConnectionId::new(app_state.get_next_unified_id().await);
128 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
129 info!("Vault connection {} from {}", connection_id, remote_addr);
130
131 use crate::state::server::{
132 ConnectionState as ServerConnectionState, ConnectionStatus,
133 ProtocolConnectionInfo,
134 };
135 let now = crate::utils::clock::Instant::now();
136 app_state
137 .add_connection_to_server(
138 server_id,
139 ServerConnectionState {
140 id: connection_id,
141 remote_addr,
142 local_addr: local_addr_conn,
143 bytes_sent: 0,
144 bytes_received: 0,
145 packets_sent: 0,
146 packets_received: 0,
147 last_activity: now,
148 status: ConnectionStatus::Active,
149 status_changed_at: now,
150 protocol_info: ProtocolConnectionInfo::empty(),
151 },
152 )
153 .await;
154 let _ = status_tx.send("__UPDATE_UI__".to_string());
155
156 let ctx = RequestContext {
157 llm_client: llm_client.clone(),
158 app_state: app_state.clone(),
159 status_tx: status_tx.clone(),
160 protocol: protocol.clone(),
161 config: config.clone(),
162 server_id,
163 connection_id,
164 };
165 let app_state_for_close = app_state.clone();
166 let status_for_close = status_tx.clone();
167
168 let task_owner = app_state.clone();
169 task_owner
170 .spawn_server_task(server_id, async move {
171 let _permit = permit;
173
174 match tokio::time::timeout(
175 FIRST_BYTE_READ_TIMEOUT,
176 stream.peek(&mut [0u8; 1]),
177 )
178 .await
179 {
180 Ok(Ok(n)) if n > 0 => {
181 serve_connection(TokioIo::new(stream), ctx).await;
182 }
183 Ok(_) => {}
184 Err(_) => {
185 debug!(
186 "Vault peer {} sent nothing for {}s; closing",
187 remote_addr,
188 FIRST_BYTE_READ_TIMEOUT.as_secs()
189 );
190 }
191 }
192
193 app_state_for_close
194 .close_connection_on_server(server_id, connection_id)
195 .await;
196 let _ = status_for_close.send(format!(
197 "[INFO] Vault connection {connection_id} closed"
198 ));
199 let _ = status_for_close.send("__UPDATE_UI__".to_string());
200 })
201 .await;
202 }
203 Err(e) => {
204 console_error!(status_tx, "Failed to accept Vault connection: {}", e);
205 break;
206 }
207 }
208 }
209 });
210
211 task_registrar
212 .register_server_task(server_id, accept_handle)
213 .await;
214
215 Ok(local_addr)
216 }
217}
218
219#[derive(Clone)]
220struct RequestContext {
221 llm_client: OllamaClient,
222 app_state: Arc<AppState>,
223 status_tx: mpsc::UnboundedSender<String>,
224 protocol: Arc<actions::VaultProtocol>,
225 config: Arc<VaultConfig>,
226 server_id: ServerId,
227 connection_id: ConnectionId,
228}
229
230async fn serve_connection<T>(io: TokioIo<T>, ctx: RequestContext)
231where
232 T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
233{
234 let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
235 let activity_for_service = Arc::clone(&activity);
236
237 let service = service_fn(move |req: Request<Incoming>| {
238 let ctx = ctx.clone();
239 let activity = Arc::clone(&activity_for_service);
240 async move {
241 let _busy = activity.busy();
242 Ok::<_, Infallible>(handle_request(req, ctx).await)
243 }
244 });
245
246 let conn = http1::Builder::new().serve_connection(io, service);
247 tokio::pin!(conn);
248 tokio::select! {
249 result = &mut conn => {
250 if let Err(err) = result {
251 debug!("Vault connection ended: {:?}", err);
252 }
253 }
254 _ = crate::server::accept_bounded::watch_idle(
255 Arc::clone(&activity),
256 IDLE_BETWEEN_REQUESTS_TIMEOUT,
257 ) => {
258 debug!(
259 "Vault connection idle for {}s; closing",
260 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
261 );
262 }
263 }
264}
265
266fn json_response(status: StatusCode, body: &Value) -> Response<Full<Bytes>> {
267 let mut bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
268 bytes.push(b'\n');
269 let mut response = Response::new(Full::new(Bytes::from(bytes)));
270 *response.status_mut() = status;
271 let headers = response.headers_mut();
272 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
273 headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
274 response
275}
276
277fn error_response(status: StatusCode, errors: &[&str]) -> Response<Full<Bytes>> {
279 let errors: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
280 json_response(status, &api::errors_body(&errors))
281}
282
283fn token_facts(presented: Option<&str>, configured: Option<&str>) -> (bool, bool, bool) {
285 let present = presented.is_some_and(|t| !t.is_empty());
286 match configured {
287 None => (present, present, false),
288 Some(expected) => {
289 let matches = presented.is_some_and(|got| {
290 got.len() == expected.len()
291 && got
292 .bytes()
293 .zip(expected.bytes())
294 .fold(0u8, |acc, (a, b)| acc | (a ^ b))
295 == 0
296 });
297 (present, matches, true)
298 }
299 }
300}
301
302async fn handle_request(req: Request<Incoming>, ctx: RequestContext) -> Response<Full<Bytes>> {
303 let (parts, body) = req.into_parts();
304 let method = parts.method.as_str().to_string();
305 let path = parts.uri.path().to_string();
306 let query = parts.uri.query().unwrap_or("");
307 let list_query = query.split('&').any(|p| p == "list=true" || p == "list=1");
308 let version_query = query
309 .split('&')
310 .find_map(|p| p.strip_prefix("version="))
311 .and_then(|v| v.parse::<u64>().ok());
312 let presented = parts
313 .headers
314 .get("x-vault-token")
315 .and_then(|v| v.to_str().ok())
316 .map(str::to_string);
317
318 debug!("Vault {} {}", method, path);
319 let _ = ctx.status_tx.send(format!("[DEBUG] Vault {method} {path}"));
320
321 let body_bytes = match http_body_util::Limited::new(body, MAX_REQUEST_BODY_BYTES)
322 .collect()
323 .await
324 {
325 Ok(collected) => collected.to_bytes(),
326 Err(e) => {
327 error!(
328 "Vault {} {}: decision=fail_closed_body_rejected (limit {} bytes): {}",
329 method, path, MAX_REQUEST_BODY_BYTES, e
330 );
331 return error_response(
332 StatusCode::PAYLOAD_TOO_LARGE,
333 &["netget: request body is larger than this server accepts"],
334 );
335 }
336 };
337 ctx.app_state
338 .update_connection_stats(
339 ctx.server_id,
340 ctx.connection_id,
341 Some(body_bytes.len() as u64),
342 None,
343 Some(1),
344 None,
345 )
346 .await;
347
348 let config = &ctx.config;
349 let route = api::resolve(&method, &path, list_query, &config.kv_mounts);
350 let (event_type, mut data): (&'static EventType, Value) = match &route {
351 Route::SealStatus => {
352 return json_response(StatusCode::OK, &api::seal_status(&config.identity))
353 }
354 Route::Health => return json_response(StatusCode::OK, &api::health(&config.identity)),
355 Route::Leader => return json_response(StatusCode::OK, &api::leader()),
356 Route::MountPreflight(p) => {
357 return match api::preflight_mount(p, &config.kv_mounts) {
358 Some(mount) => json_response(StatusCode::OK, &api::mount_preflight(&mount)),
359 None => error_response(
361 StatusCode::FORBIDDEN,
362 &[
363 "preflight capability check returned 403, please ensure client's \
364 policies grant access to path",
365 ],
366 ),
367 };
368 }
369 Route::Unsupported => {
370 info!(
371 "Vault {} {}: decision=fail_closed_not_implemented",
372 method, path
373 );
374 return error_response(
375 StatusCode::METHOD_NOT_ALLOWED,
376 &["netget's Vault implements KV v2 read, write, list and metadata read only"],
377 );
378 }
379 Route::NotFound => {
380 let route_name =
381 crate::utils::sanitize::line_field(path.strip_prefix("/v1/").unwrap_or(&path));
382 return error_response(
383 StatusCode::NOT_FOUND,
384 &[&format!(
385 "no handler for route \"{route_name}\". route entry not found."
386 )],
387 );
388 }
389 Route::ReadData { mount, path } => {
390 let mut d = json!({"mount": mount, "path": path, "what": "data"});
391 if let Some(v) = version_query {
392 d["version"] = json!(v);
393 }
394 (&actions::VAULT_READ_EVENT, d)
395 }
396 Route::ReadMetadata { mount, path } => (
397 &actions::VAULT_READ_EVENT,
398 json!({"mount": mount, "path": path, "what": "metadata"}),
399 ),
400 Route::List { mount, path } => (
401 &actions::VAULT_LIST_EVENT,
402 json!({"mount": mount, "path": path}),
403 ),
404 Route::WriteData { mount, path } => {
405 let parsed: Option<Value> = serde_json::from_slice(&body_bytes).ok();
408 let Some(secret) = parsed
409 .as_ref()
410 .and_then(|b| b.get("data"))
411 .filter(|d| d.is_object())
412 .cloned()
413 else {
414 return error_response(StatusCode::BAD_REQUEST, &["no data provided"]);
415 };
416 let mut d = json!({"mount": mount, "path": path, "data": secret});
417 if let Some(cas) = parsed
418 .as_ref()
419 .and_then(|b| b.pointer("/options/cas"))
420 .and_then(Value::as_u64)
421 {
422 d["cas"] = json!(cas);
423 }
424 (&actions::VAULT_WRITE_EVENT, d)
425 }
426 };
427
428 let (token_present, token_matches, token_configured) =
429 token_facts(presented.as_deref(), config.token.as_deref());
430 data["token_present"] = json!(token_present);
431 data["token_matches_configured"] = json!(token_matches);
432 data["token_configured"] = json!(token_configured);
433
434 console_debug!(ctx.status_tx, "Calling LLM for Vault {} {}", method, path);
435 let event = Event::new(event_type, data);
436 let llm_result = call_llm(
437 &ctx.llm_client,
438 &ctx.app_state,
439 ctx.server_id,
440 Some(ctx.connection_id),
441 &event,
442 ctx.protocol.as_ref(),
443 )
444 .await;
445
446 let expected = match &route {
447 Route::ReadData { .. } | Route::ReadMetadata { .. } => "send_vault_secret",
448 Route::WriteData { .. } => "send_vault_write_ok",
449 _ => "send_vault_list",
450 };
451 let response = match llm_result {
452 Ok(execution) => {
453 let failure_summary = execution.failure_summary();
454 let mut answer = None;
455 for result in execution.protocol_results {
456 let crate::llm::ActionResult::Custom { name, data } = result else {
457 continue;
458 };
459 if name == "send_vault_error" {
460 match api::render_error(&data) {
461 Ok((status, errors)) => {
462 info!(
463 "Vault {} {}: decision=model_reject status={}",
464 method, path, status
465 );
466 let refs: Vec<&str> = errors.iter().map(String::as_str).collect();
467 answer = Some(error_response(
468 StatusCode::from_u16(status)
469 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
470 &refs,
471 ));
472 break;
473 }
474 Err(reason) => error!(
475 "Vault {} {}: decision=fail_closed_invalid_answer ({})",
476 method, path, reason
477 ),
478 }
479 continue;
480 }
481 if name != expected {
482 debug!(
483 "Vault {} {}: ignoring {} (this route is answered by {})",
484 method, path, name, expected
485 );
486 continue;
487 }
488 let rendered = match &route {
489 Route::ReadData { .. } => api::render_secret(&data),
490 Route::ReadMetadata { .. } => api::render_metadata(&data),
491 Route::WriteData { .. } => api::render_write_ok(&data),
492 _ => api::render_list(&data),
493 };
494 match rendered {
495 Ok(body) => {
496 info!(
497 "Vault {} {}: decision=model_answer action={}",
498 method, path, name
499 );
500 answer = Some(json_response(StatusCode::OK, &body));
501 break;
502 }
503 Err(reason) => error!(
504 "Vault {} {}: decision=fail_closed_invalid_answer ({})",
505 method, path, reason
506 ),
507 }
508 }
509 match answer {
510 Some(response) => response,
511 None => {
512 match failure_summary {
513 Some(summary) => error!(
514 "Vault {} {}: decision=fail_closed_invalid_answer ({})",
515 method, path, summary
516 ),
517 None => error!(
518 "Vault {} {}: decision=fail_closed_no_action (expected {})",
519 method, path, expected
520 ),
521 }
522 console_error!(
523 ctx.status_tx,
524 "Vault: the handler returned no usable answer for {} {}",
525 method,
526 path
527 );
528 error_response(
529 StatusCode::INTERNAL_SERVER_ERROR,
530 &["netget: the handler returned no usable answer for this request"],
531 )
532 }
533 }
534 }
535 Err(e) => {
536 let failure = crate::utils::WireFailure::classify(&e);
537 error!(
538 "Vault {} {}: decision=fail_closed_llm_error category={:?} error={}",
539 method, path, failure, e
540 );
541 console_error!(ctx.status_tx, "Vault LLM call failed: {}", e);
542 match failure {
543 crate::utils::WireFailure::Overloaded => {
544 let mut response =
545 error_response(StatusCode::SERVICE_UNAVAILABLE, &[failure.prefixed_text()]);
546 response
547 .headers_mut()
548 .insert(RETRY_AFTER, HeaderValue::from_static("5"));
549 response
550 }
551 crate::utils::WireFailure::Unavailable => error_response(
552 StatusCode::INTERNAL_SERVER_ERROR,
553 &[failure.prefixed_text()],
554 ),
555 }
556 }
557 };
558 let sent = hyper::body::Body::size_hint(response.body())
559 .exact()
560 .unwrap_or(0);
561 ctx.app_state
562 .update_connection_stats(
563 ctx.server_id,
564 ctx.connection_id,
565 None,
566 Some(sent),
567 None,
568 Some(1),
569 )
570 .await;
571 response
572}