Skip to main content

netget/server/docker/
mod.rs

1//! Docker Engine API server: the read-only core a real `docker -H tcp://…` CLI needs.
2//!
3//! The CLI's first request on every command is `HEAD /_ping`; the `API-Version` header on the
4//! answer is what it negotiates down to, and every later request carries that version as a
5//! `/v1.xx/` path prefix. NetGet serves `/_ping` and the version check deterministically, and
6//! raises one `docker_api_request` event for each read the model decides — `/version`, `/info`,
7//! `/containers/json`, `/containers/{id}/json`, `/images/json`, `/networks`, `/volumes`.
8//! Mutating endpoints (create, start, exec, pull, delete…) are refused with a 501 in Docker's
9//! own `{"message": …}` shape without asking the model.
10//!
11//! The model names containers, images and states; [`api`] fills everything else the Go decoder
12//! expects. See `src/server/docker/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::{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
44/// Largest request body this server will buffer, in bytes.
45///
46/// Every endpoint served here is a read with no body, and every mutating endpoint is refused,
47/// so no legitimate request comes near this. The body is read before routing so the bound
48/// holds on every path; without it one unauthenticated `POST /containers/create` could make the
49/// server buffer without limit.
50pub const MAX_REQUEST_BODY_BYTES: usize = 1024 * 1024;
51
52/// How long to wait for a peer's first byte after it connects.
53///
54/// HTTP is client-speaks-first. Enforced with `TcpStream::peek` before hyper sees the socket,
55/// so the deadline never runs inside a model round-trip.
56const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
57
58/// How long a connection may do nothing at all between requests.
59///
60/// The Docker CLI opens a connection per command and closes it when the command ends; SDK
61/// clients pool connections and go quiet between calls. `ConnectionActivity` keeps a connection
62/// whose request is waiting on the model, or parked for a human, from ever reading as idle.
63const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
64
65/// Concurrent connections this server admits.
66const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
67
68/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
69const 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
73/// API version advertised by `/_ping` when the `api_version` startup parameter is absent.
74///
75/// 1.47 is Docker Engine 27.x. Current CLIs (29.x) negotiate down to it without complaint.
76pub const DEFAULT_API_VERSION: &str = "1.47";
77
78/// Engine version reported when `engine_version` is absent.
79pub const DEFAULT_ENGINE_VERSION: &str = "27.5.1";
80
81/// Per-server configuration.
82#[derive(Clone, Debug)]
83pub struct DockerConfig {
84    pub identity: api::EngineIdentity,
85}
86
87/// Docker Engine API server.
88pub struct DockerServer;
89
90impl DockerServer {
91    /// Bind, then spawn the accept loop. A bind failure reaches the caller so the server is
92    /// recorded as `Error`, and the accept-loop handle is registered so `stop_server` frees
93    /// the port.
94    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                                // Held for the life of the connection.
176                                let _permit = permit;
177
178                                // First-byte bound before hyper sees the socket; `peek` does
179                                // not consume, so the request line is still there afterwards.
180                                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/// Per-connection dependencies, cloned into each hyper service call.
226#[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
273/// Headers every Docker daemon response carries.
274fn 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
308/// Docker's error shape. `message` is either NetGet's own fixed text or the model's chosen
309/// wording; it never carries an internal error.
310fn 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    // Bounded, and read before routing so the bound holds on every path.
345    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                        // The executor validated this already; disagreeing here must not
519                        // become a half-built document.
520                        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
597/// Render the model's answer for a route. The executor has already validated the same data.
598fn 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}