1pub mod actions;
8
9use std::collections::HashMap;
10use std::net::SocketAddr;
11use std::sync::Arc;
12
13use bytes::Bytes;
14use http_body_util::{BodyExt, Full};
15use hyper::body::Incoming;
16use hyper::server::conn::http1;
17use hyper::service::service_fn;
18use hyper::{Request, Response};
19use hyper_util::rt::TokioIo;
20use tokio::sync::mpsc;
21use tracing::{debug, error, info};
22
23use crate::llm::ollama_client::OllamaClient;
24use crate::llm::ActionResult;
25use crate::logging::emit::Log;
26use crate::server::connection::ConnectionId;
27use crate::server::CouchDbProtocol;
28use crate::state::app_state::AppState;
29use crate::{console_error, console_info};
30
31pub const MAX_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024;
42
43const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
56
57const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75);
68
69const MAX_CONNECTIONS: usize = 128;
77
78const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
84 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
85
86pub struct CouchDbServer;
88
89impl CouchDbServer {
90 #[allow(clippy::too_many_arguments)]
92 pub async fn spawn_with_llm_actions(
93 listen_addr: SocketAddr,
94 llm_client: OllamaClient,
95 app_state: Arc<AppState>,
96 status_tx: mpsc::UnboundedSender<String>,
97 server_id: crate::state::ServerId,
98 enable_auth: bool,
99 admin_username: String,
100 admin_password: String,
101 ) -> anyhow::Result<SocketAddr> {
102 let listener =
103 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
104 let local_addr = listener.local_addr()?;
105 console_info!(
106 status_tx,
107 "CouchDB server listening on {} (auth: {})",
108 local_addr,
109 if enable_auth { "enabled" } else { "disabled" }
110 );
111
112 let protocol = Arc::new(CouchDbProtocol::new());
113 let auth_config = Arc::new(AuthConfig {
114 enabled: enable_auth,
115 admin_username,
116 admin_password,
117 });
118
119 let task_registrar = app_state.clone();
121 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
122 let accept_handle = tokio::spawn(async move {
123 loop {
124 match crate::server::accept_bounded::accept_bounded(
125 &listener,
126 &limiter,
127 CONNECTION_CAP_REFUSAL,
128 "CouchDB",
129 Some(&status_tx),
130 )
131 .await
132 {
133 Ok((stream, remote_addr, permit)) => {
134 let connection_id =
135 ConnectionId::new(app_state.get_next_unified_id().await);
136 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
137 info!("CouchDB connection {} from {}", connection_id, remote_addr);
138 Log::new(Some(&status_tx))
139 .info(format!("CouchDB connection from {}", remote_addr));
140
141 use crate::state::server::{
143 ConnectionState as ServerConnectionState, ConnectionStatus,
144 ProtocolConnectionInfo,
145 };
146 let now = crate::utils::clock::Instant::now();
147 let conn_state = ServerConnectionState {
148 id: connection_id,
149 remote_addr,
150 local_addr: local_addr_conn,
151 bytes_sent: 0,
152 bytes_received: 0,
153 packets_sent: 0,
154 packets_received: 0,
155 last_activity: now,
156 status: ConnectionStatus::Active,
157 status_changed_at: now,
158 protocol_info: ProtocolConnectionInfo::empty(),
159 };
160 app_state
161 .add_connection_to_server(server_id, conn_state)
162 .await;
163 let _ = status_tx.send("__UPDATE_UI__".to_string());
164
165 let llm_client_clone = llm_client.clone();
166 let app_state_clone = app_state.clone();
167 let status_tx_clone = status_tx.clone();
168 let protocol_clone = protocol.clone();
169 let auth_config_clone = auth_config.clone();
170
171 let task_owner = app_state.clone();
174 task_owner
175 .spawn_server_task(server_id, async move {
176 let _permit = permit;
179
180 let spoke = matches!(
191 tokio::time::timeout(
192 FIRST_BYTE_READ_TIMEOUT,
193 stream.peek(&mut [0u8; 1]),
194 )
195 .await,
196 Ok(Ok(n)) if n > 0
197 );
198
199 let io = TokioIo::new(stream);
200
201 let status_for_service = status_tx_clone.clone();
203 let app_state_for_service = app_state_clone.clone();
204
205 let activity = std::sync::Arc::new(
211 crate::server::accept_bounded::ConnectionActivity::new(),
212 );
213 let activity_for_service = std::sync::Arc::clone(&activity);
214
215 let service = service_fn(move |req: Request<Incoming>| {
217 let llm_clone = llm_client_clone.clone();
218 let state_clone = app_state_for_service.clone();
219 let status_clone = status_for_service.clone();
220 let protocol_clone = protocol_clone.clone();
221 let auth_clone = auth_config_clone.clone();
222 let activity = std::sync::Arc::clone(&activity_for_service);
223 async move {
224 let _busy = activity.busy();
225 handle_couchdb_request_with_llm(
226 req,
227 connection_id,
228 llm_clone,
229 state_clone,
230 status_clone,
231 protocol_clone,
232 server_id,
233 auth_clone,
234 )
235 .await
236 }
237 });
238
239 if !spoke {
241 Log::new(Some(&status_tx_clone)).debug(format!(
242 "CouchDB peer {} sent nothing for {}s; closing before \
243 any request",
244 remote_addr,
245 FIRST_BYTE_READ_TIMEOUT.as_secs()
246 ));
247 } else {
248 let conn = http1::Builder::new().serve_connection(io, service);
249 tokio::pin!(conn);
250 tokio::select! {
251 result = &mut conn => {
252 if let Err(err) = result {
253 error!(
254 "Error serving CouchDB connection: \
255 {:?}",
256 err
257 );
258 }
259 }
260 _ = crate::server::accept_bounded::watch_idle(
261 std::sync::Arc::clone(&activity),
262 IDLE_BETWEEN_REQUESTS_TIMEOUT,
263 ) => {
264 Log::new(Some(&status_tx_clone)).debug(format!(
265 "CouchDB connection {} idle for {}s; closing",
266 connection_id,
267 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
268 ));
269 }
270 }
271 }
272
273 app_state_clone
275 .close_connection_on_server(server_id, connection_id)
276 .await;
277 Log::new(Some(&status_tx_clone))
278 .info(format!("CouchDB connection {} closed", connection_id));
279 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
280 })
281 .await;
282 }
283 Err(e) => {
284 console_error!(status_tx, "Failed to accept CouchDB connection: {}", e);
285 break;
286 }
287 }
288 }
289 });
290
291 task_registrar
293 .register_server_task(server_id, accept_handle)
294 .await;
295
296 Ok(local_addr)
297 }
298}
299
300struct AuthConfig {
302 enabled: bool,
303 admin_username: String,
304 admin_password: String,
305}
306
307#[allow(clippy::too_many_arguments)]
309async fn handle_couchdb_request_with_llm(
310 req: Request<Incoming>,
311 _connection_id: ConnectionId,
312 llm_client: OllamaClient,
313 app_state: Arc<AppState>,
314 status_tx: mpsc::UnboundedSender<String>,
315 protocol: Arc<CouchDbProtocol>,
316 server_id: crate::state::ServerId,
317 auth_config: Arc<AuthConfig>,
318) -> Result<Response<Full<Bytes>>, std::convert::Infallible> {
319 let method = req.method().to_string();
321 let uri = req.uri().to_string();
322 let path = req.uri().path().to_string();
323 let query = req.uri().query().map(|q| q.to_string());
324
325 let authorization = req
327 .headers()
328 .get("authorization")
329 .and_then(|h| h.to_str().ok())
330 .map(|s| s.to_string());
331
332 if auth_config.enabled {
334 if let Some(auth_header) = &authorization {
335 if !check_basic_auth(
336 auth_header,
337 &auth_config.admin_username,
338 &auth_config.admin_password,
339 ) {
340 Log::new(Some(&status_tx)).debug("CouchDB authentication failed");
341 return Ok(create_auth_required_response());
342 }
343 } else {
344 Log::new(Some(&status_tx)).debug("CouchDB authentication required");
346 return Ok(create_auth_required_response());
347 }
348 }
349
350 let limit = MAX_REQUEST_BODY_BYTES;
363 let body_bytes = match http_body_util::Limited::new(req.into_body(), limit)
364 .collect()
365 .await
366 {
367 Ok(collected) => collected.to_bytes(),
368 Err(e) => {
369 error!(
370 "CouchDB {} {}: refusing request body ({}); limit is {} bytes",
371 method, path, e, limit
372 );
373 console_error!(
374 status_tx,
375 "CouchDB {} {} → 413 (request body over {} bytes)",
376 method,
377 path,
378 limit
379 );
380 let body = serde_json::json!({
381 "error": "too_large",
382 "reason": format!("netget: request body exceeds {} bytes", limit)
383 })
384 .to_string();
385 return Ok(couchdb_response_builder(413)
386 .body(Full::new(Bytes::from(body)))
387 .unwrap_or_else(|_| {
388 couchdb_infallible_error(
389 hyper::StatusCode::PAYLOAD_TOO_LARGE,
390 r#"{"error":"too_large","reason":"netget: request body too large"}"#,
391 )
392 }));
393 }
394 };
395
396 let body_str = String::from_utf8_lossy(&body_bytes).to_string();
397
398 debug!(
399 "CouchDB request: {} {} ({} bytes)",
400 method,
401 uri,
402 body_bytes.len()
403 );
404 Log::new(Some(&status_tx)).debug(format!(
405 "CouchDB {} {} ({} bytes)",
406 method,
407 path,
408 body_bytes.len()
409 ));
410
411 let query_params: HashMap<String, String> = query
413 .as_ref()
414 .map(|q| {
415 form_urlencoded::parse(q.as_bytes())
416 .map(|(k, v)| (k.into_owned(), v.into_owned()))
417 .collect()
418 })
419 .unwrap_or_default();
420
421 let (operation, database, doc_id) = detect_couchdb_operation(&method, &path);
423
424 if !body_str.is_empty() {
425 Log::new(Some(&status_tx)).trace(format!("CouchDB request body: {}", body_str));
426 }
427
428 let event = crate::protocol::Event::new(
430 &actions::COUCHDB_REQUEST_EVENT,
431 serde_json::json!({
432 "method": method,
433 "path": path,
434 "operation": operation,
435 "database": database,
436 "doc_id": doc_id,
437 "query_params": query_params,
438 "request_body": body_str,
439 "authorization": authorization.map(|_| "***"), }),
441 );
442
443 let llm_result = crate::llm::action_helper::call_llm(
444 &llm_client,
445 &app_state,
446 server_id,
447 None, &event,
449 protocol.as_ref(),
450 )
451 .await;
452
453 match llm_result {
455 Ok(execution_result) => {
456 for result in execution_result.protocol_results {
458 match result {
459 ActionResult::Custom { name, data } => {
460 if name == "couchdb_response" {
461 let status =
462 data.get("status").and_then(|v| v.as_u64()).unwrap_or(200) as u16;
463 let body = data.get("body").and_then(|v| v.as_str()).unwrap_or("{}");
464 let etag = data.get("etag").and_then(|v| v.as_str());
465 let www_authenticate =
466 data.get("www_authenticate").and_then(|v| v.as_str());
467
468 debug!("CouchDB response: status={}", status);
469 let log = Log::new(Some(&status_tx));
470 log.debug(format!("CouchDB → {} response", status));
471 log.trace(format!("CouchDB response body: {}", body));
472
473 let mut builder = couchdb_response_builder(status);
474
475 if let Some(etag_value) = etag {
476 builder = header_or_skip(builder, "ETag", etag_value);
477 }
478
479 if let Some(www_auth) = www_authenticate {
480 builder = header_or_skip(builder, "WWW-Authenticate", www_auth);
481 }
482
483 return Ok(builder
484 .body(Full::new(Bytes::from(body.to_string())))
485 .unwrap_or_else(|_| {
486 couchdb_infallible_error(
487 hyper::StatusCode::INTERNAL_SERVER_ERROR,
488 r#"{"error":"internal_server_error","reason":"netget: the response headers chosen for this reply could not be encoded"}"#,
489 )
490 }));
491 }
492 }
493 _ => {
494 }
496 }
497 }
498
499 let kind = "no_response";
509 Log::new(Some(&status_tx)).warn(
510 "CouchDB handler produced no response for this request; answering 500 \
511 (decision=no_response) rather than a success it cannot vouch for"
512 .to_string(),
513 );
514 let error_response = serde_json::json!({
515 "error": kind,
516 "reason": "netget: the handler returned no CouchDB response for this request"
517 })
518 .to_string();
519
520 Ok(couchdb_response_builder(500)
521 .body(Full::new(Bytes::from(error_response)))
522 .unwrap_or_else(|_| {
523 couchdb_infallible_error(
524 hyper::StatusCode::INTERNAL_SERVER_ERROR,
525 r#"{"error":"no_response","reason":"netget: the handler returned no CouchDB response for this request"}"#,
526 )
527 }))
528 }
529 Err(e) => {
530 let overloaded = crate::llm::is_overload_error(&e);
536 let (status, kind) = if overloaded {
537 (503u16, "unavailable")
538 } else {
539 (500u16, "internal_server_error")
540 };
541 error!(
542 "LLM error for CouchDB request (overload={}, status {}): {}",
543 overloaded, status, e
544 );
545 console_error!(status_tx, "CouchDB answering {} {}: {}", status, kind, e);
546
547 let error_response = serde_json::json!({
548 "error": kind,
549 "reason": crate::utils::WireFailure::classify(&e).prefixed_text()
550 })
551 .to_string();
552
553 Ok(couchdb_response_builder(status)
554 .body(Full::new(Bytes::from(error_response)))
555 .unwrap_or_else(|_| {
556 couchdb_infallible_error(
557 hyper::StatusCode::INTERNAL_SERVER_ERROR,
558 r#"{"error":"internal_server_error","reason":"netget: no response could be obtained"}"#,
559 )
560 }))
561 }
562 }
563}
564
565fn couchdb_infallible_error(status: hyper::StatusCode, body: &str) -> Response<Full<Bytes>> {
574 let mut response = Response::new(Full::new(Bytes::from(body.to_string())));
575 *response.status_mut() = status;
576 response
577}
578
579fn couchdb_response_builder(status: u16) -> hyper::http::response::Builder {
587 let status = hyper::StatusCode::from_u16(status).unwrap_or_else(|_| {
588 error!(
589 "Invalid CouchDB status code {}, sending 500 instead",
590 status
591 );
592 hyper::StatusCode::INTERNAL_SERVER_ERROR
593 });
594
595 Response::builder()
596 .status(status)
597 .header("Content-Type", "application/json")
598 .header("Server", "CouchDB/3.5.1 (NetGet LLM)")
599}
600
601fn header_or_skip(
608 builder: hyper::http::response::Builder,
609 name: &'static str,
610 value: &str,
611) -> hyper::http::response::Builder {
612 match hyper::header::HeaderValue::from_str(value) {
613 Ok(v) => builder.header(name, v),
614 Err(_) => {
615 error!(
616 "Dropping CouchDB {} header: {:?} is not a valid HTTP header value",
617 name, value
618 );
619 builder
620 }
621 }
622}
623
624fn detect_couchdb_operation(method: &str, path: &str) -> (String, Option<String>, Option<String>) {
626 let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
627
628 match (method, parts.as_slice()) {
629 ("GET", [""]) => ("server_info".to_string(), None, None),
631
632 ("GET", ["_all_dbs"]) => ("all_dbs".to_string(), None, None),
634 ("GET", ["_active_tasks"]) => ("active_tasks".to_string(), None, None),
635 ("GET", ["_uuids"]) => ("uuids".to_string(), None, None),
636 ("POST", ["_replicate"]) => ("replicate".to_string(), None, None),
637 ("GET", ["_session"]) => ("session".to_string(), None, None),
638
639 ("PUT", [db]) if !db.starts_with('_') => {
641 ("db_create".to_string(), Some(db.to_string()), None)
642 }
643 ("DELETE", [db]) if !db.starts_with('_') => {
644 ("db_delete".to_string(), Some(db.to_string()), None)
645 }
646 ("GET", [db]) if !db.starts_with('_') => {
647 ("db_info".to_string(), Some(db.to_string()), None)
648 }
649 ("POST", [db]) if !db.starts_with('_') => {
650 ("doc_create".to_string(), Some(db.to_string()), None)
651 }
652
653 ("GET", [db, "_all_docs"]) => ("all_docs".to_string(), Some(db.to_string()), None),
655 ("POST", [db, "_all_docs"]) => ("all_docs".to_string(), Some(db.to_string()), None),
656 ("POST", [db, "_bulk_docs"]) => ("bulk_docs".to_string(), Some(db.to_string()), None),
657 ("GET", [db, "_changes"]) => ("changes".to_string(), Some(db.to_string()), None),
658 ("POST", [db, "_ensure_full_commit"]) => {
659 ("ensure_full_commit".to_string(), Some(db.to_string()), None)
660 }
661 ("POST", [db, "_compact"]) => ("compact".to_string(), Some(db.to_string()), None),
662 ("POST", [db, "_purge"]) => ("purge".to_string(), Some(db.to_string()), None),
663
664 ("GET", [db, "_design", ddoc]) => (
666 "design_get".to_string(),
667 Some(db.to_string()),
668 Some(format!("_design/{}", ddoc)),
669 ),
670 ("PUT", [db, "_design", ddoc]) => (
671 "design_put".to_string(),
672 Some(db.to_string()),
673 Some(format!("_design/{}", ddoc)),
674 ),
675 ("DELETE", [db, "_design", ddoc]) => (
676 "design_delete".to_string(),
677 Some(db.to_string()),
678 Some(format!("_design/{}", ddoc)),
679 ),
680
681 ("GET", [db, "_design", ddoc, "_view", view]) => (
683 "view_query".to_string(),
684 Some(db.to_string()),
685 Some(format!("_design/{}/{}", ddoc, view)),
686 ),
687 ("POST", [db, "_design", ddoc, "_view", view]) => (
688 "view_query".to_string(),
689 Some(db.to_string()),
690 Some(format!("_design/{}/{}", ddoc, view)),
691 ),
692
693 ("GET", [db, doc_id]) if !doc_id.starts_with('_') => (
695 "doc_get".to_string(),
696 Some(db.to_string()),
697 Some(doc_id.to_string()),
698 ),
699 ("PUT", [db, doc_id]) if !doc_id.starts_with('_') => (
700 "doc_put".to_string(),
701 Some(db.to_string()),
702 Some(doc_id.to_string()),
703 ),
704 ("DELETE", [db, doc_id]) if !doc_id.starts_with('_') => (
705 "doc_delete".to_string(),
706 Some(db.to_string()),
707 Some(doc_id.to_string()),
708 ),
709 ("HEAD", [db, doc_id]) if !doc_id.starts_with('_') => (
710 "doc_head".to_string(),
711 Some(db.to_string()),
712 Some(doc_id.to_string()),
713 ),
714
715 ("GET", [db, doc_id, attachment]) if !doc_id.starts_with('_') => (
717 "attachment_get".to_string(),
718 Some(db.to_string()),
719 Some(format!("{}/{}", doc_id, attachment)),
720 ),
721 ("PUT", [db, doc_id, attachment]) if !doc_id.starts_with('_') => (
722 "attachment_put".to_string(),
723 Some(db.to_string()),
724 Some(format!("{}/{}", doc_id, attachment)),
725 ),
726 ("DELETE", [db, doc_id, attachment]) if !doc_id.starts_with('_') => (
727 "attachment_delete".to_string(),
728 Some(db.to_string()),
729 Some(format!("{}/{}", doc_id, attachment)),
730 ),
731
732 ("GET", [db, "_local", doc_id]) => (
734 "local_doc_get".to_string(),
735 Some(db.to_string()),
736 Some(format!("_local/{}", doc_id)),
737 ),
738 ("PUT", [db, "_local", doc_id]) => (
739 "local_doc_put".to_string(),
740 Some(db.to_string()),
741 Some(format!("_local/{}", doc_id)),
742 ),
743 ("POST", [db, "_revs_diff"]) => ("revs_diff".to_string(), Some(db.to_string()), None),
744 ("POST", [db, "_bulk_get"]) => ("bulk_get".to_string(), Some(db.to_string()), None),
745
746 _ => ("unknown".to_string(), None, None),
748 }
749}
750
751fn check_basic_auth(auth_header: &str, expected_username: &str, expected_password: &str) -> bool {
753 if !auth_header.starts_with("Basic ") {
755 return false;
756 }
757
758 let encoded = &auth_header[6..]; use base64::Engine;
762 let decoded = match base64::engine::general_purpose::STANDARD.decode(encoded) {
763 Ok(d) => d,
764 Err(_) => return false,
765 };
766
767 let credentials = match String::from_utf8(decoded) {
768 Ok(c) => c,
769 Err(_) => return false,
770 };
771
772 let parts: Vec<&str> = credentials.splitn(2, ':').collect();
774 if parts.len() != 2 {
775 return false;
776 }
777
778 parts[0] == expected_username && parts[1] == expected_password
779}
780
781fn create_auth_required_response() -> Response<Full<Bytes>> {
783 let error_response = serde_json::json!({
784 "error": "unauthorized",
785 "reason": "Authentication required"
786 })
787 .to_string();
788
789 Response::builder()
790 .status(401)
791 .header("Content-Type", "application/json")
792 .header("Server", "CouchDB/3.5.1 (NetGet LLM)")
793 .header("WWW-Authenticate", "Basic realm=\"CouchDB\"")
794 .body(Full::new(Bytes::from(error_response)))
795 .unwrap()
796}