/* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /* * Copyright 2017 Joyent, Inc. */ /* * Shared stuff for `triton profile ...` handling. */ var assert = require('assert-plus'); var auth = require('smartdc-auth'); var format = require('util').format; var fs = require('fs'); var getpass = require('getpass'); var https = require('https'); var mkdirp = require('mkdirp'); var path = require('path'); var rimraf = require('rimraf'); var semver = require('semver'); var sshpk = require('sshpk'); var mod_url = require('url'); var crypto = require('crypto'); var vasync = require('vasync'); var which = require('which'); var wordwrap = require('wordwrap')(78); var common = require('../common'); var mod_config = require('../config'); var errors = require('../errors'); // --- internal support functions function portalUrlFromCloudapiUrl(url) { assert.string(url, 'url'); var portalUrl; var JPC_RE = /^https:\/\/([a-z0-9-]+)\.api\.joyent(cloud)?\.com\/?$/; if (JPC_RE.test(url)) { return 'https://my.joyent.com'; } return portalUrl; } function downloadUrl(opts, cb) { assert.string(opts.url, 'opts.url'); assert.ok(/^https:/.test(opts.url)); assert.string(opts.dest, 'opts.dest'); assert.optionalBool(opts.insecure, 'opts.insecure'); assert.func(cb, 'cb'); var reqOpts = mod_url.parse(opts.url); if (opts.insecure) { reqOpts.rejectUnauthorized = false; } var file = fs.createWriteStream(opts.dest); var req = https.get(reqOpts, function (res) { res.pipe(file); file.on('finish', function () { file.close(cb); }); }); req.on('error', function (err) { fs.unlink(opts.dest); cb(err); }); } // --- exported functions function setCurrentProfile(opts, cb) { assert.object(opts.cli, 'opts.cli'); assert.string(opts.name, 'opts.name'); assert.func(cb, 'cb'); var cli = opts.cli; if (opts.name === '-') { if (cli.tritonapi.config.hasOwnProperty('oldProfile')) { opts.name = cli.tritonapi.config.oldProfile; } else { cb(new errors.ConfigError('"oldProfile" is not set in config')); return; } } try { var profile = mod_config.loadProfile({ configDir: cli.configDir, name: opts.name }); } catch (err) { return cb(err); } var currProfile; try { currProfile = cli.tritonapi.profile; } catch (err) { // Ignore inability to load a profile. if (!(err instanceof errors.ConfigError)) { throw err; } } if (currProfile && currProfile.name === profile.name) { console.log('"%s" is already the current profile', profile.name); return cb(); } mod_config.setConfigVars({ configDir: cli.configDir, vars: { profile: profile.name } }, function (err) { if (err) { return cb(err); } console.log('Set "%s" as current profile', profile.name); cb(); }); } /** * Setup the given profile for Docker usage. This means checking the cloudapi * has a Docker service (ListServices), finding the user's SSH *private* key, * creating the client certs that will be used to talk to the Triton Docker * Engine. * * @param {Object} opts: Required. * - {Object} cli: Required. The Triton CLI object. * - {String} name: Required. The profile name. * - {Boolean} implicit: Optional. Boolean indicating if the Docker setup * is implicit (e.g. as a default part of `triton profile create`). If * implicit, we silently skip if ListServices shows no Docker service. * - {Boolean} yes: Optional. Boolean indicating if confirmation prompts * should be skipped, assuming a "yes" answer. * - {Number} lifetime: Optional. Number of days to make the Docker * certificate valid for. Defaults to 3650 (10 years). */ function profileDockerSetup(opts, cb) { assert.object(opts.cli, 'opts.cli'); assert.string(opts.name, 'opts.name'); assert.optionalBool(opts.implicit, 'opts.implicit'); assert.optionalBool(opts.yes, 'opts.yes'); assert.optionalNumber(opts.lifetime, 'opts.lifetime'); assert.func(cb, 'cb'); /* Default to a 10 year certificate. */ if (!opts.lifetime) opts.lifetime = 3650; var cli = opts.cli; var tritonapi = cli.tritonapiFromProfileName({profileName: opts.name}); var implicit = Boolean(opts.implicit); var yes = Boolean(opts.yes); var log = cli.log; var profile = tritonapi.profile; var dockerHost; vasync.pipeline({arg: {tritonapi: tritonapi}, funcs: [ function dockerKeyWarning(arg, next) { console.log(wordwrap('WARNING: Docker uses authentication via ' + 'client TLS certificates that do not support encrypted ' + '(passphrase protected) keys or SSH agents.\n')); console.log(wordwrap('If you continue, this profile setup will ' + 'create a fresh private key to be written unencrypted to ' + 'disk in "~/.triton/docker" for use by the Docker client. ' + 'This key will be useable only for Docker.\n')); if (yes) { next(); return; } else { console.log(wordwrap('If you do not specifically want to use ' + 'Docker, you can answer "no" here.\n')); } common.promptYesNo({msg: 'Continue? [y/n] '}, function (answer) { if (answer !== 'y') { console.error('Skipping Docker setup (you can run ' + '"triton profile docker-setup" later).'); next(true); } else { console.log(); next(); } }); }, common.cliSetupTritonApi, function checkCloudapiStatus(arg, next) { tritonapi.cloudapi.ping({}, function (err, pong, res) { if (!res) { next(new errors.SetupError(err, format( 'error pinging CloudAPI <%s>', profile.url))); } else if (res.statusCode === 503) { // TODO: Use maint res headers to estimate time back up. next(new errors.SetupError(err, format('CloudAPI <%s> is ', + 'in maintenance, please try again later', profile.url))); } else if (res.statusCode === 200) { next(); } else { next(new errors.SetupError(err, format( 'error pinging CloudAPI <%s>: %s status code', profile.url, res.statusCode))); } }); }, function checkForDockerService(arg, next) { tritonapi.cloudapi.listServices({}, function (err, svcs, res) { if (!res) { next(new errors.SetupError(err, format( 'could not list services on cloudapi %s', profile.url))); } else if (res.statusCode === 401) { var portalUrl = portalUrlFromCloudapiUrl(profile.url); if (portalUrl) { next(new errors.SetupError(err, format( 'invalid credentials. Visit <%s> to create the ' + '"%s" account and/or add your SSH public key', portalUrl, profile.account))); } else { next(new errors.SetupError(err, format( 'invalid credentials. You must create the ' + '"%s" account and/or add your SSH public key', profile.account))); } } else if (res.statusCode === 200) { if (svcs.docker) { dockerHost = svcs.docker; log.trace({dockerHost: dockerHost}, 'profileDockerSetup: checkForDockerService'); next(); } else if (implicit) { /* * No Docker service on this CloudAPI and this is an * implicit attempt to setup for Docker, so that's fine. * Use the early-abort signal. */ next(true); } else { next(new errors.SetupError(err, format( 'no "docker" service on this datacenter', profile.url))); } } else { // TODO: If this doesn't show res details, add that. next(new errors.SetupError(err, format( 'unexpected response from cloudapi %s: %s status code', profile.url, res.statusCode))); } }); }, function mentionSettingUp(arg, next) { console.log('Setting up profile "%s" to use Docker.', profile.name); next(); }, /* * Find the `docker` version, if we can. This can be used later to * control some envvars that depend on the docker version. */ function whichDocker(arg, next) { which('docker', function (err, dockerPath) { if (err) { console.log(wordwrap('\nNote: No "docker" was found on ' + 'your PATH. It is not needed for this setup, but ' + 'will be to run docker commands against Triton. ' + 'You can find out how to install it at ' + '.')); } else { arg.dockerPath = dockerPath; log.trace({dockerPath: dockerPath}, 'profileDockerSetup: whichDocker'); } next(); }); }, function getDockerClientVersion(arg, next) { if (!arg.dockerPath) { next(); return; } common.execPlus({ cmd: format('"%s" --version', arg.dockerPath), log: log }, function (err, stdout, stderr) { if (err) { console.log( '\nWarning: Could not determine Docker version:\n%s', common.indent(err.toString())); } else { // E.g.: 'Docker version 1.9.1, build a34a1d5' // JSSTYLED var DOCKER_VER_RE = /^Docker version (.*?), build/; var match = DOCKER_VER_RE.exec(stdout); if (!match) { console.log('\nWarning: Could not determine Docker ' + 'version: output of `%s --version` does not ' + 'match %s: %j', arg.dockerPath, DOCKER_VER_RE, stdout); } else { arg.dockerVersion = match[1]; log.trace({dockerVersion: arg.dockerVersion}, 'profileDockerSetup: getDockerClientVersion'); } } next(); }); }, function getSigningKey(arg, next) { var kr = new auth.KeyRing(); var profileFp = sshpk.parseFingerprint(profile.keyId); kr.findSigningKeyPair(profileFp, function unlockAndStash(findErr, keyPair) { if (findErr) { next(findErr); return; } arg.signKeyPair = keyPair; if (!keyPair.isLocked()) { next(); return; } common.promptPassphraseUnlockKey({ /* Fake the `tritonapi` object, only `.keyPair` is used. */ tritonapi: { keyPair: keyPair } }, next); }); }, function generateAndSignCert(arg, next) { var key = arg.signKeyPair; var pubKey = key.getPublicKey(); /* * There isn't a particular reason this has to be ECDSA, but * Docker supports it, and ECDSA keys are much easier to * generate from inside node than RSA ones (since sshpk will * do them for us instead of us shelling out and mucking with * temporary files). */ arg.privKey = sshpk.generatePrivateKey('ecdsa'); var id = sshpk.identityFromDN('CN=' + profile.account); var parentId = sshpk.identityFromDN('CN=' + pubKey.fingerprint('md5').toString('base64')); var serial = crypto.randomBytes(8); /* * Backdate the certificate by 5 minutes to account for clock * sync -- we only allow 5 mins drift in cloudapi generally so * using the same amount here seems fine. */ var validFrom = new Date(); validFrom.setTime(validFrom.getTime() - 300*1000); var validUntil = new Date(); validUntil.setTime(validFrom.getTime() + 24*3600*1000*opts.lifetime); /* * Generate it self-signed for now -- we will clear this * signature out and replace it with the real one below. */ var cert = sshpk.createCertificate(id, arg.privKey, parentId, arg.privKey, { validFrom: validFrom, validUntil: validUntil, purposes: ['clientAuth', 'joyentDocker'], serial: serial }); var algo = pubKey.type + '-' + pubKey.defaultHashAlgorithm(); /* * This code is using private API in sshpk because there is * no public API as of 1.14.x for async signing of certificates. * * If the sshpk version in package.json is updated (even a * patch bump) this code could break! This will be fixed up * eventually, but for now we just have to be careful. */ var x509 = require('sshpk/lib/formats/x509'); cert.signatures = {}; cert.signatures.x509 = {}; cert.signatures.x509.algo = algo; var signer = key.createSign({ user: profile.account, algorithm: algo }); /* * The smartdc-auth KeyPair signer produces an object with * strings on it intended for http-signature instead of just a * Signature instance (which is what the x509 format module * expects). We wrap it up here to convert it. */ var signerConv = function (buf, ccb) { signer(buf, function convertSignature(signErr, sigData) { if (signErr) { ccb(signErr); return; } var algparts = sigData.algorithm.split('-'); var sig = sshpk.parseSignature(sigData.signature, algparts[0], 'asn1'); sig.hashAlgorithm = algparts[1]; sig.curve = pubKey.curve; ccb(null, sig); }); }; /* * Sign a "test" string first to double-check the hash algo * it's going to use. The SSH agent may not support SHA256 * signatures, for example, and we will only find out by * testing like this. */ signer('test', function afterTestSig(testErr, testSigData) { if (testErr) { next(new errors.SetupError(testErr, format( 'failed to sign Docker certificate using key ' + '"%s"', profile.keyId))); return; } cert.signatures.x509.algo = testSigData.algorithm; x509.signAsync(cert, signerConv, function afterCertSign(signErr) { if (signErr) { next(new errors.SetupError(signErr, format( 'failed to sign Docker certificate using key ' + '"%s"', profile.keyId))); return; } cert.issuerKey = undefined; /* Double-check that it came out ok. */ assert.ok(cert.isSignedByKey(pubKey)); arg.cert = cert; next(); }); }); }, function makeClientCertDir(arg, next) { arg.dockerCertPath = path.resolve(cli.configDir, 'docker', common.profileSlug(profile)); mkdirp(arg.dockerCertPath, next); }, function writeClientCertKey(arg, next) { arg.keyPath = path.resolve(arg.dockerCertPath, 'key.pem'); var data = arg.privKey.toBuffer('pkcs1'); fs.writeFile(arg.keyPath, data, function (err) { if (err) { next(new errors.SetupError(err, format( 'error writing file %s', arg.keyPath))); } else { next(); } }); }, function writeClientCert(arg, next) { arg.certPath = path.resolve(arg.dockerCertPath, 'cert.pem'); var data = arg.cert.toBuffer('pem'); fs.writeFile(arg.certPath, data, function (err) { if (err) { next(new errors.SetupError(err, format( 'error writing file %s', arg.keyPath))); } else { next(); } }); }, function getServerCa(arg, next) { arg.caPath = path.resolve(arg.dockerCertPath, 'ca.pem'); var caUrl = dockerHost.replace(/^tcp:/, 'https:') + '/ca.pem'; downloadUrl({ url: caUrl, dest: arg.caPath, insecure: profile.insecure }, next); }, function writeSetupJson(arg, next) { var setupJson = path.resolve(arg.dockerCertPath, 'setup.json'); var setup = { profile: profile, time: (new Date()).toISOString(), env: { DOCKER_CERT_PATH: arg.dockerCertPath, DOCKER_HOST: dockerHost, DOCKER_TLS_VERIFY: '1' } }; if (profile.insecure) { setup.env.DOCKER_TLS_VERIFY = null; // signal to unset it } else { setup.env.DOCKER_TLS_VERIFY = '1'; } /* * Docker version 1.9.0 was released at the same time as * Docker Compose 1.5.0. In that version they changed from using * DOCKER_CLIENT_TIMEOUT to COMPOSE_HTTP_TIMEOUT and, * annoyingly, added a warning that the user sees for all * `compose ...` runs about DOCKER_CLIENT_TIMEOUT being * deprecated. We want to avoid that deprecation message, * but need the timeout value. * * This isn't fool proof (using mismatched `docker` and * `docker-compose` versions). It is debatable if we want to * play this game. E.g. someone moving from Docker 1.8 to newer, * *but not re-setting up envvars* may start hitting timeouts. * * TODO: consider using `docker-compose` version on PATH? */ if (!arg.dockerVersion) { setup.env.DOCKER_CLIENT_TIMEOUT = '300'; setup.env.COMPOSE_HTTP_TIMEOUT = '300'; } else if (!semver.parse(arg.dockerVersion) || semver.gte(arg.dockerVersion, '1.9.0')) { // If version isn't valid semver, we are certain it's >= 1.9 // since all versions of Docker before 1.9 *were*. setup.env.COMPOSE_HTTP_TIMEOUT = '300'; } else { setup.env.DOCKER_CLIENT_TIMEOUT = '300'; } fs.writeFile(setupJson, JSON.stringify(setup, null, 4) + '\n', next); }, function mentionSuccess(arg, next) { console.log([ 'Successfully setup profile "%s" to use Docker%s.', '', 'To setup environment variables to use the Docker client, run:', ' eval "$(triton env --docker %s)"', ' docker%s info', 'Or you can place the commands in your shell profile, e.g.:', ' triton env --docker %s >> ~/.profile' ].join('\n'), profile.name, (arg.dockerVersion ? format(' (v%s)', arg.dockerVersion) : ''), profile.name, (profile.insecure ? ' --tls' : ''), profile.name); next(); } ]}, function (err) { tritonapi.close(); if (err === true) { // Early-abort signal. err = null; } if (err && !dockerHost && implicit) { console.error('Warning: Error determining ' + 'if CloudAPI "%s" provides a Docker service:\n %s', profile.url, err); err = null; } cb(err); }); } module.exports = { setCurrentProfile: setCurrentProfile, profileDockerSetup: profileDockerSetup };