Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 179 additions & 4 deletions src/rootcell/rootcell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,100 @@ describe("VM and network providers", () => {
]);
});

test("app lifecycle repairs legacy MITM CA certificates and reprovisions VMs", async () => {
const repo = makeInstanceRepo();
const buildSpyArtifacts = vi.spyOn(
RootcellApp.prototype as unknown as { buildSpyArtifacts: () => void },
"buildSpyArtifacts",
).mockImplementation(() => undefined);
try {
const env = instanceEnv(repo);
const config = buildConfig(repo, env, fakeInstance("dev", repo, env));
mkdirSync(config.generatedDir, { recursive: true });
mkdirSync(config.proxyDir, { recursive: true });
for (const file of ["allowed-https.txt", "allowed-ssh.txt", "allowed-dns.txt"]) {
writeFileSync(join(config.proxyDir, file), "\n", "utf8");
}
writeLegacyAgentCa(config.pkiDir);
const crt = join(config.pkiDir, "agent-vm-ca-cert.pem");
expect(certText(crt)).not.toContain("X509v3 Subject Key Identifier");

const calls: string[] = [];
const copiedGuestPaths: string[] = [];
const attachment: VmNetworkAttachment = { kind: "fake" };
const providers: ProviderBundle = {
network: {
id: "fake-network",
plan: () => ({
provider: "fake-network",
guest: {
firewallIp: config.firewallIp,
agentIp: config.agentIp,
networkPrefix: 24,
agentPrivateInterface: "agent0",
firewallPrivateInterface: "firewall0",
firewallEgressInterface: "egress0",
},
vms: {
agent: attachment,
firewall: attachment,
},
}),
preflight: () => Promise.resolve(),
stop: () => Promise.resolve(),
remove: () => Promise.resolve(),
ensureReady: () => Promise.resolve(),
},
vm: {
id: "fake-vm",
status: () => Promise.resolve({ state: "running" }),
stopIfRunning: () => Promise.resolve(),
forceStopIfRunning: () => Promise.resolve(),
remove: () => Promise.resolve(),
assertCompatible: () => Promise.resolve(),
ensureRunning: (input) => {
calls.push(`ensure:${input.role}:${input.name}`);
return Promise.resolve({ created: false });
},
exec: (name, args) => {
calls.push(`exec:${name}:${args.join(" ")}`);
return Promise.resolve({ status: 0 });
},
execCapture: () => Promise.resolve({ status: 0, stdout: "", stderr: "" }),
execInteractive: (name) => {
calls.push(`interactive:${name}`);
return Promise.resolve(0);
},
copyToGuest: (_name, _hostPath, guestPath) => {
copiedGuestPaths.push(guestPath);
return Promise.resolve();
},
forwardLocalPort: (_name, options) => Promise.resolve({
...options,
closed: Promise.resolve(0),
close: () => Promise.resolve(),
}),
},
secrets: new StaticSecretProviderRegistry([]),
};

const status = await new RootcellApp(config, providers).runAfterEnvironment("", [], { open: true });

expect(status).toBe(0);
const updatedCert = certText(crt);
expect(updatedCert).toContain("X509v3 Subject Key Identifier");
expect(updatedCert).toContain("X509v3 Authority Key Identifier");
expect(calls).toContain(`ensure:firewall:${config.firewallVm}`);
expect(calls).toContain(`ensure:agent:${config.agentVm}`);
expect(copiedGuestPaths).toContain("/tmp/.agent-vm-ca.pem.staged");
expect(copiedGuestPaths).toContain(`${config.guestRepoDir}/pki/agent-vm-ca-cert.pem`);
expect(calls).toContain(`interactive:${config.agentVm}`);
} finally {
buildSpyArtifacts.mockRestore();
rmSync(repo, { recursive: true, force: true });
}
});

test("spy uses the shared tunnel fallback and foreground close path", async () => {
const repo = makeInstanceRepo();
const oldSpyEnabled = process.env.ROOTCELL_SPY_ENABLED;
Expand All @@ -1146,10 +1240,7 @@ describe("VM and network providers", () => {
mkdirSync(config.instanceDir, { recursive: true });
mkdirSync(config.generatedDir, { recursive: true });
mkdirSync(config.proxyDir, { recursive: true });
mkdirSync(config.pkiDir, { recursive: true, mode: 0o700 });
for (const file of ["agent-vm-ca.key", "agent-vm-ca-cert.pem", "agent-vm-ca.pem"]) {
writeFileSync(join(config.pkiDir, file), "test\n", "utf8");
}
writeStrictAgentCa(config.pkiDir);
for (const file of ["allowed-https.txt", "allowed-ssh.txt", "allowed-dns.txt"]) {
writeFileSync(join(config.proxyDir, file), "\n", "utf8");
}
Expand Down Expand Up @@ -3002,6 +3093,90 @@ function makeInstanceRepo(): string {
return repo;
}

function writeLegacyAgentCa(pkiDir: string): void {
writeAgentCa(pkiDir, false);
}

function writeStrictAgentCa(pkiDir: string): void {
writeAgentCa(pkiDir, true);
}

function writeAgentCa(pkiDir: string, strict: boolean): void {
mkdirSync(pkiDir, { recursive: true, mode: 0o700 });
const key = join(pkiDir, "agent-vm-ca.key");
const crt = join(pkiDir, "agent-vm-ca-cert.pem");
const pem = join(pkiDir, "agent-vm-ca.pem");
if (!strict) {
const csr = join(pkiDir, "agent-vm-ca.csr");
runCapture("openssl", [
"req",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
key,
"-out",
csr,
"-subj",
"/CN=agent-vm proxy CA",
]);
runCapture("openssl", [
"x509",
"-req",
"-in",
csr,
"-signkey",
key,
"-out",
crt,
"-days",
"3650",
]);
rmSync(csr, { force: true });
writeFileSync(pem, readFileSync(key, "utf8") + readFileSync(crt, "utf8"), "utf8");
return;
}

const config = join(pkiDir, "openssl-test.cnf");
writeFileSync(config, [
"[req]",
"prompt = no",
"distinguished_name = dn",
"x509_extensions = v3_ca",
"[dn]",
"CN = agent-vm proxy CA",
"[v3_ca]",
"basicConstraints = critical,CA:TRUE,pathlen:0",
"keyUsage = critical,keyCertSign,cRLSign",
"subjectKeyIdentifier = hash",
"authorityKeyIdentifier = keyid:always",
"",
].join("\n"), "utf8");
runCapture("openssl", [
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
key,
"-out",
crt,
"-days",
"3650",
"-config",
config,
"-extensions",
"v3_ca",
]);
rmSync(config, { force: true });
writeFileSync(pem, readFileSync(key, "utf8") + readFileSync(crt, "utf8"), "utf8");
}

function certText(path: string): string {
return runCapture("openssl", ["x509", "-in", path, "-noout", "-text"]).stdout;
}

function restoreEnv(name: string, value: string | undefined): void {
if (value === undefined) {
Reflect.deleteProperty(process.env, name);
Expand Down
115 changes: 94 additions & 21 deletions src/rootcell/rootcell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
Expand Down Expand Up @@ -161,6 +163,14 @@ function shellQuote(value: string): string {

const AGENT_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
const AGENT_CA_DIR = "/etc/ssl/certs";
const AGENT_CA_DAYS = "3650";
const AGENT_CA_SUBJECT = "/CN=agent-vm proxy CA";
const AGENT_CA_EXTENSIONS = [
"basicConstraints=critical,CA:TRUE,pathlen:0",
"keyUsage=critical,keyCertSign,cRLSign",
"subjectKeyIdentifier=hash",
"authorityKeyIdentifier=keyid:always",
] as const;
const AGENT_CA_ENV = [
["NODE_EXTRA_CA_CERTS", AGENT_CA_BUNDLE],
["NIX_SSL_CERT_FILE", AGENT_CA_BUNDLE],
Expand All @@ -186,6 +196,19 @@ function sudoAgentCaEnvScript(): string {
return AGENT_CA_ENV.map(([key]) => ` ${key}="$${key}" \\`).join("\n");
}

function agentCaExtensionArgs(): readonly string[] {
return AGENT_CA_EXTENSIONS.flatMap((extension) => ["-addext", extension]);
}

function agentCaHasStrictExtensions(crt: string): boolean {
const result = runCapture("openssl", ["x509", "-in", crt, "-noout", "-text"], {
allowFailure: true,
});
return result.status === 0
&& result.stdout.includes("X509v3 Subject Key Identifier")
&& result.stdout.includes("X509v3 Authority Key Identifier");
}

function nixStringList(values: readonly string[]): string {
return `[ ${values.map(nixString).join(" ")} ]`;
}
Expand Down Expand Up @@ -295,18 +318,19 @@ export class RootcellApp<TAttachment extends VmNetworkAttachment> {
await this.providers.vm.forceStopIfRunning(name);
},
});
if (!await this.ensureFirewall(subcommand === "provision", { allowProvision: subcommand !== "spy" })) {
const caChanged = this.ensureCa();
const needsProvisionForCa = subcommand === "provision" || caChanged;
if (!await this.ensureFirewall(needsProvisionForCa, { allowProvision: subcommand !== "spy" })) {
return 1;
}
this.ensureCa();
await this.syncAllowlists();
await this.waitForFirewallListeners();

if (subcommand === "spy") {
return await this.runSpy(spyOptions);
}

await this.ensureAgent(subcommand === "provision");
await this.ensureAgent(needsProvisionForCa);
if (subcommand === "provision") {
log("done.");
return 0;
Expand Down Expand Up @@ -616,41 +640,90 @@ exit 1
throw new Error(`timeout waiting for SSH transport to ${this.config.firewallVm}${lastError.length === 0 ? "" : `: ${lastError}`}`);
}

private ensureCa(): void {
private ensureCa(): boolean {
const dir = this.config.pkiDir;
const key = join(dir, "agent-vm-ca.key");
const crt = join(dir, "agent-vm-ca-cert.pem");
const pem = join(dir, "agent-vm-ca.pem");
if (existsSync(key) && existsSync(crt) && existsSync(pem)) {
return;
}

log(`generating TLS-MITM CA for instance '${this.config.instanceName}' (one-time, persists across runs)`);
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(dir, 0o700);

let changed = false;
if (!existsSync(key) || !existsSync(crt)) {
log(`generating TLS-MITM CA for instance '${this.config.instanceName}' (one-time, persists across runs)`);
this.generateCa(key, crt);
changed = true;
} else if (!agentCaHasStrictExtensions(crt)) {
log("updating TLS-MITM CA certificate for strict Python/OpenSSL certificate verification");
if (!this.reissueCaCert(key, crt)) {
log("existing TLS-MITM CA key/cert could not be reused; generating a fresh CA");
this.generateCa(key, crt);
}
changed = true;
}

const pemContent = readFileSync(key, "utf8") + readFileSync(crt, "utf8");
if (!existsSync(pem) || readFileSync(pem, "utf8") !== pemContent) {
writeFileSync(pem, pemContent, "utf8");
changed = true;
}
chmodSync(key, 0o600);
chmodSync(pem, 0o600);
chmodSync(crt, 0o644);
return changed;
}

private generateCa(key: string, crt: string): void {
const tmpKey = `${key}.tmp`;
const tmpCrt = `${crt}.tmp`;
rmSync(tmpKey, { force: true });
rmSync(tmpCrt, { force: true });
runInherited("openssl", [
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
key,
tmpKey,
"-out",
crt,
tmpCrt,
"-days",
"3650",
AGENT_CA_DAYS,
"-subj",
"/CN=agent-vm proxy CA",
"-addext",
"basicConstraints=critical,CA:TRUE,pathlen:0",
"-addext",
"keyUsage=critical,keyCertSign,cRLSign",
AGENT_CA_SUBJECT,
...agentCaExtensionArgs(),
], { ignoredOutput: true });
writeFileSync(pem, readFileSync(key, "utf8") + readFileSync(crt, "utf8"), "utf8");
chmodSync(key, 0o600);
chmodSync(pem, 0o600);
chmodSync(crt, 0o644);
renameSync(tmpKey, key);
renameSync(tmpCrt, crt);
}

private reissueCaCert(key: string, crt: string): boolean {
const tmpCrt = `${crt}.tmp`;
rmSync(tmpCrt, { force: true });
const result = runInherited("openssl", [
"req",
"-x509",
"-new",
"-key",
key,
"-out",
tmpCrt,
"-days",
AGENT_CA_DAYS,
"-subj",
AGENT_CA_SUBJECT,
...agentCaExtensionArgs(),
], {
allowFailure: true,
ignoredOutput: true,
});
if (result.status !== 0) {
rmSync(tmpCrt, { force: true });
return false;
}
renameSync(tmpCrt, crt);
return true;
}

private async syncFirewallCa(): Promise<void> {
Expand Down