Skip to main content

netget/server/vault/
mod.rs

1//! HashiCorp Vault server, KV version 2: what `vault status` and `vault kv get/put/list/
2//! metadata get` need.
3//!
4//! The CLI's first request for any `kv` command is `GET /v1/sys/internal/ui/mounts/<path>`; the
5//! `options.version` it finds there decides whether it uses KV v1 paths or v2's
6//! `<mount>/data/…` and `<mount>/metadata/…`. NetGet answers that preflight, seal status, health
7//! and leader deterministically from its configuration, and raises `vault_read`, `vault_write`
8//! or `vault_list` for everything that touches a secret. **NetGet stores nothing**: the model
9//! (or its memory, or the SQLite facility) holds the secrets.
10//!
11//! `X-Vault-Token` reaches the model only as booleans — present, matches the configured token,
12//! a token is configured — never the token itself. See `src/server/vault/CLAUDE.md`.
13
14pub mod actions;
15/// Routing and rendering. Public so it can be tested without a socket.
16pub 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
44/// Largest request body this server will buffer, in bytes.
45///
46/// A KV write carries the secret's key/value pairs, and those are then embedded whole in an LLM
47/// prompt, so there is no legitimate large one. Vault's own default `max_request_size` is
48/// 32 MiB; this is far below it on purpose. Read before routing so it holds on every path.
49pub const MAX_REQUEST_BODY_BYTES: usize = 1024 * 1024;
50
51/// How long to wait for a peer's first byte after it connects (`peek` before hyper).
52const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
53
54/// How long a connection may do nothing at all between requests. The CLI connects per
55/// command; SDK clients pool connections. `ConnectionActivity` keeps a request waiting on the
56/// model, or parked for a human, from reading as idle.
57const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
58
59/// Concurrent connections this server admits.
60const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
61
62/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
63const 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
67/// Version reported by `sys/seal-status` and `sys/health` when `vault_version` is absent.
68pub const DEFAULT_VAULT_VERSION: &str = "1.18.3";
69
70/// Per-server configuration.
71#[derive(Clone, Debug)]
72pub struct VaultConfig {
73    pub identity: api::VaultIdentity,
74    /// KV v2 mount paths, without slashes (`secret`, `kv/prod`).
75    pub kv_mounts: Vec<String>,
76    /// The token requests are compared against. `None` means none was configured, and then any
77    /// non-empty token counts as matching.
78    pub token: Option<String>,
79}
80
81/// Vault server.
82pub struct VaultServer;
83
84impl VaultServer {
85    /// Bind, then spawn the accept loop, registered so `stop_server` frees the port.
86    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                                // Held for the life of the connection.
172                                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
277/// Vault's error shape. The messages are NetGet's fixed text or the model's chosen wording.
278fn 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
283/// `(token_present, token_matches_configured, token_configured)`, compared in constant time.
284fn 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                // What Vault answers for a path no mount covers; the CLI then reports it.
360                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            // KV v2's write body is {"data": {...}, "options": {"cas": N}}. Anything else is
406            // Vault's own 400, decided here rather than shown to the model as an empty write.
407            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}