Merge pull request #2470 from balena-io/2469-build-docker-tls

build: Ensure HTTPS is used with dockerPort 2376 or with ca/cert/key
This commit is contained in:
bulldozer-balena[bot] 2022-04-07 10:22:54 +00:00 committed by GitHub
commit 7fbd1de063
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 201 additions and 33 deletions

View File

@ -7,9 +7,14 @@ npm:
node_versions:
- "12"
- "14"
- name: linux
os: alpine
architecture: x86_64
node_versions:
- "12"
- "14"
##
## Temporarily skip Alpine tests until the following issues are resolved:
## * https://github.com/concourse/concourse/issues/7905
## * https://github.com/product-os/balena-concourse/issues/631
##
# - name: linux
# os: alpine
# architecture: x86_64
# node_versions:
# - "12"
# - "14"

View File

@ -174,14 +174,8 @@ export async function isBalenaEngine(docker: dockerode): Promise<boolean> {
);
}
export interface ExtendedDockerOptions extends dockerode.DockerOptions {
docker?: string; // socket path, e.g. /var/run/docker.sock
dockerHost?: string; // host name or IP address
dockerPort?: number; // TCP port number, e.g. 2375
}
export async function getDocker(
options: ExtendedDockerOptions,
options: DockerConnectionCliFlags,
): Promise<dockerode> {
const connectOpts = await generateConnectOpts(options);
const client = await createClient(connectOpts);
@ -196,14 +190,18 @@ export async function createClient(
return new Docker(opts);
}
async function generateConnectOpts(opts: ExtendedDockerOptions) {
let connectOpts: dockerode.DockerOptions = {};
// Start with docker-modem defaults which take several env vars into account,
// including DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH, SSH_AUTH_SOCK
// https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L15-L70
const Modem = require('docker-modem');
const defaultOpts = new Modem();
/**
* Initialize Docker connection options with the default values from the
* 'docker-modem' package, which takes several env vars into account,
* including DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH, SSH_AUTH_SOCK
* https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L15-L70
*
* @param opts Command line options like --dockerHost and --dockerPort
*/
export function getDefaultDockerModemOpts(
opts: DockerConnectionCliFlags,
): dockerode.DockerOptions {
const connectOpts: dockerode.DockerOptions = {};
const optsOfInterest: Array<keyof dockerode.DockerOptions> = [
'ca',
'cert',
@ -215,9 +213,33 @@ async function generateConnectOpts(opts: ExtendedDockerOptions) {
'username',
'timeout',
];
for (const opt of optsOfInterest) {
connectOpts[opt] = defaultOpts[opt];
const Modem = require('docker-modem');
const originalDockerHost = process.env.DOCKER_HOST;
try {
if (opts.dockerHost) {
process.env.DOCKER_HOST ||= opts.dockerPort
? `${opts.dockerHost}:${opts.dockerPort}`
: opts.dockerHost;
}
const defaultOpts = new Modem();
for (const opt of optsOfInterest) {
connectOpts[opt] = defaultOpts[opt];
}
} finally {
// Did you know? Any value assigned to `process.env.XXX` becomes a string.
// For example, `process.env.DOCKER_HOST = undefined` results in
// value 'undefined' (a 9-character string) being assigned.
if (originalDockerHost) {
process.env.DOCKER_HOST = originalDockerHost;
} else {
delete process.env.DOCKER_HOST;
}
}
return connectOpts;
}
export async function generateConnectOpts(opts: DockerConnectionCliFlags) {
let connectOpts = getDefaultDockerModemOpts(opts);
// Now override the default options with any explicit command line options
if (opts.docker != null && opts.dockerHost == null) {
@ -241,9 +263,9 @@ async function generateConnectOpts(opts: ExtendedDockerOptions) {
// These should be file paths (strings)
const tlsOpts = [opts.ca, opts.cert, opts.key];
// If any are set...
// If any tlsOpts are set...
if (tlsOpts.some((opt) => opt)) {
// but not all ()
// but not all
if (!tlsOpts.every((opt) => opt)) {
throw new ExpectedError(
'You must provide a CA, certificate and key in order to use TLS',
@ -258,7 +280,11 @@ async function generateConnectOpts(opts: ExtendedDockerOptions) {
const [ca, cert, key] = await Promise.all(
tlsOpts.map((opt: string) => fs.readFile(opt, 'utf8')),
);
connectOpts = { ...connectOpts, ca, cert, key };
// Also ensure that the protocol is 'https' like 'docker-modem' does:
// https://github.com/apocas/docker-modem/blob/v3.0.0/lib/modem.js#L101-L103
// TODO: delete redundant logic from this function now that similar logic
// exists in the 'docker-modem' package.
connectOpts = { ...connectOpts, ca, cert, key, protocol: 'https' };
}
return connectOpts;

12
npm-shrinkwrap.json generated
View File

@ -2557,9 +2557,9 @@
}
},
"@types/dockerode": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.0.tgz",
"integrity": "sha512-3Mc0b2gnypJB8Gwmr+8UVPkwjpf4kg1gVxw8lAI4Y/EzpK50LixU1wBSPN9D+xqiw2Ubb02JO8oM0xpwzvi2mg==",
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.8.tgz",
"integrity": "sha512-/Hip29GzPBWfbSS87lyQDVoB7Ja+kr8oOFWXsySxNFa7jlyj3Yws8LaZRmn1xZl7uJH3Xxsg0oI09GHpT1pIBw==",
"dev": true,
"requires": {
"@types/docker-modem": "*",
@ -2989,9 +2989,9 @@
}
},
"@types/ssh2": {
"version": "0.5.49",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.49.tgz",
"integrity": "sha512-ffxhQhJqgTzrw8NxHTgkaDtAmAj2qxCyoves7ztpRgqvzbHcZTpTcm+ATWuuCbPQzxnnF4F3SGGTLGEWTZpwqA==",
"version": "0.5.52",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-0.5.52.tgz",
"integrity": "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==",
"dev": true,
"requires": {
"@types/node": "*",

View File

@ -127,7 +127,7 @@
"@types/chai-as-promised": "^7.1.4",
"@types/cli-truncate": "^2.0.0",
"@types/common-tags": "^1.8.1",
"@types/dockerode": "^3.3.0",
"@types/dockerode": "^3.3.8",
"@types/ejs": "^3.1.0",
"@types/express": "^4.17.13",
"@types/fs-extra": "^9.0.13",

137
tests/utils/docker.spec.ts Normal file
View File

@ -0,0 +1,137 @@
/**
* @license
* Copyright 2022 Balena Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { expect } from 'chai';
import {
DockerConnectionCliFlags,
generateConnectOpts,
getDefaultDockerModemOpts,
} from '../../build/utils/docker';
const defaultSocketPath =
process.platform === 'win32'
? '//./pipe/docker_engine'
: '/var/run/docker.sock';
describe('getDefaultDockerModemOpts() function', function () {
it('should use a Unix socket when --dockerHost is not used', () => {
const cliFlags: DockerConnectionCliFlags = {
dockerPort: 2376,
};
const defaultOps = getDefaultDockerModemOpts(cliFlags);
expect(defaultOps).to.deep.include({
host: undefined,
port: undefined,
protocol: 'http',
socketPath: defaultSocketPath,
});
});
it('should use the HTTP protocol when --dockerPort is 2375', () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2375,
};
const defaultOps = getDefaultDockerModemOpts(cliFlags);
expect(defaultOps).to.deep.include({
host: 'foo',
port: '2375',
protocol: 'http',
socketPath: undefined,
});
});
it('should use the HTTPS protocol when --dockerPort is 2376', () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2376,
};
const defaultOps = getDefaultDockerModemOpts(cliFlags);
expect(defaultOps).to.deep.include({
host: 'foo',
port: '2376',
protocol: 'https',
socketPath: undefined,
});
});
});
describe('generateConnectOpts() function', function () {
it('should use a Unix socket when --docker is used', async () => {
const cliFlags: DockerConnectionCliFlags = {
docker: 'foo',
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
protocol: 'http',
socketPath: 'foo',
});
expect(connectOpts).to.not.have.any.keys('host', 'port');
});
it('should use the HTTP protocol when --dockerPort is 2375', async () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2375,
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
host: 'foo',
port: 2375,
protocol: 'http',
});
expect(connectOpts).to.not.have.any.keys('socketPath');
});
it('should use the HTTPS protocol when --dockerPort is 2376', async () => {
const cliFlags: DockerConnectionCliFlags = {
dockerHost: 'foo',
dockerPort: 2376,
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
host: 'foo',
port: 2376,
protocol: 'https',
});
expect(connectOpts).to.not.have.any.keys('socketPath');
});
it('should use the HTTPS protocol when ca/cert/key are used', async () => {
const path = await import('path');
const aFile = path.join(
__dirname,
'../test-data/projects/no-docker-compose/dockerignore1/a.txt',
);
const cliFlags: DockerConnectionCliFlags = {
ca: aFile,
cert: aFile,
key: aFile,
};
const connectOpts = await generateConnectOpts(cliFlags);
expect(connectOpts).to.deep.include({
ca: 'a',
cert: 'a',
key: 'a',
host: undefined,
port: undefined,
protocol: 'https',
socketPath: defaultSocketPath,
});
});
});