diff --git a/migrations/20220115070646-subdomains-add-environmentVariable.js b/migrations/20220115070646-subdomains-add-environmentVariable.js new file mode 100644 index 000000000..b0b3c0d18 --- /dev/null +++ b/migrations/20220115070646-subdomains-add-environmentVariable.js @@ -0,0 +1,15 @@ +'use strict'; + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE subdomains ADD COLUMN environmentVariable VARCHAR(128)', function (error) { + if (error) console.error(error); + callback(error); + }); +}; + +exports.down = function(db, callback) { + db.runSql('ALTER TABLE subdomains DROP COLUMN environmentVariable', function (error) { + if (error) console.error(error); + callback(error); + }); +}; diff --git a/migrations/schema.sql b/migrations/schema.sql index 13c53fc1c..090553f90 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -218,7 +218,8 @@ CREATE TABLE IF NOT EXISTS subdomains( appId VARCHAR(128) NOT NULL, domain VARCHAR(128) NOT NULL, subdomain VARCHAR(128) NOT NULL, - type VARCHAR(128) NOT NULL, /* primary or redirect */ + type VARCHAR(128) NOT NULL, /* primary, secondary, redirect, alias */ + environmentVariable VARCHAR(128), /* only set for secondary */ certificateJson MEDIUMTEXT, diff --git a/package-lock.json b/package-lock.json index 3eef7542c..804efacdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "aws-sdk": "^2.1053.0", "basic-auth": "^2.0.1", "body-parser": "^1.19.1", - "cloudron-manifestformat": "^5.14.0", + "cloudron-manifestformat": "^5.15.0", "connect": "^3.7.0", "connect-lastmile": "^2.1.1", "connect-timeout": "^1.9.0", @@ -917,9 +917,9 @@ } }, "node_modules/cloudron-manifestformat": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/cloudron-manifestformat/-/cloudron-manifestformat-5.14.0.tgz", - "integrity": "sha512-2rDBzCvDJDWIKVQQfssxd5xdSOEROQUYgBYKAh10czWjlGnJDfqGpVRpdFwejN+R4Cu58rL1rpyvoDQCsiawSw==", + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/cloudron-manifestformat/-/cloudron-manifestformat-5.15.0.tgz", + "integrity": "sha512-Z8pfoKu98xriysit4WZp0tC0eLNSB+B7pG2WoiXDdg1p7YrM/ux8z0cF/sV4LxjvYBGuEFFwZ5yhbCQAAYvSEg==", "dependencies": { "cron": "^1.8.2", "java-packagename-regex": "^1.0.0", @@ -7490,9 +7490,9 @@ } }, "cloudron-manifestformat": { - "version": "5.14.0", - "resolved": "https://registry.npmjs.org/cloudron-manifestformat/-/cloudron-manifestformat-5.14.0.tgz", - "integrity": "sha512-2rDBzCvDJDWIKVQQfssxd5xdSOEROQUYgBYKAh10czWjlGnJDfqGpVRpdFwejN+R4Cu58rL1rpyvoDQCsiawSw==", + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/cloudron-manifestformat/-/cloudron-manifestformat-5.15.0.tgz", + "integrity": "sha512-Z8pfoKu98xriysit4WZp0tC0eLNSB+B7pG2WoiXDdg1p7YrM/ux8z0cF/sV4LxjvYBGuEFFwZ5yhbCQAAYvSEg==", "requires": { "cron": "^1.8.2", "java-packagename-regex": "^1.0.0", diff --git a/package.json b/package.json index 4968ea790..22f07889e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "aws-sdk": "^2.1053.0", "basic-auth": "^2.0.1", "body-parser": "^1.19.1", - "cloudron-manifestformat": "^5.14.0", + "cloudron-manifestformat": "^5.15.0", "connect": "^3.7.0", "connect-lastmile": "^2.1.1", "connect-timeout": "^1.9.0", diff --git a/src/apps.js b/src/apps.js index 1acb6bcf6..4e6f2a0f7 100644 --- a/src/apps.js +++ b/src/apps.js @@ -127,6 +127,7 @@ exports = module.exports = { // subdomain table types SUBDOMAIN_TYPE_PRIMARY: 'primary', + SUBDOMAIN_TYPE_SECONDARY: 'secondary', SUBDOMAIN_TYPE_REDIRECT: 'redirect', SUBDOMAIN_TYPE_ALIAS: 'alias', @@ -186,7 +187,6 @@ const APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationS // const PORT_BINDINGS_FIELDS = [ 'hostPort', 'type', 'environmentVariable', 'appId' ].join(','); -// validate the port bindings function validatePortBindings(portBindings, manifest) { assert.strictEqual(typeof portBindings, 'object'); assert.strictEqual(typeof manifest, 'object'); @@ -269,6 +269,29 @@ function translatePortBindings(portBindings, manifest) { return result; } +function validateSecondaryDomains(secondaryDomains, manifest) { + assert.strictEqual(typeof secondaryDomains, 'object'); + assert.strictEqual(typeof manifest, 'object'); + + const httpPorts = manifest.httpPorts || {}; + + for (const envName in secondaryDomains) { + if (!(envName in httpPorts)) return new BoxError(BoxError.BAD_FIELD, `Invalid secondaryDomain in ${envName}`); + } + + return null; +} + +function translateSecondaryDomains(secondaryDomains) { + assert.strictEqual(typeof secondaryDomains, 'object'); + + const result = []; + for (const envName in secondaryDomains) { + result.push([ { domain: secondaryDomains[envName].domain, subdomain: secondaryDomains[envName].subdomain, environmentVariable: envName }]); + } + return result; +} + function parseCrontab(crontab) { assert(crontab === null || typeof crontab === 'string'); @@ -499,14 +522,14 @@ function removeInternalFields(app) { 'location', 'domain', 'fqdn', 'crontab', 'accessRestriction', 'manifest', 'portBindings', 'iconUrl', 'memoryLimit', 'cpuShares', 'operators', 'sso', 'debugMode', 'reverseProxyConfig', 'enableBackup', 'creationTime', 'updateTime', 'ts', 'tags', - 'label', 'redirectDomains', 'aliasDomains', 'env', 'enableAutomaticUpdate', 'dataDir', 'mounts', + 'label', 'secondaryDomains', 'redirectDomains', 'aliasDomains', 'env', 'enableAutomaticUpdate', 'dataDir', 'mounts', 'enableMailbox', 'mailboxName', 'mailboxDomain', 'enableInbox', 'inboxName', 'inboxDomain'); } // non-admins can only see these function removeRestrictedFields(app) { return _.pick(app, - 'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', 'accessRestriction', 'redirectDomains', 'aliasDomains', 'sso', + 'id', 'appStoreId', 'installationState', 'error', 'runState', 'health', 'taskId', 'accessRestriction', 'secondaryDomains', 'redirectDomains', 'aliasDomains', 'sso', 'location', 'domain', 'fqdn', 'manifest', 'portBindings', 'iconUrl', 'creationTime', 'ts', 'tags', 'label', 'enableBackup'); } @@ -602,17 +625,25 @@ function postProcess(result) { result.servicesConfig = safe.JSON.parse(result.servicesConfigJson) || {}; delete result.servicesConfigJson; - let subdomains = JSON.parse(result.subdomains), domains = JSON.parse(result.domains), subdomainTypes = JSON.parse(result.subdomainTypes); + const subdomains = JSON.parse(result.subdomains), + domains = JSON.parse(result.domains), + subdomainTypes = JSON.parse(result.subdomainTypes), + subdomainEnvironmentVariables = JSON.parse(result.subdomainEnvironmentVariables); + delete result.subdomains; delete result.domains; delete result.subdomainTypes; + delete result.subdomainEnvironmentVariables; + result.secondaryDomains = []; result.redirectDomains = []; result.aliasDomains = []; for (let i = 0; i < subdomainTypes.length; i++) { if (subdomainTypes[i] === exports.SUBDOMAIN_TYPE_PRIMARY) { result.location = subdomains[i]; result.domain = domains[i]; + } else if (subdomainTypes[i] === exports.SUBDOMAIN_TYPE_SECONDARY) { + result.secondaryDomains.push({ domain: domains[i], subdomain: subdomains[i], environmentVariable: subdomainEnvironmentVariables[i] }); } else if (subdomainTypes[i] === exports.SUBDOMAIN_TYPE_REDIRECT) { result.redirectDomains.push({ domain: domains[i], subdomain: subdomains[i] }); } else if (subdomainTypes[i] === exports.SUBDOMAIN_TYPE_ALIAS) { @@ -652,6 +683,7 @@ function attachProperties(app, domainObjectMap) { app.portBindings = result; app.iconUrl = app.hasIcon || app.hasAppStoreIcon ? `/api/v1/apps/${app.id}/icon` : null; app.fqdn = dns.fqdn(app.location, domainObjectMap[app.domain]); + app.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); app.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); app.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); } @@ -755,6 +787,15 @@ async function add(id, appStoreId, manifest, location, domain, portBindings, dat }); }); + if (data.secondaryDomains) { + data.secondaryDomains.forEach(function (d) { + queries.push({ + query: 'INSERT INTO subdomains (appId, domain, subdomain, type, environmentVariable) VALUES (?, ?, ?, ?, ?)', + args: [ id, d.domain, d.subdomain, exports.SUBDOMAIN_TYPE_SECONDARY, d.environmentVariable ] + }); + }); + } + if (data.redirectDomains) { data.redirectDomains.forEach(function (d) { queries.push({ @@ -793,6 +834,7 @@ async function updateWithConstraints(id, app, constraints) { assert.strictEqual(typeof constraints, 'string'); assert(!('portBindings' in app) || typeof app.portBindings === 'object'); assert(!('accessRestriction' in app) || typeof app.accessRestriction === 'object' || app.accessRestriction === ''); + assert(!('secondaryDomains' in app) || Array.isArray(app.secondaryDomains)); assert(!('redirectDomains' in app) || Array.isArray(app.redirectDomains)); assert(!('aliasDomains' in app) || Array.isArray(app.aliasDomains)); assert(!('tags' in app) || Array.isArray(app.tags)); @@ -825,6 +867,12 @@ async function updateWithConstraints(id, app, constraints) { queries.push({ query: 'DELETE FROM subdomains WHERE appId = ?', args: [ id ]}); // all locations of an app must be updated together queries.push({ query: 'INSERT INTO subdomains (appId, domain, subdomain, type) VALUES (?, ?, ?, ?)', args: [ id, app.domain, app.location, exports.SUBDOMAIN_TYPE_PRIMARY ]}); + if ('secondaryDomains' in app) { + app.secondaryDomains.forEach(function (d) { + queries.push({ query: 'INSERT INTO subdomains (appId, domain, subdomain, type, environmentVariable) VALUES (?, ?, ?, ?, ?)', args: [ id, d.domain, d.subdomain, exports.SUBDOMAIN_TYPE_SECONDARY, d.environmentVariable ]}); + }); + } + if ('redirectDomains' in app) { app.redirectDomains.forEach(function (d) { queries.push({ query: 'INSERT INTO subdomains (appId, domain, subdomain, type) VALUES (?, ?, ?, ?)', args: [ id, d.domain, d.subdomain, exports.SUBDOMAIN_TYPE_REDIRECT ]}); @@ -850,7 +898,7 @@ async function updateWithConstraints(id, app, constraints) { if (p === 'manifest' || p === 'tags' || p === 'accessRestriction' || p === 'debugMode' || p === 'error' || p === 'reverseProxyConfig' || p === 'servicesConfig' || p === 'operators') { fields.push(`${p}Json = ?`); values.push(JSON.stringify(app[p])); - } else if (p !== 'portBindings' && p !== 'location' && p !== 'domain' && p !== 'redirectDomains' && p !== 'aliasDomains' && p !== 'env' && p !== 'mounts') { + } else if (p !== 'portBindings' && p !== 'location' && p !== 'domain' && p !== 'secondaryDomains' && p !== 'redirectDomains' && p !== 'aliasDomains' && p !== 'env' && p !== 'mounts') { fields.push(p + ' = ?'); values.push(app[p]); } @@ -940,9 +988,9 @@ async function getDomainObjectMap() { // each query simply join apps table with another table by id. we then join the full result together const PB_QUERY = 'SELECT id, GROUP_CONCAT(CAST(appPortBindings.hostPort AS CHAR(6))) AS hostPorts, GROUP_CONCAT(appPortBindings.environmentVariable) AS environmentVariables, GROUP_CONCAT(appPortBindings.type) AS portTypes FROM apps LEFT JOIN appPortBindings ON apps.id = appPortBindings.appId GROUP BY apps.id'; const ENV_QUERY = 'SELECT id, JSON_ARRAYAGG(appEnvVars.name) AS envNames, JSON_ARRAYAGG(appEnvVars.value) AS envValues FROM apps LEFT JOIN appEnvVars ON apps.id = appEnvVars.appId GROUP BY apps.id'; -const SUBDOMAIN_QUERY = 'SELECT id, JSON_ARRAYAGG(subdomains.subdomain) AS subdomains, JSON_ARRAYAGG(subdomains.domain) AS domains, JSON_ARRAYAGG(subdomains.type) AS subdomainTypes FROM apps LEFT JOIN subdomains ON apps.id = subdomains.appId GROUP BY apps.id'; +const SUBDOMAIN_QUERY = 'SELECT id, JSON_ARRAYAGG(subdomains.subdomain) AS subdomains, JSON_ARRAYAGG(subdomains.domain) AS domains, JSON_ARRAYAGG(subdomains.type) AS subdomainTypes, JSON_ARRAYAGG(subdomains.environmentVariable) AS subdomainEnvironmentVariables FROM apps LEFT JOIN subdomains ON apps.id = subdomains.appId GROUP BY apps.id'; const MOUNTS_QUERY = 'SELECT id, JSON_ARRAYAGG(appMounts.volumeId) AS volumeIds, JSON_ARRAYAGG(appMounts.readOnly) AS volumeReadOnlys FROM apps LEFT JOIN appMounts ON apps.id = appMounts.appId GROUP BY apps.id'; -const APPS_QUERY = `SELECT ${APPS_FIELDS_PREFIXED}, hostPorts, environmentVariables, portTypes, envNames, envValues, subdomains, domains, subdomainTypes, volumeIds, volumeReadOnlys FROM apps` +const APPS_QUERY = `SELECT ${APPS_FIELDS_PREFIXED}, hostPorts, environmentVariables, portTypes, envNames, envValues, subdomains, domains, subdomainTypes, subdomainEnvironmentVariables, volumeIds, volumeReadOnlys FROM apps` + ` LEFT JOIN (${PB_QUERY}) AS q1 on q1.id = apps.id` + ` LEFT JOIN (${ENV_QUERY}) AS q2 on q2.id = apps.id` + ` LEFT JOIN (${SUBDOMAIN_QUERY}) AS q3 on q3.id = apps.id` @@ -1222,6 +1270,14 @@ async function install(data, auditSource) { error = validateTags(tags); if (error) throw error; + let secondaryDomains = []; + if ('secondaryDomains' in data) { + error = validateSecondaryDomains(data.secondaryDomains, manifest); + if (error) throw error; + + secondaryDomains = translateSecondaryDomains(data.secondaryDomains); + } + let sso = 'sso' in data ? data.sso : null; if ('sso' in data && !('optionalSso' in manifest)) throw new BoxError(BoxError.BAD_FIELD, 'sso can only be specified for apps with optionalSso'); // if sso was unspecified, enable it by default if possible @@ -1243,9 +1299,10 @@ async function install(data, auditSource) { icon = Buffer.from(icon, 'base64'); } - const locations = [{ subdomain: location, domain, type: 'primary' }] - .concat(redirectDomains.map(ad => _.extend(ad, { type: 'redirect' }))) - .concat(aliasDomains.map(ad => _.extend(ad, { type: 'alias' }))); + const locations = [{ subdomain: location, domain, type: exports.SUBDOMAIN_TYPE_PRIMARY }] + .concat(secondaryDomains.map(ad => _.extend(ad, { type: exports.SUBDOMAIN_TYPE_SECONDARY }))) + .concat(redirectDomains.map(ad => _.extend(ad, { type: exports.SUBDOMAIN_TYPE_REDIRECT }))) + .concat(aliasDomains.map(ad => _.extend(ad, { type: exports.SUBDOMAIN_TYPE_ALIAS }))); const domainObjectMap = await validateLocations(locations); @@ -1263,6 +1320,7 @@ async function install(data, auditSource) { mailboxDomain, enableBackup, enableAutomaticUpdate, + secondaryDomains, redirectDomains, aliasDomains, env, @@ -1290,6 +1348,7 @@ async function install(data, auditSource) { const newApp = _.extend({}, _.omit(app, 'icon'), { appStoreId, manifest, location, domain, portBindings }); newApp.fqdn = dns.fqdn(newApp.location, domainObjectMap[newApp.domain]); + newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); @@ -1643,6 +1702,7 @@ async function setLocation(app, data, auditSource) { domain: data.domain.toLowerCase(), // these are intentionally reset, if not set portBindings: null, + secondaryDomains: [], redirectDomains: [], aliasDomains: [] }; @@ -1660,6 +1720,13 @@ async function setLocation(app, data, auditSource) { values.mailboxDomain = values.domain; } + if ('secondaryDomains' in data) { + error = validateSecondaryDomains(data.secondaryDomains, app.manifest); + if (error) throw error; + + values.secondaryDomains = translateSecondaryDomains(data.secondaryDomains); + } + if ('redirectDomains' in data) { values.redirectDomains = data.redirectDomains; } @@ -1668,15 +1735,16 @@ async function setLocation(app, data, auditSource) { values.aliasDomains = data.aliasDomains; } - const locations = [{ subdomain: values.location, domain: values.domain, type: 'primary' }] - .concat(values.redirectDomains.map(ad => _.extend(ad, { type: 'redirect' }))) - .concat(values.aliasDomains.map(ad => _.extend(ad, { type: 'alias' }))); + const locations = [{ subdomain: values.location, domain: values.domain, type: exports.SUBDOMAIN_TYPE_PRIMARY }] + .concat(values.secondaryDomains.map(ad => _.extend(ad, { type: exports.SUBDOMAIN_TYPE_SECONDARY }))) + .concat(values.redirectDomains.map(ad => _.extend(ad, { type: exports.SUBDOMAIN_TYPE_REDIRECT }))) + .concat(values.aliasDomains.map(ad => _.extend(ad, { type: exports.SUBDOMAIN_TYPE_ALIAS }))); const domainObjectMap = await validateLocations(locations); const task = { args: { - oldConfig: _.pick(app, 'location', 'domain', 'fqdn', 'redirectDomains', 'aliasDomains', 'portBindings'), + oldConfig: _.pick(app, 'location', 'domain', 'fqdn', 'secondaryDomains', 'redirectDomains', 'aliasDomains', 'portBindings'), skipDnsSetup: !!data.skipDnsSetup, overwriteDns: !!data.overwriteDns }, @@ -1687,6 +1755,7 @@ async function setLocation(app, data, auditSource) { if (taskError) throw taskError; values.fqdn = dns.fqdn(values.location, domainObjectMap[values.domain]); + values.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); values.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); values.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); @@ -2126,6 +2195,7 @@ async function clone(app, data, user, auditSource) { enableBackup: app.enableBackup, reverseProxyConfig: app.reverseProxyConfig, env: app.env, + secondaryDomains: [], redirectDomains: [], aliasDomains: [], servicesConfig: app.servicesConfig, @@ -2152,6 +2222,7 @@ async function clone(app, data, user, auditSource) { const newApp = _.extend({}, _.omit(obj, 'icon'), { appStoreId, manifest, location, domain, portBindings }); newApp.fqdn = dns.fqdn(newApp.location, domainObjectMap[newApp.domain]); + newApp.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, domainObjectMap[ad.domain]); }); diff --git a/src/apptask.js b/src/apptask.js index ff4626564..90cc4a3d4 100644 --- a/src/apptask.js +++ b/src/apptask.js @@ -255,7 +255,8 @@ async function waitForDnsPropagation(app) { } // now wait for redirectDomains and aliasDomains, if any - for (const domain of app.redirectDomains.concat(app.aliasDomains)) { + const allDomains = app.secondaryDomains.concat(app.redirectDomains).concat(app.aliasDomains); + for (const domain of allDomains) { [error] = await safe(dns.waitForDnsRecord(domain.subdomain, domain.domain, 'A', ipv4, { times: 240 })); if (error) throw new BoxError(BoxError.DNS_ERROR, `DNS A Record is not synced yet: ${error.message}`, { ipv4, subdomain: domain.subdomain, domain: domain.domain }); if (ipv6Enabled) { @@ -346,7 +347,7 @@ async function install(app, args, progressCallback) { if (!skipDnsSetup) { await progressCallback({ percent: 30, message: 'Registering subdomains' }); - await dns.registerLocations([ { subdomain: app.location, domain: app.domain }].concat(app.redirectDomains).concat(app.aliasDomains), { overwriteDns }, progressCallback); + await dns.registerLocations([ { subdomain: app.location, domain: app.domain }].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), { overwriteDns }, progressCallback); } await progressCallback({ percent: 40, message: 'Downloading image' }); @@ -462,9 +463,19 @@ async function changeLocation(app, args, progressCallback) { await deleteContainers(app, { managedOnly: true }); // unregister old domains - let obsoleteDomains = oldConfig.redirectDomains.filter(function (o) { - return !app.redirectDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; }); - }); + let obsoleteDomains = []; + + if (oldConfig.secondaryDomains) { + obsoleteDomains = obsoleteDomains.concat(oldConfig.secondaryDomains.filter(function (o) { + return !app.secondaryDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; }); + })); + } + + if (oldConfig.redirectDomains) { + obsoleteDomains = obsoleteDomains.concat(oldConfig.redirectDomains.filter(function (o) { + return !app.redirectDomains.some(function (n) { return n.subdomain === o.subdomain && n.domain === o.domain; }); + })); + } if (oldConfig.aliasDomains) { obsoleteDomains = obsoleteDomains.concat(oldConfig.aliasDomains.filter(function (o) { @@ -479,7 +490,7 @@ async function changeLocation(app, args, progressCallback) { // setup dns if (!skipDnsSetup) { await progressCallback({ percent: 30, message: 'Registering subdomains' }); - await dns.registerLocations([ { subdomain: app.location, domain: app.domain }].concat(app.redirectDomains).concat(app.aliasDomains), { overwriteDns }, progressCallback); + await dns.registerLocations([ { subdomain: app.location, domain: app.domain }].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), { overwriteDns }, progressCallback); } // re-setup addons since they rely on the app's fqdn (e.g oauth) @@ -723,7 +734,7 @@ async function uninstall(app, args, progressCallback) { await docker.deleteImage(app.manifest); await progressCallback({ percent: 70, message: 'Unregistering domains' }); - await dns.unregisterLocations([ { subdomain: app.location, domain: app.domain } ].concat(app.redirectDomains).concat(app.aliasDomains), progressCallback); + await dns.unregisterLocations([ { subdomain: app.location, domain: app.domain } ].concat(app.secondaryDomains).concat(app.redirectDomains).concat(app.aliasDomains), progressCallback); await progressCallback({ percent: 90, message: 'Cleanup logs' }); await cleanupLogs(app); diff --git a/src/reverseproxy.js b/src/reverseproxy.js index d40e20d97..3a71c6724 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -487,7 +487,9 @@ async function writeAppNginxConfig(app, fqdn, type, bundle) { }; let nginxConfigFilenameSuffix = ''; - if (type === apps.SUBDOMAIN_TYPE_PRIMARY || type === apps.SUBDOMAIN_TYPE_ALIAS) { + if (type === apps.SUBDOMAIN_TYPE_PRIMARY || type === apps.SUBDOMAIN_TYPE_ALIAS || type === apps.SUBDOMAIN_TYPE_SECONDARY) { + data.endpoint = 'app'; + // maybe these should become per domain at some point const reverseProxyConfig = app.reverseProxyConfig || {}; // some of our code uses fake app objects if (reverseProxyConfig.robotsTxt) data.robotsTxtQuoted = JSON.stringify(app.reverseProxyConfig.robotsTxt); if (reverseProxyConfig.csp) { @@ -495,15 +497,23 @@ async function writeAppNginxConfig(app, fqdn, type, bundle) { data.hideHeaders = [ 'Content-Security-Policy' ]; if (reverseProxyConfig.csp.includes('frame-ancestors ')) data.hideHeaders.push('X-Frame-Options'); } - data.proxyAuth = { - enabled: app.sso && app.manifest.addons && app.manifest.addons.proxyAuth, - id: app.id, - location: nginxLocation(safe.query(app.manifest, 'addons.proxyAuth.path') || '/') - }; - data.endpoint = 'app'; - data.ip = app.containerIp; - data.port = app.manifest.httpPort; - if (type === apps.SUBDOMAIN_TYPE_ALIAS) nginxConfigFilenameSuffix = `-alias-${fqdn.replace('*', '_')}`; + if (type === apps.SUBDOMAIN_TYPE_PRIMARY) { + nginxConfigFilenameSuffix = ''; + data.proxyAuth = { + enabled: app.sso && app.manifest.addons && app.manifest.addons.proxyAuth, + id: app.id, + location: nginxLocation(safe.query(app.manifest, 'addons.proxyAuth.path') || '/') + }; + data.ip = app.containerIp; + data.port = app.manifest.httpPort; + } else if (type === apps.SUBDOMAIN_TYPE_SECONDARY) { + nginxConfigFilenameSuffix = `-secondary-${fqdn}`; + data.ip = app.containerIp; + const secondaryDomain = app.secondaryDomains.find(sd => sd.fqdn === fqdn); + data.port = app.manifest.httpPorts[secondaryDomain.environmentVariable].containerPort; + } else if (type === apps.SUBDOMAIN_TYPE_ALIAS) { + nginxConfigFilenameSuffix = `-alias-${fqdn.replace('*', '_')}`; + } } else if (type === apps.SUBDOMAIN_TYPE_REDIRECT) { data.proxyAuth = { enabled: false, id: app.id, location: nginxLocation('/') }; data.endpoint = 'redirect'; @@ -528,6 +538,7 @@ async function writeAppConfig(app) { assert.strictEqual(typeof app, 'object'); const appDomains = [{ domain: app.domain, fqdn: app.fqdn, type: apps.SUBDOMAIN_TYPE_PRIMARY }] + .concat(app.secondaryDomains.map(sd => { return { domain: sd.domain, fqdn: sd.fqdn, type: apps.SUBDOMAIN_TYPE_SECONDARY }; })) .concat(app.redirectDomains.map(rd => { return { domain: rd.domain, fqdn: rd.fqdn, type: apps.SUBDOMAIN_TYPE_REDIRECT }; })) .concat(app.aliasDomains.map(ad => { return { domain: ad.domain, fqdn: ad.fqdn, type: apps.SUBDOMAIN_TYPE_REDIRECT }; })); @@ -589,6 +600,11 @@ async function renewCerts(options, auditSource, progressCallback) { appDomains.push({ domain: app.domain, fqdn: app.fqdn, type: apps.SUBDOMAIN_TYPE_PRIMARY, app: app, nginxConfigFilename: path.join(paths.NGINX_APPCONFIG_DIR, app.id + '.conf') }); + app.secondaryDomains.forEach(function (secondaryDomain) { + const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `${app.id}-secondary-${secondaryDomain.fqdn}.conf`); + appDomains.push({ domain: secondaryDomain.domain, fqdn: secondaryDomain.fqdn, type: apps.SUBDOMAIN_TYPE_SECONDARY, app: app, nginxConfigFilename }); + }); + app.redirectDomains.forEach(function (redirectDomain) { const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `${app.id}-redirect-${redirectDomain.fqdn}.conf`); appDomains.push({ domain: redirectDomain.domain, fqdn: redirectDomain.fqdn, type: apps.SUBDOMAIN_TYPE_REDIRECT, app: app, nginxConfigFilename }); diff --git a/src/routes/apps.js b/src/routes/apps.js index 25e523d5f..cc782997f 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -141,6 +141,11 @@ async function install(req, res, next) { if (('debugMode' in data) && typeof data.debugMode !== 'object') return next(new HttpError(400, 'debugMode must be an object')); + if ('secondaryDomains' in data) { + if (!data.secondaryDomains || typeof data.secondaryDomains !== 'object') return next(new HttpError(400, 'secondaryDomains must be an object')); + if (Object.keys(data.secondaryDomains).some(function (key) { return typeof data.secondaryDomains[key].domain !== 'string' || typeof data.secondaryDomains[key].subdomain !== 'string'; })) return next(new HttpError(400, 'secondaryDomain object must contain domain and subdomain strings')); + } + if ('redirectDomains' in data) { if (!Array.isArray(data.redirectDomains)) return next(new HttpError(400, 'redirectDomains must be an array')); if (data.redirectDomains.some(function (d) { return (typeof d.domain !== 'string' || typeof d.subdomain !== 'string'); })) return next(new HttpError(400, 'redirectDomains array must contain objects with domain and subdomain strings')); @@ -396,6 +401,11 @@ async function setLocation(req, res, next) { if ('portBindings' in req.body && typeof req.body.portBindings !== 'object') return next(new HttpError(400, 'portBindings must be an object')); + if ('secondaryDomains' in req.body) { + if (!req.body.secondaryDomains || typeof req.body.secondaryDomains !== 'object') return next(new HttpError(400, 'secondaryDomains must be an object')); + if (Object.keys(req.body.secondaryDomains).some(function (key) { return typeof req.body.secondaryDomains[key].domain !== 'string' || typeof req.body.secondaryDomains[key].subdomain !== 'string'; })) return next(new HttpError(400, 'secondaryDomain object must contain domain and subdomain strings')); + } + if ('redirectDomains' in req.body) { if (!Array.isArray(req.body.redirectDomains)) return next(new HttpError(400, 'redirectDomains must be an array')); if (req.body.redirectDomains.some(function (d) { return (typeof d.domain !== 'string' || typeof d.subdomain !== 'string'); })) return next(new HttpError(400, 'redirectDomains array must contain objects with domain and subdomain strings')); diff --git a/src/test/common.js b/src/test/common.js index 8e5a23a0e..0a8772818 100644 --- a/src/test/common.js +++ b/src/test/common.js @@ -105,6 +105,7 @@ const app = { accessRestriction: null, memoryLimit: 0, mailboxDomain: domain.domain, + secondaryDomains: [], redirectDomains: [], aliasDomains: [] };