1
0
mirror of https://github.com/containers/bootc.git synced 2026-02-05 15:45:53 +01:00

lib: Set user agent header for container image pulls

This allows registries to distinguish "image pulls for bootc client
runs" from other skopeo/containers-image users. The user agent will
be in the format "bootc/<version> skopeo/<version>".

All places in bootc that create ImageProxyConfig now use a new helper
function that sets the user_agent_prefix field.

Closes: https://github.com/bootc-dev/bootc/issues/1686
Assisted-by: OpenCode (Sonnet 4)
Signed-off-by: Colin Walters <walters@verbum.org>
This commit is contained in:
Colin Walters
2026-01-22 14:50:43 -05:00
committed by John Eckersberg
parent 21babe7616
commit 1d8cf090f9
11 changed files with 292 additions and 20 deletions

4
Cargo.lock generated
View File

@@ -712,9 +712,9 @@ dependencies = [
[[package]]
name = "containers-image-proxy"
version = "0.9.0"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08ca6531917f9b250bf6a1af43603b2e083c192565774451411f9bf4f8bf8f2b"
checksum = "71a4f5afd361728fbc377e8ec4194040cbd733e9171ff6e35ab31a866ccef1a7"
dependencies = [
"cap-std-ext",
"futures-util",

View File

@@ -214,7 +214,7 @@ fn get_sorted_type1_boot_entries_helper(
pub(crate) async fn get_container_manifest_and_config(
imgref: &String,
) -> Result<ImgConfigManifest> {
let config = containers_image_proxy::ImageProxyConfig::default();
let config = crate::deploy::new_proxy_config();
let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?;
let img = proxy

View File

@@ -121,7 +121,8 @@ pub(crate) fn query_bound_images(root: &Dir) -> Result<Vec<BoundImage>> {
impl ResolvedBoundImage {
#[context("resolving bound image {}", src.image)]
pub(crate) async fn from_image(src: &BoundImage) -> Result<Self> {
let proxy = containers_image_proxy::ImageProxy::new().await?;
let config = crate::deploy::new_proxy_config();
let proxy = containers_image_proxy::ImageProxy::new_with_config(config).await?;
let img = proxy
.open_image(&format!("containers-storage:{}", src.image))
.await?;

View File

@@ -26,7 +26,7 @@ use ostree_ext::composefs::fsverity;
use ostree_ext::composefs::fsverity::FsVerityHashValue;
use ostree_ext::composefs::splitstream::SplitStreamWriter;
use ostree_ext::container as ostree_container;
use ostree_ext::containers_image_proxy::ImageProxyConfig;
use ostree_ext::keyfileext::KeyFileExt;
use ostree_ext::ostree;
use ostree_ext::sysroot::SysrootLock;
@@ -1554,7 +1554,7 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
} => {
let (_td_guard, repo) = new_temp_composefs_repo()?;
let mut proxycfg = ImageProxyConfig::default();
let mut proxycfg = crate::deploy::new_proxy_config();
let image = if let Some(image) = image {
image

View File

@@ -32,6 +32,17 @@ use crate::utils::async_task_with_spinner;
// TODO use https://github.com/ostreedev/ostree-rs-ext/pull/493/commits/afc1837ff383681b947de30c0cefc70080a4f87a
const BASE_IMAGE_PREFIX: &str = "ostree/container/baseimage/bootc";
/// Create an ImageProxyConfig with bootc's user agent prefix set.
///
/// This allows registries to distinguish "image pulls for bootc client runs"
/// from other skopeo/containers-image users.
pub(crate) fn new_proxy_config() -> ostree_ext::containers_image_proxy::ImageProxyConfig {
ostree_ext::containers_image_proxy::ImageProxyConfig {
user_agent_prefix: Some(format!("bootc/{}", env!("CARGO_PKG_VERSION"))),
..Default::default()
}
}
/// Set on an ostree commit if this is a derived commit
const BOOTC_DERIVED_KEY: &str = "bootc.derived";
@@ -87,7 +98,7 @@ pub(crate) async fn new_importer(
repo: &ostree::Repo,
imgref: &ostree_container::OstreeImageReference,
) -> Result<ostree_container::store::ImageImporter> {
let config = Default::default();
let config = new_proxy_config();
let mut imp = ostree_container::store::ImageImporter::new(repo, imgref, config).await?;
imp.require_bootable();
Ok(imp)
@@ -460,7 +471,7 @@ pub(crate) async fn prepare_for_pull_unified(
let ostree_imgref = OstreeImageReference::from(containers_storage_imgref);
// Configure the importer to use bootc storage as an additional image store
let mut config = ostree_ext::containers_image_proxy::ImageProxyConfig::default();
let mut config = new_proxy_config();
let mut cmd = Command::new("skopeo");
// Use the physical path to bootc storage from the Storage struct
let storage_path = format!(
@@ -1248,6 +1259,23 @@ pub(crate) fn fixup_etc_fstab(root: &Dir) -> Result<()> {
mod tests {
use super::*;
#[test]
fn test_new_proxy_config_user_agent() {
let config = new_proxy_config();
let prefix = config
.user_agent_prefix
.expect("user_agent_prefix should be set");
assert!(
prefix.starts_with("bootc/"),
"User agent should start with bootc/"
);
// Verify the version is present (not just "bootc/")
assert!(
prefix.len() > "bootc/".len(),
"Version should be present after bootc/"
);
}
#[test]
fn test_switch_inplace() -> Result<()> {
use cap_std::fs::DirBuilderExt;

View File

@@ -938,7 +938,7 @@ async fn install_container(
}
};
let proxy_cfg = ostree_container::store::ImageProxyConfig::default();
let proxy_cfg = crate::deploy::new_proxy_config();
(src_imageref, Some(proxy_cfg))
};
let src_imageref = ostree_container::OstreeImageReference {

View File

@@ -41,7 +41,7 @@ xshell = { workspace = true, optional = true }
# Crate-specific dependencies
comfy-table = "7.1.1"
containers-image-proxy = "0.9.0"
containers-image-proxy = "0.9.1"
flate2 = { features = ["zlib"], default-features = false, version = "1.0.20" }
futures-util = "0.3.13"
gvariant = "0.5.0"

View File

@@ -865,7 +865,7 @@ pub(crate) fn update_integration() -> Result<()> {
// Define tests in order
let mut tests = vec![];
// Scan for test-*.nu and test-*.sh files in tmt/tests/booted/
// Scan for test-*.nu, test-*.sh, and test-*.py files in tmt/tests/booted/
let booted_dir = Utf8Path::new("tmt/tests/booted");
for entry in std::fs::read_dir(booted_dir)? {
@@ -876,10 +876,11 @@ pub(crate) fn update_integration() -> Result<()> {
};
// Extract stem (filename without "test-" prefix and extension)
let Some(stem) = filename
.strip_prefix("test-")
.and_then(|s| s.strip_suffix(".nu").or_else(|| s.strip_suffix(".sh")))
else {
let Some(stem) = filename.strip_prefix("test-").and_then(|s| {
s.strip_suffix(".nu")
.or_else(|| s.strip_suffix(".sh"))
.or_else(|| s.strip_suffix(".py"))
}) else {
continue;
};
@@ -908,16 +909,16 @@ pub(crate) fn update_integration() -> Result<()> {
.with_context(|| format!("Failed to get relative path for {}", filename))?;
// Determine test command based on file extension
let extension = if filename.ends_with(".nu") {
"nu"
let test_command = if filename.ends_with(".nu") {
format!("nu {}", relative_path.display())
} else if filename.ends_with(".sh") {
"bash"
format!("bash {}", relative_path.display())
} else if filename.ends_with(".py") {
format!("python3 {}", relative_path.display())
} else {
anyhow::bail!("Unsupported test file extension: {}", filename);
};
let test_command = format!("{} {}", extension, relative_path.display());
// Check if test wants bind storage
let try_bind_storage = metadata
.extra

View File

@@ -166,4 +166,11 @@ execute:
how: fmf
test:
- /tmt/tests/tests/test-33-bib-build
/plan-34-user-agent:
summary: Verify bootc sends correct User-Agent header to registries
discover:
how: fmf
test:
- /tmt/tests/tests/test-34-user-agent
# END GENERATED PLANS

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env python3
# number: 34
# tmt:
# summary: Verify bootc sends correct User-Agent header to registries
# duration: 10m
#
"""
Test that bootc sends the correct User-Agent header when pulling images.
This test starts a mock HTTP registry server, configures it as an insecure
registry, and verifies that bootc's requests include "bootc/" in the User-Agent.
Note: The --user-agent-prefix feature requires skopeo >= 1.21.0. If the
installed skopeo doesn't support it, this test will be skipped.
Note: When insecure=true, container tools first attempt TLS then fall back to
plain HTTP. Our HTTP server will receive an invalid TLS handshake first, which
we ignore and continue serving.
"""
import http.server
import json
import os
import subprocess
import sys
import threading
# Global to capture the user agent
captured_user_agent = None
server_ready = threading.Event()
request_received = threading.Event()
# Global to store the dynamically allocated port
allocated_port = None
def skopeo_supports_user_agent_prefix() -> bool:
"""Check if the installed skopeo supports --user-agent-prefix."""
try:
result = subprocess.run(
["skopeo", "--help"],
capture_output=True,
text=True,
timeout=10,
)
return "--user-agent-prefix" in result.stdout
except Exception:
return False
def parse_os_release() -> dict[str, str]:
"""Parse /usr/lib/os-release into a dictionary."""
os_release = {}
try:
with open("/usr/lib/os-release") as f:
for line in f:
line = line.strip()
if "=" in line and not line.startswith("#"):
key, _, value = line.partition("=")
# Remove quotes if present
value = value.strip('"\'')
os_release[key] = value
except FileNotFoundError:
pass
return os_release
def distro_requires_user_agent_support() -> bool:
"""Check if the current distro should have skopeo with --user-agent-prefix.
Returns True if we're on a distro version that ships skopeo >= 1.21.0,
meaning the test must not be skipped.
"""
os_release = parse_os_release()
distro_id = os_release.get("ID", "")
version_id = os_release.get("VERSION_ID", "")
try:
version = int(version_id)
except ValueError:
return False
# Fedora 43+ ships skopeo 1.21.0+
if distro_id == "fedora" and version >= 43:
return True
return False
class RegistryHandler(http.server.BaseHTTPRequestHandler):
"""Mock registry that captures User-Agent and returns 404."""
def do_GET(self):
global captured_user_agent
captured_user_agent = self.headers.get("User-Agent", "")
print(f"Request: {self.path}", flush=True)
print(f"User-Agent: {captured_user_agent}", flush=True)
# Return a registry-style 404
self.send_response(404)
self.send_header("Content-Type", "application/json")
body = json.dumps({
"errors": [{"code": "NAME_UNKNOWN", "message": "repository not found"}]
}).encode()
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
# Signal that we received a valid HTTP request
request_received.set()
def log_message(self, format, *args):
print(format % args, flush=True)
class TolerantHTTPServer(http.server.HTTPServer):
"""HTTP server that ignores errors from TLS probe attempts."""
def handle_error(self, request, client_address):
# Silently ignore errors - these are typically TLS handshake attempts
# that we can't handle. The client will retry with plain HTTP.
print(f"Ignoring error from {client_address} (likely TLS probe)", flush=True)
def run_server():
"""Run the mock registry server on a dynamically allocated port."""
global allocated_port
# Bind to port 0 to let the OS allocate an available port
server = TolerantHTTPServer(("127.0.0.1", 0), RegistryHandler)
allocated_port = server.server_address[1]
server.timeout = 30
server_ready.set()
# Handle multiple requests - first few may be TLS probes
for _ in range(20):
server.handle_request()
if captured_user_agent:
# Got a valid HTTP request with User-Agent, we're done
break
def main():
# Check if skopeo supports --user-agent-prefix
if not skopeo_supports_user_agent_prefix():
# Get skopeo version for the skip message
try:
result = subprocess.run(
["skopeo", "--version"],
capture_output=True,
text=True,
timeout=10,
)
version = result.stdout.strip()
except Exception:
version = "unknown"
# On distros that should have new enough skopeo, fail hard
if distro_requires_user_agent_support():
print(f"ERROR: skopeo ({version}) does not support --user-agent-prefix", flush=True)
print("This distro should have skopeo >= 1.21.0", flush=True)
return 1
print(f"SKIP: skopeo ({version}) does not support --user-agent-prefix", flush=True)
print("This feature requires skopeo >= 1.21.0", flush=True)
# Exit 0 to skip the test gracefully
return 0
print("=== User-Agent Header Test ===", flush=True)
# Start server in background thread (port allocated dynamically)
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Wait for server to be ready and get the allocated port
if not server_ready.wait(timeout=5):
print("ERROR: Server failed to start", flush=True)
return 1
registry = f"127.0.0.1:{allocated_port}"
print(f"Server listening on {registry}", flush=True)
# Configure insecure registry
registries_conf = f"""[[registry]]
location = "{registry}"
insecure = true
"""
conf_path = "/etc/containers/registries.conf.d/99-test-insecure.conf"
print(f"Writing registries config to {conf_path}", flush=True)
with open(conf_path, "w") as f:
f.write(registries_conf)
print(registries_conf, flush=True)
try:
# Test with bootc
print("\n=== Testing with bootc ===", flush=True)
result = subprocess.run(
["bootc", "switch", "--transport", "registry", f"{registry}/test:latest"],
capture_output=True,
text=True,
timeout=60,
)
print(f"bootc exit code: {result.returncode}", flush=True)
print(f"bootc stdout: {result.stdout}", flush=True)
print(f"bootc stderr: {result.stderr}", flush=True)
# Wait for server to receive the HTTP request (after TLS probes)
if not request_received.wait(timeout=10):
print("ERROR: No HTTP request was received by server", flush=True)
return 1
# Check result
if not captured_user_agent:
print("ERROR: No User-Agent was captured", flush=True)
return 1
print(f"\nCaptured User-Agent: {captured_user_agent}", flush=True)
if "bootc/" not in captured_user_agent:
print(f"ERROR: User-Agent does not contain 'bootc/'", flush=True)
return 1
print("\nSUCCESS: User-Agent contains 'bootc/'", flush=True)
return 0
finally:
# Cleanup
if os.path.exists(conf_path):
os.remove(conf_path)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -96,3 +96,8 @@
require:
- qemu-img
test: nu booted/test-bib-build.nu
/test-34-user-agent:
summary: Verify bootc sends correct User-Agent header to registries
duration: 10m
test: python3 booted/test-user-agent.py