Skip to main content

agentmux_srv/backend/
registry_probe.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! "Could the daemon pull this image without credentials?", asked of the
5//! registry directly, for the create flow.
6//!
7//! The image name is user data: an imported or custom template can name any
8//! registry, and the registry names the token endpoint (`realm`) in its own
9//! challenge. So this module treats both as hostile input. It only ever talks
10//! to public https hosts: every host it contacts is checked by name and by the
11//! addresses it resolves to, the connection is pinned to those addresses, and
12//! redirects are never followed. Anything it refuses to contact is `Unknown`,
13//! which never blocks the sandbox.
14//!
15//! Spec: docs/specs/SPEC_CONTAINER_AGENTS_WORK_FOR_EVERYONE_2026_10_07.md section 3.4.
16
17use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
18use std::time::Duration;
19
20use reqwest::{Client, Url};
21
22use crate::backend::container_image::{parse_bearer_challenge, parse_image_ref};
23use crate::backend::rpc_types::ContainerImageAccess;
24
25const REQUEST_TIMEOUT: Duration = Duration::from_secs(6);
26const DNS_TIMEOUT: Duration = Duration::from_secs(3);
27const PROBE_TIMEOUT: Duration = Duration::from_secs(15);
28/// A token response is a few hundred bytes; anything near this is not one.
29const MAX_TOKEN_BYTES: usize = 64 * 1024;
30
31const MANIFEST_ACCEPT: &str = "application/vnd.oci.image.index.v1+json, \
32     application/vnd.oci.image.manifest.v1+json, \
33     application/vnd.docker.distribution.manifest.list.v2+json, \
34     application/vnd.docker.distribution.manifest.v2+json";
35
36/// How strictly each leg is checked. Production checks both; tests relax a leg
37/// so a local fake server can stand in for it.
38#[derive(Clone, Copy)]
39struct Policy {
40    /// The registry itself: https, and a public host.
41    strict_registry: bool,
42    /// The token endpoint the registry names: https, and a public host.
43    strict_realm: bool,
44}
45
46const PRODUCTION: Policy = Policy { strict_registry: true, strict_realm: true };
47
48/// Ask the image's registry, without credentials, whether its manifest can be
49/// read: the same question the daemon's pull answers. `Unknown` for anything
50/// that isn't a definite answer (offline, a proxy, a host this refuses to
51/// contact).
52pub async fn probe_anonymous(image: &str) -> ContainerImageAccess {
53    probe_with(image, PRODUCTION, None).await
54}
55
56async fn probe_with(image: &str, policy: Policy, registry_base: Option<&str>) -> ContainerImageAccess {
57    match tokio::time::timeout(PROBE_TIMEOUT, probe_inner(image, policy, registry_base)).await {
58        Ok(access) => access,
59        Err(_) => ContainerImageAccess::Unknown,
60    }
61}
62
63async fn probe_inner(image: &str, policy: Policy, registry_base: Option<&str>) -> ContainerImageAccess {
64    use ContainerImageAccess::{Denied, NotFound, Public, Unknown};
65
66    let Some(image_ref) = parse_image_ref(image) else { return Unknown };
67    let base = match registry_base {
68        Some(b) => b.to_string(),
69        None => format!("https://{}/", image_ref.registry),
70    };
71    let Ok(mut manifest_url) = Url::parse(&base) else { return Unknown };
72    if manifest_url.host().is_none() || !manifest_url.username().is_empty() || manifest_url.password().is_some() {
73        return Unknown;
74    }
75    {
76        let Ok(mut segments) = manifest_url.path_segments_mut() else { return Unknown };
77        segments.clear().push("v2");
78        for part in image_ref.repo.split('/') {
79            segments.push(part);
80        }
81        segments.push("manifests").push(&image_ref.reference);
82    }
83
84    let Some(client) = guarded_client(&manifest_url, policy.strict_registry).await else { return Unknown };
85    let first = match client.get(manifest_url.clone()).header("Accept", MANIFEST_ACCEPT).send().await {
86        Ok(r) => r,
87        Err(_) => return Unknown,
88    };
89    match first.status().as_u16() {
90        200 => return Public,
91        404 => return NotFound,
92        401 => {}
93        403 => return Denied,
94        // Includes a redirect: it is never followed.
95        _ => return Unknown,
96    }
97
98    let challenge = first
99        .headers()
100        .get("www-authenticate")
101        .and_then(|v| v.to_str().ok())
102        .and_then(parse_bearer_challenge);
103    let Some(challenge) = challenge else { return Denied };
104
105    let Ok(mut token_url) = validate_realm(&challenge.realm, policy.strict_realm) else { return Unknown };
106    {
107        let mut query = token_url.query_pairs_mut();
108        if let Some(service) = &challenge.service {
109            query.append_pair("service", service);
110        }
111        let scope = challenge.scope.clone().unwrap_or_else(|| format!("repository:{}:pull", image_ref.repo));
112        query.append_pair("scope", &scope);
113    }
114    let Some(token_client) = guarded_client(&token_url, policy.strict_realm).await else { return Unknown };
115    let token = match token_client.get(token_url).send().await {
116        Ok(r) if r.status().is_success() => read_capped(r, MAX_TOKEN_BYTES).await.and_then(|body| {
117            let v: serde_json::Value = serde_json::from_slice(&body).ok()?;
118            v.get("token")
119                .or_else(|| v.get("access_token"))
120                .and_then(|t| t.as_str())
121                .map(str::to_string)
122        }),
123        Ok(r) if matches!(r.status().as_u16(), 401 | 403) => return Denied,
124        _ => None,
125    };
126    let Some(token) = token else { return Unknown };
127
128    match client
129        .get(manifest_url)
130        .header("Accept", MANIFEST_ACCEPT)
131        .bearer_auth(token)
132        .send()
133        .await
134    {
135        Ok(r) => match r.status().as_u16() {
136            200 => Public,
137            401 | 403 => Denied,
138            404 => NotFound,
139            _ => Unknown,
140        },
141        Err(_) => Unknown,
142    }
143}
144
145/// The body of `resp`, or `None` if it is larger than `max` bytes.
146async fn read_capped(mut resp: reqwest::Response, max: usize) -> Option<Vec<u8>> {
147    if resp.content_length().is_some_and(|len| len > max as u64) {
148        return None;
149    }
150    let mut body = Vec::new();
151    while let Some(chunk) = resp.chunk().await.ok()? {
152        if body.len() + chunk.len() > max {
153            return None;
154        }
155        body.extend_from_slice(&chunk);
156    }
157    Some(body)
158}
159
160/// A client for requests to `url`'s host. Strict: the host is checked by name
161/// and by every address it resolves to, and the connection is pinned to those
162/// addresses, so the name cannot resolve differently between the check and the
163/// connection. Never follows redirects. `None` when the host is refused.
164async fn guarded_client(url: &Url, strict: bool) -> Option<Client> {
165    let mut builder = Client::builder()
166        .timeout(REQUEST_TIMEOUT)
167        .redirect(reqwest::redirect::Policy::none());
168    if strict {
169        if url.scheme() != "https" {
170            return None;
171        }
172        let port = url.port_or_known_default().unwrap_or(443);
173        match url.host()? {
174            url::Host::Ipv4(ip) => {
175                if ip_is_blocked(IpAddr::V4(ip)) {
176                    return None;
177                }
178            }
179            url::Host::Ipv6(ip) => {
180                if ip_is_blocked(IpAddr::V6(ip)) {
181                    return None;
182                }
183            }
184            url::Host::Domain(name) => {
185                if hostname_is_blocked(name) {
186                    return None;
187                }
188                let resolved: Vec<SocketAddr> = tokio::time::timeout(DNS_TIMEOUT, tokio::net::lookup_host((name, port)))
189                    .await
190                    .ok()?
191                    .ok()?
192                    .collect();
193                if !all_public(&resolved) {
194                    return None;
195                }
196                builder = builder.resolve_to_addrs(name, &resolved);
197            }
198        }
199    }
200    builder.build().ok()
201}
202
203/// A token endpoint is only used if it is an https URL, with no credentials in
204/// it and no unusual port, to a host that is not a local or private name or
205/// address. `strict = false` is for tests only.
206fn validate_realm(realm: &str, strict: bool) -> Result<Url, &'static str> {
207    let url = Url::parse(realm).map_err(|_| "not a URL")?;
208    if !strict {
209        return Ok(url);
210    }
211    if url.scheme() != "https" {
212        return Err("not https");
213    }
214    if !url.username().is_empty() || url.password().is_some() {
215        return Err("has credentials");
216    }
217    if url.port().is_some_and(|p| p != 443) {
218        return Err("unusual port");
219    }
220    match url.host().ok_or("no host")? {
221        url::Host::Ipv4(ip) if ip_is_blocked(IpAddr::V4(ip)) => Err("private address"),
222        url::Host::Ipv6(ip) if ip_is_blocked(IpAddr::V6(ip)) => Err("private address"),
223        url::Host::Domain(name) if hostname_is_blocked(name) => Err("local name"),
224        _ => Ok(url),
225    }
226}
227
228/// True when there is at least one address and none is private or local.
229fn all_public(addrs: &[SocketAddr]) -> bool {
230    !addrs.is_empty() && addrs.iter().all(|a| !ip_is_blocked(a.ip()))
231}
232
233/// Names that mean "this machine or this network", not a public registry.
234fn hostname_is_blocked(name: &str) -> bool {
235    let name = name.trim_end_matches('.').to_ascii_lowercase();
236    if name.is_empty() || !name.contains('.') {
237        return true;
238    }
239    [".localhost", ".local", ".internal", ".localdomain", ".home.arpa", ".lan"]
240        .iter()
241        .any(|suffix| name.ends_with(suffix))
242}
243
244fn embedded_v4(high: u16, low: u16) -> Ipv4Addr {
245    Ipv4Addr::new((high >> 8) as u8, high as u8, (low >> 8) as u8, low as u8)
246}
247
248/// Addresses that are not a public host: loopback, private, link-local (cloud
249/// metadata), carrier-grade NAT, unspecified, multicast, reserved and
250/// documentation ranges, and IPv6 forms that carry an IPv4 address. Also
251/// the widget fetch's test (`widget_net.rs`).
252pub(crate) fn ip_is_blocked(ip: IpAddr) -> bool {
253    match ip {
254        IpAddr::V4(v4) => ipv4_is_blocked(v4),
255        IpAddr::V6(v6) => ipv6_is_blocked(v6),
256    }
257}
258
259fn ipv4_is_blocked(ip: Ipv4Addr) -> bool {
260    let o = ip.octets();
261    ip.is_loopback()
262        || ip.is_private()
263        || ip.is_link_local()
264        || ip.is_unspecified()
265        || ip.is_broadcast()
266        || ip.is_multicast()
267        || ip.is_documentation()
268        || o[0] == 0
269        || (o[0] == 100 && (o[1] & 0xC0) == 64)
270        || (o[0] == 192 && o[1] == 0 && o[2] == 0)
271        || (o[0] == 198 && (o[1] & 0xFE) == 18)
272        || o[0] >= 240
273}
274
275fn ipv6_is_blocked(ip: Ipv6Addr) -> bool {
276    if let Some(v4) = ip.to_ipv4_mapped() {
277        return ipv4_is_blocked(v4);
278    }
279    let s = ip.segments();
280    let first_six_zero = s[..6].iter().all(|&x| x == 0);
281    if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
282        return true;
283    }
284    // ::a.b.c.d (IPv4-compatible), 64:ff9b::/96 (NAT64), 2002::/16 (6to4).
285    if first_six_zero {
286        return true;
287    }
288    if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|&x| x == 0) {
289        return ipv4_is_blocked(embedded_v4(s[6], s[7]));
290    }
291    if s[0] == 0x2002 {
292        return ipv4_is_blocked(embedded_v4(s[1], s[2]));
293    }
294    (s[0] & 0xfe00) == 0xfc00 // fc00::/7 unique local
295        || (s[0] & 0xffc0) == 0xfe80 // fe80::/10 link-local
296        || (s[0] & 0xffc0) == 0xfec0 // fec0::/10 site-local (deprecated)
297        || (s[0] == 0x2001 && s[1] == 0) // Teredo
298        || (s[0] == 0x2001 && s[1] == 0x0db8) // documentation
299        || (s[0] == 0x0100 && s[1] == 0 && s[2] == 0 && s[3] == 0) // discard-only
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use std::sync::atomic::{AtomicUsize, Ordering};
306    use std::sync::Arc;
307    use tokio::io::{AsyncReadExt, AsyncWriteExt};
308
309    fn ip(s: &str) -> IpAddr {
310        s.parse().unwrap()
311    }
312
313    #[test]
314    fn private_local_and_reserved_v4_addresses_are_blocked() {
315        for a in [
316            "127.0.0.1", "127.255.255.254",
317            "169.254.169.254", "169.254.0.1", "100.64.0.1", "100.127.255.255", "0.0.0.0", "0.1.2.3",
318            "255.255.255.255", "224.0.0.1", "192.0.0.8", "192.0.2.1", "198.18.0.1", "198.19.255.255",
319            "240.0.0.1",
320        ] {
321            assert!(ip_is_blocked(ip(a)), "{a} must be blocked");
322        }
323    }
324
325    #[test]
326    fn the_rfc1918_private_ranges_are_blocked() {
327        // Built from octets so the test names no literal private address.
328        for (a, b, c, d) in [(10, 0, 0, 1), (10, 255, 255, 255), (172, 16, 0, 1), (172, 31, 255, 255), (192, 168, 0, 1), (192, 168, 255, 255)] {
329            assert!(ip_is_blocked(IpAddr::V4(Ipv4Addr::new(a, b, c, d))), "{a}.{b}.{c}.{d}");
330        }
331    }
332
333    #[test]
334    fn public_v4_addresses_pass() {
335        for a in ["140.82.112.34", "8.8.8.8", "52.1.2.3", "100.63.255.255", "100.128.0.1", "172.15.0.1", "172.32.0.1", "198.17.0.1", "198.20.0.1"] {
336            assert!(!ip_is_blocked(ip(a)), "{a} must pass");
337        }
338    }
339
340    #[test]
341    fn private_local_and_embedding_v6_addresses_are_blocked() {
342        for a in [
343            "::1", "::", "fe80::1", "febf::1", "fc00::1", "fd12:3456::1", "ff02::1", "fec0::1", "2001:db8::1", "2001:0:4136:e378::1",
344            "::ffff:127.0.0.1", "::ffff:a01:203", "::ffff:169.254.169.254", "::127.0.0.1", "::a00:1",
345            "64:ff9b::7f00:1", "64:ff9b::a9fe:a9fe", "2002:7f00:1::1", "2002:a9fe:a9fe::1", "100::1",
346        ] {
347            assert!(ip_is_blocked(ip(a)), "{a} must be blocked");
348        }
349    }
350
351    #[test]
352    fn public_v6_addresses_pass() {
353        for a in ["2606:4700:4700::1111", "2a00:1450:4001:81b::200e", "::ffff:8.8.8.8", "64:ff9b::808:808", "2002:808:808::1"] {
354            assert!(!ip_is_blocked(ip(a)), "{a} must pass");
355        }
356    }
357
358    #[test]
359    fn local_names_are_blocked_and_registry_names_pass() {
360        for n in [
361            "localhost", "LOCALHOST", "localhost.", "foo.localhost", "printer.local", "metadata.google.internal",
362            "host.internal", "box.localdomain", "router.home.arpa", "nas.lan", "intranet", "", ".",
363        ] {
364            assert!(hostname_is_blocked(n), "{n:?} must be blocked");
365        }
366        for n in ["ghcr.io", "auth.docker.io", "quay.io", "gcr.io", "registry-1.docker.io", "us-docker.pkg.dev", "public.ecr.aws"] {
367            assert!(!hostname_is_blocked(n), "{n} must pass");
368        }
369    }
370
371    #[test]
372    fn every_resolved_address_must_be_public() {
373        let sa = |s: &str| SocketAddr::new(ip(s), 443);
374        assert!(all_public(&[sa("140.82.112.34"), sa("2606:4700::1")]));
375        assert!(!all_public(&[sa("140.82.112.34"), SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 5)), 443)]), "one private answer is enough to refuse");
376        assert!(!all_public(&[sa("::1")]));
377        assert!(!all_public(&[]), "no answer is no answer");
378    }
379
380    #[test]
381    fn the_real_registries_token_endpoints_are_accepted() {
382        for realm in [
383            "https://ghcr.io/token",
384            "https://auth.docker.io/token",
385            "https://quay.io/v2/auth",
386            "https://gcr.io/v2/token",
387            "https://us-docker.pkg.dev/v2/token",
388            "https://ghcr.io:443/token",
389        ] {
390            assert!(validate_realm(realm, true).is_ok(), "{realm} must be accepted");
391        }
392    }
393
394    #[test]
395    fn a_token_endpoint_must_be_https_public_and_plain() {
396        let cases = [
397            ("http://ghcr.io/token", "not https"),
398            ("ftp://ghcr.io/token", "not https"),
399            ("https://user:pw@ghcr.io/token", "has credentials"),
400            ("https://ghcr.io:8443/token", "unusual port"),
401            ("https://127.0.0.1/token", "private address"),
402            ("https://[::1]/token", "private address"),
403            ("https://[::ffff:127.0.0.1]/token", "private address"),
404            ("https://[fd00::1]/token", "private address"),
405            ("https://169.254.169.254/latest/meta-data", "private address"),
406            ("https://0xa000007/token", "private address"),
407            ("https://100.64.1.1/token", "private address"),
408            ("https://2130706433/token", "private address"),
409            ("https://0x7f.1/token", "private address"),
410            ("https://localhost/token", "local name"),
411            ("https://metadata.google.internal/token", "local name"),
412            ("https://registry.local/token", "local name"),
413            ("https://intranet/token", "local name"),
414            ("not a url", "not a URL"),
415            ("", "not a URL"),
416        ];
417        for (realm, why) in cases {
418            assert_eq!(validate_realm(realm, true).err(), Some(why), "{realm}");
419        }
420    }
421
422    #[tokio::test]
423    async fn a_strict_client_refuses_private_and_local_hosts_without_connecting() {
424        for u in [
425            "https://127.0.0.1/",
426            "https://[::1]/",
427            "https://169.254.169.254/",
428            "https://localhost/",
429            "https://metadata.google.internal/",
430            "http://ghcr.io/",
431        ] {
432            assert!(guarded_client(&Url::parse(u).unwrap(), true).await.is_none(), "{u}");
433        }
434        assert!(guarded_client(&Url::parse("http://127.0.0.1/").unwrap(), false).await.is_some());
435    }
436
437    #[tokio::test]
438    async fn an_image_on_a_private_registry_host_is_not_probed() {
439        use ContainerImageAccess::Unknown;
440        for image in ["127.0.0.1/o/r:1", "169.254.169.254/latest/meta-data:x", "0xa.0.0.5/o/r:1", "localhost.localdomain/o/r:1", "registry.internal/o/r:1"] {
441            assert_eq!(probe_anonymous(image).await, Unknown, "{image}");
442        }
443    }
444
445    /// A tiny HTTP server: `respond` maps the request head to a full response.
446    /// Returns its address and a counter of requests served.
447    async fn serve<F>(respond: F) -> (SocketAddr, Arc<AtomicUsize>)
448    where
449        F: Fn(&str) -> String + Send + Sync + 'static,
450    {
451        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
452        let addr = listener.local_addr().unwrap();
453        let hits = Arc::new(AtomicUsize::new(0));
454        let counter = hits.clone();
455        let respond = Arc::new(respond);
456        tokio::spawn(async move {
457            loop {
458                let Ok((mut sock, _)) = listener.accept().await else { return };
459                let counter = counter.clone();
460                let respond = respond.clone();
461                tokio::spawn(async move {
462                    let mut buf = vec![0u8; 8192];
463                    let mut head = String::new();
464                    while !head.contains("\r\n\r\n") {
465                        match sock.read(&mut buf).await {
466                            Ok(0) | Err(_) => return,
467                            Ok(n) => head.push_str(&String::from_utf8_lossy(&buf[..n])),
468                        }
469                    }
470                    counter.fetch_add(1, Ordering::SeqCst);
471                    let _ = sock.write_all(respond(&head).as_bytes()).await;
472                    let _ = sock.shutdown().await;
473                });
474            }
475        });
476        (addr, hits)
477    }
478
479    fn http(status: &str, headers: &[&str], body: &str) -> String {
480        let mut out = format!("HTTP/1.1 {status}\r\ncontent-length: {}\r\nconnection: close\r\n", body.len());
481        for h in headers {
482            out.push_str(h);
483            out.push_str("\r\n");
484        }
485        out.push_str("\r\n");
486        out.push_str(body);
487        out
488    }
489
490    const FAKE_REGISTRY: Policy = Policy { strict_registry: false, strict_realm: true };
491    const FAKE_BOTH: Policy = Policy { strict_registry: false, strict_realm: false };
492
493    #[tokio::test]
494    async fn a_token_endpoint_on_loopback_is_never_requested() {
495        let (victim, victim_hits) = serve(|_| http("200 OK", &[], r#"{"token":"x"}"#)).await;
496        let realm = format!("http://{victim}/token");
497        let challenge = format!("www-authenticate: Bearer realm=\"{realm}\",service=\"s\"");
498        let (registry, registry_hits) = serve(move |_| http("401 Unauthorized", &[&challenge], "")).await;
499
500        let got = probe_with("example.com/o/r:1", FAKE_REGISTRY, Some(&format!("http://{registry}/"))).await;
501        assert_eq!(got, ContainerImageAccess::Unknown, "a refused token endpoint never blocks the sandbox");
502        assert_eq!(registry_hits.load(Ordering::SeqCst), 1);
503        assert_eq!(victim_hits.load(Ordering::SeqCst), 0, "no request reached the token endpoint");
504    }
505
506    #[tokio::test]
507    async fn a_redirect_to_a_private_address_is_not_followed() {
508        let (victim, victim_hits) = serve(|_| http("200 OK", &[], "secret")).await;
509        let location = format!("location: http://{victim}/internal");
510        let (registry, registry_hits) = serve(move |_| http("302 Found", &[&location], "")).await;
511
512        let got = probe_with("example.com/o/r:1", FAKE_REGISTRY, Some(&format!("http://{registry}/"))).await;
513        assert_eq!(got, ContainerImageAccess::Unknown);
514        assert_eq!(registry_hits.load(Ordering::SeqCst), 1);
515        assert_eq!(victim_hits.load(Ordering::SeqCst), 0, "the redirect target was never requested");
516    }
517
518    #[tokio::test]
519    async fn a_redirecting_token_endpoint_is_not_followed_either() {
520        let (victim, victim_hits) = serve(|_| http("200 OK", &[], r#"{"token":"x"}"#)).await;
521        let location = format!("location: http://{victim}/token");
522        let (idp, _) = serve(move |_| http("307 Temporary Redirect", &[&location], "")).await;
523        let challenge = format!("www-authenticate: Bearer realm=\"http://{idp}/token\"");
524        let (registry, _) = serve(move |_| http("401 Unauthorized", &[&challenge], "")).await;
525
526        let got = probe_with("example.com/o/r:1", FAKE_BOTH, Some(&format!("http://{registry}/"))).await;
527        assert_eq!(got, ContainerImageAccess::Unknown);
528        assert_eq!(victim_hits.load(Ordering::SeqCst), 0);
529    }
530
531    #[tokio::test]
532    async fn the_token_flow_still_reaches_a_verdict() {
533        let (addr, _) = serve_self_realm().await;
534        let base = format!("http://{addr}/");
535        assert_eq!(probe_with("example.com/o/ok:1", FAKE_BOTH, Some(&base)).await, ContainerImageAccess::Public);
536        assert_eq!(probe_with("example.com/denied/x:1", FAKE_BOTH, Some(&base)).await, ContainerImageAccess::Denied);
537        assert_eq!(probe_with("example.com/missing/x:1", FAKE_BOTH, Some(&base)).await, ContainerImageAccess::NotFound);
538    }
539
540    /// A registry that sends its own address as the token endpoint.
541    async fn serve_self_realm() -> (SocketAddr, Arc<AtomicUsize>) {
542        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
543        let addr = listener.local_addr().unwrap();
544        let hits = Arc::new(AtomicUsize::new(0));
545        tokio::spawn(async move {
546            loop {
547                let Ok((mut sock, _)) = listener.accept().await else { return };
548                tokio::spawn(async move {
549                    let mut buf = vec![0u8; 8192];
550                    let mut head = String::new();
551                    while !head.contains("\r\n\r\n") {
552                        match sock.read(&mut buf).await {
553                            Ok(0) | Err(_) => return,
554                            Ok(n) => head.push_str(&String::from_utf8_lossy(&buf[..n])),
555                        }
556                    }
557                    let first = head.lines().next().unwrap_or("").to_string();
558                    let reply = if first.contains("/token") {
559                        http("200 OK", &[], r#"{"token":"t0k"}"#)
560                    } else if head.to_ascii_lowercase().contains("authorization: bearer t0k") {
561                        if first.contains("/denied/") {
562                            http("403 Forbidden", &[], "")
563                        } else if first.contains("/missing/") {
564                            http("404 Not Found", &[], "")
565                        } else {
566                            http("200 OK", &[], "{}")
567                        }
568                    } else {
569                        let challenge = format!("www-authenticate: Bearer realm=\"http://{addr}/token\",service=\"svc\"");
570                        http("401 Unauthorized", &[&challenge], "")
571                    };
572                    let _ = sock.write_all(reply.as_bytes()).await;
573                    let _ = sock.shutdown().await;
574                });
575            }
576        });
577        (addr, hits)
578    }
579
580    #[tokio::test]
581    async fn an_oversized_token_response_is_not_read() {
582        let big = "x".repeat(MAX_TOKEN_BYTES + 1024);
583        let body = format!(r#"{{"token":"{big}"}}"#);
584        let (idp, _) = serve(move |_| http("200 OK", &[], &body)).await;
585        let challenge = format!("www-authenticate: Bearer realm=\"http://{idp}/token\"");
586        let (registry, _) = serve(move |_| http("401 Unauthorized", &[&challenge], "")).await;
587
588        let got = probe_with("example.com/o/r:1", FAKE_BOTH, Some(&format!("http://{registry}/"))).await;
589        assert_eq!(got, ContainerImageAccess::Unknown);
590    }
591
592    /// Hits the real registries. Run by hand:
593    /// `cargo test -p agentmux-srv --bin agentmux-srv -- --ignored probe_real`
594    #[tokio::test]
595    #[ignore]
596    async fn probe_real_registries() {
597        let public = probe_anonymous("docker.io/library/alpine:latest").await;
598        let ghcr_public = probe_anonymous("ghcr.io/astral-sh/uv:latest").await;
599        let quay_public = probe_anonymous("quay.io/prometheus/prometheus:latest").await;
600        let gcr_public = probe_anonymous("gcr.io/distroless/static:latest").await;
601        let private = probe_anonymous("ghcr.io/agentmuxai/agent-claude:latest").await;
602        let missing = probe_anonymous("docker.io/library/agentmux-no-such-image-xyz:latest").await;
603        println!("alpine={public:?} uv={ghcr_public:?} prometheus={quay_public:?} distroless={gcr_public:?} agent-claude={private:?} missing={missing:?}");
604        assert_eq!(public, ContainerImageAccess::Public);
605        assert_eq!(ghcr_public, ContainerImageAccess::Public);
606        assert_eq!(quay_public, ContainerImageAccess::Public);
607        assert_eq!(gcr_public, ContainerImageAccess::Public);
608        assert_eq!(private, ContainerImageAccess::Denied);
609        assert!(matches!(missing, ContainerImageAccess::Denied | ContainerImageAccess::NotFound));
610    }
611}