apps: rework portBindings

ports is REST API input . Map of env var to the host port
portBinding is the database structure. Map of env var to host port, count, type etc

also, rename portCount -> count in various places to keep things consistent
This commit is contained in:
Girish Ramakrishnan
2024-07-16 22:21:36 +02:00
parent eb314ef507
commit aeddaa4566
12 changed files with 151 additions and 147 deletions

View File

@@ -134,11 +134,10 @@ exports = module.exports = {
// exported for testing
_checkForPortBindingConflict: checkForPortBindingConflict,
_validatePortBindings: validatePortBindings,
_validatePorts: validatePorts,
_validateAccessRestriction: validateAccessRestriction,
_validateUpstreamUri: validateUpstreamUri,
_validateLocations: validateLocations,
_translatePortBindings: translatePortBindings,
_parseCrontab: parseCrontab,
_clear: clear
};
@@ -196,8 +195,9 @@ const LOCATION_FIELDS = [ 'appId', 'subdomain', 'domain', 'type', 'certificateJs
const CHECKVOLUME_CMD = path.join(__dirname, 'scripts/checkvolume.sh');
function validatePortBindings(portBindings, manifest) {
assert.strictEqual(typeof portBindings, 'object');
// ports is a map of envvar -> hostPort
function validatePorts(ports, manifest) {
assert.strictEqual(typeof ports, 'object');
assert.strictEqual(typeof manifest, 'object');
// keep the public ports in sync with firewall rules in setup/start/cloudron-firewall.sh
@@ -236,45 +236,48 @@ function validatePortBindings(portBindings, manifest) {
853 // dns over tls
];
if (!portBindings) return null;
if (!ports) return null;
const tcpPorts = manifest.tcpPorts || {};
const udpPorts = manifest.udpPorts || {};
for (const portName in portBindings) {
if (!/^[A-Z0-9_]+$/.test(portName)) return new BoxError(BoxError.BAD_FIELD, `${portName} is not a valid environment variable in portBindings`);
for (const portName in ports) {
if (!/^[A-Z0-9_]+$/.test(portName)) return new BoxError(BoxError.BAD_FIELD, `${portName} is not a valid environment variable in ports`);
const hostPort = portBindings[portName];
if (!Number.isInteger(hostPort)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} is not an integer in ${portName} portBindings`);
if (RESERVED_PORTS.indexOf(hostPort) !== -1) return new BoxError(BoxError.BAD_FIELD, `Port ${hostPort} for ${portName} is reserved in portBindings`);
if (RESERVED_PORT_RANGES.find(range => (hostPort >= range[0] && hostPort <= range[1]))) return new BoxError(BoxError.BAD_FIELD, `Port ${hostPort} for ${portName} is reserved in portBindings`);
if (ALLOWED_PORTS.indexOf(hostPort) === -1 && (hostPort <= 1023 || hostPort > 65535)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} for ${portName} is not in permitted range in portBindings`);
const hostPort = ports[portName];
if (!Number.isInteger(hostPort)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} is not an integer in ${portName} ports`);
if (RESERVED_PORTS.indexOf(hostPort) !== -1) return new BoxError(BoxError.BAD_FIELD, `Port ${hostPort} for ${portName} is reserved in ports`);
if (RESERVED_PORT_RANGES.find(range => (hostPort >= range[0] && hostPort <= range[1]))) return new BoxError(BoxError.BAD_FIELD, `Port ${hostPort} for ${portName} is reserved in ports`);
if (ALLOWED_PORTS.indexOf(hostPort) === -1 && (hostPort <= 1023 || hostPort > 65535)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} for ${portName} is not in permitted range in ports`);
// it is OK if there is no 1-1 mapping between values in manifest.tcpPorts and portBindings. missing values implies the service is disabled
// it is OK if there is no 1-1 mapping between values in manifest.tcpPorts and ports. missing values implies the service is disabled
const portSpec = tcpPorts[portName] || udpPorts[portName];
if (!portSpec) return new BoxError(BoxError.BAD_FIELD, `Invalid portBinding ${portName}`);
if (portSpec.readOnly && portSpec.defaultValue !== hostPort) return new BoxError(BoxError.BAD_FIELD, `portBinding ${portName} is readOnly and cannot have a different value that the default`);
if ((hostPort + (portSpec.portCount || 1)) > 65535) return new BoxError(BoxError.BAD_FIELD, `${hostPort}+${portSpec.portCount} for ${portName} exceeds valid port range`);
}
return null;
}
function translatePortBindings(portBindings, manifest) {
assert.strictEqual(typeof portBindings, 'object');
// translates the REST API ports (envvar -> hostPort) to database portBindings (envvar -> { hostPort, count, type })
function translateToPortBindings(ports, manifest) {
assert.strictEqual(typeof ports, 'object');
assert.strictEqual(typeof manifest, 'object');
if (!portBindings) return null;
const portBindings = {};
const result = {};
const tcpPorts = manifest.tcpPorts || { };
if (!ports) return portBindings;
for (const portName in portBindings) {
const tcpPorts = manifest.tcpPorts || {};
for (const portName in ports) {
const portType = portName in tcpPorts ? exports.PORT_TYPE_TCP : exports.PORT_TYPE_UDP;
const portCount = portBindings[portName].portCount || (portName in tcpPorts ? manifest.tcpPorts[portName].portCount : manifest.udpPorts[portName].portCount);
result[portName] = { hostPort: portBindings[portName], type: portType, portCount: portCount || 1 };
const portCount = portName in tcpPorts ? tcpPorts[portName].portCount : manifest.udpPorts[portName].portCount; // since count is optional, this can be undefined
portBindings[portName] = { hostPort: ports[portName], type: portType, count: portCount || 1 };
}
return result;
return portBindings;
}
function validateSecondaryDomains(secondaryDomains, manifest) {
@@ -543,9 +546,8 @@ function getDuplicateErrorDetails(errorMessage, locations, portBindings) {
}
}
// check if any of the port bindings conflict
for (const portName in portBindings) {
if (portBindings[portName] === parseInt(match[1])) return new BoxError(BoxError.ALREADY_EXISTS, `Port ${match[1]} is in use`);
if (portBindings[portName].hostPort === parseInt(match[1])) return new BoxError(BoxError.ALREADY_EXISTS, `Port ${match[1]} is in use`);
}
if (match[2] === 'apps_storageVolume') {
@@ -647,7 +649,7 @@ function postProcess(result) {
assert(result.hostPorts === null || typeof result.hostPorts === 'string');
assert(result.environmentVariables === null || typeof result.environmentVariables === 'string');
result.portBindings = { };
result.portBindings = {};
const hostPorts = result.hostPorts === null ? [ ] : result.hostPorts.split(',');
const environmentVariables = result.environmentVariables === null ? [ ] : result.environmentVariables.split(',');
const portTypes = result.portTypes === null ? [ ] : result.portTypes.split(',');
@@ -659,7 +661,7 @@ function postProcess(result) {
delete result.portCounts;
for (let i = 0; i < environmentVariables.length; i++) {
result.portBindings[environmentVariables[i]] = { hostPort: parseInt(hostPorts[i], 10), type: portTypes[i], portCount: parseInt(portCounts[i], 10) };
result.portBindings[environmentVariables[i]] = { hostPort: parseInt(hostPorts[i], 10), type: portTypes[i], count: parseInt(portCounts[i], 10) };
}
assert(result.accessRestrictionJson === null || typeof result.accessRestrictionJson === 'string');
@@ -740,15 +742,11 @@ function postProcess(result) {
result.taskId = result.taskId ? String(result.taskId) : null;
}
// attaches computed properties
function attachProperties(app, domainObjectMap) {
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof domainObjectMap, 'object');
const result = {};
for (const portName in app.portBindings) {
result[portName] = app.portBindings[portName].hostPort;
}
app.portBindings = result;
app.iconUrl = app.hasIcon || app.hasAppStoreIcon ? `/api/v1/apps/${app.id}/icon` : null;
app.fqdn = dns.fqdn(app.subdomain, app.domain);
app.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
@@ -804,26 +802,26 @@ async function checkForPortBindingConflict(portBindings, options) {
if (existingPortBindings.length === 0) return;
const tcpPorts = existingPortBindings.filter((p) => p.type === 'tcp');
const udpPorts = existingPortBindings.filter((p) => p.type === 'udp');
const tcpPortBindings = existingPortBindings.filter((p) => p.type === 'tcp');
const udpPortBindings = existingPortBindings.filter((p) => p.type === 'udp');
for (const portName in portBindings) {
const p = portBindings[portName];
const testPorts = p.type === 'tcp' ? tcpPorts : udpPorts;
const portBinding = portBindings[portName];
const existingPortBinding = portBinding.type === 'tcp' ? tcpPortBindings : udpPortBindings;
const found = testPorts.find((e) => {
const found = existingPortBinding.find((epb) => {
// if one is true we dont have a conflict
// a1 <----> a2 b1 <-------> b2
// b1 <------> b2 a1 <-----> a2
const a2 = (e.hostPort + e.count - 1);
const b1 = p.hostPort;
const b2 = (p.hostPort + p.portCount -1);
const a1 = e.hostPort;
const a2 = (epb.hostPort + epb.count - 1);
const b1 = portBinding.hostPort;
const b2 = (portBinding.hostPort + portBinding.count - 1);
const a1 = epb.hostPort;
return !((a2 < b1) || (b2 < a1));
});
if (found) throw new BoxError(BoxError.CONFLICT, `Conflicting port ${p.hostPort}`);
if (found) throw new BoxError(BoxError.CONFLICT, `Conflicting ${portBinding.type} port ${portBinding.hostPort}`);
}
}
@@ -834,11 +832,9 @@ async function add(id, appStoreId, manifest, subdomain, domain, portBindings, da
assert.strictEqual(typeof manifest.version, 'string');
assert.strictEqual(typeof subdomain, 'string');
assert.strictEqual(typeof domain, 'string');
assert.strictEqual(typeof portBindings, 'object');
assert(portBindings && typeof portBindings === 'object');
assert(data && typeof data === 'object');
portBindings = portBindings || { };
const manifestJson = JSON.stringify(manifest),
accessRestriction = data.accessRestriction || null,
accessRestrictionJson = JSON.stringify(accessRestriction),
@@ -863,7 +859,7 @@ async function add(id, appStoreId, manifest, subdomain, domain, portBindings, da
enableRedis = 'enableRedis' in data ? data.enableRedis : true,
icon = data.icon || null;
await checkForPortBindingConflict(portBindings, {});
await checkForPortBindingConflict(portBindings, { appId: null });
const queries = [];
@@ -885,7 +881,7 @@ async function add(id, appStoreId, manifest, subdomain, domain, portBindings, da
Object.keys(portBindings).forEach(function (env) {
queries.push({
query: 'INSERT INTO appPortBindings (environmentVariable, hostPort, type, appId, count) VALUES (?, ?, ?, ?, ?)',
args: [ env, portBindings[env].hostPort, portBindings[env].type, id, portBindings[env].portCount ]
args: [ env, portBindings[env].hostPort, portBindings[env].type, id, portBindings[env].count ]
});
});
@@ -953,14 +949,14 @@ async function updateWithConstraints(id, app, constraints) {
const queries = [ ];
if ('portBindings' in app) {
const portBindings = app.portBindings || { };
const portBindings = app.portBindings;
await checkForPortBindingConflict(portBindings, { appId: id });
// replace entries by app id
queries.push({ query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] });
Object.keys(portBindings).forEach(function (env) {
const values = [ portBindings[env].hostPort, portBindings[env].type, env, id, portBindings[env].portCount ];
const values = [ portBindings[env].hostPort, portBindings[env].type, env, id, portBindings[env].count ];
queries.push({ query: 'INSERT INTO appPortBindings (hostPort, type, environmentVariable, appId, count) VALUES(?, ?, ?, ?, ?)', args: values });
});
}
@@ -1342,9 +1338,9 @@ async function install(data, auditSource) {
error = await checkManifest(manifest);
if (error) throw error;
error = validatePortBindings(data.portBindings || null, manifest);
error = validatePorts(data.ports || null, manifest);
if (error) throw error;
const portBindings = translatePortBindings(data.portBindings || null, manifest);
const portBindings = translateToPortBindings(data.ports || null, manifest);
error = validateAccessRestriction(accessRestriction);
if (error) throw error;
@@ -1906,16 +1902,16 @@ async function setLocation(app, data, auditSource) {
subdomain: data.subdomain.toLowerCase(),
domain: data.domain.toLowerCase(),
// these are intentionally reset, if not set
portBindings: null,
portBindings: {},
secondaryDomains: [],
redirectDomains: [],
aliasDomains: []
};
if ('portBindings' in data) {
error = validatePortBindings(data.portBindings || null, app.manifest);
if ('ports' in data) {
error = validatePorts(data.ports || null, app.manifest);
if (error) throw error;
values.portBindings = translatePortBindings(data.portBindings || null, app.manifest);
values.portBindings = translateToPortBindings(data.ports || null, app.manifest);
}
// rename the auto-created mailbox to match the new location
@@ -1954,7 +1950,7 @@ async function setLocation(app, data, auditSource) {
};
const [taskError, taskId] = await safe(addTask(appId, exports.ISTATE_PENDING_LOCATION_CHANGE, task, auditSource));
if (taskError && taskError.reason !== BoxError.ALREADY_EXISTS) throw taskError;
if (taskError && taskError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(taskError.message, locations, data.portBindings);
if (taskError && taskError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(taskError.message, locations, values.portBindings);
values.fqdn = dns.fqdn(values.subdomain, values.domain);
values.secondaryDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
@@ -2359,9 +2355,9 @@ async function clone(app, data, user, auditSource) {
error = await checkManifest(manifest);
if (error) throw error;
error = validatePortBindings(data.portBindings || null, manifest);
error = validatePorts(data.ports || null, manifest);
if (error) throw error;
const portBindings = translatePortBindings(data.portBindings || null, manifest);
const portBindings = translateToPortBindings(data.ports || null, manifest);
// should we copy the original app's mailbox settings instead?
const mailboxName = manifest.addons?.sendmail ? mailboxNameForSubdomain(subdomain, manifest) : null;