Skip to content

Commit 0d33b32

Browse files
feloyclaude
andcommitted
feat(ssl-certs): add --ssl-certs flag to bundle CA certificates into images
Enterprise environments that intercept HTTPS traffic with corporate proxies need a custom CA certificate trusted both during the build (so dnf/apt can reach repos) and at runtime (so the agent API calls work). The flag accepts an optional FILE; omitting the value triggers auto-discovery across 7 common Linux CA bundle paths. Certs are installed in the final image via update-ca-trust (DNF) or update-ca-certificates (Ubuntu), and persist into the final sandbox image via the FROM system AS final inheritance. To test auto-discover mode (uses the host's CA bundle if present): openshell-image-builder --ssl-certs= myimage:latest To test explicit file mode: openshell-image-builder \ --ssl-certs /etc/ssl/certs/ca-certificates.crt \ myimage:latest To verify the cert landed in the final image — Ubuntu base (default): podman run --rm myimage:latest -c \ 'test -f /usr/local/share/ca-certificates/system-ca.crt && echo found' To test with other base images, create a config.toml first. DNF-based images (Fedora, UBI, Hummingbird) store the cert under the pki trust path instead. Example configs and the verification command: # Fedora echo '[openshell_image_builder.base_image] image = "fedora" tag = "latest"' > /tmp/myconfig/config.toml # UBI echo '[openshell_image_builder.base_image] image = "ubi" tag = "latest"' > /tmp/myconfig/config.toml # Hummingbird echo '[openshell_image_builder.base_image] image = "hummingbird" tag = "latest-builder"' > /tmp/myconfig/config.toml Then build and verify: openshell-image-builder \ --config /tmp/myconfig \ --ssl-certs /etc/ssl/certs/ca-certificates.crt \ myimage:latest podman run --rm myimage:latest -c \ 'test -f /etc/pki/ca-trust/source/anchors/system-ca.crt && echo found' To verify error on a missing file: openshell-image-builder --ssl-certs /nonexistent/bundle.crt myimage:latest # exits non-zero with an OS error Closes #62 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: Philippe Martin <phmartin@redhat.com>
1 parent 36d30a2 commit 0d33b32

7 files changed

Lines changed: 629 additions & 8 deletions

File tree

.agents/skills/add-cli-flag/SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The existing flags are the canonical reference:
1616
- `--agent` / `--inference` — enum flags backed by `ValueEnum`, gated by a compatibility check in `run()`
1717
- `--endpoint``Option<String>` that overrides a provider URL, validated early in `run()`, flows into `resolve_base_url()` and `stage_agent_settings()`
1818
- `--model``Option<String>` threaded through `stage_agent_settings()` and `agent.env_vars()`
19+
- `--ssl-certs``Option<String>` with `num_args = 0..=1` and `default_missing_value = ""` (optional value flag); converted to `Option<Option<PathBuf>>` before being passed to `run()`
1920

2021
## Step 1 — declare the argument in the `Cli` struct (`src/main.rs`)
2122

@@ -46,6 +47,7 @@ Then extend the `run()` signature:
4647
fn run(
4748
...
4849
my_flag: Option<&str>, // add here
50+
ssl_certs: Option<Option<PathBuf>>,
4951
runner: &dyn build::Runner,
5052
) -> Result<(), Box<dyn std::error::Error>> {
5153
```
@@ -108,6 +110,7 @@ fn run_with_my_flag_succeeds() {
108110
None, // endpoint
109111
None, // model
110112
Some("my-value"), // my_flag
113+
None, // ssl_certs
111114
&FakeRunner(0),
112115
);
113116
assert!(result.is_ok(), "expected Ok, got {result:?}");

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,68 @@ Use `-v` (info) or `-vv` (debug) to increase log verbosity — useful for tracin
141141
openshell-image-builder -v myimage:latest
142142
```
143143

144+
## Enterprise environments
145+
146+
### Corporate proxy support (`--ssl-certs`)
147+
148+
In environments where outbound HTTPS traffic is intercepted by a corporate proxy (e.g. Netskope, Zscaler, or a custom MITM proxy), `dnf install` and `apt-get install` fail during the build because the proxy presents a self-signed or corporate-issued certificate that the container doesn't trust.
149+
150+
Use `--ssl-certs` to copy a CA bundle into the build context and install it in the image. The certificate is trusted both **during the build** (so package installation succeeds) and **at runtime** (so the agent can reach its LLM backend through the same proxy).
151+
152+
**Auto-discover** — the tool searches for a CA bundle in common system locations and uses the first one it finds. Use `--ssl-certs=` (with a trailing `=` and no value) so that the image tag is not mistaken for the certificate path:
153+
154+
```sh
155+
openshell-image-builder --ssl-certs= myimage:latest
156+
```
157+
158+
Paths searched, in order:
159+
160+
| Distribution | Path |
161+
| ------------ | ---- |
162+
| Debian / Ubuntu / Gentoo | `/etc/ssl/certs/ca-certificates.crt` |
163+
| Fedora / RHEL 6 | `/etc/pki/tls/certs/ca-bundle.crt` |
164+
| Fedora / RHEL 7+ / CentOS / Rocky / Alma | `/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem` |
165+
| OpenSUSE | `/etc/ssl/ca-bundle.pem` |
166+
| SUSE / older | `/etc/ssl/certs/ca-bundle.crt` |
167+
| Alpine | `/etc/ssl/cert.pem` |
168+
| Arch | `/etc/ca-certificates/extracted/tls-ca-bundle.pem` |
169+
170+
If none of the above paths exist, the build proceeds without adding any certificates.
171+
172+
**Explicit file** — point directly to a specific CA bundle. The build fails immediately if the file does not exist:
173+
174+
```sh
175+
openshell-image-builder --ssl-certs /etc/pki/tls/certs/ca-bundle.crt myimage:latest
176+
```
177+
178+
#### How it works
179+
180+
The CA bundle is copied into the build context and installed in the image's system trust store during the `system` stage, before any packages are installed:
181+
182+
- **Fedora / UBI / Hummingbird** — copied to `/etc/pki/ca-trust/source/anchors/system-ca.crt`, then `update-ca-trust` is run before `dnf install`.
183+
- **Ubuntu** — copied to `/usr/local/share/ca-certificates/system-ca.crt`, then `update-ca-certificates` is run after `apt-get install` (Ubuntu mirrors use HTTP, so the cert is not needed for package installation itself, but is available to the agent at runtime).
184+
185+
Because the `final` image stage inherits the full filesystem from `system`, the CA bundle and updated trust database are present in the running sandbox.
186+
187+
#### Example — agent build behind a corporate proxy
188+
189+
```sh
190+
# Auto-discover the host CA bundle and build with Claude Code
191+
# (--ssl-certs is followed by another --flag, so no trailing = needed)
192+
openshell-image-builder \
193+
--ssl-certs \
194+
--agent claude \
195+
--inference anthropic \
196+
myimage:latest
197+
198+
# Or point to a specific bundle
199+
openshell-image-builder \
200+
--ssl-certs /usr/local/share/ca-certificates/my-corp-ca.crt \
201+
--agent claude \
202+
--inference anthropic \
203+
myimage:latest
204+
```
205+
144206
## Installing an agent
145207

146208
Pass `--agent` to install an agent into the image.
@@ -431,6 +493,7 @@ openshell-image-builder [OPTIONS] <TAG>
431493
| `--inference <INFERENCE>` | Inference server the agent will connect to (`anthropic`, `vertexai`, `ollama`, `openai`) |
432494
| `--endpoint <URL>` | Override the inference provider's default endpoint URL (see [Custom endpoint](#custom-endpoint---endpoint)) |
433495
| `--model <MODEL>` | Default model for the agent to use (see [Default model](#default-model---model)) |
496+
| `--ssl-certs=[FILE]` | Install system CA certificates in the image (see [Corporate proxy support](#corporate-proxy-support---ssl-certs)). `--ssl-certs=` auto-discovers from common system paths; `--ssl-certs /path/to/bundle.crt` uses that specific file (fails if not found). |
434497
| `-v` / `-vv` | Increase log verbosity (info / debug) |
435498

436499
## Examples

src/certs.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (C) 2026 Red Hat, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
// SPDX-License-Identifier: Apache-2.0
16+
17+
use std::io;
18+
use std::path::Path;
19+
20+
/// Ordered list of common CA bundle paths across Linux distributions.
21+
pub const SYSTEM_CA_CERT_PATHS: &[&str] = &[
22+
"/etc/ssl/certs/ca-certificates.crt",
23+
"/etc/pki/tls/certs/ca-bundle.crt",
24+
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
25+
"/etc/ssl/ca-bundle.pem",
26+
"/etc/ssl/certs/ca-bundle.crt",
27+
"/etc/ssl/cert.pem",
28+
"/etc/ca-certificates/extracted/tls-ca-bundle.pem",
29+
];
30+
31+
/// Tries each path in order; returns the content of the first non-empty regular file found.
32+
pub fn find_system_ca_certificates(cert_paths: &[&str]) -> Option<Vec<u8>> {
33+
for path in cert_paths {
34+
let p = Path::new(path);
35+
if !p.is_file() {
36+
continue;
37+
}
38+
if let Ok(content) = std::fs::read(p)
39+
&& !content.is_empty()
40+
{
41+
return Some(content);
42+
}
43+
}
44+
None
45+
}
46+
47+
fn write_cert_to_context(context_dir: &Path, content: &[u8]) -> io::Result<()> {
48+
let certs_dir = context_dir.join("certs");
49+
std::fs::create_dir_all(&certs_dir)?;
50+
std::fs::write(certs_dir.join("system-ca.crt"), content)
51+
}
52+
53+
/// Auto-discover mode: copies the first found bundle to `<context_dir>/certs/system-ca.crt`.
54+
/// Returns `true` if a cert was found and copied, `false` if none found.
55+
pub fn copy_from_paths(context_dir: &Path, cert_paths: &[&str]) -> io::Result<bool> {
56+
match find_system_ca_certificates(cert_paths) {
57+
None => Ok(false),
58+
Some(content) => {
59+
write_cert_to_context(context_dir, &content)?;
60+
Ok(true)
61+
}
62+
}
63+
}
64+
65+
/// Specific-file mode: reads from `path` and copies to `<context_dir>/certs/system-ca.crt`.
66+
/// Returns an error if the file doesn't exist or can't be read.
67+
pub fn copy_from_file(context_dir: &Path, path: &Path) -> io::Result<()> {
68+
let content = std::fs::read(path)?;
69+
write_cert_to_context(context_dir, &content)
70+
}
71+
72+
#[cfg(test)]
73+
mod tests {
74+
use super::*;
75+
76+
#[test]
77+
fn find_returns_none_for_empty_paths() {
78+
assert!(find_system_ca_certificates(&[]).is_none());
79+
}
80+
81+
#[test]
82+
fn find_reads_first_existing_path() {
83+
let dir = tempfile::tempdir().unwrap();
84+
let cert_path = dir.path().join("bundle.crt");
85+
std::fs::write(&cert_path, b"CERT_DATA").unwrap();
86+
let path_str = cert_path.to_string_lossy().into_owned();
87+
let result = find_system_ca_certificates(&[path_str.as_str()]);
88+
assert_eq!(result, Some(b"CERT_DATA".to_vec()));
89+
}
90+
91+
#[test]
92+
fn find_skips_directories() {
93+
let dir = tempfile::tempdir().unwrap();
94+
let subdir = dir.path().join("subdir");
95+
std::fs::create_dir(&subdir).unwrap();
96+
let cert = dir.path().join("bundle.crt");
97+
std::fs::write(&cert, b"REAL_CERT").unwrap();
98+
let dir_str = subdir.to_string_lossy().into_owned();
99+
let cert_str = cert.to_string_lossy().into_owned();
100+
let result = find_system_ca_certificates(&[dir_str.as_str(), cert_str.as_str()]);
101+
assert_eq!(result, Some(b"REAL_CERT".to_vec()));
102+
}
103+
104+
#[test]
105+
fn find_skips_empty_files() {
106+
let dir = tempfile::tempdir().unwrap();
107+
let empty = dir.path().join("empty.crt");
108+
let real = dir.path().join("real.crt");
109+
std::fs::write(&empty, b"").unwrap();
110+
std::fs::write(&real, b"REAL_CERT").unwrap();
111+
let empty_str = empty.to_string_lossy().into_owned();
112+
let real_str = real.to_string_lossy().into_owned();
113+
let result = find_system_ca_certificates(&[empty_str.as_str(), real_str.as_str()]);
114+
assert_eq!(result, Some(b"REAL_CERT".to_vec()));
115+
}
116+
117+
#[test]
118+
fn find_falls_through_to_second_path_when_first_missing() {
119+
let dir = tempfile::tempdir().unwrap();
120+
let real = dir.path().join("real.crt");
121+
std::fs::write(&real, b"SECOND_CERT").unwrap();
122+
let real_str = real.to_string_lossy().into_owned();
123+
let result = find_system_ca_certificates(&["/nonexistent/path.crt", real_str.as_str()]);
124+
assert_eq!(result, Some(b"SECOND_CERT".to_vec()));
125+
}
126+
127+
#[test]
128+
fn copy_from_paths_returns_false_when_no_certs_found() {
129+
let ctx = tempfile::tempdir().unwrap();
130+
let copied = copy_from_paths(ctx.path(), &[]).unwrap();
131+
assert!(!copied);
132+
assert!(!ctx.path().join("certs").exists());
133+
}
134+
135+
#[test]
136+
fn copy_from_paths_creates_certs_dir_and_file() {
137+
let dir = tempfile::tempdir().unwrap();
138+
let cert = dir.path().join("bundle.crt");
139+
std::fs::write(&cert, b"MY_CERT").unwrap();
140+
let cert_str = cert.to_string_lossy().into_owned();
141+
142+
let ctx = tempfile::tempdir().unwrap();
143+
copy_from_paths(ctx.path(), &[cert_str.as_str()]).unwrap();
144+
145+
assert!(ctx.path().join("certs").join("system-ca.crt").exists());
146+
}
147+
148+
#[test]
149+
fn copy_from_paths_returns_true_when_cert_found() {
150+
let dir = tempfile::tempdir().unwrap();
151+
let cert = dir.path().join("bundle.crt");
152+
std::fs::write(&cert, b"MY_CERT").unwrap();
153+
let cert_str = cert.to_string_lossy().into_owned();
154+
155+
let ctx = tempfile::tempdir().unwrap();
156+
let copied = copy_from_paths(ctx.path(), &[cert_str.as_str()]).unwrap();
157+
assert!(copied);
158+
}
159+
160+
#[test]
161+
fn copy_from_file_writes_content_correctly() {
162+
let dir = tempfile::tempdir().unwrap();
163+
let cert = dir.path().join("bundle.crt");
164+
std::fs::write(&cert, b"CUSTOM_CERT").unwrap();
165+
166+
let ctx = tempfile::tempdir().unwrap();
167+
copy_from_file(ctx.path(), &cert).unwrap();
168+
169+
let written = std::fs::read(ctx.path().join("certs").join("system-ca.crt")).unwrap();
170+
assert_eq!(written, b"CUSTOM_CERT");
171+
}
172+
173+
#[test]
174+
fn copy_from_file_errors_when_file_missing() {
175+
let ctx = tempfile::tempdir().unwrap();
176+
let result = copy_from_file(ctx.path(), Path::new("/nonexistent/bundle.crt"));
177+
assert!(result.is_err());
178+
}
179+
}

0 commit comments

Comments
 (0)