11import * as core from "@actions/core" ;
2+ import * as fs from "fs" ;
23import * as os from "os" ;
4+ import * as path from "path" ;
35import axios , { type AxiosInstance } from "axios" ;
46import axiosRetry from "axios-retry" ;
57import { 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.
2022const 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+
2234export 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 */
243361async 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