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::{HeaderName, HeaderValue, CONTENT_TYPE, RETRY_AFTER, SERVER};
26use hyper::server::conn::http1;
27use hyper::service::service_fn;
28use hyper::{Request, Response, StatusCode};
29use hyper_util::rt::TokioIo;
30use serde_json::{json, Map, 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;
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;
51
52const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
57
58const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
64
65const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
67
68const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
70 Content-Type: application/json\r\nContent-Length: 43\r\nRetry-After: 5\r\n\
71 Connection: close\r\n\r\n{\"message\":\"netget: too many connections\"}\n";
72
73pub const DEFAULT_API_VERSION: &str = "1.47";
77
78pub const DEFAULT_ENGINE_VERSION: &str = "27.5.1";
80
81#[derive(Clone, Debug)]
83pub struct DockerConfig {
84 pub identity: api::EngineIdentity,
85}
86
87pub struct DockerServer;
89
90impl DockerServer {
91 pub async fn spawn_with_llm_actions(
95 listen_addr: SocketAddr,
96 llm_client: OllamaClient,
97 app_state: Arc<AppState>,
98 status_tx: mpsc::UnboundedSender<String>,
99 server_id: ServerId,
100 config: DockerConfig,
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
106 console_info!(
107 status_tx,
108 "Docker Engine API listening on tcp://{} (engine {}, API {})",
109 local_addr,
110 config.identity.engine_version,
111 config.identity.api_version
112 );
113
114 let protocol = Arc::new(actions::DockerProtocol::new());
115 let config = Arc::new(config);
116 let task_registrar = app_state.clone();
117 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
118 let accept_handle = tokio::spawn(async move {
119 loop {
120 match crate::server::accept_bounded::accept_bounded(
121 &listener,
122 &limiter,
123 CONNECTION_CAP_REFUSAL,
124 "Docker",
125 Some(&status_tx),
126 )
127 .await
128 {
129 Ok((stream, remote_addr, permit)) => {
130 let connection_id =
131 ConnectionId::new(app_state.get_next_unified_id().await);
132 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
133 info!("Docker connection {} from {}", connection_id, remote_addr);
134
135 use crate::state::server::{
136 ConnectionState as ServerConnectionState, ConnectionStatus,
137 ProtocolConnectionInfo,
138 };
139 let now = crate::utils::clock::Instant::now();
140 app_state
141 .add_connection_to_server(
142 server_id,
143 ServerConnectionState {
144 id: connection_id,
145 remote_addr,
146 local_addr: local_addr_conn,
147 bytes_sent: 0,
148 bytes_received: 0,
149 packets_sent: 0,
150 packets_received: 0,
151 last_activity: now,
152 status: ConnectionStatus::Active,
153 status_changed_at: now,
154 protocol_info: ProtocolConnectionInfo::empty(),
155 },
156 )
157 .await;
158 let _ = status_tx.send("__UPDATE_UI__".to_string());
159
160 let ctx = RequestContext {
161 llm_client: llm_client.clone(),
162 app_state: app_state.clone(),
163 status_tx: status_tx.clone(),
164 protocol: protocol.clone(),
165 config: config.clone(),
166 server_id,
167 connection_id,
168 };
169 let app_state_for_close = app_state.clone();
170 let status_for_close = status_tx.clone();
171
172 let task_owner = app_state.clone();
173 task_owner
174 .spawn_server_task(server_id, async move {
175 let _permit = permit;
177
178 match tokio::time::timeout(
181 FIRST_BYTE_READ_TIMEOUT,
182 stream.peek(&mut [0u8; 1]),
183 )
184 .await
185 {
186 Ok(Ok(n)) if n > 0 => {
187 serve_connection(TokioIo::new(stream), ctx).await;
188 }
189 Ok(_) => {}
190 Err(_) => {
191 debug!(
192 "Docker peer {} sent nothing for {}s; closing",
193 remote_addr,
194 FIRST_BYTE_READ_TIMEOUT.as_secs()
195 );
196 }
197 }
198
199 app_state_for_close
200 .close_connection_on_server(server_id, connection_id)
201 .await;
202 let _ = status_for_close.send(format!(
203 "[INFO] Docker connection {connection_id} closed"
204 ));
205 let _ = status_for_close.send("__UPDATE_UI__".to_string());
206 })
207 .await;
208 }
209 Err(e) => {
210 console_error!(status_tx, "Failed to accept Docker connection: {}", e);
211 break;
212 }
213 }
214 }
215 });
216
217 task_registrar
218 .register_server_task(server_id, accept_handle)
219 .await;
220
221 Ok(local_addr)
222 }
223}
224
225#[derive(Clone)]
227struct RequestContext {
228 llm_client: OllamaClient,
229 app_state: Arc<AppState>,
230 status_tx: mpsc::UnboundedSender<String>,
231 protocol: Arc<actions::DockerProtocol>,
232 config: Arc<DockerConfig>,
233 server_id: ServerId,
234 connection_id: ConnectionId,
235}
236
237async fn serve_connection<T>(io: TokioIo<T>, ctx: RequestContext)
238where
239 T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
240{
241 let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
242 let activity_for_service = Arc::clone(&activity);
243
244 let service = service_fn(move |req: Request<Incoming>| {
245 let ctx = ctx.clone();
246 let activity = Arc::clone(&activity_for_service);
247 async move {
248 let _busy = activity.busy();
249 Ok::<_, Infallible>(handle_request(req, ctx).await)
250 }
251 });
252
253 let conn = http1::Builder::new().serve_connection(io, service);
254 tokio::pin!(conn);
255 tokio::select! {
256 result = &mut conn => {
257 if let Err(err) = result {
258 debug!("Docker connection ended: {:?}", err);
259 }
260 }
261 _ = crate::server::accept_bounded::watch_idle(
262 Arc::clone(&activity),
263 IDLE_BETWEEN_REQUESTS_TIMEOUT,
264 ) => {
265 debug!(
266 "Docker connection idle for {}s; closing",
267 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
268 );
269 }
270 }
271}
272
273fn stamp(response: &mut Response<Full<Bytes>>, identity: &api::EngineIdentity) {
275 let headers = response.headers_mut();
276 if let Ok(v) = HeaderValue::from_str(&identity.api_version) {
277 headers.insert(HeaderName::from_static("api-version"), v);
278 }
279 if let Ok(v) = HeaderValue::from_str(&format!("Docker/{} (linux)", identity.engine_version)) {
280 headers.insert(SERVER, v);
281 }
282 headers.insert(
283 HeaderName::from_static("docker-experimental"),
284 HeaderValue::from_static("false"),
285 );
286 headers.insert(
287 HeaderName::from_static("ostype"),
288 HeaderValue::from_static("linux"),
289 );
290}
291
292fn json_response(
293 status: StatusCode,
294 body: &Value,
295 identity: &api::EngineIdentity,
296) -> Response<Full<Bytes>> {
297 let mut bytes = serde_json::to_vec(body).unwrap_or_else(|_| b"{}".to_vec());
298 bytes.push(b'\n');
299 let mut response = Response::new(Full::new(Bytes::from(bytes)));
300 *response.status_mut() = status;
301 response
302 .headers_mut()
303 .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
304 stamp(&mut response, identity);
305 response
306}
307
308fn error_response(
311 status: StatusCode,
312 message: &str,
313 identity: &api::EngineIdentity,
314) -> Response<Full<Bytes>> {
315 json_response(status, &api::error_body(message), identity)
316}
317
318fn parse_query(query: &str) -> Map<String, Value> {
319 let mut map = Map::new();
320 for pair in query.split('&').filter(|p| !p.is_empty()) {
321 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
322 let decode = |s: &str| {
323 urlencoding::decode(&s.replace('+', " "))
324 .map(|c| c.into_owned())
325 .unwrap_or_else(|_| s.to_string())
326 };
327 map.insert(decode(k), Value::String(decode(v)));
328 }
329 map
330}
331
332async fn handle_request(req: Request<Incoming>, ctx: RequestContext) -> Response<Full<Bytes>> {
333 let identity = &ctx.config.identity;
334 let (parts, body) = req.into_parts();
335 let method = parts.method.as_str().to_string();
336 let raw_path = parts.uri.path().to_string();
337 let query = parse_query(parts.uri.query().unwrap_or(""));
338
339 debug!("Docker {} {}", method, raw_path);
340 let _ = ctx
341 .status_tx
342 .send(format!("[DEBUG] Docker {method} {raw_path}"));
343
344 let body_len = match http_body_util::Limited::new(body, MAX_REQUEST_BODY_BYTES)
346 .collect()
347 .await
348 {
349 Ok(collected) => collected.to_bytes().len(),
350 Err(e) => {
351 error!(
352 "Docker {} {}: decision=fail_closed_body_rejected (limit {} bytes): {}",
353 method, raw_path, MAX_REQUEST_BODY_BYTES, e
354 );
355 return error_response(
356 StatusCode::PAYLOAD_TOO_LARGE,
357 "netget: request body is larger than this server accepts",
358 identity,
359 );
360 }
361 };
362 ctx.app_state
363 .update_connection_stats(
364 ctx.server_id,
365 ctx.connection_id,
366 Some(body_len as u64),
367 None,
368 Some(1),
369 None,
370 )
371 .await;
372
373 let (requested_version, path) = api::split_version(&raw_path);
374 if let Some(v) = &requested_version {
375 let requested = api::parse_version(v);
376 let max = api::parse_version(&identity.api_version);
377 let min = api::parse_version(api::MIN_API_VERSION);
378 if requested > max {
379 return error_response(
380 StatusCode::BAD_REQUEST,
381 &format!(
382 "client version {v} is too new. Maximum supported API version is {}",
383 identity.api_version
384 ),
385 identity,
386 );
387 }
388 if requested < min {
389 return error_response(
390 StatusCode::BAD_REQUEST,
391 &format!(
392 "client version {v} is too old. Minimum supported API version is {}, \
393 please upgrade your client to a newer version",
394 api::MIN_API_VERSION
395 ),
396 identity,
397 );
398 }
399 }
400
401 let route = api::resolve(&method, &path);
402 match &route {
403 Route::Ping => {
404 let mut response = Response::new(Full::new(Bytes::from_static(b"OK")));
405 let headers = response.headers_mut();
406 headers.insert(
407 CONTENT_TYPE,
408 HeaderValue::from_static("text/plain; charset=utf-8"),
409 );
410 headers.insert(
411 hyper::header::CACHE_CONTROL,
412 HeaderValue::from_static("no-cache, no-store, must-revalidate"),
413 );
414 headers.insert(hyper::header::PRAGMA, HeaderValue::from_static("no-cache"));
415 headers.insert(
416 HeaderName::from_static("builder-version"),
417 HeaderValue::from_static("1"),
418 );
419 stamp(&mut response, identity);
420 return response;
421 }
422 Route::Mutating => {
423 info!(
424 "Docker {} {}: decision=fail_closed_not_implemented",
425 method, raw_path
426 );
427 return error_response(
428 StatusCode::NOT_IMPLEMENTED,
429 "netget's Docker Engine API is read-only: creating, starting, executing in, \
430 pulling and removing are not implemented",
431 identity,
432 );
433 }
434 Route::NotFound => {
435 return error_response(StatusCode::NOT_FOUND, "page not found", identity);
436 }
437 _ => {}
438 }
439
440 let resource = route.resource().unwrap_or("unknown");
441 let mut data = json!({
442 "method": method,
443 "path": path,
444 "api_version": requested_version.clone().unwrap_or_else(|| identity.api_version.clone()),
445 "query": query,
446 "resource": resource,
447 });
448 if let Route::ContainerInspect(id) = &route {
449 data["id"] = json!(id);
450 }
451 if let Some(answer_with) = route.answer_with(&data["query"]) {
452 data["answer_with"] = json!(answer_with);
453 }
454
455 console_debug!(ctx.status_tx, "Calling LLM for Docker {} {}", method, path);
456 let event = Event::new(actions::event_for_resource(resource), data);
457 let llm_result = call_llm(
458 &ctx.llm_client,
459 &ctx.app_state,
460 ctx.server_id,
461 Some(ctx.connection_id),
462 &event,
463 ctx.protocol.as_ref(),
464 )
465 .await;
466
467 let response = match llm_result {
468 Ok(execution) => {
469 let failure_summary = execution.failure_summary();
470 let expected = route.answering_action();
471 let mut answer = None;
472 for result in execution.protocol_results {
473 let crate::llm::ActionResult::Custom { name, data } = result else {
474 continue;
475 };
476 if name == "send_docker_error" {
477 let status = data
478 .get("status")
479 .and_then(Value::as_u64)
480 .and_then(|s| u16::try_from(s).ok())
481 .and_then(|s| StatusCode::from_u16(s).ok())
482 .filter(|s| s.is_client_error() || s.is_server_error())
483 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
484 let message = data
485 .get("message")
486 .and_then(Value::as_str)
487 .unwrap_or("error");
488 info!(
489 "Docker {} {}: decision=model_reject status={}",
490 method,
491 path,
492 status.as_u16()
493 );
494 answer = Some(error_response(
495 status,
496 &crate::utils::sanitize::line_field(message),
497 identity,
498 ));
499 break;
500 }
501 if Some(name.as_str()) != expected {
502 debug!(
503 "Docker {} {}: ignoring {} (this route is answered by {:?})",
504 method, path, name, expected
505 );
506 continue;
507 }
508 match render(&route, &data, identity) {
509 Ok(body) => {
510 info!(
511 "Docker {} {}: decision=model_answer action={}",
512 method, path, name
513 );
514 answer = Some(json_response(StatusCode::OK, &body, identity));
515 break;
516 }
517 Err(reason) => {
518 error!(
521 "Docker {} {}: decision=fail_closed_invalid_answer ({})",
522 method, path, reason
523 );
524 }
525 }
526 }
527 match answer {
528 Some(response) => response,
529 None => {
530 match failure_summary {
531 Some(summary) => error!(
532 "Docker {} {}: decision=fail_closed_invalid_answer ({})",
533 method, path, summary
534 ),
535 None => error!(
536 "Docker {} {}: decision=fail_closed_no_action (expected {:?})",
537 method, path, expected
538 ),
539 }
540 console_error!(
541 ctx.status_tx,
542 "Docker: the handler returned no usable answer for {} {}",
543 method,
544 path
545 );
546 error_response(
547 StatusCode::INTERNAL_SERVER_ERROR,
548 "netget: the handler returned no usable answer for this request",
549 identity,
550 )
551 }
552 }
553 }
554 Err(e) => {
555 let failure = crate::utils::WireFailure::classify(&e);
556 error!(
557 "Docker {} {}: decision=fail_closed_llm_error category={:?} error={}",
558 method, path, failure, e
559 );
560 console_error!(ctx.status_tx, "Docker LLM call failed: {}", e);
561 match failure {
562 crate::utils::WireFailure::Overloaded => {
563 let mut response = error_response(
564 StatusCode::SERVICE_UNAVAILABLE,
565 failure.prefixed_text(),
566 identity,
567 );
568 response
569 .headers_mut()
570 .insert(RETRY_AFTER, HeaderValue::from_static("5"));
571 response
572 }
573 crate::utils::WireFailure::Unavailable => error_response(
574 StatusCode::INTERNAL_SERVER_ERROR,
575 failure.prefixed_text(),
576 identity,
577 ),
578 }
579 }
580 };
581 let sent = hyper::body::Body::size_hint(response.body())
582 .exact()
583 .unwrap_or(0);
584 ctx.app_state
585 .update_connection_stats(
586 ctx.server_id,
587 ctx.connection_id,
588 None,
589 Some(sent),
590 None,
591 Some(1),
592 )
593 .await;
594 response
595}
596
597fn render(route: &Route, data: &Value, identity: &api::EngineIdentity) -> Result<Value, String> {
599 match route {
600 Route::Version => api::render_version(data, identity),
601 Route::Info => api::render_info(data, identity),
602 Route::ContainerList => {
603 api::render_container_list(data.get("containers").unwrap_or(&Value::Null))
604 }
605 Route::ContainerInspect(_) => {
606 api::render_container(data.get("container").unwrap_or(&Value::Null))
607 }
608 Route::ImageList => api::render_images(data.get("images").unwrap_or(&Value::Null)),
609 Route::NetworkList => api::render_networks(data.get("networks").unwrap_or(&Value::Null)),
610 Route::VolumeList => api::render_volumes(data.get("volumes").unwrap_or(&Value::Null)),
611 Route::Ping | Route::Mutating | Route::NotFound => Err("route is not answered".into()),
612 }
613}