Inject the corporate CA certificate into containers
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Build App / compute-version (pull_request) Successful in 6s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m16s
Build Container / build-container (pull_request) Successful in 10m15s
Build App / build-linux (pull_request) Successful in 6m35s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Behind a TLS-terminating corporate proxy every HTTPS call inside a container fails — npm, pip, git, curl, the browser-view pane, and Claude Code's own API requests. There was no mechanism at all: installing the certificate by hand inside a container is lost on Reset and had to be repeated per project. A global CA path in AppSettings with a per-project override on Project, taking either a single certificate file or a directory. It is bind-mounted read-only at /tmp/.host-ca (mirroring /tmp/.host-ssh and /tmp/.host-aws) and applied by entrypoint.sh on every start, so it survives recreation, migration and Reset. Four things this gets right that are easy to get wrong: * update-ca-certificates globs *.crt case-sensitively, so a .pem that is merely copied in is ignored in silence. Certificates are renamed, by container_cert_name() in Rust and a mirrored few lines of shell. * The system store only serves curl/git/apt. Node — and so Claude Code itself — needs NODE_EXTRA_CA_CERTS, Python needs REQUESTS_CA_BUNDLE/SSL_CERT_FILE, and Chromium reads neither: it wants ~/.pki/nssdb, seeded with certutil (libnss3-tools, added to the image). * Those vars are set from Rust at creation, never exported by the entrypoint — a terminal is a docker exec and sees nothing the entrypoint exported. They are emitted empty when no CA is configured, since docker commit bakes env into the snapshot image. * triple-c.ca-fingerprint hashes the certificate bytes as well as the path, so a CA rotated in at the same location still forces a recreation. Verified end to end against a real container and a self-signed CA: curl, node, python and git all complete a TLS handshake against a server signed by it and all three fail in the same container without it; the env vars are visible from a docker exec session; the store is cleaned when the setting is cleared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
This commit is contained in:
@@ -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({
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Corporate CA certificate"
|
||||
hint="Overrides the global certificate for this project only. A certificate file, or a folder of them, trusted inside the container by curl, git, npm, pip, Chromium and Claude Code."
|
||||
>
|
||||
{(id) => (
|
||||
<CaCertPathInput
|
||||
id={id}
|
||||
value={caCertPath}
|
||||
onChange={setCaCertPath}
|
||||
onCommit={(value) => 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`}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div className="pt-2 border-t border-[var(--border-color)]">
|
||||
<span className="block text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Environment variables
|
||||
|
||||
@@ -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> = {}): CaCertInfo => ({
|
||||
exists: true,
|
||||
is_directory: false,
|
||||
cert_count: 1,
|
||||
installed_names: ["corp-root.crt"],
|
||||
error: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
function renderInput(value = "", over: Partial<Parameters<typeof CaCertPathInput>[0]> = {}) {
|
||||
const onChange = vi.fn();
|
||||
const onCommit = vi.fn();
|
||||
const utils = render(
|
||||
<CaCertPathInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onCommit={onCommit}
|
||||
inputClassName="input"
|
||||
{...over}
|
||||
/>,
|
||||
);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<CaCertInfo | null>(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 (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onBlur={() => onCommit(value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={inputClassName}
|
||||
/>
|
||||
<Button size="md" disabled={disabled} onClick={() => browse(false)}>
|
||||
File…
|
||||
</Button>
|
||||
<Button size="md" disabled={disabled} onClick={() => browse(true)}>
|
||||
Folder…
|
||||
</Button>
|
||||
</div>
|
||||
<CaCertStatus value={value} info={info} emptyHint={emptyHint} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CaCertStatus({
|
||||
value,
|
||||
info,
|
||||
emptyHint,
|
||||
}: {
|
||||
value: string;
|
||||
info: CaCertInfo | null;
|
||||
emptyHint?: string;
|
||||
}) {
|
||||
if (!value.trim()) {
|
||||
return emptyHint ? (
|
||||
<p className="text-xs text-[var(--text-secondary)]">{emptyHint}</p>
|
||||
) : null;
|
||||
}
|
||||
if (!info) return null;
|
||||
|
||||
if (info.error) {
|
||||
// Glyph + word, never colour alone.
|
||||
return (
|
||||
<p className="text-xs text-[var(--error)]" role="status">
|
||||
<span aria-hidden="true">✕ </span>
|
||||
Problem: {info.error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (info.cert_count === 0) return null;
|
||||
|
||||
return (
|
||||
<p className="text-xs text-[var(--success)]" role="status">
|
||||
<span aria-hidden="true">✓ </span>
|
||||
Found {info.cert_count} certificate{info.cert_count === 1 ? "" : "s"}
|
||||
{info.installed_names.length > 0 && (
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
{" "}
|
||||
— installed as {info.installed_names.slice(0, 4).join(", ")}
|
||||
{info.installed_names.length > 4
|
||||
? ` and ${info.installed_names.length - 4} more`
|
||||
: ""}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
htmlFor="global-ca-cert-path"
|
||||
>
|
||||
Corporate CA Certificate
|
||||
</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
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.
|
||||
</p>
|
||||
<CaCertPathInput
|
||||
id="global-ca-cert-path"
|
||||
value={path}
|
||||
onChange={setPath}
|
||||
onCommit={commit}
|
||||
placeholder="/etc/ssl/certs/corp-root.pem"
|
||||
emptyHint="Not set — containers trust only the public CAs shipped with the image."
|
||||
inputClassName={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<DockerSettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
|
||||
<CertificateSettings />
|
||||
</AccordionSection>
|
||||
|
||||
<AccordionSection id="git-ssh" title="Git / SSH" defaultOpen={false}>
|
||||
{/* Default SSH Key Directory */}
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user