diff --git a/migrations/20201120212726-apps-add-containerIp.js b/migrations/20201120212726-apps-add-containerIp.js new file mode 100644 index 000000000..6c51a494f --- /dev/null +++ b/migrations/20201120212726-apps-add-containerIp.js @@ -0,0 +1,29 @@ +'use strict'; + +const async = require('async'), + iputils = require('../src/iputils.js'); + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN containerIp VARCHAR(16) UNIQUE', function (error) { + if (error) console.error(error); + + let baseIp = iputils.intFromIp('172.18.16.0'); + + db.all('SELECT * FROM apps', function (error, apps) { + if (error) return callback(error); + + async.eachSeries(apps, function (app, iteratorDone) { + const nextIp = iputils.ipFromInt(++baseIp); + db.runSql('UPDATE apps SET containerIp=? WHERE id=?', [ nextIp, app.id ], iteratorDone); + }, callback); + }); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE apps DROP COLUMN containerIp', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + diff --git a/migrations/schema.sql b/migrations/schema.sql index 54cc40ba2..39aff9f65 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS apps( taskId INTEGER, // current task errorJson TEXT, servicesConfigJson TEXT, // app services configuration + containerIp VARCHAR(16) UNIQUE, // this is not-null because of ip allocation fails, user can 'repair' FOREIGN KEY(mailboxDomain) REFERENCES domains(domain), FOREIGN KEY(taskId) REFERENCES tasks(id), diff --git a/runTests b/runTests index 7c07f0b7c..7557f16b4 100755 --- a/runTests +++ b/runTests @@ -34,7 +34,7 @@ echo "=> Delete all docker containers first" docker ps -qa | xargs --no-run-if-empty docker rm -f # create docker network (while the infra code does this, most tests skip infra setup) -docker network create --subnet=172.18.0.0/16 cloudron || true +docker network create --subnet=172.18.0.0/16 --ip-range=172.18.0.0/20 cloudron || true # create the same mysql server version to test with OUT=`docker inspect mysql-server` || true diff --git a/setup/start.sh b/setup/start.sh index e8cbeca1a..3f535d782 100755 --- a/setup/start.sh +++ b/setup/start.sh @@ -32,7 +32,6 @@ systemctl enable apparmor systemctl restart apparmor usermod ${USER} -a -G docker -docker network create --subnet=172.18.0.0/16 cloudron || true mkdir -p "${BOX_DATA_DIR}" mkdir -p "${APPS_DATA_DIR}" diff --git a/src/appdb.js b/src/appdb.js index 884b55cce..30a97f4dc 100644 --- a/src/appdb.js +++ b/src/appdb.js @@ -41,7 +41,7 @@ var APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationSta 'apps.health', 'apps.containerId', 'apps.manifestJson', 'subdomains.subdomain AS location', 'subdomains.domain', 'apps.accessRestrictionJson', 'apps.memoryLimit', 'apps.cpuShares', 'apps.label', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', 'apps.servicesConfigJson', - 'apps.sso', 'apps.debugModeJson', 'apps.enableBackup', 'apps.proxyAuth', + 'apps.sso', 'apps.debugModeJson', 'apps.enableBackup', 'apps.proxyAuth', 'apps.containerIp', 'apps.creationTime', 'apps.updateTime', 'apps.mailboxName', 'apps.mailboxDomain', 'apps.enableAutomaticUpdate', 'apps.dataDir', 'apps.ts', 'apps.healthTime' ].join(','); diff --git a/src/apphealthmonitor.js b/src/apphealthmonitor.js index 9f4595353..edc46e2d0 100644 --- a/src/apphealthmonitor.js +++ b/src/apphealthmonitor.js @@ -85,11 +85,7 @@ function checkAppHealth(app, callback) { // non-appstore apps may not have healthCheckPath if (!manifest.healthCheckPath) return setHealth(app, apps.HEALTH_HEALTHY, callback); - const ip = safe.query(data, 'NetworkSettings.Networks.cloudron.IPAddress', null); - if (!ip) return setHealth(app, apps.HEALTH_ERROR, callback); - - // poll through docker network instead of nginx to bypass any potential oauth proxy - var healthCheckUrl = `http://${ip}:${manifest.httpPort}${manifest.healthCheckPath}`; + const healthCheckUrl = `http://${app.containerIp}:${manifest.httpPort}${manifest.healthCheckPath}`; superagent .get(healthCheckUrl) .set('Host', app.fqdn) // required for some apache configs with rewrite rules diff --git a/src/apptask.js b/src/apptask.js index e49202f7e..252865989 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -33,6 +33,7 @@ var addons = require('./addons.js'), ejs = require('ejs'), eventlog = require('./eventlog.js'), fs = require('fs'), + iputils = require('./iputils.js'), manifestFormat = require('cloudron-manifestformat'), os = require('os'), path = require('path'), @@ -87,6 +88,18 @@ function updateApp(app, values, callback) { }); } +function allocateContainerIp(app, callback) { + assert.strictEqual(typeof app, 'object'); + assert.strictEqual(typeof callback, 'function'); + + async.retry({ times: 10 }, function (retryCallback) { + const iprange = iputils.intFromIp('172.18.20.255') - iputils.intFromIp('172.18.16.1'); + let rnd = Math.floor(Math.random() * iprange); + const containerIp = iputils.ipFromInt(iputils.intFromIp('172.18.16.1') + rnd); + updateApp(app, { containerIp }, retryCallback); + }, callback); +} + function configureReverseProxy(app, callback) { assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof callback, 'function'); @@ -144,7 +157,6 @@ function deleteContainers(app, options, callback) { removeLogrotateConfig.bind(null, app), docker.stopContainers.bind(null, app.id), docker.deleteContainers.bind(null, app.id, options), - unconfigureReverseProxy.bind(null, app), updateApp.bind(null, app, { containerId: null }) ], callback); } @@ -469,10 +481,7 @@ function startApp(app, callback){ if (app.runState === apps.RSTATE_STOPPED) return callback(); - async.series([ - docker.startContainer.bind(null, app.id), - configureReverseProxy.bind(null, app), - ], callback); + docker.startContainer(app.id, callback); } function install(app, args, progressCallback, callback) { @@ -492,6 +501,7 @@ function install(app, args, progressCallback, callback) { // teardown for re-installs progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }), + unconfigureReverseProxy.bind(null, app), deleteContainers.bind(null, app, { managedOnly: true }), function teardownAddons(next) { // when restoring, app does not require these addons anymore. remove carefully to preserve the db passwords @@ -517,6 +527,9 @@ function install(app, args, progressCallback, callback) { docker.deleteImage(oldManifest, done); }, + // allocating container ip here, lets the users "repair" an app if allocation fails at appdb.add time + allocateContainerIp.bind(null, app), + progressCallback.bind(null, { percent: 20, message: 'Downloading icon' }), downloadIcon.bind(null, app), @@ -563,6 +576,9 @@ function install(app, args, progressCallback, callback) { progressCallback.bind(null, { percent: 85, message: 'Waiting for DNS propagation' }), exports._waitForDnsPropagation.bind(null, app), + progressCallback.bind(null, { percent: 95, message: 'Configuring reverse proxy' }), + configureReverseProxy.bind(null, app), + progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) ], function seriesDone(error) { @@ -640,6 +656,7 @@ function changeLocation(app, args, progressCallback, callback) { async.series([ progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }), + unconfigureReverseProxy.bind(null, app), deleteContainers.bind(null, app, { managedOnly: true }), function (next) { let obsoleteDomains = oldConfig.alternateDomains.filter(function (o) { @@ -668,6 +685,9 @@ function changeLocation(app, args, progressCallback, callback) { progressCallback.bind(null, { percent: 80, message: 'Waiting for DNS propagation' }), exports._waitForDnsPropagation.bind(null, app), + progressCallback.bind(null, { percent: 90, message: 'Configuring reverse proxy' }), + configureReverseProxy.bind(null, app), + progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) ], function seriesDone(error) { @@ -728,6 +748,7 @@ function configure(app, args, progressCallback, callback) { async.series([ progressCallback.bind(null, { percent: 10, message: 'Cleaning up old install' }), + unconfigureReverseProxy.bind(null, app), deleteContainers.bind(null, app, { managedOnly: true }), progressCallback.bind(null, { percent: 20, message: 'Downloading icon' }), @@ -748,6 +769,9 @@ function configure(app, args, progressCallback, callback) { startApp.bind(null, app), + progressCallback.bind(null, { percent: 90, message: 'Configuring reverse proxy' }), + configureReverseProxy.bind(null, app), + progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null }) ], function seriesDone(error) { @@ -771,7 +795,8 @@ function update(app, args, progressCallback, callback) { // app does not want these addons anymore // FIXME: this does not handle option changes (like multipleDatabases) - var unusedAddons = _.omit(app.manifest.addons, Object.keys(updateConfig.manifest.addons)); + const unusedAddons = _.omit(app.manifest.addons, Object.keys(updateConfig.manifest.addons)); + const httpPathsChanged = app.manifest.httpPaths !== updateConfig.manifest.httpPaths; async.series([ // this protects against the theoretical possibility of an app being marked for update from @@ -846,6 +871,14 @@ function update(app, args, progressCallback, callback) { startApp.bind(null, app), + // needed for httpPaths changes + progressCallback.bind(null, { percent: 90, message: 'Configuring reverse proxy' }), + function (next) { + if (!httpPathsChanged) return next(); + + configureReverseProxy(app, next); + }, + progressCallback.bind(null, { percent: 100, message: 'Done' }), updateApp.bind(null, app, { installationState: apps.ISTATE_INSTALLED, error: null, health: null, updateTime: new Date() }) ], function seriesDone(error) { @@ -948,6 +981,7 @@ function uninstall(app, args, progressCallback, callback) { async.series([ progressCallback.bind(null, { percent: 20, message: 'Deleting container' }), + unconfigureReverseProxy.bind(null, app), deleteContainers.bind(null, app, {}), progressCallback.bind(null, { percent: 30, message: 'Teardown addons' }), diff --git a/src/docker.js b/src/docker.js index b1687b3cb..4fd093d1b 100644 --- a/src/docker.js +++ b/src/docker.js @@ -343,6 +343,9 @@ function createSubcontainer(app, name, cmd, options, callback) { containerOptions.NetworkingConfig = { EndpointsConfig: { cloudron: { + IPAMConfig: { + IPv4Address: app.containerIp + }, Aliases: [ name ] // adds hostname entry with container name } } diff --git a/src/infra_version.js b/src/infra_version.js index f38b6fe12..d6ddde512 100644 --- a/src/infra_version.js +++ b/src/infra_version.js @@ -6,7 +6,7 @@ exports = module.exports = { // a version change recreates all containers with latest docker config - 'version': '48.18.0', + 'version': '48.19.0', 'baseImages': [ { repo: 'cloudron/base', tag: 'cloudron/base:2.0.0@sha256:f9fea80513aa7c92fe2e7bf3978b54c8ac5222f47a9a32a7f8833edf0eb5a4f4' } diff --git a/src/iputils.js b/src/iputils.js new file mode 100644 index 000000000..a8e88bfd2 --- /dev/null +++ b/src/iputils.js @@ -0,0 +1,35 @@ +'use strict'; + +exports = module.exports = { + ipFromInt, + intFromIp +}; + +const assert = require('assert'); + +function intFromIp(address) { + assert.strictEqual(typeof address, 'string'); + + const parts = address.split('.'); + + if (parts.length !== 4) return null; + + return (parseInt(parts[0], 10) << (8*3)) & 0xFF000000 | + (parseInt(parts[1], 10) << (8*2)) & 0x00FF0000 | + (parseInt(parts[2], 10) << (8*1)) & 0x0000FF00 | + (parseInt(parts[3], 10) << (8*0)) & 0x000000FF; +} + +function ipFromInt(input) { + assert.strictEqual(typeof input, 'number'); + + let output = []; + + for (let i = 3; i >= 0; --i) { + const octet = (input >> (i*8)) & 0x000000FF; + output.push(octet); + } + + return output.join('.'); +} + diff --git a/src/platform.js b/src/platform.js index 21e5691b9..e4eab8a82 100644 --- a/src/platform.js +++ b/src/platform.js @@ -54,7 +54,8 @@ function start(callback) { if (error) return callback(error); async.series([ - (next) => { if (existingInfra.version !== infra.version) removeAllContainers(existingInfra, next); else next(); }, + (next) => { if (existingInfra.version !== infra.version) removeAllContainers(next); else next(); }, + createDockerNetwork, markApps.bind(null, existingInfra), // mark app state before we start addons. this gives the db import logic a chance to mark an app as errored graphs.startGraphite.bind(null, existingInfra), sftp.startSftp.bind(null, existingInfra), @@ -122,7 +123,7 @@ function pruneInfraImages(callback) { }, callback); } -function removeAllContainers(existingInfra, callback) { +function removeAllContainers(callback) { debug('removeAllContainers: removing all containers for infra upgrade'); async.series([ @@ -131,6 +132,16 @@ function removeAllContainers(existingInfra, callback) { ], callback); } +function createDockerNetwork(callback) { + debug('createDockerNetwork: creating cloudron network'); + + // this gives docker the network range 172.18.0.0-172.18.15.255 + async.series([ + shell.exec.bind(null, 'createDockerNetwork', 'docker network rm cloudron || true'), // may not exist on first run + shell.exec.bind(null, 'createDockerNetwork', 'docker network create --subnet=172.18.0.0/16 --ip-range=172.18.0.0/20 cloudron || true') // can fail if (user) containers are still attached + ], callback); +} + function markApps(existingInfra, callback) { if (existingInfra.version === 'none') { // cloudron is being restored from backup debug('markApps: restoring installed apps'); diff --git a/src/reverseproxy.js b/src/reverseproxy.js index 5fcda8104..b716c8cb6 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -38,7 +38,6 @@ var acme2 = require('./cert/acme2.js'), constants = require('./constants.js'), crypto = require('crypto'), debug = require('debug')('box:reverseproxy'), - docker = require('./docker.js'), domains = require('./domains.js'), ejs = require('ejs'), eventlog = require('./eventlog.js'), @@ -434,52 +433,48 @@ function writeAppNginxConfig(app, bundle, callback) { assert.strictEqual(typeof bundle, 'object'); assert.strictEqual(typeof callback, 'function'); - docker.getContainerIp(app.containerId, function (error, ip) { - if (error) return callback(error); + var sourceDir = path.resolve(__dirname, '..'); + var endpoint = 'app'; - var sourceDir = path.resolve(__dirname, '..'); - var endpoint = 'app'; + let robotsTxtQuoted = null, hideHeaders = [], cspQuoted = null; + const reverseProxyConfig = app.reverseProxyConfig || {}; // some of our code uses fake app objects + if (reverseProxyConfig.robotsTxt) robotsTxtQuoted = JSON.stringify(app.reverseProxyConfig.robotsTxt); + if (reverseProxyConfig.csp) { + cspQuoted = `"${app.reverseProxyConfig.csp}"`; + hideHeaders = [ 'Content-Security-Policy' ]; + if (reverseProxyConfig.csp.includes('frame-ancestors ')) hideHeaders.push('X-Frame-Options'); + } - let robotsTxtQuoted = null, hideHeaders = [], cspQuoted = null; - const reverseProxyConfig = app.reverseProxyConfig || {}; // some of our code uses fake app objects - if (reverseProxyConfig.robotsTxt) robotsTxtQuoted = JSON.stringify(app.reverseProxyConfig.robotsTxt); - if (reverseProxyConfig.csp) { - cspQuoted = `"${app.reverseProxyConfig.csp}"`; - hideHeaders = [ 'Content-Security-Policy' ]; - if (reverseProxyConfig.csp.includes('frame-ancestors ')) hideHeaders.push('X-Frame-Options'); - } + var data = { + sourceDir: sourceDir, + adminOrigin: settings.adminOrigin(), + vhost: app.fqdn, + hasIPv6: sysinfo.hasIPv6(), + ip: app.containerIp, + port: app.manifest.httpPort, + endpoint: endpoint, + certFilePath: bundle.certFilePath, + keyFilePath: bundle.keyFilePath, + robotsTxtQuoted, + cspQuoted, + hideHeaders, + proxyAuth: { + enabled: app.sso && app.manifest.addons && app.manifest.addons.proxyAuth, + id: app.id + }, + httpPaths: app.manifest.httpPaths || {} + }; + var nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data); - var data = { - sourceDir: sourceDir, - adminOrigin: settings.adminOrigin(), - vhost: app.fqdn, - hasIPv6: sysinfo.hasIPv6(), - ip, - port: app.manifest.httpPort, - endpoint: endpoint, - certFilePath: bundle.certFilePath, - keyFilePath: bundle.keyFilePath, - robotsTxtQuoted, - cspQuoted, - hideHeaders, - proxyAuth: { - enabled: app.sso && app.manifest.addons && app.manifest.addons.proxyAuth, - id: app.id - }, - httpPaths: app.manifest.httpPaths || {} - }; - var nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data); + var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf'); + debug('writeAppNginxConfig: writing config for "%s" to %s with options %j', app.fqdn, nginxConfigFilename, data); - var nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf'); - debug('writeAppNginxConfig: writing config for "%s" to %s with options %j', app.fqdn, nginxConfigFilename, data); + if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) { + debug('Error creating nginx config for "%s" : %s', app.fqdn, safe.error.message); + return callback(new BoxError(BoxError.FS_ERROR, safe.error)); + } - if (!safe.fs.writeFileSync(nginxConfigFilename, nginxConf)) { - debug('Error creating nginx config for "%s" : %s', app.fqdn, safe.error.message); - return callback(new BoxError(BoxError.FS_ERROR, safe.error)); - } - - reload(callback); - }); + reload(callback); } function writeAppRedirectNginxConfig(app, fqdn, bundle, callback) { diff --git a/src/test/database-test.js b/src/test/database-test.js index d3a8e2cc0..b76f727f3 100644 --- a/src/test/database-test.js +++ b/src/test/database-test.js @@ -398,6 +398,7 @@ describe('database', function () { domain: DOMAIN_0.domain, manifest: { version: '0.1', dockerImage: 'docker/app0', healthCheckPath: '/', httpPort: 80, title: 'app0' }, containerId: null, + containerIp: null, portBindings: { port: { hostPort: 5678, type: 'tcp' } }, health: null, accessRestriction: null, @@ -869,6 +870,7 @@ describe('database', function () { domain: DOMAIN_0.domain, manifest: { version: '0.1', dockerImage: 'docker/app0', healthCheckPath: '/', httpPort: 80, title: 'app0' }, containerId: null, + containerIp: null, portBindings: { port: { hostPort: 5678, type: 'tcp' } }, health: null, accessRestriction: null, @@ -904,6 +906,7 @@ describe('database', function () { domain: DOMAIN_0.domain, manifest: { version: '0.2', dockerImage: 'docker/app1', healthCheckPath: '/', httpPort: 80, title: 'app1' }, containerId: null, + containerIp: null, portBindings: { }, health: null, accessRestriction: { users: [ 'foobar' ] },