From 7ba21e471fc4e2102427ef0abfa94319ca257127 Mon Sep 17 00:00:00 2001 From: CrazyMax Date: Mon, 19 Sep 2022 11:30:33 +0200 Subject: [PATCH] auth support for ssh and tls endpoints Signed-off-by: CrazyMax --- README.md | 2 + __tests__/auth.test.ts | 106 ++++++++++++++++++++++++++++++++++++++ __tests__/buildx.test.ts | 16 +++--- __tests__/context.test.ts | 9 ++-- docs/advanced/auth.md | 101 ++++++++++++++++++++++++++++++++++++ src/auth.ts | 91 ++++++++++++++++++++++++++++++++ src/main.ts | 15 ++++++ src/state-helper.ts | 5 ++ 8 files changed, 329 insertions(+), 16 deletions(-) create mode 100644 __tests__/auth.test.ts create mode 100644 docs/advanced/auth.md create mode 100644 src/auth.ts diff --git a/README.md b/README.md index 1d05d9b1..dcc1434d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ___ * [Usage](#usage) * [Advanced usage](#advanced-usage) + * [Authentication to a remote node](docs/advanced/auth.md) * [Install by default](docs/advanced/install-default.md) * [BuildKit daemon configuration](docs/advanced/buildkit-config.md) * [Standalone mode](docs/advanced/standalone.md) @@ -58,6 +59,7 @@ jobs: ## Advanced usage +* [Authentication to a remote node](docs/advanced/auth.md) * [Install by default](docs/advanced/install-default.md) * [BuildKit daemon configuration](docs/advanced/buildkit-config.md) * [Standalone mode](docs/advanced/standalone.md) diff --git a/__tests__/auth.test.ts b/__tests__/auth.test.ts new file mode 100644 index 00000000..3b3c44ba --- /dev/null +++ b/__tests__/auth.test.ts @@ -0,0 +1,106 @@ +import {describe, expect, jest, test, beforeEach} from '@jest/globals'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as auth from '../src/auth'; + +const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), 'docker-setup-buildx-jest')).split(path.sep).join(path.posix.sep); +const dockerConfigHome = path.join(tmpdir, '.docker'); +const credsdir = path.join(dockerConfigHome, 'buildx', 'creds'); + +jest.spyOn(auth, 'getSSHDir').mockImplementation((): string => { + return path.join(tmpdir, '.ssh'); +}); + +describe('setCredentials', () => { + beforeEach(() => { + process.env = Object.keys(process.env).reduce((object, key) => { + if (!key.startsWith(auth.envPrefix)) { + object[key] = process.env[key]; + } + return object; + }, {}); + }); + + // prettier-ignore + test.each([ + [ + 'mycontext', + 'docker-container', + {}, + [], + [] + ], + [ + 'docker-container://mycontainer', + 'docker-container', + {}, + [], + [] + ], + [ + 'ssh://me@graviton2', + 'docker-container', + {}, + [], + [] + ], + [ + 'ssh://me@graviton2', + 'docker-container', + {'BUILDER_NODE_0_AUTH_SSH_PPK': 'foo'}, + [path.join(credsdir, 'ssh_graviton2.ppk')], + [] + ], + [ + 'tcp://graviton2:1234', + 'remote', + {}, + [], + [] + ], + [ + 'tcp://graviton2:1234', + 'remote', + { + 'BUILDER_NODE_0_AUTH_TLS_CACERT': 'foo', + 'BUILDER_NODE_0_AUTH_TLS_CERT': 'foo', + 'BUILDER_NODE_0_AUTH_TLS_KEY': 'foo' + }, + [ + path.join(credsdir, 'cacert_graviton2-1234.pem'), + path.join(credsdir, 'cert_graviton2-1234.pem'), + path.join(credsdir, 'key_graviton2-1234.pem') + ], + [ + `cacert=${path.join(credsdir, 'cacert_graviton2-1234.pem')}`, + `cert=${path.join(credsdir, 'cert_graviton2-1234.pem')}`, + `key=${path.join(credsdir, 'key_graviton2-1234.pem')}` + ] + ], + [ + 'tcp://graviton2:1234', + 'docker-container', + { + 'BUILDER_NODE_0_AUTH_TLS_CACERT': 'foo', + 'BUILDER_NODE_0_AUTH_TLS_CERT': 'foo', + 'BUILDER_NODE_0_AUTH_TLS_KEY': 'foo' + }, + [ + path.join(credsdir, 'cacert_graviton2-1234.pem'), + path.join(credsdir, 'cert_graviton2-1234.pem'), + path.join(credsdir, 'key_graviton2-1234.pem') + ], + [] + ], + ])('given %p endpoint', async (endpoint: string, driver: string, envs: Record, expectedFiles: Array, expectedOpts: Array) => { + fs.mkdirSync(credsdir, {recursive: true}); + for (const [key, value] of Object.entries(envs)) { + process.env[key] = value; + } + expect(auth.setCredentials(credsdir, 0, driver, endpoint)).toEqual(expectedOpts); + expectedFiles.forEach( (file) => { + expect(fs.existsSync(file)).toBe(true); + }); + }); +}); diff --git a/__tests__/buildx.test.ts b/__tests__/buildx.test.ts index b331a2f6..5015f9b4 100644 --- a/__tests__/buildx.test.ts +++ b/__tests__/buildx.test.ts @@ -7,18 +7,14 @@ import * as context from '../src/context'; import * as semver from 'semver'; import * as exec from '@actions/exec'; -const tmpNameSync = path.join('/tmp/.docker-setup-buildx-jest', '.tmpname-jest').split(path.sep).join(path.posix.sep); - +const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), 'docker-setup-buildx-')).split(path.sep).join(path.posix.sep); jest.spyOn(context, 'tmpDir').mockImplementation((): string => { - const tmpDir = path.join('/tmp/.docker-setup-buildx-jest').split(path.sep).join(path.posix.sep); - if (!fs.existsSync(tmpDir)) { - fs.mkdirSync(tmpDir, {recursive: true}); - } - return tmpDir; + return tmpdir; }); +const tmpname = path.join(tmpdir, '.tmpname').split(path.sep).join(path.posix.sep); jest.spyOn(context, 'tmpNameSync').mockImplementation((): string => { - return tmpNameSync; + return tmpname; }); describe('isAvailable', () => { @@ -136,8 +132,8 @@ describe('getConfig', () => { config = await buildx.getConfigInline(val); } expect(true).toBe(!invalid); - expect(config).toEqual(`${tmpNameSync}`); - const configValue = fs.readFileSync(tmpNameSync, 'utf-8'); + expect(config).toEqual(tmpname); + const configValue = fs.readFileSync(tmpname, 'utf-8'); expect(configValue).toEqual(exValue); } catch (err) { // eslint-disable-next-line jest/no-conditional-expect diff --git a/__tests__/context.test.ts b/__tests__/context.test.ts index 8e03a036..5fb156c2 100644 --- a/__tests__/context.test.ts +++ b/__tests__/context.test.ts @@ -4,16 +4,13 @@ import * as os from 'os'; import * as path from 'path'; import * as context from '../src/context'; +const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), 'docker-setup-buildx-')).split(path.sep).join(path.posix.sep); jest.spyOn(context, 'tmpDir').mockImplementation((): string => { - const tmpDir = path.join('/tmp/.docker-setup-buildx-jest').split(path.sep).join(path.posix.sep); - if (!fs.existsSync(tmpDir)) { - fs.mkdirSync(tmpDir, {recursive: true}); - } - return tmpDir; + return tmpdir; }); jest.spyOn(context, 'tmpNameSync').mockImplementation((): string => { - return path.join('/tmp/.docker-setup-buildx-jest', '.tmpname-jest').split(path.sep).join(path.posix.sep); + return path.join(tmpdir, '.tmpname').split(path.sep).join(path.posix.sep); }); describe('getInputList', () => { diff --git a/docs/advanced/auth.md b/docs/advanced/auth.md new file mode 100644 index 00000000..b3cdcc9b --- /dev/null +++ b/docs/advanced/auth.md @@ -0,0 +1,101 @@ +# Authentication to a remote node + +```yaml +name: ci + +on: + push: + +jobs: + buildx: + runs-on: ubuntu-latest + steps: + - + name: Set up SSH config + run: | + # set up SSH config and keys to connect to remote node + # in the next step + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + with: + endpoint: ssh://me@graviton2,platforms=linux/arm64 +``` + +In this example, we assume you know how to set up SSH config and keys in the +first step to connect to `graviton2` remote node. + +To ease the integration in your workflow, we put in place environment variables +that will set up authentication for `tcp://` and `ssh://` endpoints: + +## SSH authentication + +To set up SSH authentication, you need to add the environment variable +`BUILDER_NODE__AUTH_SSH_PPK` that contains the SSH private key and where +`` is the position of the node in the list of nodes. + +> **Note** +> +> The index is always `0` at the moment as we don't support (yet) appending new +> nodes with this action. + +With the example above it would look like this: + +```yaml +name: ci + +on: + push: + +jobs: + buildx: + runs-on: ubuntu-latest + steps: + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + with: + endpoint: ssh://me@graviton2,platforms=linux/arm64 + env: + BUILDER_NODE_0_AUTH_SSH_PPK: ${{ secrets.GRAVITON2_SSH_PPK }} +``` + +## TLS authentication + +You can also [set up a remote BuildKit instance](https://docs.docker.com/build/building/drivers/remote/#remote-buildkit-in-docker-container) +using the remote driver. Like the SSH authentication, you need to add the +following environment variables to set up TLS authentication with the BuildKit +client certificates: + +* `BUILDER_NODE__AUTH_TLS_CACERT` +* `BUILDER_NODE__AUTH_TLS_CERT` +* `BUILDER_NODE__AUTH_TLS_KEY` + +Where `` is the position of the node in the list of nodes. + +> **Note** +> +> The index is always `0` at the moment as we don't support (yet) appending new +> nodes with this action. + +```yaml +name: ci + +on: + push: + +jobs: + buildx: + runs-on: ubuntu-latest + steps: + - + name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + with: + driver: remote + endpoint: tcp://graviton2:1234 + env: + BUILDER_NODE_0_AUTH_TLS_CACERT: ${{ secrets.GRAVITON2_CA }} + BUILDER_NODE_0_AUTH_TLS_CERT: ${{ secrets.GRAVITON2_CERT }} + BUILDER_NODE_0_AUTH_TLS_KEY: ${{ secrets.GRAVITON2_KEY }} +``` diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 00000000..da02b038 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,91 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export const envPrefix = 'BUILDER_NODE'; + +export function setCredentials(credsdir: string, index: number, driver: string, endpoint: string): Array { + let url: URL; + try { + url = new URL(endpoint); + } catch (e) { + return []; + } + switch (url.protocol) { + case 'ssh:': { + return setSSHCreds(credsdir, index, driver, url); + } + case 'tcp:': { + return setBuildKitClientCerts(credsdir, index, driver, url); + } + } + return []; +} + +function setSSHCreds(credsdir: string, index: number, driver: string, endpoint: URL): Array { + const driverOpts: Array = []; + const sshkey = process.env[`${envPrefix}_${index}_AUTH_SSH_PPK`] || ''; + if (sshkey.length == 0) { + return driverOpts; + } + + const sshkeypath = `${credsdir}/ssh_${endpoint.host}.ppk`; + fs.writeFileSync(sshkeypath, sshkey); + fs.chmodSync(sshkeypath, 0o600); + + const sshdir = getSSHDir(); + fs.mkdirSync(sshdir, {recursive: true}); + + const sshconfig = `${sshdir}/config`; + fs.appendFileSync( + fs.openSync(sshconfig, 'a'), + ` +Host ${endpoint.host} + IdentityFile ${sshkeypath} + ControlMaster auto + ControlPath ~/.ssh/control-%C + ControlPersist yes + StrictHostKeyChecking no + UserKnownHostsFile /dev/null +` + ); + fs.chmodSync(sshconfig, 0o600); + return driverOpts; +} + +function setBuildKitClientCerts(credsdir: string, index: number, driver: string, endpoint: URL): Array { + const driverOpts: Array = []; + const buildkitCacert = process.env[`${envPrefix}_${index}_AUTH_TLS_CACERT`] || ''; + const buildkitCert = process.env[`${envPrefix}_${index}_AUTH_TLS_CERT`] || ''; + const buildkitKey = process.env[`${envPrefix}_${index}_AUTH_TLS_KEY`] || ''; + if (buildkitCacert.length == 0 && buildkitCert.length == 0 && buildkitKey.length == 0) { + return driverOpts; + } + let host = endpoint.hostname; + if (endpoint.port.length > 0) { + host += `-${endpoint.port}`; + } + if (buildkitCacert.length > 0) { + const cacertpath = `${credsdir}/cacert_${host}.pem`; + fs.writeFileSync(cacertpath, buildkitCacert); + driverOpts.push(`cacert=${cacertpath}`); + } + if (buildkitCert.length > 0) { + const certpath = `${credsdir}/cert_${host}.pem`; + fs.writeFileSync(certpath, buildkitCert); + driverOpts.push(`cert=${certpath}`); + } + if (buildkitKey.length > 0) { + const keypath = `${credsdir}/key_${host}.pem`; + fs.writeFileSync(keypath, buildkitKey); + driverOpts.push(`key=${keypath}`); + } + if (driver != 'remote') { + return []; + } + return driverOpts; +} + +export function getSSHDir(): string { + return path.join(os.homedir(), '.ssh'); +} diff --git a/src/main.ts b/src/main.ts index f252c44c..2d34d68a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,8 @@ +import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as uuid from 'uuid'; +import * as auth from './auth'; import * as buildx from './buildx'; import * as context from './context'; import * as docker from './docker'; @@ -56,8 +58,16 @@ async function run(): Promise { context.setOutput('name', builderName); stateHelper.setBuilderName(builderName); + const credsdir = path.join(dockerConfigHome, 'buildx', 'creds', builderName); + fs.mkdirSync(credsdir, {recursive: true}); + stateHelper.setCredsDir(credsdir); + if (inputs.driver !== 'docker') { core.startGroup(`Creating a new builder instance`); + const authOpts = auth.setCredentials(credsdir, 0, inputs.driver, inputs.endpoint); + if (authOpts.length > 0) { + inputs.driverOpts = [...inputs.driverOpts, ...authOpts]; + } const createArgs: Array = ['create', '--name', builderName, '--driver', inputs.driver]; if (buildx.satisfies(buildxVersion, '>=0.3.0')) { await context.asyncForEach(inputs.driverOpts, async driverOpt => { @@ -156,6 +166,11 @@ async function cleanup(): Promise { }); core.endGroup(); } + + if (stateHelper.credsDir.length > 0 && fs.existsSync(stateHelper.credsDir)) { + core.info(`Cleaning up credentials`); + fs.rmdirSync(stateHelper.credsDir, {recursive: true}); + } } if (!stateHelper.IsPost) { diff --git a/src/state-helper.ts b/src/state-helper.ts index f6b1a75e..f048bfa6 100644 --- a/src/state-helper.ts +++ b/src/state-helper.ts @@ -5,6 +5,7 @@ export const IsDebug = !!process.env['STATE_isDebug']; export const standalone = process.env['STATE_standalone'] || ''; export const builderName = process.env['STATE_builderName'] || ''; export const containerName = process.env['STATE_containerName'] || ''; +export const credsDir = process.env['STATE_credsDir'] || ''; export function setDebug(debug: string) { core.saveState('isDebug', debug); @@ -22,6 +23,10 @@ export function setContainerName(containerName: string) { core.saveState('containerName', containerName); } +export function setCredsDir(credsDir: string) { + core.saveState('credsDir', credsDir); +} + if (!IsPost) { core.saveState('isPost', 'true'); }