Skip to content

Commit b7c41cb

Browse files
action: add mTLS security to multi-arch buildkitd
Co-Authored-By: maru@blacksmith.sh <adityamaru@gmail.com>
1 parent c0bee9f commit b7c41cb

4 files changed

Lines changed: 169 additions & 18 deletions

File tree

dist/index.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/main.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -730,14 +730,20 @@ void actionsToolkit.run(
730730
}
731731
});
732732

733-
// Append follower node for the other architecture.
733+
// Append follower node for the other architecture with mTLS.
734734
const appendArgs = [
735735
"create",
736736
"--name",
737737
name,
738738
"--append",
739739
"--driver",
740740
"remote",
741+
"--driver-opt",
742+
`cacert=${followerInfo.mtlsCerts.caCertPath}`,
743+
"--driver-opt",
744+
`cert=${followerInfo.mtlsCerts.clientCertPath}`,
745+
"--driver-opt",
746+
`key=${followerInfo.mtlsCerts.clientKeyPath}`,
741747
"--platform",
742748
`linux/${followerInfo.arch}`,
743749
followerInfo.buildkitdAddr,

src/multiarch.ts

Lines changed: 160 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import * as core from "@actions/core";
2+
import * as fs from "fs";
23
import * as os from "os";
4+
import * as path from "path";
35
import axios, { type AxiosInstance } from "axios";
46
import axiosRetry from "axios-retry";
57
import { promisify } from "util";
@@ -19,13 +21,25 @@ const SSH_READY_TIMEOUT_MS = 60_000;
1921
// Maximum time (ms) to wait for follower buildkitd to be ready.
2022
const BUILDKITD_READY_TIMEOUT_MS = 30_000;
2123

24+
/** Paths to mTLS certificates used by the leader to connect to the follower. */
25+
export interface MTLSCerts {
26+
/** Path to the ephemeral CA certificate. */
27+
caCertPath: string;
28+
/** Path to the client certificate (signed by the CA). */
29+
clientCertPath: string;
30+
/** Path to the client private key. */
31+
clientKeyPath: string;
32+
}
33+
2234
export interface FollowerInfo {
2335
/** The VM ID returned by the sandbox API. */
2436
vmId: string;
2537
/** The buildx endpoint address reachable from the leader (via tunnel manager). */
2638
buildkitdAddr: string;
2739
/** The architecture of the follower (e.g. "amd64", "arm64"). */
2840
arch: string;
41+
/** mTLS certificates for securing the buildx → buildkitd connection. */
42+
mtlsCerts: MTLSCerts;
2943
}
3044

3145
/**
@@ -136,6 +150,71 @@ async function generateEphemeralSSHKey(): Promise<{
136150
return { privateKeyPath: keyPath, publicKey: publicKey.trim() };
137151
}
138152

153+
/**
154+
* Generates an ephemeral mTLS certificate bundle (CA, server cert, client cert)
155+
* in a temporary directory. All certs are valid for 1 hour — long enough for
156+
* any build but short-lived enough to limit exposure.
157+
*
158+
* Returns paths to the CA cert, server cert/key, and client cert/key.
159+
*/
160+
async function generateMTLSCerts(): Promise<{
161+
caCertPath: string;
162+
caKeyPath: string;
163+
serverCertPath: string;
164+
serverKeyPath: string;
165+
clientCertPath: string;
166+
clientKeyPath: string;
167+
}> {
168+
const certDir = `/tmp/blacksmith_mtls_${Date.now()}`;
169+
fs.mkdirSync(certDir, { mode: 0o700 });
170+
171+
// Generate ephemeral CA.
172+
await execAsync(
173+
`openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` +
174+
`-x509 -sha256 -days 1 -nodes ` +
175+
`-keyout ${certDir}/ca.key -out ${certDir}/ca.crt ` +
176+
`-subj "/CN=blacksmith-multiarch-ca"`,
177+
);
178+
179+
// Generate server certificate (used by follower buildkitd).
180+
await execAsync(
181+
`openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` +
182+
`-nodes -keyout ${certDir}/server.key -out ${certDir}/server.csr ` +
183+
`-subj "/CN=blacksmith-buildkitd"`,
184+
);
185+
// Sign with CA — add SAN with wildcard for vm hostnames.
186+
await execAsync(
187+
`openssl x509 -req -in ${certDir}/server.csr ` +
188+
`-CA ${certDir}/ca.crt -CAkey ${certDir}/ca.key -CAcreateserial ` +
189+
`-out ${certDir}/server.crt -days 1 -sha256 ` +
190+
`-extfile <(printf "subjectAltName=DNS:*.vm.blacksmith.sh,DNS:localhost,IP:127.0.0.1")`,
191+
{ shell: "/bin/bash" },
192+
);
193+
194+
// Generate client certificate (used by leader buildx).
195+
await execAsync(
196+
`openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` +
197+
`-nodes -keyout ${certDir}/client.key -out ${certDir}/client.csr ` +
198+
`-subj "/CN=blacksmith-buildx-client"`,
199+
);
200+
await execAsync(
201+
`openssl x509 -req -in ${certDir}/client.csr ` +
202+
`-CA ${certDir}/ca.crt -CAkey ${certDir}/ca.key -CAcreateserial ` +
203+
`-out ${certDir}/client.crt -days 1 -sha256`,
204+
);
205+
206+
core.info("Generated ephemeral mTLS certificate bundle");
207+
208+
return {
209+
caCertPath: path.join(certDir, "ca.crt"),
210+
caKeyPath: path.join(certDir, "ca.key"),
211+
serverCertPath: path.join(certDir, "server.crt"),
212+
serverKeyPath: path.join(certDir, "server.key"),
213+
clientCertPath: path.join(certDir, "client.crt"),
214+
clientKeyPath: path.join(certDir, "client.key"),
215+
};
216+
}
217+
139218
/**
140219
* Spawns a follower sandbox VM on the specified architecture.
141220
* Returns the VM ID and SSH connection details.
@@ -238,37 +317,85 @@ async function sshExec(
238317
}
239318

240319
/**
241-
* Starts buildkitd on the follower VM via SSH and waits for it to be ready.
320+
* Copies mTLS server certificates to the follower VM via SCP.
321+
*/
322+
async function pushServerCertsToFollower(
323+
privateKeyPath: string,
324+
host: string,
325+
port: number,
326+
caCertPath: string,
327+
serverCertPath: string,
328+
serverKeyPath: string,
329+
): Promise<void> {
330+
core.info("Copying mTLS server certificates to follower...");
331+
332+
// Create cert directory on follower.
333+
await sshExec(privateKeyPath, host, port, "mkdir -p /tmp/buildkitd-certs");
334+
335+
const scpOpts = [
336+
`-i ${privateKeyPath}`,
337+
`-P ${port}`,
338+
"-o StrictHostKeyChecking=no",
339+
"-o UserKnownHostsFile=/dev/null",
340+
"-o LogLevel=ERROR",
341+
].join(" ");
342+
343+
for (const [localPath, remoteName] of [
344+
[caCertPath, "ca.crt"],
345+
[serverCertPath, "server.crt"],
346+
[serverKeyPath, "server.key"],
347+
] as const) {
348+
await execAsync(
349+
`scp ${scpOpts} ${localPath} runner@${host}:/tmp/buildkitd-certs/${remoteName}`,
350+
{ timeout: 15_000 },
351+
);
352+
}
353+
354+
core.info("mTLS server certificates copied to follower");
355+
}
356+
357+
/**
358+
* Starts buildkitd on the follower VM via SSH with mTLS enabled, and waits
359+
* for it to be ready.
242360
*/
243361
async function startFollowerBuildkitd(
244362
privateKeyPath: string,
245363
host: string,
246364
port: number,
247365
): Promise<void> {
248-
core.info("Starting buildkitd on follower VM...");
366+
core.info("Starting buildkitd on follower VM with mTLS...");
367+
368+
// Start buildkitd with TLS flags.
369+
const buildkitdCmd = [
370+
"nohup sudo buildkitd",
371+
`--addr tcp://0.0.0.0:${FOLLOWER_BUILDKITD_PORT}`,
372+
"--tlscacert /tmp/buildkitd-certs/ca.crt",
373+
"--tlscert /tmp/buildkitd-certs/server.crt",
374+
"--tlskey /tmp/buildkitd-certs/server.key",
375+
"> /tmp/buildkitd.log 2>&1 &",
376+
].join(" ");
249377

250-
// Start buildkitd in the background, listening on TCP.
251-
await sshExec(
252-
privateKeyPath,
253-
host,
254-
port,
255-
`nohup sudo buildkitd --addr tcp://0.0.0.0:${FOLLOWER_BUILDKITD_PORT} > /tmp/buildkitd.log 2>&1 &`,
256-
);
378+
await sshExec(privateKeyPath, host, port, buildkitdCmd);
257379

258-
// Wait for buildkitd to be ready.
380+
// Wait for buildkitd to be ready (use buildctl with matching TLS certs).
259381
const startTime = Date.now();
260382
while (Date.now() - startTime < BUILDKITD_READY_TIMEOUT_MS) {
261383
try {
262384
const result = await sshExec(
263385
privateKeyPath,
264386
host,
265387
port,
266-
`sudo buildctl --addr tcp://127.0.0.1:${FOLLOWER_BUILDKITD_PORT} debug workers 2>/dev/null`,
388+
`sudo buildctl ` +
389+
`--addr tcp://127.0.0.1:${FOLLOWER_BUILDKITD_PORT} ` +
390+
`--tlscacert /tmp/buildkitd-certs/ca.crt ` +
391+
`--tlscert /tmp/buildkitd-certs/server.crt ` +
392+
`--tlskey /tmp/buildkitd-certs/server.key ` +
393+
`debug workers 2>/dev/null`,
267394
10_000,
268395
);
269396
if (result.includes("Platforms:")) {
270397
core.info(
271-
`Follower buildkitd ready (took ${Date.now() - startTime}ms)`,
398+
`Follower buildkitd ready with mTLS (took ${Date.now() - startTime}ms)`,
272399
);
273400
return;
274401
}
@@ -328,17 +455,30 @@ export async function setupMultiArchFollower(
328455
// Step 1: Generate ephemeral SSH keypair.
329456
const { privateKeyPath, publicKey } = await generateEphemeralSSHKey();
330457

331-
// Step 2: Spawn follower sandbox.
458+
// Step 2: Generate ephemeral mTLS certificates.
459+
const mtls = await generateMTLSCerts();
460+
461+
// Step 3: Spawn follower sandbox.
332462
const { vmId, sshHost, sshPort } = await spawnFollowerSandbox(
333463
client,
334464
followerArch,
335465
publicKey,
336466
);
337467

338-
// Step 3: Start buildkitd on the follower.
468+
// Step 4: Copy server certificates to follower.
469+
await pushServerCertsToFollower(
470+
privateKeyPath,
471+
sshHost,
472+
sshPort,
473+
mtls.caCertPath,
474+
mtls.serverCertPath,
475+
mtls.serverKeyPath,
476+
);
477+
478+
// Step 5: Start buildkitd on the follower with mTLS.
339479
await startFollowerBuildkitd(privateKeyPath, sshHost, sshPort);
340480

341-
// Step 4: Expose follower buildkitd via tunnel manager.
481+
// Step 6: Expose follower buildkitd via tunnel manager.
342482
const buildkitdAddr = await exposeFollowerBuildkitd(
343483
privateKeyPath,
344484
sshHost,
@@ -349,6 +489,11 @@ export async function setupMultiArchFollower(
349489
vmId,
350490
buildkitdAddr,
351491
arch: followerArch,
492+
mtlsCerts: {
493+
caCertPath: mtls.caCertPath,
494+
clientCertPath: mtls.clientCertPath,
495+
clientKeyPath: mtls.clientKeyPath,
496+
},
352497
};
353498
}
354499

0 commit comments

Comments
 (0)