1pub mod actions;
2pub mod schema;
3use crate::client::{command_support, llm_budget::call_llm_for_client};
4use crate::logging::emit::Log;
5use crate::protocol::{ConnectContext, Event};
6use crate::state::{
7 client_handles::{ClientCommand, ClientSendOutcome},
8 AccessLogOwner, ClientStatus,
9};
10pub use actions::DockerClientProtocol;
11use anyhow::{ensure, Context, Result};
12use serde_json::{json, Value};
13use std::{net::SocketAddr, time::Duration};
14use tokio::sync::mpsc;
15pub const DEFAULT_API_VERSION: &str = "1.47";
16pub const DEFAULT_TIMEOUT_SECS: u64 = 15;
17pub const MAX_TIMEOUT_SECS: u64 = 30;
18pub const MAX_FOLLOWUPS: u8 = 4;
19pub const QUEUE_CAPACITY: usize = 8;
20pub fn api_version(v: &str) -> Result<(u16, u16)> {
21 let (major, minor) = v
22 .split_once('.')
23 .context("API version must be major.minor")?;
24 ensure!(
25 !major.is_empty()
26 && !minor.is_empty()
27 && major.len() <= 3
28 && minor.len() <= 3
29 && major
30 .bytes()
31 .chain(minor.bytes())
32 .all(|c| c.is_ascii_digit()),
33 "invalid API version"
34 );
35 Ok((major.parse()?, minor.parse()?))
36}
37pub struct Request {
38 pub operation: String,
39 pub path: String,
40}
41pub fn request(action: &Value, version: &str) -> Result<Request> {
42 api_version(version)?;
43 ensure!(
44 action["type"] == "docker_request",
45 "expected docker_request action"
46 );
47 let operation = action["operation"]
48 .as_str()
49 .context("operation must be string")?;
50 let (route, allowed) = match operation {
51 "ping" => ("/_ping".to_owned(), vec![]),
52 "version" => ("/version".into(), vec![]),
53 "info" => ("/info".into(), vec![]),
54 "containers" => (
55 "/containers/json".into(),
56 vec!["all", "limit", "size", "filters"],
57 ),
58 "container" => {
59 let id = action["container_id"]
60 .as_str()
61 .context("container_id required")?;
62 ensure!(
63 id.as_bytes().first().is_some_and(u8::is_ascii_alphanumeric)
64 && id.len() <= 256
65 && id
66 .bytes()
67 .all(|c| c.is_ascii_alphanumeric() || b"_-.".contains(&c)),
68 "container_id must be a bounded ID or name"
69 );
70 (
71 format!("/containers/{id}/json"),
72 vec!["container_id", "size"],
73 )
74 }
75 "images" => ("/images/json".into(), vec!["all", "digests", "filters"]),
76 "networks" => ("/networks".into(), vec!["filters"]),
77 "volumes" => ("/volumes".into(), vec!["filters"]),
78 _ => anyhow::bail!("unknown read-only Docker operation"),
79 };
80 let object = action.as_object().context("action must be object")?;
81 for field in object.keys() {
82 ensure!(
83 ["type", "operation"].contains(&field.as_str()) || allowed.contains(&field.as_str()),
84 "field {field} is not allowed for {operation}"
85 );
86 }
87 let mut query = Vec::new();
88 for field in ["all", "size", "digests"] {
89 if let Some(v) = action.get(field) {
90 let value = v
91 .as_bool()
92 .with_context(|| format!("{field} must be boolean"))?;
93 query.push(format!("{field}={value}"));
94 }
95 }
96 if let Some(v) = action.get("limit") {
97 let limit = v.as_u64().context("limit must be integer")?;
98 ensure!((1..=1000).contains(&limit), "limit must be 1..1000");
99 query.push(format!("limit={limit}"));
100 }
101 if let Some(v) = action.get("filters") {
102 let filters = v
103 .as_object()
104 .context("filters must be object of string arrays")?;
105 ensure!(filters.len() <= 64, "filter count limit");
106 for (key, values) in filters {
107 ensure!(
108 !key.is_empty()
109 && key.len() <= 64
110 && key
111 .bytes()
112 .all(|c| c.is_ascii_alphanumeric() || b"_-".contains(&c)),
113 "invalid filter name"
114 );
115 let values = values
116 .as_array()
117 .context("filter values must be string arrays")?;
118 ensure!(values.len() <= 64, "filter value count limit");
119 for v in values {
120 let text = v.as_str().context("filter value must be string")?;
121 ensure!(
122 text.len() <= 1024 && !crate::utils::sanitize::has_controls(&text),
123 "filter value limit/control character"
124 );
125 }
126 }
127 let encoded = serde_json::to_string(v)?;
128 ensure!(encoded.len() <= 8192, "encoded filter limit");
129 query.push(format!("filters={}", urlencoding::encode(&encoded)));
130 }
131 let mut path = if operation == "ping" {
132 route
133 } else {
134 format!("/v{version}{route}")
135 };
136 if !query.is_empty() {
137 path.push('?');
138 path.push_str(&query.join("&"));
139 }
140 Ok(Request {
141 operation: operation.into(),
142 path,
143 })
144}
145#[derive(Clone)]
146enum Endpoint {
147 Tcp {
148 origin: String,
149 host: String,
150 port: u16,
151 authority: String,
152 },
153 #[cfg(all(unix, not(target_arch = "wasm32")))]
154 Unix(String),
155}
156impl Endpoint {
157 fn parse(address: &str) -> Result<Self> {
158 if let Some(path) = address.strip_prefix("unix://") {
159 ensure!(
160 path.starts_with('/')
161 && path.len() <= 4096
162 && !crate::utils::sanitize::has_controls(&path)
163 && !path.contains(['?', '#']),
164 "Unix endpoint must be an absolute socket path"
165 );
166 #[cfg(all(unix, not(target_arch = "wasm32")))]
167 return Ok(Self::Unix(path.into()));
168 #[cfg(not(all(unix, not(target_arch = "wasm32"))))]
169 anyhow::bail!("Unix Docker transport is unavailable on this platform");
170 }
171 let address = if address.contains("://") {
172 address.into()
173 } else {
174 format!("http://{address}")
175 };
176 let url = url::Url::parse(&address).context("invalid Docker endpoint")?;
177 ensure!(
178 url.scheme() == "http" && url.host_str().is_some(),
179 "Docker transport requires HTTP TCP or native unix://; HTTPS/TLS is not implemented"
180 );
181 ensure!(
182 url.username().is_empty()
183 && url.password().is_none()
184 && url.path() == "/"
185 && url.query().is_none()
186 && url.fragment().is_none(),
187 "Docker endpoint must be an origin without credentials/path/query"
188 );
189 let host = url.host_str().unwrap().trim_matches(['[', ']']).to_owned();
190 let port = url.port_or_known_default().unwrap();
191 let authority = url[url::Position::BeforeHost..url::Position::AfterPort].to_owned();
192 Ok(Self::Tcp {
193 origin: url.origin().ascii_serialization(),
194 host,
195 port,
196 authority,
197 })
198 }
199 fn name(&self) -> String {
200 match self {
201 Self::Tcp { origin, .. } => origin.clone(),
202 #[cfg(all(unix, not(target_arch = "wasm32")))]
203 Self::Unix(path) => format!("unix://{path}"),
204 }
205 }
206}
207trait Socket: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin {}
208impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin> Socket for T {}
209async fn fetch(
210 endpoint: &Endpoint,
211 method: &str,
212 path: &str,
213) -> Result<(u16, hyper::HeaderMap, Vec<u8>)> {
214 use http_body_util::{BodyExt, Full, Limited};
215 let (stream, authority): (Box<dyn Socket>, &str) = match endpoint {
216 Endpoint::Tcp {
217 host,
218 port,
219 authority,
220 ..
221 } => (
222 Box::new(
223 tokio::net::TcpStream::connect((host.as_str(), *port))
224 .await
225 .context("connect Docker TCP")?,
226 ),
227 authority,
228 ),
229 #[cfg(all(unix, not(target_arch = "wasm32")))]
230 Endpoint::Unix(path) => (
231 Box::new(
232 tokio::net::UnixStream::connect(path)
233 .await
234 .context("connect Docker Unix socket")?,
235 ),
236 "localhost",
237 ),
238 };
239 let (mut sender, connection) =
240 hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream)).await?;
241 let exchange = async {
242 let response = sender
243 .send_request(
244 hyper::Request::builder()
245 .method(method)
246 .uri(path)
247 .header("Host", authority)
248 .header("Accept", "application/json")
249 .header("Accept-Encoding", "identity")
250 .header("User-Agent", "NetGet-Docker/1.0")
251 .header("Connection", "close")
252 .body(Full::new(bytes::Bytes::new()))?,
253 )
254 .await?;
255 let (parts, body) = response.into_parts();
256 if let Some(length) = parts.headers.get("content-length") {
257 ensure!(
258 length.to_str()?.parse::<u64>()? <= schema::MAX_BODY as u64,
259 "Docker body limit"
260 );
261 }
262 let body = Limited::new(body, schema::MAX_BODY)
263 .collect()
264 .await
265 .map_err(|e| anyhow::anyhow!("read bounded Docker body: {e}"))?
266 .to_bytes()
267 .to_vec();
268 Ok((parts.status.as_u16(), parts.headers, body))
269 };
270 tokio::pin!(exchange, connection);
271 tokio::select! {biased;result=&mut exchange=>result,result=&mut connection=>{
273 match exchange.await {Ok(response)=>Ok(response),Err(error)=>{result.context("Docker connection driver")?;Err(error)}}
276 }}
277}
278fn header<'a>(headers: &'a hyper::HeaderMap, name: &str) -> Result<&'a str> {
279 ensure!(
280 headers.get_all(name).iter().count() == 1,
281 "expected exactly one {name} header"
282 );
283 Ok(headers.get(name).unwrap().to_str()?)
284}
285fn ping(status: u16, headers: &hyper::HeaderMap, body: &[u8], head: bool) -> Result<Value> {
286 ensure!(status == 200, "Docker ping returned HTTP {status}");
287 let api = header(headers, "api-version")?;
288 api_version(api)?;
289 ensure!(head || body == b"OK", "invalid Docker ping body");
290 let os = headers.get("ostype").map(|v| v.to_str()).transpose()?;
291 let experimental = headers
292 .get("docker-experimental")
293 .map(|v| match v.to_str()? {
294 "true" => Ok(true),
295 "false" => Ok(false),
296 _ => anyhow::bail!("invalid Docker-Experimental header"),
297 })
298 .transpose()?;
299 Ok(json!({"api_version":api,"os_type":os,"experimental":experimental}))
300}
301fn response(
302 request: &Request,
303 status: u16,
304 headers: &hyper::HeaderMap,
305 body: &[u8],
306) -> Result<Value> {
307 if request.operation == "ping" {
308 return ping(status, headers, body, false);
309 }
310 ensure!(status == 200, "Docker returned HTTP {status}");
311 let mime = header(headers, "content-type")?
312 .split(';')
313 .next()
314 .unwrap()
315 .trim();
316 ensure!(
317 mime.eq_ignore_ascii_case("application/json"),
318 "expected Docker application/json response"
319 );
320 for encoding in headers.get_all("content-encoding") {
321 ensure!(
322 encoding.to_str()?.eq_ignore_ascii_case("identity"),
323 "unsupported Docker Content-Encoding"
324 );
325 }
326 schema::parse(&request.operation, body)
327}
328type HandlerEvent = (Event, u8);
329type HandlerAction = (Value, u8);
330pub async fn connect(ctx: ConnectContext) -> Result<SocketAddr> {
331 let endpoint = Endpoint::parse(&ctx.remote_addr)?;
332 let mut preferred = DEFAULT_API_VERSION.to_owned();
333 let mut timeout_secs = DEFAULT_TIMEOUT_SECS;
334 if let Some(params) = &ctx.startup_params {
335 preferred = params
336 .get_optional_string("api_version")?
337 .unwrap_or_else(|| DEFAULT_API_VERSION.into());
338 timeout_secs = params
339 .get_optional_u64("request_timeout_secs")?
340 .unwrap_or(DEFAULT_TIMEOUT_SECS);
341 }
342 let preferred_version = api_version(&preferred)?;
343 ensure!(
344 (api_version("1.24")?..=api_version(DEFAULT_API_VERSION)?).contains(&preferred_version),
345 "api_version must be 1.24..1.47"
346 );
347 ensure!(
348 (1..=MAX_TIMEOUT_SECS).contains(&timeout_secs),
349 "request_timeout_secs must be 1..30"
350 );
351 let timeout = Duration::from_secs(timeout_secs);
352 let (status, headers, body) = tokio::time::timeout(timeout, fetch(&endpoint, "HEAD", "/_ping"))
353 .await
354 .context("Docker negotiation deadline")??;
355 let negotiated = ping(status, &headers, &body, true)?;
356 let maximum = api_version(negotiated["api_version"].as_str().unwrap())?;
357 ensure!(
358 maximum >= api_version("1.24")?,
359 "Docker daemon API is below 1.24"
360 );
361 let selected = maximum.min(preferred_version);
362 let version = format!("{}.{}", selected.0, selected.1);
363 let remote = match &endpoint {
364 Endpoint::Tcp { host, port, .. } => {
365 tokio::time::timeout(timeout, tokio::net::lookup_host((host.as_str(), *port)))
366 .await
367 .context("Docker resolution deadline")??
368 .next()
369 .context("Docker resolved no addresses")?
370 }
371 #[cfg(all(unix, not(target_arch = "wasm32")))]
372 Endpoint::Unix(_) => "0.0.0.0:0".parse()?,
373 };
374 let external = command_support::register_command_channel(&ctx.state, ctx.client_id).await;
375 let (actions_tx, actions_rx) = mpsc::channel::<HandlerAction>(QUEUE_CAPACITY);
376 let (events_tx, mut events_rx) = mpsc::channel::<HandlerEvent>(QUEUE_CAPACITY);
377 let handler_ctx = ctx.clone();
378 let handler = tokio::spawn(async move {
379 while let Some((event, depth)) = events_rx.recv().await {
380 let instruction = handler_ctx
381 .state
382 .get_instruction_for_client(handler_ctx.client_id)
383 .await
384 .unwrap_or_default();
385 let memory = handler_ctx
386 .state
387 .get_memory_for_client(handler_ctx.client_id)
388 .await
389 .unwrap_or_default();
390 match call_llm_for_client(
391 &handler_ctx.llm_client,
392 &handler_ctx.state,
393 handler_ctx.client_id.to_string(),
394 &instruction,
395 &memory,
396 Some(&event),
397 &DockerClientProtocol,
398 &handler_ctx.status_tx,
399 )
400 .await
401 {
402 Ok(result) => {
403 if let Some(memory) = result.memory_updates {
404 handler_ctx
405 .state
406 .set_memory_for_client(handler_ctx.client_id, memory)
407 .await;
408 }
409 for action in result.actions {
410 if depth >= MAX_FOLLOWUPS && action["type"] != "disconnect" {
411 Log::new(Some(&handler_ctx.status_tx))
412 .warn("Docker handler followup limit reached");
413 continue;
414 }
415 if actions_tx.send((action, depth + 1)).await.is_err() {
416 return;
417 }
418 }
419 }
420 Err(e) => {
421 Log::new(Some(&handler_ctx.status_tx)).warn(format!("Docker handler: {e}"))
422 }
423 }
424 }
425 });
426 let handler_abort = handler.abort_handle();
427 ctx.state.register_client_task(ctx.client_id, handler).await;
428 let session_ctx = ctx.clone();
429 let task = tokio::spawn(async move {
430 let result = session(
431 &session_ctx,
432 &endpoint,
433 &version,
434 negotiated,
435 timeout,
436 external,
437 actions_rx,
438 events_tx,
439 )
440 .await;
441 handler_abort.abort();
442 let status = match result {
443 Ok(()) => ClientStatus::Disconnected,
444 Err(e) => {
445 Log::new(Some(&session_ctx.status_tx)).warn(format!("Docker ended: {e}"));
446 ClientStatus::Error(e.to_string())
447 }
448 };
449 session_ctx
450 .state
451 .update_client_status(session_ctx.client_id, status)
452 .await;
453 session_ctx
454 .state
455 .remove_client_handle(session_ctx.client_id)
456 .await;
457 let _ = session_ctx.status_tx.send("__UPDATE_UI__".into());
458 });
459 ctx.state.register_client_task(ctx.client_id, task).await;
460 Ok(remote)
461}
462#[allow(clippy::too_many_arguments)]
463async fn session(
464 ctx: &ConnectContext,
465 endpoint: &Endpoint,
466 version: &str,
467 negotiated: Value,
468 timeout: Duration,
469 mut external: mpsc::Receiver<ClientCommand>,
470 mut internal: mpsc::Receiver<HandlerAction>,
471 events: mpsc::Sender<HandlerEvent>,
472) -> Result<()> {
473 events
474 .try_send((
475 Event::new(
476 &actions::CONNECTED_EVENT,
477 json!({"endpoint":endpoint.name(),"api_version":version,"daemon":negotiated}),
478 ),
479 0,
480 ))
481 .context("Docker event queue full")?;
482 loop {
483 let (action, depth, command) = tokio::select! {command=external.recv()=>match command {Some(c)=>(c.action.clone(),0,Some(c)),None=>return Ok(())},action=internal.recv()=>match action {Some((a,d))=>(a,d,None),None=>return Ok(())}};
484 if action["type"] == "disconnect" {
485 if let Some(c) = command {
486 command_support::reply(c, Ok(ClientSendOutcome::Disconnected));
487 }
488 return Ok(());
489 }
490 let request = match request(&action, version) {
491 Ok(r) => r,
492 Err(e) => {
493 if let Some(c) = command {
494 command_support::reply(
495 c,
496 Ok(ClientSendOutcome::Rejected {
497 error: e.to_string(),
498 }),
499 );
500 } else {
501 Log::new(Some(&ctx.status_tx)).warn(format!("Docker action rejected: {e}"));
502 }
503 continue;
504 }
505 };
506 if let Some(c) = command {
507 ctx.state
508 .record_access_log(
509 AccessLogOwner::Client(ctx.client_id.as_u32()),
510 "Docker",
511 None,
512 "injected_action",
513 action.clone(),
514 vec![json!({"accepted":true})],
515 )
516 .await;
517 command_support::reply(
518 c,
519 Ok(ClientSendOutcome::Executed {
520 detail: "read-only request accepted; result follows as an event".into(),
521 }),
522 );
523 }
524 let result = {
525 let exchange = tokio::time::timeout(timeout, fetch(endpoint, "GET", &request.path));
526 tokio::pin!(exchange);
527 loop {
528 tokio::select! {biased;command=external.recv()=>{let Some(c)=command else{return Ok(())};if c.action["type"]=="disconnect" {command_support::reply(c,Ok(ClientSendOutcome::Disconnected));return Ok(());}command_support::reply(c,Ok(ClientSendOutcome::Rejected{error:"Docker request pending; retry after its event".into()}));},result=&mut exchange=>break result.context("whole Docker request deadline").and_then(|r|r)}
529 }
530 };
531 let event = match result {
532 Ok((status, headers, body)) => {
533 if status != 200 {
534 let error =
535 schema::error(&body).unwrap_or_else(|e| format!("HTTP {status}; {e:#}"));
536 Event::new(
537 &actions::ERROR_EVENT,
538 json!({"request":action,"api_version":version,"status":status,"error":error,"category":"http"}),
539 )
540 } else {
541 match response(&request, status, &headers, &body) {
542 Ok(data) => Event::new(
543 &actions::RESPONSE_EVENT,
544 json!({"request":action,"api_version":version,"operation":request.operation,"status":status,"data":data}),
545 ),
546 Err(e) => Event::new(
547 &actions::ERROR_EVENT,
548 json!({"request":action,"api_version":version,"status":status,"error":format!("{e:#}"),"category":"schema"}),
549 ),
550 }
551 }
552 }
553 Err(e) => Event::new(
554 &actions::ERROR_EVENT,
555 json!({"request":action,"api_version":version,"status":null,"error":format!("{e:#}"),"category":"transport"}),
556 ),
557 };
558 events
559 .try_send((event, depth))
560 .context("Docker event queue full; disconnecting bounded client")?;
561 }
562}