diff --git a/CLAUDE.md b/CLAUDE.md index 4476b56..9dae98a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,6 +201,36 @@ ruff, the OAuth login, `~/.claude.json`, skills, transcripts, scheduler tasks an re-attach for free when a container is recreated from a *different* image — which is what makes base-image migration cheap. +### Corporate CA certificates (`docker/ca_certs.rs`, `entrypoint.sh`) + +A global `AppSettings::ca_cert_path` with a per-project `Project::ca_cert_path` override, accepting +a single certificate file **or** a directory. Follows the SSH/AWS host-mount pattern: read-only +bind mount at `/tmp/.host-ca`, applied by the entrypoint on every start, so it survives recreation, +migration and Reset. Four things here are not obvious: + +- **`update-ca-certificates` globs `*.crt`, case-sensitively.** A `.pem` that is merely copied into + `/usr/local/share/ca-certificates/` is ignored in total silence. Certificates are *renamed* — + `container_cert_name()` in Rust, mirrored in a few lines of shell in `entrypoint.sh` (the Rust + side carries the unit tests). A single-file mount lands at `/tmp/.host-ca/.crt` so the + entrypoint only ever sees a directory and the file keeps a recognisable name. +- **The system store is not enough.** Only curl/git/apt read it. Node — and therefore Claude Code + itself — needs `NODE_EXTRA_CA_CERTS`; Python/requests need `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`; + Chrome/Chromium read neither and want their own NSS database at `~/.pki/nssdb`, seeded with + `certutil` (`libnss3-tools`, added to the image for this). The NSS step warns and continues if + `certutil` is missing rather than failing the start. +- **Those env vars are set from Rust at creation, never exported by the entrypoint.** A terminal + session is a `docker exec`, which inherits the container's configured env and sees nothing the + entrypoint exported — the same lesson that made `$BROWSER` an image-level `ENV`. The bundle path + is deterministic (`/etc/ssl/certs/ca-certificates.crt`), so Rust can set them up front. They are + emitted **empty** when no CA is configured, for the `MANAGED_AUTH_KEYS` reason: `docker commit` + bakes env into the snapshot image. Empty is safe — verified on Ubuntu 24.04 that curl, `openssl + s_client` and Python's `ssl` behave exactly as with the vars unset. +- **`triple-c.ca-fingerprint` covers the certificate *bytes*, not just the path.** Replacing a + rotated CA at the same location must recreate the container; the copy inside is made once, at + start, so nothing else would notice. The entrypoint is stamped/idempotent on restart, and + actively **removes** `triple-c-*.crt` when the setting is cleared — `/usr/local/share` rides the + project's snapshot image, so turning the feature off has to undo, not merely stop. + ### Container Lifecycle Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation. diff --git a/HOW-TO-USE.md b/HOW-TO-USE.md index 3822ce7..09c1979 100644 --- a/HOW-TO-USE.md +++ b/HOW-TO-USE.md @@ -828,8 +828,8 @@ Notes: ## Settings Access global settings via the **Settings** tab in the sidebar. The panel is a set of collapsible -sections: **General**, **Claude Authentication**, **Backends**, **Container**, **Git / SSH**, -**Tools** and **Updates**. +sections: **General**, **Claude Authentication**, **Backends**, **Container**, **Certificates**, +**Git / SSH**, **Tools** and **Updates**. ### Claude Authentication @@ -859,6 +859,36 @@ Environment variables applied to **all** project containers. Per-project variabl Path to your SSH key directory (typically `~/.ssh`). This is mounted into **all** containers that don't have a per-project SSH path set. Per-project SSH paths take precedence. +### Corporate CA Certificate + +If your organisation's network inspects TLS (a corporate proxy, a VPN that terminates HTTPS at the +edge), containers need your organisation's root certificate or **every** HTTPS call inside them +fails — `npm install`, `pip`, `git clone` over HTTPS, `curl`, the browser-view pane, and Claude +Code's own calls to the API. + +Point this at either a **single certificate file** or a **folder** of them. It is mounted read-only +into every container and applied on every start, so it survives container recreation, base-image +migration and Reset — unlike a certificate you install by hand inside a running container, which is +lost the first time any of those happens. + +The status line under the field tells you how many certificates were found and the names they will +be installed as inside the container. That rename matters: the container's trust store only reads +files ending in `.crt`, so a `.pem` is renamed rather than merely copied, which is the step that is +easiest to get wrong by hand. + +Inside the container the certificate is trusted by: + +| Consumer | How | +|---|---| +| curl, git, apt, wget | the system trust store (`update-ca-certificates`) | +| Node, npm, **Claude Code itself** | `NODE_EXTRA_CA_CERTS` | +| Python, pip, requests | `REQUESTS_CA_BUNDLE` and `SSL_CERT_FILE` | +| Chrome / Chromium (browser view) | its own NSS database at `~/.pki/nssdb` | + +A per-project override lives in **Project Home → Config → Access**; leave it blank to use this +global setting. Changing either recreates the project's container on its next start — replacing the +certificate file in place counts as a change, so a rotated CA is picked up too. + ### Default Git Name / Email Sets `git user.name` and `git user.email` inside all containers. Per-project Git Name / Email settings take precedence. This is useful so you don't have to set the same name and email on every project. diff --git a/app/src-tauri/src/commands/project_commands.rs b/app/src-tauri/src/commands/project_commands.rs index 4d5d3f3..b5e2dd0 100644 --- a/app/src-tauri/src/commands/project_commands.rs +++ b/app/src-tauri/src/commands/project_commands.rs @@ -78,6 +78,7 @@ pub(crate) async fn create_container_for_project( settings.timezone.as_deref(), settings.global_claude_code_settings.as_ref(), settings.default_ssh_key_path.as_deref(), + settings.ca_cert_path.as_deref(), settings.default_git_user_name.as_deref(), settings.default_git_user_email.as_deref(), ) @@ -406,6 +407,7 @@ pub async fn start_project_container( settings.timezone.as_deref(), settings.global_claude_code_settings.as_ref(), settings.default_ssh_key_path.as_deref(), + settings.ca_cert_path.as_deref(), settings.default_git_user_name.as_deref(), settings.default_git_user_email.as_deref(), ).await.unwrap_or(false); diff --git a/app/src-tauri/src/commands/settings_commands.rs b/app/src-tauri/src/commands/settings_commands.rs index 8a9a7fe..51d949a 100644 --- a/app/src-tauri/src/commands/settings_commands.rs +++ b/app/src-tauri/src/commands/settings_commands.rs @@ -155,6 +155,78 @@ pub async fn detect_aws_config() -> Result, String> { Ok(None) } +/// What the UI shows next to a corporate CA certificate path. +/// +/// Errors are returned *inside* the payload rather than as `Err` so the field +/// can render its own inline message while the user is still typing — a toast +/// per keystroke would be unusable. The same check runs again, as a hard error, +/// when the container is created. +#[derive(Debug, serde::Serialize)] +pub struct CaCertInfo { + pub exists: bool, + pub is_directory: bool, + /// How many certificate files were found. + pub cert_count: usize, + /// The names they will be installed as inside the container. Surfacing + /// these makes the silent `.pem` → `.crt` rename visible, which is the one + /// step users most often do by hand and get wrong. + pub installed_names: Vec, + /// Why the path is unusable, if it is. + pub error: Option, +} + +#[tauri::command] +pub async fn inspect_ca_cert_path(path: String) -> Result { + use crate::docker::ca_certs; + + let trimmed = path.trim(); + if trimmed.is_empty() { + return Ok(CaCertInfo { + exists: false, + is_directory: false, + cert_count: 0, + installed_names: Vec::new(), + error: None, + }); + } + + let p = std::path::Path::new(trimmed); + let exists = p.exists(); + let is_directory = p.is_dir(); + + match ca_certs::resolve(Some(trimmed)) { + Ok(Some(resolved)) => Ok(CaCertInfo { + exists, + is_directory, + cert_count: resolved.cert_files.len(), + installed_names: resolved + .cert_files + .iter() + .map(|f| { + ca_certs::container_cert_name( + &f.file_name().unwrap_or_default().to_string_lossy(), + ) + }) + .collect(), + error: None, + }), + Ok(None) => Ok(CaCertInfo { + exists, + is_directory, + cert_count: 0, + installed_names: Vec::new(), + error: None, + }), + Err(e) => Ok(CaCertInfo { + exists, + is_directory, + cert_count: 0, + installed_names: Vec::new(), + error: Some(e), + }), + } +} + #[tauri::command] pub async fn list_aws_profiles() -> Result, String> { let mut profiles = Vec::new(); diff --git a/app/src-tauri/src/docker/ca_certs.rs b/app/src-tauri/src/docker/ca_certs.rs new file mode 100644 index 0000000..58b1da7 --- /dev/null +++ b/app/src-tauri/src/docker/ca_certs.rs @@ -0,0 +1,580 @@ +//! Corporate CA certificate injection. +//! +//! Users behind a TLS-terminating corporate proxy need their organisation's +//! root CA inside every container, or **every** HTTPS call fails — npm, pip, +//! git, curl, the Playwright browser, and Claude Code's own API calls. +//! +//! The mechanism follows the SSH/AWS host-mount pattern in [`super::container`]: +//! a host path is bind-mounted **read-only** into the container and +//! `entrypoint.sh` applies it on every start. That is what makes it durable +//! across container recreation, base-image migration and Reset — a certificate +//! installed by hand inside a running container is lost the first time any of +//! those happen. +//! +//! ## Two things that are easy to get wrong +//! +//! 1. **`update-ca-certificates` only reads `*.crt`.** It globs +//! `/usr/local/share/ca-certificates/*.crt` case-sensitively, so a `.pem` +//! (the far more common export format) that is merely *copied* in is +//! silently ignored — no warning, no error, just a container that still +//! cannot speak HTTPS. Certificates must be **renamed**, which is what +//! [`container_cert_name`] does. +//! +//! 2. **The system trust store is not enough.** Only curl/git/apt read it. +//! Node — and therefore Claude Code itself — needs `NODE_EXTRA_CA_CERTS`, +//! Python/requests need `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE`, and +//! Chrome/Chromium read neither: they have their own NSS database at +//! `~/.pki/nssdb`, seeded by `certutil` in the entrypoint. +//! +//! ## Why the env vars are set from Rust and not exported by the entrypoint +//! +//! An `export` in `entrypoint.sh` reaches only the entrypoint's own children. +//! Every terminal session is a separate `docker exec`, which inherits the +//! *container's* configured env and sees nothing the entrypoint exported — +//! the same lesson that forced `$BROWSER` to become an image-level `ENV` for +//! the URL relay shim. Since the bundle path written by +//! `update-ca-certificates` is deterministic ([`CA_BUNDLE_PATH`]), Rust can set +//! all three vars at container creation, where `docker exec` will see them. + +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +/// Where the host's CA material is bind-mounted, read-only. Mirrors +/// `/tmp/.host-ssh` and `/tmp/.host-aws`. +/// +/// A *directory* on the host is mounted here as-is. A single *file* is mounted +/// at `/` — Docker creates the parent — so the +/// entrypoint only ever has to deal with a directory, and the certificate keeps +/// a recognisable name instead of becoming the literal path `.host-ca`. +pub const CA_MOUNT_DIR: &str = "/tmp/.host-ca"; + +/// The concatenated PEM bundle `update-ca-certificates` writes on +/// Debian/Ubuntu. Deterministic, which is what lets the env vars below be set +/// at container-creation time, before the entrypoint has run. +pub const CA_BUNDLE_PATH: &str = "/etc/ssl/certs/ca-certificates.crt"; + +/// Consulted by Node — and therefore by Claude Code itself, which is the whole +/// reason this feature exists. +pub const NODE_EXTRA_CA_CERTS: &str = "NODE_EXTRA_CA_CERTS"; +/// Consulted by `requests` (and so by pip's vendored copy). +pub const REQUESTS_CA_BUNDLE: &str = "REQUESTS_CA_BUNDLE"; +/// Consulted by OpenSSL, and so by Python's `ssl` module. +pub const SSL_CERT_FILE: &str = "SSL_CERT_FILE"; + +/// Every env var this module owns, in a fixed order. +/// +/// Also the list that must be *cleared* when no CA is configured: `docker +/// commit` bakes a container's env into the project's snapshot image, and +/// create-time env replaces image `ENV` per key — so without an explicit empty +/// value, removing the setting would leave the vars live in every future +/// container. Empty is safe for all three (verified on Ubuntu 24.04: curl, +/// `openssl s_client` and Python's `ssl` all behave exactly as they do with the +/// variable unset). +pub const CA_ENV_KEYS: &[&str] = &[NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, SSL_CERT_FILE]; + +/// Extensions treated as certificates when the configured path is a directory. +/// Matched case-insensitively. DER is deliberately absent — the system store +/// and every consumer here want PEM. +const CERT_EXTENSIONS: &[&str] = &["crt", "pem", "cer", "cert", "ca-bundle"]; + +/// A configured CA path that has been checked and resolved into everything the +/// container creation path needs. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedCa { + /// The host path, as configured. + pub host_path: String, + /// Whether the host path is a directory (as opposed to a single file). + pub is_dir: bool, + /// The bind-mount target inside the container. + pub mount_target: String, + /// The certificate files found, sorted. + pub cert_files: Vec, +} + +fn sha256_hex(input: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(input.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// The file name a certificate is installed as under +/// `/usr/local/share/ca-certificates/`. +/// +/// `update-ca-certificates` globs `*.crt` **case-sensitively**, so `.pem`, +/// `.cer`, `.CRT` and extension-less files all have to end up as a lowercase +/// `.crt` or they are ignored without a word. Characters outside +/// `[A-Za-z0-9._-]` are replaced so that whitespace cannot break the shell +/// loops that walk the store, and leading dots are stripped so a hidden file +/// does not stay hidden. +/// +/// `entrypoint.sh` reimplements exactly this in a few lines of shell (it has to +/// rename the files inside the container); the two must agree, which is what +/// the unit tests below pin down. +pub fn container_cert_name(file_name: &str) -> String { + let sanitized: String = file_name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' { + c + } else { + '_' + } + }) + .collect(); + let sanitized = sanitized.trim_start_matches('.'); + // Strip one trailing extension, whatever it is, then force `.crt`. A name + // with no dot keeps its whole self as the stem. + let stem = match sanitized.rfind('.') { + Some(i) => &sanitized[..i], + None => sanitized, + }; + let stem = if stem.is_empty() { "corporate-ca" } else { stem }; + format!("{}.crt", stem) +} + +/// Whether a directory entry looks like a certificate worth installing. +fn is_cert_file(path: &Path) -> bool { + let Some(ext) = path.extension().and_then(|e| e.to_str()) else { + return false; + }; + let ext = ext.to_ascii_lowercase(); + CERT_EXTENSIONS.contains(&ext.as_str()) +} + +/// The certificate files a configured path contributes. +/// +/// A file is taken at face value — the user pointed at it explicitly, so its +/// extension is not second-guessed. A directory is scanned one level deep +/// (matching the entrypoint's `find -maxdepth 1`) and filtered by extension, +/// so an `openssl.cnf` or a README sitting next to the certs is skipped. +/// The result is sorted, so the fingerprint is stable across filesystem +/// enumeration order. +pub fn collect_cert_files(path: &Path) -> Vec { + if path.is_file() { + return vec![path.to_path_buf()]; + } + if !path.is_dir() { + return Vec::new(); + } + let Ok(entries) = std::fs::read_dir(path) else { + return Vec::new(); + }; + let mut files: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file() && is_cert_file(p)) + .collect(); + files.sort(); + files +} + +/// Resolve the configured CA path, or explain why it cannot be used. +/// +/// `Ok(None)` means "no CA configured", which is the overwhelmingly common +/// case and must stay free. An `Err` aborts the container start: behind a +/// TLS-intercepting proxy a container without the CA is broken in a dozen +/// confusing ways, so naming the bad path once is far kinder than letting npm, +/// pip and Claude Code each fail their own way. +pub fn resolve(path: Option<&str>) -> Result, String> { + let Some(raw) = path.map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(None); + }; + let root = Path::new(raw); + if !root.exists() { + return Err(format!( + "Corporate CA certificate path '{}' does not exist. Update it in \ + Settings → Certificates, or clear this project's override in \ + Project Home → Config → Access.", + raw + )); + } + + let is_dir = root.is_dir(); + if !is_dir && !root.is_file() { + return Err(format!( + "Corporate CA certificate path '{}' is neither a file nor a directory.", + raw + )); + } + + let cert_files = collect_cert_files(root); + if cert_files.is_empty() { + return Err(format!( + "Corporate CA certificate directory '{}' contains no certificate files \ + (looked for {} one level deep).", + raw, + CERT_EXTENSIONS + .iter() + .map(|e| format!(".{}", e)) + .collect::>() + .join(", ") + )); + } + + let mount_target = if is_dir { + CA_MOUNT_DIR.to_string() + } else { + let name = root + .file_name() + .map(|n| container_cert_name(&n.to_string_lossy())) + .unwrap_or_else(|| "corporate-ca.crt".to_string()); + format!("{}/{}", CA_MOUNT_DIR, name) + }; + + Ok(Some(ResolvedCa { + host_path: raw.to_string(), + is_dir, + mount_target, + cert_files, + })) +} + +/// Fingerprint of the CA configuration, for the `triple-c.ca-fingerprint` +/// label. +/// +/// `container_needs_recreation` is label-based and never diffs env or mounts, +/// so without this, changing the CA path would silently do nothing until some +/// unrelated setting forced a rebuild. +/// +/// It covers **both** the resolved path *and the bytes of every certificate*, +/// because replacing a rotated CA at the same path is at least as common as +/// moving it — and the container's copy is made once, at start, so nothing else +/// would notice. +/// +/// Never returns an error: a path that has gone missing hashes differently from +/// one that is present, which is exactly the "something changed, recreate" +/// signal wanted here. Reporting the problem is [`resolve`]'s job. +pub fn compute_ca_fingerprint(path: Option<&str>) -> String { + let Some(raw) = path.map(str::trim).filter(|s| !s.is_empty()) else { + return String::new(); + }; + let mut parts: Vec = vec![raw.to_string()]; + let root = Path::new(raw); + if !root.exists() { + parts.push("".to_string()); + } else { + for file in collect_cert_files(root) { + let name = file + .file_name() + .map(|n| container_cert_name(&n.to_string_lossy())) + .unwrap_or_default(); + let digest = match std::fs::read(&file) { + Ok(bytes) => { + let mut hasher = Sha256::new(); + hasher.update(&bytes); + format!("{:x}", hasher.finalize()) + } + Err(_) => "".to_string(), + }; + parts.push(format!("{}:{}", name, digest)); + } + } + sha256_hex(&parts.join("|")) +} + +/// The env vars to set on the container. +/// +/// Always returns all of [`CA_ENV_KEYS`]: pointing at the bundle when a CA is +/// configured, empty when it is not. The empty case is not cosmetic — see the +/// note on [`CA_ENV_KEYS`]. +pub fn ca_env_vars(resolved: Option<&ResolvedCa>) -> Vec<(&'static str, String)> { + let value = if resolved.is_some() { CA_BUNDLE_PATH } else { "" }; + CA_ENV_KEYS + .iter() + .map(|key| (*key, value.to_string())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + /// A scratch directory that cleans itself up. `tempfile` is not a + /// dependency of this crate and this is the only test that needs one. + struct TempDir(PathBuf); + + impl TempDir { + fn new(tag: &str) -> Self { + let mut p = std::env::temp_dir(); + p.push(format!( + "triple-c-ca-test-{}-{}-{:?}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&p).unwrap(); + TempDir(p) + } + fn path(&self) -> &Path { + &self.0 + } + fn write(&self, name: &str, contents: &str) -> PathBuf { + let p = self.0.join(name); + fs::write(&p, contents).unwrap(); + p + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + // ── container_cert_name ──────────────────────────────────────────────── + + #[test] + fn a_pem_is_renamed_to_crt_not_merely_copied() { + // The whole point: update-ca-certificates globs *.crt and would + // silently ignore corp-root.pem. + assert_eq!(container_cert_name("corp-root.pem"), "corp-root.crt"); + } + + #[test] + fn a_crt_keeps_its_name() { + assert_eq!(container_cert_name("corp-root.crt"), "corp-root.crt"); + } + + #[test] + fn other_certificate_extensions_are_renamed_too() { + assert_eq!(container_cert_name("zscaler.cer"), "zscaler.crt"); + assert_eq!(container_cert_name("zscaler.cert"), "zscaler.crt"); + assert_eq!(container_cert_name("bundle.ca-bundle"), "bundle.crt"); + } + + #[test] + fn an_uppercase_extension_is_lowercased() { + // `find -name '*.crt'` is case-sensitive, so CA.CRT would be ignored. + assert_eq!(container_cert_name("CA.CRT"), "CA.crt"); + assert_eq!(container_cert_name("CA.PEM"), "CA.crt"); + } + + #[test] + fn a_name_without_an_extension_gains_one() { + assert_eq!(container_cert_name("corporate-root"), "corporate-root.crt"); + } + + #[test] + fn only_the_last_extension_is_replaced() { + assert_eq!(container_cert_name("corp.root.ca.pem"), "corp.root.ca.crt"); + } + + #[test] + fn unsafe_characters_are_replaced() { + assert_eq!( + container_cert_name("Corp Root CA (2026).pem"), + "Corp_Root_CA__2026_.crt" + ); + assert_eq!(container_cert_name("a/b.pem"), "a_b.crt"); + } + + #[test] + fn leading_dots_are_stripped_so_the_file_is_not_hidden() { + assert_eq!(container_cert_name(".hidden.pem"), "hidden.crt"); + } + + #[test] + fn a_degenerate_name_still_produces_a_usable_file() { + assert_eq!(container_cert_name(".pem"), "pem.crt"); + assert_eq!(container_cert_name(""), "corporate-ca.crt"); + assert_eq!(container_cert_name("..."), "corporate-ca.crt"); + } + + #[test] + fn every_produced_name_ends_in_lowercase_crt() { + for input in [ + "a.pem", "b.CRT", "c", ".d.pem", "", "e f.cer", "...", "ç.pem", + ] { + let out = container_cert_name(input); + assert!( + out.ends_with(".crt"), + "{:?} produced {:?}, which update-ca-certificates would ignore", + input, + out + ); + assert!( + out.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-'), + "{:?} produced {:?}, which is not shell-safe", + input, + out + ); + } + } + + // ── fingerprint ──────────────────────────────────────────────────────── + + #[test] + fn no_configured_path_fingerprints_as_empty() { + assert_eq!(compute_ca_fingerprint(None), ""); + assert_eq!(compute_ca_fingerprint(Some("")), ""); + assert_eq!(compute_ca_fingerprint(Some(" ")), ""); + } + + #[test] + fn changing_the_path_changes_the_fingerprint() { + let a = TempDir::new("path-a"); + let b = TempDir::new("path-b"); + // Identical *content* in both, so only the path differs. + a.write("corp.pem", "CERT-BODY"); + b.write("corp.pem", "CERT-BODY"); + + let fp_a = compute_ca_fingerprint(Some(a.path().to_str().unwrap())); + let fp_b = compute_ca_fingerprint(Some(b.path().to_str().unwrap())); + assert_ne!(fp_a, ""); + assert_ne!( + fp_a, fp_b, + "two different paths must not share a fingerprint" + ); + } + + #[test] + fn changing_the_certificate_content_at_the_same_path_changes_the_fingerprint() { + // The case a path-only fingerprint would miss: the corporate CA is + // rotated and the new one dropped in at exactly the same location. + let dir = TempDir::new("rotate"); + dir.write("corp.pem", "OLD-CERT"); + let before = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); + + dir.write("corp.pem", "NEW-CERT"); + let after = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); + + assert_ne!( + before, after, + "replacing the certificate at the same path must force a recreation" + ); + } + + #[test] + fn adding_or_removing_a_certificate_changes_the_fingerprint() { + let dir = TempDir::new("add"); + dir.write("one.pem", "A"); + let one = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); + dir.write("two.pem", "B"); + let two = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); + assert_ne!(one, two); + fs::remove_file(dir.path().join("two.pem")).unwrap(); + assert_eq!(compute_ca_fingerprint(Some(dir.path().to_str().unwrap())), one); + } + + #[test] + fn an_unchanged_directory_fingerprints_identically() { + let dir = TempDir::new("stable"); + dir.write("corp.pem", "SAME"); + let a = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); + let b = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); + assert_eq!(a, b, "the fingerprint must not churn on repeated reads"); + } + + #[test] + fn a_missing_path_fingerprints_differently_from_a_present_one() { + let dir = TempDir::new("missing"); + let present = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); + let missing = + compute_ca_fingerprint(Some(&format!("{}-gone", dir.path().to_str().unwrap()))); + assert_ne!(present, missing); + assert_ne!(missing, ""); + } + + #[test] + fn non_certificate_files_in_the_directory_are_ignored() { + let dir = TempDir::new("noise"); + dir.write("corp.pem", "CERT"); + let before = compute_ca_fingerprint(Some(dir.path().to_str().unwrap())); + dir.write("README.md", "hello"); + dir.write("openssl.cnf", "[req]"); + assert_eq!( + compute_ca_fingerprint(Some(dir.path().to_str().unwrap())), + before + ); + } + + // ── resolve ──────────────────────────────────────────────────────────── + + #[test] + fn no_path_resolves_to_nothing() { + assert_eq!(resolve(None).unwrap(), None); + assert_eq!(resolve(Some(" ")).unwrap(), None); + } + + #[test] + fn a_missing_path_is_an_actionable_error() { + let err = resolve(Some("/definitely/not/here/corp.pem")).unwrap_err(); + assert!(err.contains("/definitely/not/here/corp.pem"), "{}", err); + assert!(err.contains("Settings"), "{}", err); + } + + #[test] + fn an_empty_directory_is_an_actionable_error() { + let dir = TempDir::new("empty"); + let err = resolve(Some(dir.path().to_str().unwrap())).unwrap_err(); + assert!(err.contains("no certificate files"), "{}", err); + assert!(err.contains(".pem"), "{}", err); + } + + #[test] + fn a_directory_mounts_at_the_shared_mount_point() { + let dir = TempDir::new("dir"); + dir.write("corp.pem", "CERT"); + let resolved = resolve(Some(dir.path().to_str().unwrap())).unwrap().unwrap(); + assert!(resolved.is_dir); + assert_eq!(resolved.mount_target, CA_MOUNT_DIR); + assert_eq!(resolved.cert_files.len(), 1); + } + + #[test] + fn a_single_file_mounts_under_the_mount_point_with_a_crt_name() { + // Mounting a file *at* /tmp/.host-ca would leave the entrypoint with no + // name to work from, and would make the mount point a file rather than + // the directory the entrypoint expects. + let dir = TempDir::new("file"); + let file = dir.write("corp root.pem", "CERT"); + let resolved = resolve(Some(file.to_str().unwrap())).unwrap().unwrap(); + assert!(!resolved.is_dir); + assert_eq!( + resolved.mount_target, + format!("{}/corp_root.crt", CA_MOUNT_DIR) + ); + } + + #[test] + fn a_file_is_accepted_whatever_its_extension() { + // The user pointed at it explicitly; don't second-guess. + let dir = TempDir::new("odd-ext"); + let file = dir.write("corp.txt", "CERT"); + let resolved = resolve(Some(file.to_str().unwrap())).unwrap().unwrap(); + assert_eq!(resolved.cert_files, vec![file]); + } + + // ── env vars ─────────────────────────────────────────────────────────── + + #[test] + fn configured_ca_points_every_consumer_at_the_bundle() { + let dir = TempDir::new("env"); + dir.write("corp.pem", "CERT"); + let resolved = resolve(Some(dir.path().to_str().unwrap())).unwrap(); + let vars = ca_env_vars(resolved.as_ref()); + assert_eq!( + vars, + vec![ + (NODE_EXTRA_CA_CERTS, CA_BUNDLE_PATH.to_string()), + (REQUESTS_CA_BUNDLE, CA_BUNDLE_PATH.to_string()), + (SSL_CERT_FILE, CA_BUNDLE_PATH.to_string()), + ] + ); + } + + #[test] + fn no_ca_clears_every_var_rather_than_omitting_it() { + // Omitting them would let a value baked into the project's snapshot + // image survive the setting being turned off. + let vars = ca_env_vars(None); + assert_eq!(vars.len(), CA_ENV_KEYS.len()); + assert!(vars.iter().all(|(_, v)| v.is_empty())); + } +} diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 8e8c558..2674cf9 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -7,6 +7,7 @@ use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBi use std::collections::HashMap; use sha2::{Sha256, Digest}; +use super::ca_certs; use super::client::get_docker; use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath}; @@ -792,6 +793,7 @@ pub async fn create_container( timezone: Option<&str>, global_claude_code_settings: Option<&ClaudeCodeSettings>, default_ssh_key_path: Option<&str>, + default_ca_cert_path: Option<&str>, default_git_user_name: Option<&str>, default_git_user_email: Option<&str>, ) -> Result { @@ -1029,6 +1031,34 @@ pub async fn create_container( ); } + // ── Corporate CA certificates ─────────────────────────────────────────── + // Resolved here (rather than down with the mounts) so the env vars land + // *before* the neutralization pass below and are seen as already-set. + // + // A bad path is a hard error, not a warning: behind a TLS-terminating + // proxy a container without the CA fails every HTTPS call — npm, pip, git, + // and Claude Code's own API requests — each in its own confusing way. One + // message naming the path is far kinder. + // + // The values are set here rather than exported by the entrypoint because a + // terminal session is a `docker exec`, which sees the container's + // configured env and nothing the entrypoint exported. Same lesson as + // `$BROWSER` and the URL relay shim. + let effective_ca_path = + resolve_with_global(project.ca_cert_path.as_deref(), default_ca_cert_path); + let resolved_ca = ca_certs::resolve(effective_ca_path)?; + if let Some(ref ca) = resolved_ca { + log::info!( + "Mounting {} corporate CA certificate(s) from {} into project {}", + ca.cert_files.len(), + ca.host_path, + project.id + ); + } + for (key, value) in ca_certs::ca_env_vars(resolved_ca.as_ref()) { + env_vars.push(format!("{}={}", key, value)); + } + // ── Neutralize stale backend auth env vars ────────────────────────────── // When a project switches backends (e.g. Bedrock → Anthropic) the container // is recreated *from a snapshot image* committed off the previous container, @@ -1073,11 +1103,19 @@ pub async fn create_container( // authenticating the container with a credential the user removed. CLAUDE_OAUTH_TOKEN_ENV, ]; + // Same reasoning for the CA vars — `ca_env_vars` already emits them empty + // when no CA is configured, so this list is belt-and-braces for a snapshot + // committed by a build that predates the feature. + let managed_keys: Vec<&str> = MANAGED_AUTH_KEYS + .iter() + .copied() + .chain(ca_certs::CA_ENV_KEYS.iter().copied()) + .collect(); let already_set: std::collections::HashSet = env_vars .iter() .filter_map(|e| e.split('=').next().map(|k| k.to_string())) .collect(); - for key in MANAGED_AUTH_KEYS { + for key in &managed_keys { if !already_set.contains(*key) { env_vars.push(format!("{}=", key)); } @@ -1209,6 +1247,23 @@ pub async fn create_container( }); } + // Corporate CA certificates mount (read-only staging; the entrypoint copies + // them into /usr/local/share/ca-certificates with a `.crt` name and runs + // update-ca-certificates). Mirrors /tmp/.host-ssh and /tmp/.host-aws. + // + // A directory mounts at /tmp/.host-ca; a single file mounts at + // /tmp/.host-ca/.crt so the entrypoint always sees a directory and + // the certificate keeps a recognisable name. Docker creates the parent. + if let Some(ref ca) = resolved_ca { + mounts.push(Mount { + target: Some(ca.mount_target.clone()), + source: Some(ca.host_path.clone()), + typ: Some(MountTypeEnum::BIND), + read_only: Some(true), + ..Default::default() + }); + } + // AWS config mount (read-only) // Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set let should_mount_aws = if project.backend == Backend::Bedrock { @@ -1306,6 +1361,13 @@ pub async fn create_container( compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled)); labels.insert("triple-c.instructions-fingerprint".to_string(), combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default()); + // Written unconditionally, even when empty — `container_needs_recreation` + // is label-based and never diffs env or mounts, so without this a changed + // CA path would silently do nothing until some unrelated setting forced a + // rebuild. The fingerprint covers the certificate *bytes* as well as the + // path, so swapping a rotated CA in at the same location is caught too. + labels.insert("triple-c.ca-fingerprint".to_string(), + ca_certs::compute_ca_fingerprint(effective_ca_path)); labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string()); labels.insert("triple-c.git-user-email".to_string(), effective_git_email.unwrap_or_default().to_string()); labels.insert("triple-c.git-token-hash".to_string(), @@ -1911,6 +1973,7 @@ pub async fn container_needs_recreation( timezone: Option<&str>, global_claude_code_settings: Option<&ClaudeCodeSettings>, default_ssh_key_path: Option<&str>, + default_ca_cert_path: Option<&str>, default_git_user_name: Option<&str>, default_git_user_email: Option<&str>, ) -> Result { @@ -2078,6 +2141,28 @@ pub async fn container_needs_recreation( return Ok(true); } + // ── Corporate CA certificates ──────────────────────────────────────── + // Both the resolved path and the certificate contents, so replacing a + // rotated CA at the same path recreates the container — the copy inside + // the container is made once, at start, and nothing else would notice. + // + // A container predating this feature has no label, i.e. "", which is also + // what an unconfigured CA fingerprints as — so existing installs are not + // churned until a CA is actually set. + let expected_ca_fp = ca_certs::compute_ca_fingerprint(resolve_with_global( + project.ca_cert_path.as_deref(), + default_ca_cert_path, + )); + let container_ca_fp = get_label("triple-c.ca-fingerprint").unwrap_or_default(); + if container_ca_fp != expected_ca_fp { + log::info!( + "Corporate CA certificate mismatch (container={:?}, expected={:?})", + container_ca_fp, + expected_ca_fp + ); + return Ok(true); + } + // ── Git settings (label-based to avoid stale snapshot env vars) ───── let expected_git_name = project.git_user_name.as_deref() .or(default_git_user_name) diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index a0e0ad7..97113fe 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -1,3 +1,4 @@ +pub mod ca_certs; pub mod client; pub mod container; pub mod image; @@ -23,3 +24,6 @@ pub use exec::*; pub use legacy_cleanup::*; #[allow(unused_imports)] pub use migration::*; +// Deliberately *not* re-exported flat: `ca_certs::resolve` and +// `ca_certs::CA_MOUNT_DIR` are far clearer than bare `resolve` in a module that +// already re-exports five other namespaces. diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 281b360..d442ede 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -439,6 +439,7 @@ pub fn run() { commands::settings_commands::update_settings, commands::settings_commands::pull_image, commands::settings_commands::detect_aws_config, + commands::settings_commands::inspect_ca_cert_path, commands::settings_commands::list_aws_profiles, commands::settings_commands::detect_host_timezone, // Terminal diff --git a/app/src-tauri/src/models/app_settings.rs b/app/src-tauri/src/models/app_settings.rs index 25b0e08..a5bcfb8 100644 --- a/app/src-tauri/src/models/app_settings.rs +++ b/app/src-tauri/src/models/app_settings.rs @@ -87,6 +87,14 @@ pub struct GlobalOpenAiCompatibleSettings { pub struct AppSettings { #[serde(default)] pub default_ssh_key_path: Option, + /// Path to the organisation's root CA — a single certificate file or a + /// directory of them. Mounted read-only into every container, which then + /// installs it into the system trust store, Node's `NODE_EXTRA_CA_CERTS`, + /// Python's `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE` and Chrome's NSS database. + /// Required when the host sits behind a TLS-terminating corporate proxy. + /// Overridden per project by `Project::ca_cert_path`. + #[serde(default)] + pub ca_cert_path: Option, #[serde(default)] pub default_git_user_name: Option, #[serde(default)] @@ -197,6 +205,7 @@ impl Default for AppSettings { fn default() -> Self { Self { default_ssh_key_path: None, + ca_cert_path: None, default_git_user_name: None, default_git_user_email: None, docker_socket_path: None, diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index c292aac..090526b 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -166,6 +166,13 @@ pub struct Project { #[serde(default)] pub permission_mode: Option, pub ssh_key_path: Option, + /// Per-project override for the corporate CA certificate path (file or + /// directory). Blank falls back to `AppSettings::ca_cert_path`. + /// + /// `#[serde(default)]` rather than a required field: every project stored + /// before this existed must keep loading. + #[serde(default)] + pub ca_cert_path: Option, #[serde(skip_serializing, default)] pub git_token: Option, pub git_user_name: Option, @@ -363,6 +370,7 @@ impl Project { full_permissions: false, permission_mode: None, ssh_key_path: None, + ca_cert_path: None, git_token: None, git_user_name: None, git_user_email: None, diff --git a/app/src/components/projects/home/config/AccessSection.tsx b/app/src/components/projects/home/config/AccessSection.tsx index 255b3dc..a499910 100644 --- a/app/src/components/projects/home/config/AccessSection.tsx +++ b/app/src/components/projects/home/config/AccessSection.tsx @@ -3,6 +3,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import type { Project } from "../../../../lib/types"; import Button from "../../../ui/Button"; import Field, { ConfigGroup, inputClass } from "../../../ui/Field"; +import CaCertPathInput from "../../../settings/CaCertPathInput"; import EnvVarsEditor from "../../EnvVarsEditor"; import PortMappingsEditor from "../../PortMappingsEditor"; @@ -20,12 +21,14 @@ export default function AccessSection({ disabledReason, }: Props) { const [sshKeyPath, setSshKeyPath] = useState(project.ssh_key_path ?? ""); + const [caCertPath, setCaCertPath] = useState(project.ca_cert_path ?? ""); const [gitName, setGitName] = useState(project.git_user_name ?? ""); const [gitEmail, setGitEmail] = useState(project.git_user_email ?? ""); const [gitToken, setGitToken] = useState(project.git_token ?? ""); useEffect(() => { setSshKeyPath(project.ssh_key_path ?? ""); + setCaCertPath(project.ca_cert_path ?? ""); setGitName(project.git_user_name ?? ""); setGitEmail(project.git_user_email ?? ""); setGitToken(project.git_token ?? ""); @@ -114,6 +117,24 @@ export default function AccessSection({ )} + + {(id) => ( + save({ ca_cert_path: value.trim() || null })} + disabled={disabled} + placeholder="/etc/ssl/certs/corp-root.pem" + emptyHint="Using the global certificate from Settings → Certificates." + inputClassName={`${inputClass} min-w-0`} + /> + )} + +
Environment variables diff --git a/app/src/components/settings/CaCertPathInput.test.tsx b/app/src/components/settings/CaCertPathInput.test.tsx new file mode 100644 index 0000000..a42c289 --- /dev/null +++ b/app/src/components/settings/CaCertPathInput.test.tsx @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import CaCertPathInput from "./CaCertPathInput"; +import type { CaCertInfo } from "../../lib/types"; + +const inspectCaCertPath = vi.fn(); +vi.mock("../../lib/tauri-commands", () => ({ + inspectCaCertPath: (path: string) => inspectCaCertPath(path), +})); + +const openDialog = vi.fn(); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: (opts: unknown) => openDialog(opts), +})); + +const info = (over: Partial = {}): CaCertInfo => ({ + exists: true, + is_directory: false, + cert_count: 1, + installed_names: ["corp-root.crt"], + error: null, + ...over, +}); + +function renderInput(value = "", over: Partial[0]> = {}) { + const onChange = vi.fn(); + const onCommit = vi.fn(); + const utils = render( + , + ); + return { onChange, onCommit, ...utils }; +} + +describe("CaCertPathInput", () => { + beforeEach(() => { + vi.clearAllMocks(); + inspectCaCertPath.mockResolvedValue(info()); + }); + + it("does not inspect anything while the path is empty", async () => { + renderInput(""); + await new Promise((r) => setTimeout(r, 350)); + expect(inspectCaCertPath).not.toHaveBeenCalled(); + }); + + it("shows the empty hint instead of a status when unset", () => { + renderInput("", { emptyHint: "Using the global certificate." }); + expect(screen.getByText("Using the global certificate.")).toBeTruthy(); + }); + + it("reports the certificate count and the names they are installed as", async () => { + // The rename is the whole point: update-ca-certificates ignores a .pem. + inspectCaCertPath.mockResolvedValue( + info({ cert_count: 2, installed_names: ["corp-root.crt", "corp-intermediate.crt"] }), + ); + renderInput("/certs"); + await waitFor(() => expect(screen.getByText(/Found 2 certificates/)).toBeTruthy()); + expect(screen.getByText(/corp-root\.crt, corp-intermediate\.crt/)).toBeTruthy(); + }); + + it("uses the singular for one certificate", async () => { + renderInput("/certs/corp.pem"); + await waitFor(() => expect(screen.getByText(/Found 1 certificate$|Found 1 certificate/)).toBeTruthy()); + expect(screen.queryByText(/Found 1 certificates/)).toBeNull(); + }); + + it("surfaces an unusable path inline rather than silently accepting it", async () => { + inspectCaCertPath.mockResolvedValue( + info({ exists: false, cert_count: 0, installed_names: [], error: "path does not exist" }), + ); + renderInput("/gone"); + await waitFor(() => expect(screen.getByText(/path does not exist/)).toBeTruthy()); + }); + + it("commits on blur", () => { + const { onCommit } = renderInput("/certs"); + fireEvent.blur(screen.getByRole("textbox")); + expect(onCommit).toHaveBeenCalledWith("/certs"); + }); + + it("offers both a file and a folder picker, because the setting accepts either", async () => { + openDialog.mockResolvedValue("/picked/corp.pem"); + const { onChange, onCommit } = renderInput(""); + + fireEvent.click(screen.getByText("File…")); + await waitFor(() => expect(onCommit).toHaveBeenCalledWith("/picked/corp.pem")); + expect(openDialog).toHaveBeenCalledWith({ directory: false, multiple: false }); + + openDialog.mockResolvedValue("/picked/certs"); + fireEvent.click(screen.getByText("Folder…")); + await waitFor(() => expect(openDialog).toHaveBeenLastCalledWith({ directory: true, multiple: false })); + expect(onChange).toHaveBeenCalledWith("/picked/certs"); + }); + + it("does not commit when the picker is dismissed", async () => { + openDialog.mockResolvedValue(null); + const { onCommit } = renderInput(""); + fireEvent.click(screen.getByText("Folder…")); + await new Promise((r) => setTimeout(r, 0)); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("disables the inputs when the container is running", () => { + renderInput("/certs", { disabled: true }); + expect((screen.getByRole("textbox") as HTMLInputElement).disabled).toBe(true); + for (const label of ["File…", "Folder…"]) { + expect((screen.getByText(label) as HTMLButtonElement).disabled).toBe(true); + } + }); +}); diff --git a/app/src/components/settings/CaCertPathInput.tsx b/app/src/components/settings/CaCertPathInput.tsx new file mode 100644 index 0000000..9c7eff4 --- /dev/null +++ b/app/src/components/settings/CaCertPathInput.tsx @@ -0,0 +1,147 @@ +import { useEffect, useRef, useState } from "react"; +import { open } from "@tauri-apps/plugin-dialog"; +import Button from "../ui/Button"; +import { inspectCaCertPath } from "../../lib/tauri-commands"; +import type { CaCertInfo } from "../../lib/types"; + +interface Props { + /** Wired to the calling `Field`'s label, where there is one. */ + id?: string; + value: string; + onChange: (value: string) => void; + /** Persist the value — called on blur and immediately after a Browse. */ + onCommit: (value: string) => void; + disabled?: boolean; + placeholder?: string; + /** Shown in place of the status line while the field is empty. */ + emptyHint?: string; + /** Tailwind classes for the text input, so each caller keeps its local + * convention (the host settings panel and the project Config tab do not + * style their inputs the same way). */ + inputClassName: string; +} + +/** + * Path field for a corporate CA certificate — a single file *or* a directory + * of them — shared by the global setting and the per-project override. + * + * Two Browse buttons rather than one: the platform file dialog cannot offer + * "a file or a folder" in a single call, and which one the user wants is not + * guessable (a lone `corp-root.pem` is as common as a folder of chained certs). + * + * The status line is what makes the feature debuggable. It reports the + * certificate count and, crucially, the `.crt` names each file is installed + * as: `update-ca-certificates` matches `*.crt` case-sensitively and ignores a + * `.pem` in complete silence, so seeing `corp-root.pem → corp-root.crt` is the + * difference between trusting the setting and guessing at it. + */ +export default function CaCertPathInput({ + id, + value, + onChange, + onCommit, + disabled = false, + placeholder, + emptyHint, + inputClassName, +}: Props) { + const [info, setInfo] = useState(null); + // Guards against a slow inspect for an earlier value landing after a newer + // one and describing the wrong path. + const requestId = useRef(0); + + useEffect(() => { + const trimmed = value.trim(); + if (!trimmed) { + setInfo(null); + return; + } + const id = ++requestId.current; + const timer = setTimeout(() => { + inspectCaCertPath(trimmed) + .then((result) => { + if (requestId.current === id) setInfo(result); + }) + .catch(() => { + if (requestId.current === id) setInfo(null); + }); + }, 250); + return () => clearTimeout(timer); + }, [value]); + + const browse = async (directory: boolean) => { + const selected = await open({ directory, multiple: false }); + if (typeof selected === "string") { + onChange(selected); + onCommit(selected); + } + }; + + return ( +
+
+ onChange(e.target.value)} + onBlur={() => onCommit(value)} + placeholder={placeholder} + disabled={disabled} + className={inputClassName} + /> + + +
+ +
+ ); +} + +function CaCertStatus({ + value, + info, + emptyHint, +}: { + value: string; + info: CaCertInfo | null; + emptyHint?: string; +}) { + if (!value.trim()) { + return emptyHint ? ( +

{emptyHint}

+ ) : null; + } + if (!info) return null; + + if (info.error) { + // Glyph + word, never colour alone. + return ( +

+ + Problem: {info.error} +

+ ); + } + if (info.cert_count === 0) return null; + + return ( +

+ + Found {info.cert_count} certificate{info.cert_count === 1 ? "" : "s"} + {info.installed_names.length > 0 && ( + + {" "} + — installed as {info.installed_names.slice(0, 4).join(", ")} + {info.installed_names.length > 4 + ? ` and ${info.installed_names.length - 4} more` + : ""} + + )} +

+ ); +} diff --git a/app/src/components/settings/CertificateSettings.tsx b/app/src/components/settings/CertificateSettings.tsx new file mode 100644 index 0000000..df6e936 --- /dev/null +++ b/app/src/components/settings/CertificateSettings.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from "react"; +import { useSettings } from "../../hooks/useSettings"; +import CaCertPathInput from "./CaCertPathInput"; + +const INPUT_CLASS = + "flex-1 min-w-0 px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:border-[var(--accent)]"; + +/** + * Global corporate CA certificate setting. + * + * Applies to every project unless one overrides it in Project Home → Config → + * Access. Changing it recreates each container on its next start — the + * certificate is copied into the container's trust store once, at start, so + * there is nowhere else for a change to land. + */ +export default function CertificateSettings() { + const { appSettings, saveSettings } = useSettings(); + const [path, setPath] = useState(appSettings?.ca_cert_path ?? ""); + + useEffect(() => { + setPath(appSettings?.ca_cert_path ?? ""); + }, [appSettings?.ca_cert_path]); + + const commit = async (value: string) => { + if (!appSettings) return; + const next = value.trim() || null; + if (next === appSettings.ca_cert_path) return; + await saveSettings({ ...appSettings, ca_cert_path: next }); + }; + + return ( +
+ +

+ A certificate file, or a folder of them, for organisations whose network + inspects TLS. Mounted read-only into every container and trusted by + curl, git, npm, pip, Chromium and Claude Code itself. Per-project + settings override this; changing it recreates containers on next start. +

+ +
+ ); +} diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index c5e325f..062a9f4 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -18,6 +18,7 @@ import Toggle from "../ui/Toggle"; import WebTerminalSettings from "./WebTerminalSettings"; import SttSettings from "./SttSettings"; import SharedAuthSettings from "./SharedAuthSettings"; +import CertificateSettings from "./CertificateSettings"; export default function SettingsPanel() { const { appSettings, saveSettings } = useSettings(); @@ -172,6 +173,10 @@ export default function SettingsPanel() { + + + + {/* Default SSH Key Directory */}
diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 89de993..d02013a 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -37,6 +37,10 @@ export const detectAwsConfig = () => invoke("detect_aws_config"); export const listAwsProfiles = () => invoke("list_aws_profiles"); +/** Check a corporate CA path and report what would be installed. Never + * rejects for a bad path — the reason comes back in `error`. */ +export const inspectCaCertPath = (path: string) => + invoke("inspect_ca_cert_path", { path }); export const detectHostTimezone = () => invoke("detect_host_timezone"); diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 485172e..524939c 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -44,6 +44,10 @@ export interface Project { /** null = not set → falls back to `full_permissions` (true → "bypass"). */ permission_mode: PermissionMode | null; ssh_key_path: string | null; + /** Per-project override for the corporate CA certificate path (a single + * certificate file or a directory of them). null falls back to + * `AppSettings.ca_cert_path`. Changing it recreates the container. */ + ca_cert_path: string | null; git_token: string | null; git_user_name: string | null; git_user_email: string | null; @@ -190,6 +194,12 @@ export interface GlobalOpenAiCompatibleSettings { export interface AppSettings { default_ssh_key_path: string | null; + /** Corporate root CA — a single certificate file or a directory of them — + * mounted read-only into every container and installed into the system + * trust store, Node's `NODE_EXTRA_CA_CERTS`, Python's + * `REQUESTS_CA_BUNDLE`/`SSL_CERT_FILE` and Chrome's NSS database. + * Needed when the host is behind a TLS-terminating corporate proxy. */ + ca_cert_path: string | null; default_git_user_name: string | null; default_git_user_email: string | null; docker_socket_path: string | null; @@ -212,6 +222,20 @@ export interface AppSettings { global_claude_code_settings: ClaudeCodeSettings | null; } +/** What `inspect_ca_cert_path` reports about a corporate CA path. Errors ride + * in the payload rather than rejecting, so the field can render them inline + * while the user is still typing. */ +export interface CaCertInfo { + exists: boolean; + is_directory: boolean; + cert_count: number; + /** The `.crt` names the certificates are installed as inside the container — + * surfacing the silent `.pem` → `.crt` rename that + * `update-ca-certificates` requires. */ + installed_names: string[]; + error: string | null; +} + export interface SttSettings { enabled: boolean; model: string; diff --git a/container/Dockerfile b/container/Dockerfile index dabe5b2..9a52eb7 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -25,6 +25,7 @@ RUN for i in 1 2 3 4 5; do \ jq \ sudo \ ca-certificates \ + libnss3-tools \ gnupg \ locales \ unzip \ @@ -35,6 +36,12 @@ RUN for i in 1 2 3 4 5; do \ socat \ && rm -rf /var/lib/apt/lists/* +# `libnss3-tools` above provides `certutil`. Chrome/Chromium read neither +# /etc/ssl/certs nor $SSL_CERT_FILE — they have their own NSS database at +# ~/.pki/nssdb — so without it the browser-view pane cannot be made to trust a +# corporate CA, no matter what the system trust store says. entrypoint.sh +# degrades to a warning if it is ever missing. + # Remove default ubuntu user to free UID 1000 for host-user remapping RUN if id ubuntu >/dev/null 2>&1; then userdel -r ubuntu 2>/dev/null || userdel ubuntu; fi \ && if getent group ubuntu >/dev/null 2>&1; then groupdel ubuntu 2>/dev/null || true; fi diff --git a/container/entrypoint.sh b/container/entrypoint.sh index e7ce594..1ce3701 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -58,6 +58,167 @@ remap_uid_gid # Fix ownership of home directory after UID/GID change chown -R claude:claude /home/claude +# ── Corporate CA certificates ─────────────────────────────────────────────── +# The host's CA material is bind-mounted read-only at /tmp/.host-ca. Triple-C +# mounts a *directory* as-is and a *single file* as /tmp/.host-ca/.crt, +# so this only ever has to deal with a directory (the file branch below is +# defensive). +# +# Runs before everything that touches the network — the git credential helper, +# ssh-keyscan, and especially the `claude update` at the bottom of this file, +# which is itself an HTTPS call that fails behind a TLS-terminating proxy +# without this. +# +# Two things are easy to get wrong here: +# 1. `update-ca-certificates` globs /usr/local/share/ca-certificates/*.crt +# case-sensitively. A `.pem` that is merely copied in is ignored in total +# silence, so certificates are *renamed*, not copied. +# 2. Chrome/Chromium read neither /etc/ssl nor $SSL_CERT_FILE; they have +# their own NSS database at ~/.pki/nssdb, seeded below with certutil. +# +# NODE_EXTRA_CA_CERTS / REQUESTS_CA_BUNDLE / SSL_CERT_FILE are deliberately NOT +# exported here. Every terminal is a separate `docker exec`, which inherits the +# container's configured env and sees nothing this script exported — the same +# reason $BROWSER had to become an image-level ENV. Triple-C sets them on the +# container at creation time instead. (They are forwarded into the cron +# environment file further down, because cron jobs start from a bare env.) +CA_SRC="/tmp/.host-ca" +CA_STORE="/usr/local/share/ca-certificates" +CA_PREFIX="triple-c-" +CA_BUNDLE="/etc/ssl/certs/ca-certificates.crt" +CA_STAMP="/var/lib/triple-c/ca.stamp" +CA_NSSDB="/home/claude/.pki/nssdb" + +# Mirror of `container_cert_name()` in app/src-tauri/src/docker/ca_certs.rs. +# The two must agree; the Rust side has the unit tests. +ca_normalise_name() { + local name stem + name=$(printf '%s' "$1" | tr -c 'A-Za-z0-9._-' '_') + while [ "${name#.}" != "$name" ]; do name="${name#.}"; done + stem="${name%.*}" + [ -z "$stem" ] && stem="corporate-ca" + printf '%s.crt' "$stem" +} + +ca_source_files() { + if [ -d "$CA_SRC" ]; then + find "$CA_SRC" -maxdepth 1 -type f \ + \( -iname '*.crt' -o -iname '*.pem' -o -iname '*.cer' \ + -o -iname '*.cert' -o -iname '*.ca-bundle' \) 2>/dev/null | sort + elif [ -f "$CA_SRC" ]; then + printf '%s\n' "$CA_SRC" + fi +} + +# Seed Chrome/Chromium's NSS database. Tolerant by design: a missing certutil +# or a broken profile must warn, never fail the container start. +# ~/.pki lives in the home volume, so this persists once done; the system store +# lives in the writable layer and is re-applied on every start. +ca_seed_nssdb() { + if ! command -v certutil >/dev/null 2>&1; then + echo "entrypoint: warning — certutil not found (install libnss3-tools); Chrome/Chromium in this container will not trust the corporate CA" + return 0 + fi + su -s /bin/bash claude -c ' + db="$HOME/.pki/nssdb" + mkdir -p "$db" || exit 1 + if [ ! -f "$db/cert9.db" ]; then + certutil -d "sql:$db" -N --empty-password >/dev/null 2>&1 || exit 1 + fi + for f in /usr/local/share/ca-certificates/triple-c-*.crt; do + [ -f "$f" ] || continue + nick="triple-c:$(basename "$f" .crt)" + # Delete first so re-running replaces rather than duplicates. + certutil -d "sql:$db" -D -n "$nick" >/dev/null 2>&1 + certutil -d "sql:$db" -A -t "C,," -n "$nick" -i "$f" >/dev/null 2>&1 \ + || echo "entrypoint: warning — certutil could not add $nick" + done + ' && echo "entrypoint: seeded Chrome/Chromium NSS database with the corporate CA" \ + || echo "entrypoint: warning — NSS database seeding failed (continuing)" +} + +install_corporate_ca() { + local files fp stamp f base name count installed + + files=$(ca_source_files) + + if [ -z "$files" ]; then + # Nothing configured — but /usr/local/share is in the writable layer and + # `docker commit` bakes it into the project's snapshot image, so a cert + # installed by a previous configuration would ride that snapshot into + # every future container. Turning the setting off has to actively undo. + if ls "$CA_STORE/$CA_PREFIX"*.crt >/dev/null 2>&1; then + echo "entrypoint: removing previously installed corporate CA certificates" + rm -f "$CA_STORE/$CA_PREFIX"*.crt + update-ca-certificates --fresh >/dev/null 2>&1 \ + || echo "entrypoint: warning — update-ca-certificates failed while removing certificates" + rm -f "$CA_STAMP" + fi + if [ -e "$CA_SRC" ]; then + echo "entrypoint: warning — $CA_SRC holds no certificate files" + fi + return 0 + fi + + # Idempotent and cheap: the certs are already installed on a plain restart + # (the writable layer survives stop/start), so hash the sources and skip the + # work when nothing has moved. The NSS database is checked separately + # because it lives in the home volume and can be wiped independently. + fp=$(printf '%s\n' "$files" | xargs -d '\n' -r sha256sum 2>/dev/null | sha256sum | cut -d' ' -f1) + stamp=$(cat "$CA_STAMP" 2>/dev/null) + if [ -n "$fp" ] && [ "$fp" = "$stamp" ] && [ -s "$CA_BUNDLE" ]; then + if [ -f "$CA_NSSDB/cert9.db" ]; then + echo "entrypoint: corporate CA certificates already installed" + return 0 + fi + ca_seed_nssdb + return 0 + fi + + mkdir -p "$CA_STORE" "$(dirname "$CA_STAMP")" + rm -f "$CA_STORE/$CA_PREFIX"*.crt + installed=0 + + while IFS= read -r f; do + [ -n "$f" ] || continue + base=$(basename "$f") + name="$CA_PREFIX$(ca_normalise_name "$base")" + count=$(grep -c -- '-----BEGIN CERTIFICATE-----' "$f" 2>/dev/null || true) + [ -n "$count" ] || count=0 + if [ "$count" -gt 1 ]; then + # A corporate trust chain is usually delivered as one PEM holding + # root + intermediates. update-ca-certificates handles exactly one + # certificate per file, so split it. + awk -v out="$CA_STORE/${name%.crt}" ' + /-----BEGIN CERTIFICATE-----/ { n++; f = out "-" n ".crt" } + n > 0 { print > f } + ' "$f" && installed=$((installed + count)) + elif [ "$count" -eq 1 ]; then + cp -f "$f" "$CA_STORE/$name" && installed=$((installed + 1)) + else + echo "entrypoint: warning — $f holds no PEM certificate (DER is not supported), skipping" + fi + done <<< "$files" + + chmod 644 "$CA_STORE/$CA_PREFIX"*.crt 2>/dev/null + + if [ "$installed" -eq 0 ]; then + echo "entrypoint: warning — no usable certificates found under $CA_SRC" + return 0 + fi + + if update-ca-certificates >/dev/null 2>&1; then + echo "entrypoint: installed $installed corporate CA certificate(s) into the system trust store" + printf '%s' "$fp" > "$CA_STAMP" + else + echo "entrypoint: warning — update-ca-certificates failed; corporate certificates may not be trusted" + fi + + ca_seed_nssdb +} + +install_corporate_ca + # ── SSH key setup ────────────────────────────────────────────────────────── # Host SSH dir is mounted read-only at /tmp/.host-ssh. # Copy to /home/claude/.ssh so we can fix permissions. @@ -277,7 +438,7 @@ ENV_FILE="$SCHEDULER_DIR/.env" : > "$ENV_FILE" env | while IFS='=' read -r key value; do case "$key" in - ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER) + ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|TRIPLE_C_PERMISSION_MODE|PATH|HOME|LANG|TZ|COLORTERM|BROWSER|NODE_EXTRA_CA_CERTS|REQUESTS_CA_BUNDLE|SSL_CERT_FILE) # Escape single quotes in value and write as KEY='VALUE' escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g") printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"