server/lib/tests/integration_oci_tls.rs
//! End-to-end: a writable OCI registry served over TLS.
//!
//! Drives the OCI Distribution v2 push/pull flow (the modern `helm push oci://`
//! path) with a rustls HTTPS client, and verifies that write access is gated on
//! auth while reads and health probes stay open.
use std::io::Write;
use traits::test_support::hold_port;
use holger_server_lib::{read_ron_config, wire_holger, Holger};
fn sha256_hex(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(data);
hex::encode(h.finalize())
}
/// Self-signed server cert + key (PEM) valid for localhost / 127.0.0.1.
fn server_cert_pem() -> (String, String) {
let ck =
rcgen::generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])
.unwrap();
(ck.cert.pem(), ck.signing_key.serialize_pem())
}
fn write_temp(dir: &std::path::Path, name: &str, contents: &str) -> String {
let p = dir.join(name);
std::fs::write(&p, contents).unwrap();
std::fs::set_permissions(
&p,
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
)
.unwrap();
p.to_string_lossy().into_owned()
}
struct Running {
_data: tempfile::TempDir,
_certs: tempfile::TempDir,
_cfg: tempfile::NamedTempFile,
base: String,
/// The started server, held so its doors stay up for the test.
_holger: Holger,
}
async fn start(auth_ron: &str, https_port: u16, grpc_port: u16) -> Running {
let data = tempfile::tempdir().unwrap();
let certs = tempfile::tempdir().unwrap();
let (cert_pem, key_pem) = server_cert_pem();
let cert_path = write_temp(certs.path(), "tls.crt", &cert_pem);
let key_path = write_temp(certs.path(), "tls.key", &key_pem);
let config = format!(
r#"(
repositories: [
(
ron_name: "charts",
ron_repo_type: "oci",
ron_upstreams: [],
ron_in: None,
ron_out: Some((
ron_storage_endpoint: "storage",
ron_exposed_endpoint: "default",
)),
ron_data_dir: Some("{data}"),
),
],
exposed_endpoints: [
(
ron_name: "default",
ron_url: "127.0.0.1:{grpc}",
ron_http_url: Some("127.0.0.1:{https}"),
ron_tls: Some((cert: "{cert}", key: "{key}")),
),
],
storage_endpoints: [
(ron_name: "storage", ron_storage_type: "znippy", ron_path: "{data}"),
],
{auth}
)"#,
data = data.path().display(),
grpc = grpc_port,
https = https_port,
cert = cert_path,
key = key_path,
auth = auth_ron,
);
let mut cfg = tempfile::NamedTempFile::new().unwrap();
cfg.write_all(config.as_bytes()).unwrap();
let mut holger: Holger = read_ron_config(cfg.path()).unwrap();
holger.instantiate_backends().unwrap();
wire_holger(&mut holger).unwrap();
holger.start().unwrap();
let base = format!("https://127.0.0.1:{https_port}");
Running {
_data: data,
_certs: certs,
_cfg: cfg,
base,
_holger: holger,
}
}
fn client() -> reqwest::Client {
// reqwest's rustls client also needs the process default provider.
holger_server_lib::exposed::tls::ensure_crypto_provider();
tls_roots(reqwest::Client::builder())
.danger_accept_invalid_certs(true) // self-signed test cert
.build()
.unwrap()
}
async fn wait_healthy(c: &reqwest::Client, base: &str) {
for _ in 0..100 {
if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
if r.status() == 200 {
return;
}
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("gateway never became healthy at {base}");
}
/// Push a blob via the POST→PUT session flow, return its `sha256:…` digest.
async fn push_blob(c: &reqwest::Client, base: &str, image: &str, data: &[u8]) -> String {
let digest = format!("sha256:{}", sha256_hex(data));
let start = c
.post(format!("{base}/v2/{image}/blobs/uploads/"))
.send()
.await
.unwrap();
assert_eq!(start.status(), 202, "blob upload start");
let location = start
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.to_string();
let put = c
.put(format!("{base}{location}?digest={digest}"))
.body(data.to_vec())
.send()
.await
.unwrap();
assert_eq!(put.status(), 201, "blob upload finalize");
digest
}
#[tokio::test(flavor = "multi_thread")]
async fn oci_push_pull_over_tls() {
let r = start("", hold_port(), hold_port()).await;
let c = client();
wait_healthy(&c, &r.base).await;
// Version probe.
let v2 = c.get(format!("{}/v2/", r.base)).send().await.unwrap();
assert_eq!(v2.status(), 200);
assert_eq!(
v2.headers().get("docker-distribution-api-version").unwrap(),
"registry/2.0"
);
// Push a Helm config blob and a chart-content blob.
let config_blob = br#"{"name":"njord","version":"0.1.0"}"#;
let chart_blob = b"fake-chart-tarball-bytes";
let config_digest = push_blob(&c, &r.base, "charts/njord", config_blob).await;
let chart_digest = push_blob(&c, &r.base, "charts/njord", chart_blob).await;
// Push the manifest referencing both blobs.
let manifest = format!(
r#"{{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{{"mediaType":"application/vnd.cncf.helm.config.v1+json","digest":"{config_digest}","size":{cs}}},"layers":[{{"mediaType":"application/vnd.cncf.helm.chart.content.v1.tar+gzip","digest":"{chart_digest}","size":{ls}}}]}}"#,
cs = config_blob.len(),
ls = chart_blob.len(),
);
let put = c
.put(format!("{}/v2/charts/njord/manifests/0.1.0", r.base))
.header("Content-Type", "application/vnd.oci.image.manifest.v1+json")
.body(manifest.clone())
.send()
.await
.unwrap();
assert_eq!(put.status(), 201, "manifest put");
// Pull manifest by tag (what `helm pull oci://…` resolves first).
let got = c
.get(format!("{}/v2/charts/njord/manifests/0.1.0", r.base))
.send()
.await
.unwrap();
assert_eq!(got.status(), 200);
assert_eq!(got.text().await.unwrap(), manifest);
// Pull the chart blob back, verify bytes.
let blob = c
.get(format!("{}/v2/charts/njord/blobs/{chart_digest}", r.base))
.send()
.await
.unwrap();
assert_eq!(blob.status(), 200);
assert_eq!(blob.bytes().await.unwrap().as_ref(), chart_blob);
// Tags list.
let tags = c
.get(format!("{}/v2/charts/njord/tags/list", r.base))
.send()
.await
.unwrap()
.text()
.await
.unwrap();
assert!(tags.contains("\"0.1.0\""), "{tags}");
}
#[tokio::test(flavor = "multi_thread")]
async fn writes_blocked_without_auth_reads_open() {
// OIDC issuer that can't be reached: any token fails, so writes must be
// rejected; reads and health stay open.
//
// The fixture's intent is an IdP that cannot be REACHED, not one that cannot be
// NAMED — the 401 assertions below only need "no token authenticates".
//
// It carries an inline, EMPTY key set rather than only a `jwks_uri`, because a
// uri-only method fetches keys over the network, and a method with no
// audience is refused at boot (A34-1, `auth::refuse_unbound_oidc`). An
// empty `keys` array binds the method authoritatively and verifies nothing, so
// every token is rejected offline and never reaches the network at all. That
// is a sharper version of what this fixture always wanted.
let auth = r#"access: (reads: Open, front: Open), auth: Some((methods: [Oidc(issuer_url: "http://127.0.0.1:1/nope", audience: Some("holger"), jwks: Some("{\"keys\":[]}"))],)),"#;
let r = start(auth, hold_port(), hold_port()).await;
let c = client();
wait_healthy(&c, &r.base).await;
// The version probe CHALLENGES a credentialless client on a server that has
// an auth method — deliberately, since `ab65d75`: a 200 here made podman
// conclude the registry wants nothing and never send the credential it had
// stored. A 401 with `WWW-Authenticate` is a successful v2 detection to a
// distribution client, not a closed door.
let probe = c.get(format!("{}/v2/", r.base)).send().await.unwrap();
assert_eq!(probe.status(), 401, "the probe must challenge");
assert!(
probe.headers().contains_key("www-authenticate"),
"a challenge without WWW-Authenticate is just a refusal"
);
// Read path open: an anonymous READ of a repository is still answered on
// its merits (here: nothing pushed, so not-found) and never refused for
// want of a credential. `require_auth_for_reads` is off in this fixture.
let read = c
.get(format!("{}/v2/charts/njord/tags/list", r.base))
.send()
.await
.unwrap()
.status();
assert_ne!(
read, 401,
"an anonymous read was refused for want of a credential"
);
assert_ne!(read, 403, "an anonymous read was forbidden");
// Write (blob upload start is a POST) rejected without creds.
let resp = c
.post(format!("{}/v2/charts/njord/blobs/uploads/", r.base))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 401);
// Manifest PUT also rejected.
let resp = c
.put(format!("{}/v2/charts/njord/manifests/0.1.0", r.base))
.body("{}")
.send()
.await
.unwrap();
assert_eq!(resp.status(), 401);
}
/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
let _ = rustls::crypto::ring::default_provider().install_default();
b.tls_certs_only(
webpki_root_certs::TLS_SERVER_ROOT_CERTS
.iter()
.filter_map(|c| reqwest::Certificate::from_der(c).ok()),
)
}
// ── TLS 1.3 only, on the booted listeners (stinky SB #39) ──────────────────────
/// Accepts any server certificate: these handshakes test the protocol version a
/// booted door agrees to, not its identity (the self-signed test certificate).
#[derive(Debug)]
struct AnyServer(Arc<rustls::crypto::CryptoProvider>);
use std::sync::Arc;
impl rustls::client::danger::ServerCertVerifier for AnyServer {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.0.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.0.signature_verification_algorithms.supported_schemes()
}
}
/// One TLS handshake with the listener on `port`, offering only `version`: the
/// version agreed, or why the handshake failed.
fn handshake(
port: u16,
version: &'static rustls::SupportedProtocolVersion,
alpn: &[&[u8]],
) -> Result<rustls::ProtocolVersion, String> {
let provider = Arc::new(rustls::crypto::ring::default_provider());
let mut cfg = rustls::ClientConfig::builder_with_provider(Arc::clone(&provider))
.with_protocol_versions(&[version])
.map_err(|e| e.to_string())?
.dangerous()
.with_custom_certificate_verifier(Arc::new(AnyServer(provider)))
.with_no_client_auth();
cfg.alpn_protocols = alpn.iter().map(|a| a.to_vec()).collect();
let name = rustls::pki_types::ServerName::try_from("127.0.0.1").unwrap();
let mut conn = rustls::ClientConnection::new(Arc::new(cfg), name).map_err(|e| e.to_string())?;
let mut sock =
std::net::TcpStream::connect(("127.0.0.1", port)).map_err(|e| format!("connect: {e}"))?;
sock.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
while conn.is_handshaking() {
conn.complete_io(&mut sock).map_err(|e| e.to_string())?;
}
conn.protocol_version()
.ok_or_else(|| "no version agreed".into())
}
const HTTP1: &[&[u8]] = &[b"http/1.1"];
const H2: &[&[u8]] = &[b"h2"];
/// The listener on `port` accepts a TCP connection (the gRPC door binds after the HTTP one).
async fn wait_listening(port: u16) {
for _ in 0..250 {
if std::net::TcpStream::connect(("127.0.0.1", port)).is_ok() {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
panic!("nothing listens on 127.0.0.1:{port}");
}
/// ★ **The booted server's doors speak TLS 1.3 only** (stinky SB #39), through the
/// listeners `Holger::start` opens from the RON's `ron_tls`, not the rustls configs
/// alone (`exposed::tls`'s unit test). The HTTPS gateway and the gRPC door both
/// refuse a client that offers TLS 1.2 at most, and both agree TLS 1.3 with a client
/// that offers it, over the same listener, right after the refusal.
#[tokio::test(flavor = "multi_thread")]
async fn the_booted_https_and_grpc_doors_refuse_tls_1_2_and_speak_tls_1_3() {
let (https, grpc) = (hold_port(), hold_port());
let r = start("", https, grpc).await;
let c = client();
wait_healthy(&c, &r.base).await;
wait_listening(grpc).await;
for (door, port, alpn) in [
("the HTTPS gateway", https, HTTP1),
("the gRPC door", grpc, H2),
] {
let refused =
tokio::task::spawn_blocking(move || handshake(port, &rustls::version::TLS12, alpn))
.await
.unwrap();
assert!(refused.is_err(), "{door} agreed TLS 1.2: {refused:?}");
let spoken =
tokio::task::spawn_blocking(move || handshake(port, &rustls::version::TLS13, alpn))
.await
.unwrap();
assert_eq!(
spoken,
Ok(rustls::ProtocolVersion::TLSv1_3),
"{door} speaks TLS 1.3"
);
}
// The door still serves after the refused handshakes.
assert_eq!(
c.get(format!("{}/healthz", r.base))
.send()
.await
.unwrap()
.status(),
200
);
// HOL-NCSC (NCSC P1 item 5): each refused handshake left an audit row, per door.
let dir = r._holger.resolved_audit_dir();
let mut rows = String::new();
for _ in 0..50 {
rows = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| std::fs::read_to_string(e.path()).ok())
.collect();
if rows.contains("TLS handshake refused")
&& rows.contains("gRPC door")
&& rows.contains("HTTPS door")
{
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
for door in ["gRPC door", "HTTPS door"] {
assert!(
rows.lines().any(|l| l.contains("TLS handshake refused")
&& l.contains(door)
&& l.contains("127.0.0.1")),
"no audit row for the {door}'s refused handshake: {rows}"
);
}
}