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:
committed by
John Eckersberg
parent
21babe7616
commit
1d8cf090f9
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
230
tmt/tests/booted/test-user-agent.py
Normal file
230
tmt/tests/booted/test-user-agent.py
Normal 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())
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user