Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/dockermap-daemon/src/docker_collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ impl DockerCollector {
}
let options = StatsOptionsBuilder::new()
.stream(false)
.one_shot(false)
.one_shot(true)
.build();
let mut stream = self.client.stats(container_id, Some(options));
stream.next().await.transpose().map_err(|_| ())?.ok_or(())
Expand Down
9 changes: 8 additions & 1 deletion crates/dockermap-daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ mod tests {

let trace = tokio::spawn(async move {
let mut requests = Vec::new();
for _ in 0..5 {
for _ in 0..6 {
let (mut stream, _) = listener
.accept()
.await
Expand Down Expand Up @@ -427,6 +427,8 @@ mod tests {
r#"{"Volumes":[],"Warnings":null}"#
} else if target.contains("/containers/api/logs") {
""
} else if target.contains("/containers/api/stats") {
"{}"
} else {
panic!("unexpected Bollard target: {target}");
};
Expand Down Expand Up @@ -478,6 +480,10 @@ mod tests {
)
.await
.expect("bounded historical log read should succeed");
collector
.collect_one_shot_stats("api")
.await
.expect("finite stats read should succeed");

let requests = trace.await.expect("wire trace should finish");
assert_eq!(requests, vec![
Expand All @@ -486,6 +492,7 @@ mod tests {
"GET /volumes? HTTP/1.1",
"GET /containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=0&timestamps=true&tail=4096 HTTP/1.1",
"GET /containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=1706000124&timestamps=true&tail=4096 HTTP/1.1",
"GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1",
], "Bollard wire contract changed; update the gateway ADR and policy review before permitting a new request shape");
}

Expand Down
48 changes: 24 additions & 24 deletions crates/dockermap-docker-gateway/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use tokio::net::{UnixListener, UnixStream};
pub const LOG_TAIL: &str = "4096";
/// Exact bounded one-shot request emitted by Bollard 0.19.4 for a container
/// stats sample. Streaming stats remain outside the gateway authority.
pub const STATS_QUERY: &str = "stream=false&one-shot=false";
pub const STATS_QUERY: &str = "stream=false&one-shot=true";
/// Event replay and live-tail starts may inspect at most this recent window.
pub const EVENT_MAX_LOOKBACK_SECONDS: u64 = 300;

Expand Down Expand Up @@ -563,29 +563,29 @@ mod tests {
#[test]
fn only_measured_unfiltered_requests_pass() {
let policy = Policy::new(None);
for target in ["/containers/json?all=true&size=false", "/networks?", "/volumes?", "/containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=0&timestamps=true&tail=4096", "/containers/api/stats?stream=false&one-shot=false"] { assert!(policy.allow(&req(target)).is_ok(), "{target}"); }
for target in ["/containers/json?all=true&size=false", "/networks?", "/volumes?", "/containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=0&timestamps=true&tail=4096", "/containers/api/stats?stream=false&one-shot=true"] { assert!(policy.allow(&req(target)).is_ok(), "{target}"); }
for target in ["/containers/json?all=true", "/events", "/v1.44/containers/json?all=true&size=false", "/containers/api/json", "/containers/api/logs?follow=true&stdout=true&stderr=true&since=0&until=0&timestamps=true&tail=4096", "/containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=0&timestamps=true&tail=4097"] { assert!(policy.allow(&req(target)).is_err(), "{target}"); }
}

#[test]
fn stats_allow_only_the_measured_finite_request() {
let policy = Policy::new(None);
let valid = "/containers/api/stats?stream=false&one-shot=false";
let valid = "/containers/api/stats?stream=false&one-shot=true";
assert_eq!(policy.allow(&req(valid)), Ok(valid.into()));
for target in [
"/containers/stats?stream=false&one-shot=false",
"/containers//stats?stream=false&one-shot=false",
"/containers/stats?stream=false&one-shot=true",
"/containers//stats?stream=false&one-shot=true",
"/containers/api/stats",
"/containers/api/stats?",
"/containers/api/stats?stream=true&one-shot=false",
"/containers/api/stats?stream=false&one-shot=true",
"/containers/api/stats?one-shot=false&stream=false",
"/containers/api/stats?stream=true&one-shot=true",
"/containers/api/stats?stream=false&one-shot=false",
"/containers/api/stats?one-shot=true&stream=false",
"/containers/api/stats?stream=false",
"/containers/api/stats?stream=false&one-shot=false&one-shot=false",
"/containers/api/stats?stream=false&one-shot=false&x=1",
"/containers/%61pi/stats?stream=false&one-shot=false",
"/containers/api%2fstats?stream=false&one-shot=false",
"/v1.44/containers/api/stats?stream=false&one-shot=false",
"/containers/api/stats?stream=false&one-shot=true&one-shot=true",
"/containers/api/stats?stream=false&one-shot=true&x=1",
"/containers/%61pi/stats?stream=false&one-shot=true",
"/containers/api%2fstats?stream=false&one-shot=true",
"/v1.44/containers/api/stats?stream=false&one-shot=true",
] {
assert!(policy.allow(&req(target)).is_err(), "{target}");
}
Expand All @@ -602,7 +602,7 @@ mod tests {
fn stats_fail_closed_when_a_gateway_label_scope_is_configured() {
let policy = Policy::new(Some("com.dockermap.fixture=trace-123".into()));
assert_eq!(
policy.allow(&req("/containers/api/stats?stream=false&one-shot=false")),
policy.allow(&req("/containers/api/stats?stream=false&one-shot=true")),
Err(Deny::Query)
);
}
Expand Down Expand Up @@ -1047,7 +1047,7 @@ mod tests {
.unwrap();
let options = StatsOptionsBuilder::new()
.stream(false)
.one_shot(false)
.one_shot(true)
.build();
let mut stream = Box::pin(docker.stats("api", Some(options)));
let received = tokio::time::timeout(Duration::from_secs(2), stream.next())
Expand All @@ -1062,7 +1062,7 @@ mod tests {
let mut lines = head.lines();
assert_eq!(
lines.next(),
Some("GET /containers/api/stats?stream=false&one-shot=false HTTP/1.1")
Some("GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1")
);
let headers = lines.collect::<Vec<_>>();
assert!(headers
Expand Down Expand Up @@ -1112,7 +1112,7 @@ mod tests {
}
let response = request(
&gateway_socket,
"GET /containers/api/stats?stream=false&one-shot=false HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1\r\nHost: docker\r\n\r\n",
)
.await;
assert!(!response.starts_with("HTTP/1.1 200"), "{response}");
Expand Down Expand Up @@ -1177,13 +1177,13 @@ mod tests {
"GET /containers/json?all=true&size=%GG HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/logs?follow=true&stdout=true&stderr=true&since=0&until=0&timestamps=true&tail=4096 HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/logs?follow=false&stdout=true&stderr=true&since=0&until=0&timestamps=true&tail=4096&tail=4096 HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/stats?stream=true&one-shot=false HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/stats?one-shot=false&stream=false HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/stats?stream=false&one-shot=false&x=1 HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api%2fstats?stream=false&one-shot=false HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/stats?stream=false&one-shot=false HTTP/1.1\r\nHost: docker\r\nContent-Length: 1\r\n\r\nx",
"GET /containers/api/stats?stream=false&one-shot=false HTTP/1.1\r\nHost: docker\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n",
"GET /containers/api/stats?stream=true&one-shot=true HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/stats?stream=false&one-shot=false HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/stats?one-shot=true&stream=false HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/stats?stream=false&one-shot=true&x=1 HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api%2fstats?stream=false&one-shot=true HTTP/1.1\r\nHost: docker\r\n\r\n",
"GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1\r\nHost: docker\r\nContent-Length: 1\r\n\r\nx",
"GET /containers/api/stats?stream=false&one-shot=true HTTP/1.1\r\nHost: docker\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n",
"GET /networks? HTTP/1.1\r\nHost: docker\r\nContent-Length: 0\r\n\r\n",
"GET /networks? HTTP/1.1\r\nHost: docker\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n",
"GET /networks? HTTP/1.1\r\nHost: docker\r\nUpgrade: h2c\r\n\r\n",
Expand Down
10 changes: 6 additions & 4 deletions docs/architecture/DOCKER_AUTHORITY_BOUNDARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,15 @@ regression measures Bollard 0.19.4 against the real filtered Unix gateway and
an isolated raw-Docker Unix stub. The one approved origin-form target is:

```text
/containers/<single-unescaped-name-or-id>/stats?stream=false&one-shot=false
/containers/<single-unescaped-name-or-id>/stats?stream=false&one-shot=true
```

The test proves the exact query order, empty request body, normal HTTP/1.1
framing, and verbatim forwarding. The policy rejects every other stats form:
the streaming form, one-shot variation, reordered/duplicate/unknown or missing
query keys, root/versioned/encoded paths, request bodies, and upgrade framing.
framing, and verbatim forwarding. `one-shot=true` asks Docker for one finite
sample; the gateway never permits a live stats stream. The policy rejects every
other stats form: `stream=true`, `one-shot=false`, reordered/duplicate/unknown
or missing query keys, root/versioned/encoded paths, request bodies, and upgrade
framing.
It provides no general Docker read or path wildcard, but an unfiltered gateway
does permit that single finite stats shape for any valid container name or ID
segment. It does not itself implement a stats collector or publish Docker's
Expand Down
2 changes: 1 addition & 1 deletion docs/deployment/DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ port on the interface of your choice, e.g. `-p 3233:3233`.
- Gateway only: `/var/run/docker.sock` read-only. The gateway independently
permits only reviewed inventory, bounded non-following logs, bounded Docker
events, and the exact finite per-container stats request
`stream=false&one-shot=false` on an unfiltered profile. It denies all stats
`stream=false&one-shot=true` on an unfiltered profile. It denies all stats
requests when `DOCKERMAP_DOCKER_LABEL_FILTER` is set because that Docker
endpoint cannot express the inventory label scope.
- Collector only: `/opt/dockermap/project` read-only plus the filtered gateway
Expand Down
2 changes: 1 addition & 1 deletion docs/security/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Protections:
- The gateway is default-deny and permits only the measured container/network/
volume inventory calls, fixed bounded non-following logs, bounded Docker
events, and—only when no Docker label scope is configured—the exact finite
per-container stats request `stream=false&one-shot=false`. It rejects
per-container stats request `stream=false&one-shot=true`. It rejects
mutations, inspect/archive/top/exec, streaming or malformed stats, every
stats request under a configured label scope, images/builds, ambiguous
targets, unknown queries, bodies, and upgrades before opening Docker.
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/dockermapHarness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ export async function startLiveDockerStack(options: {
}
return requestFixedGatewayStatus(
gatewaySocket,
`/containers/${containerId}/stats?stream=false&one-shot=false`,
`/containers/${containerId}/stats?stream=false&one-shot=true`,
);
},
stop: async () => {
Expand Down
Loading