Skip to content

Commit 167e6fa

Browse files
committed
feat(runner-server): configurable CA roots for outbound GitHub HTTPS (#354)
* feat(runner-server): configurable CA roots for outbound GitHub HTTPS Pointing the engine at a GHES/emulator forge that serves its own CA (e.g. gh-simulate's generated CA) fails TLS verification on every server-side GitHub call — the clients were built ad-hoc with only the bundled roots. Add shared_http::ca_certificates() honoring PRELOOP_GITHUB_CA_FILE (forge- scoped, additive) and SSL_CERT_FILE (the OpenSSL/native convention the runner already honors in client::http::with_control). Apply via github_client_builder() to the shared CLIENT plus the ad-hoc builders in actions.rs, remote_workflows.rs, github_app.rs, and the snapshots LFS forge client, so custom roots reach every GitHub-facing call uniformly. Missing/unreadable/invalid bundles log a warning and add no roots — verification stays on; startup never aborts on a bad CA path. Refs #349 * style: format CA bundle helper * ci: rerun checks after shared engine recovery The previous check run used the broken macstudio golden/provisioning state. No source files changed. --------- Co-authored-by: Bnjoroge1 <Bnjoroge1@users.noreply.github.com>
1 parent 9a4a189 commit 167e6fa

5 files changed

Lines changed: 124 additions & 13 deletions

File tree

‎crates/preloop-runner-server/src/actions.rs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,8 +375,7 @@ pub async fn download_action_tarball(
375375
repo, git_ref, github_url, "Downloading action to server cache"
376376
);
377377

378-
let client = reqwest::Client::builder()
379-
.user_agent("preloop-runner-server")
378+
let client = crate::shared_http::github_client_builder()
380379
.build()
381380
.map_err(|e| ApiError::internal(format!("failed to build reqwest client: {e}")))?;
382381

‎crates/preloop-runner-server/src/github_app.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1269,7 +1269,7 @@ async fn verify_repo_access_at(
12691269
repo: &str,
12701270
) -> anyhow::Result<Vec<(String, String)>> {
12711271
let (owner, name) = split_repository(repo)?;
1272-
let client = reqwest::Client::builder()
1272+
let client = crate::shared_http::github_client_builder()
12731273
.user_agent("preloop-doctor")
12741274
.build()
12751275
.context("building GitHub API client")?;

‎crates/preloop-runner-server/src/remote_workflows.rs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,7 @@ pub async fn resolve_remote_workflows(
7171
continue;
7272
}
7373
let client = client.get_or_insert(
74-
reqwest::Client::builder()
75-
.user_agent("preloop-runner-server")
74+
crate::shared_http::github_client_builder()
7675
.build()
7776
.map_err(|error| ApiError::internal(format!("build GitHub client: {error}")))?,
7877
);
Lines changed: 120 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,127 @@
11
use std::time::Duration;
22

3-
/// Shared HTTP client for server-side outbound requests (GitHub API, scheduler).
4-
/// Configured with standard timeouts and user-agent.
5-
pub static CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
6-
reqwest::Client::builder()
3+
/// Root certificates loaded for server-side outbound HTTPS.
4+
///
5+
/// Sources (in order):
6+
/// 1. `PRELOOP_GITHUB_CA_FILE` — a PEM bundle that should additionally be
7+
/// trusted when talking to a GHES / GitHub-emulator forge (gh-simulate
8+
/// serves a self-signed CA). Forge-scoped, additive to the system roots.
9+
/// 2. `SSL_CERT_FILE` — the OpenSSL/native convention the runner already
10+
/// honors in `client::http::with_control`; kept here so the engine's
11+
/// GitHub clients follow the same environment override on platforms
12+
/// whose reqwest backend does not read it.
13+
///
14+
/// Both are optional and additive to the bundled webpki/native roots. A
15+
/// missing or unreadable file fails closed (the client simply has the default
16+
/// roots) — we log rather than abort startup, since TLS verification itself
17+
/// stays on.
18+
fn ca_certificates() -> Vec<reqwest::Certificate> {
19+
let mut out = Vec::new();
20+
for var in ["PRELOOP_GITHUB_CA_FILE", "SSL_CERT_FILE"] {
21+
let Ok(path) = std::env::var(var) else {
22+
continue;
23+
};
24+
if path.trim().is_empty() {
25+
continue;
26+
}
27+
out.extend(load_ca_bundle(std::path::Path::new(&path), var));
28+
}
29+
out
30+
}
31+
32+
/// Read one PEM bundle from `path` and parse its certificates. Missing or
33+
/// invalid files log and return empty — verification stays on, we just add no
34+
/// roots. Split out so tests exercise it without mutating process env.
35+
fn load_ca_bundle(path: &std::path::Path, env: &str) -> Vec<reqwest::Certificate> {
36+
match std::fs::read(path) {
37+
Ok(pem) => match reqwest::Certificate::from_pem_bundle(&pem) {
38+
Ok(certs) => {
39+
tracing::info!(path = %path.display(), env, count = certs.len(),
40+
"loaded additional TLS root certificates for outbound GitHub calls");
41+
certs
42+
}
43+
Err(e) => {
44+
tracing::warn!(path = %path.display(), env, error = %e,
45+
"ignoring CA bundle with invalid PEM");
46+
Vec::new()
47+
}
48+
},
49+
Err(e) => {
50+
tracing::warn!(path = %path.display(), env, error = %e,
51+
"ignoring unreadable CA bundle");
52+
Vec::new()
53+
}
54+
}
55+
}
56+
57+
/// Build a reqwest client with the shared timeouts and the extra CA roots
58+
/// from [`ca_certificates`]. Use this for every server-side outbound HTTPS
59+
/// call that can target a GHES/emulator forge instead of ad-hoc
60+
/// `reqwest::Client::builder()` so custom roots apply uniformly.
61+
pub fn github_client_builder() -> reqwest::ClientBuilder {
62+
let mut b = reqwest::Client::builder()
763
.timeout(Duration::from_secs(30))
864
.connect_timeout(Duration::from_secs(10))
9-
.user_agent("preloop-runner-server")
65+
.user_agent("preloop-runner-server");
66+
for cert in ca_certificates() {
67+
b = b.add_root_certificate(cert);
68+
}
69+
b
70+
}
71+
72+
/// Shared HTTP client for server-side outbound requests (GitHub API, scheduler).
73+
/// Configured with standard timeouts, user-agent, and the CA roots from
74+
/// [`ca_certificates`] so `PRELOOP_GITHUB_CA_FILE`/`SSL_CERT_FILE` reach every
75+
/// caller.
76+
pub static CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
77+
github_client_builder()
1078
.build()
1179
.expect("HTTP client builder")
1280
});
81+
82+
#[cfg(test)]
83+
mod tests {
84+
use super::*;
85+
86+
fn write_self_signed_pem(path: &std::path::Path) {
87+
let key = std::env::temp_dir().join("ghsim_ca_test_key.pem");
88+
let _ = std::process::Command::new("openssl")
89+
.args([
90+
"req",
91+
"-x509",
92+
"-newkey",
93+
"rsa:2048",
94+
"-keyout",
95+
key.to_str().unwrap(),
96+
"-out",
97+
path.to_str().unwrap(),
98+
"-days",
99+
"1",
100+
"-nodes",
101+
"-subj",
102+
"/CN=gh-simulate-test",
103+
])
104+
.output();
105+
}
106+
107+
#[test]
108+
fn parses_pem_bundle() {
109+
let p = std::env::temp_dir().join("ghsim_ca_file.pem");
110+
write_self_signed_pem(&p);
111+
let certs = load_ca_bundle(&p, "TEST");
112+
assert!(!certs.is_empty());
113+
}
114+
115+
#[test]
116+
fn missing_ca_file_returns_empty_not_panic() {
117+
let certs = load_ca_bundle(std::path::Path::new("/nonexistent/ca.pem"), "TEST");
118+
assert!(certs.is_empty());
119+
}
120+
121+
#[test]
122+
fn invalid_pem_returns_empty_not_panic() {
123+
let p = std::env::temp_dir().join("ghsim_bad.pem");
124+
std::fs::write(&p, b"not a pem").unwrap();
125+
assert!(load_ca_bundle(&p, "TEST").is_empty());
126+
}
127+
}

‎crates/preloop-runner-server/src/snapshots.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1906,10 +1906,8 @@ async fn lfs_batch_response(
19061906
/// purpose: blobs stream straight to disk and can be large; the per-object
19071907
/// byte cap, not the clock, is the backstop.
19081908
static LFS_FORGE_CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
1909-
reqwest::Client::builder()
1909+
crate::shared_http::github_client_builder()
19101910
.timeout(std::time::Duration::from_secs(300))
1911-
.connect_timeout(std::time::Duration::from_secs(10))
1912-
.user_agent("preloop-runner-server")
19131911
.build()
19141912
.expect("LFS forge client builds")
19151913
});

0 commit comments

Comments
 (0)