mostly because code is being autogenerated by all the AI stuff using this prefix. it's also used in the stack trace.
194 lines
7.4 KiB
JavaScript
194 lines
7.4 KiB
JavaScript
'use strict';
|
|
|
|
exports = module.exports = {
|
|
start,
|
|
stop
|
|
};
|
|
|
|
const apps = require('./apps.js'),
|
|
assert = require('node:assert'),
|
|
constants = require('./constants.js'),
|
|
express = require('express'),
|
|
debug = require('debug')('box:dockerproxy'),
|
|
http = require('node:http'),
|
|
HttpError = require('@cloudron/connect-lastmile').HttpError,
|
|
middleware = require('./middleware'),
|
|
net = require('node:net'),
|
|
path = require('node:path'),
|
|
paths = require('./paths.js'),
|
|
safe = require('safetydance'),
|
|
util = require('node:util'),
|
|
volumes = require('./volumes.js');
|
|
|
|
let gHttpServer = null;
|
|
|
|
async function authorizeApp(req, res, next) {
|
|
// make the tests pass for now
|
|
if (constants.TEST) {
|
|
req.resources.app = { id: 'testappid' };
|
|
return next();
|
|
}
|
|
|
|
const [error, app] = await safe(apps.getByIpAddress(req.socket.remoteAddress));
|
|
if (error) return next(new HttpError(500, error));
|
|
if (!app) return next(new HttpError(401, 'Unauthorized'));
|
|
if (!app.manifest.addons?.docker) return next(new HttpError(401, 'Unauthorized'));
|
|
|
|
req.resources.app = app;
|
|
|
|
next();
|
|
}
|
|
|
|
function attachDockerRequest(req, res, next) {
|
|
const options = {
|
|
socketPath: '/var/run/docker.sock',
|
|
method: req.method,
|
|
path: req.url,
|
|
headers: req.headers
|
|
};
|
|
|
|
req.dockerRequest = http.request(options, function (dockerResponse) {
|
|
res.writeHead(dockerResponse.statusCode, dockerResponse.headers);
|
|
|
|
// Force node to send out the headers, this is required for the /container/wait api to make the docker cli proceed
|
|
res.write(' ');
|
|
|
|
dockerResponse.on('error', function (error) { debug('dockerResponse error: %o', error); });
|
|
dockerResponse.pipe(res, { end: true });
|
|
});
|
|
|
|
req.dockerRequest.on('error', () => {}); // abort() throws
|
|
|
|
next();
|
|
}
|
|
|
|
async function containersCreate(req, res, next) {
|
|
safe.set(req.body, 'HostConfig.NetworkMode', 'cloudron'); // overwrite the network the container lives in
|
|
safe.set(req.body, 'NetworkingConfig', {}); // drop any custom network configs
|
|
safe.set(req.body, 'Labels', Object.assign({}, safe.query(req.body, 'Labels'), { appId: req.resources.app.id, isCloudronManaged: String(false) })); // overwrite the app id to track containers of an app
|
|
safe.set(req.body, 'HostConfig.LogConfig', { Type: 'syslog', Config: { 'tag': req.resources.app.id, 'syslog-address': `unix://${paths.SYSLOG_SOCKET_FILE}`, 'syslog-format': 'rfc5424' }});
|
|
|
|
const appDataDir = path.join(paths.APPS_DATA_DIR, req.resources.app.id, 'data');
|
|
|
|
debug('containersCreate: original bind mounts:', req.body.HostConfig.Binds);
|
|
|
|
const [error, result] = await safe(volumes.list());
|
|
if (error) return next(new HttpError(500, `Error listing volumes: ${error.message}`));
|
|
|
|
const volumesByName = {};
|
|
result.forEach(r => volumesByName[r.name] = r);
|
|
|
|
const binds = [];
|
|
for (const bind of (req.body.HostConfig.Binds || [])) { // bind is of the host:container:rw format
|
|
if (bind.startsWith('/app/data')) {
|
|
binds.push(bind.replace(new RegExp('^/app/data/'), appDataDir + '/'));
|
|
} else if (bind.startsWith('/media/')) {
|
|
const volumeName = bind.match(new RegExp('/media/([^:/]+)/?'))[1];
|
|
const volume = volumesByName[volumeName];
|
|
if (volume) binds.push(bind.replace(new RegExp(`^/media/${volumeName}`), volume.hostPath));
|
|
else debug(`containersCreate: dropped unknown volume ${volumeName}`);
|
|
} else {
|
|
req.dockerRequest.abort();
|
|
return next(new HttpError(400, 'Binds must be under /app/data/ or /media'));
|
|
}
|
|
}
|
|
|
|
debug('containersCreate: rewritten bind mounts:', binds);
|
|
safe.set(req.body, 'HostConfig.Binds', binds);
|
|
|
|
const plainBody = JSON.stringify(req.body);
|
|
req.dockerRequest.setHeader('Content-Length', Buffer.byteLength(plainBody));
|
|
req.dockerRequest.end(plainBody);
|
|
}
|
|
|
|
// eslint-disable-next-line no-unused-vars
|
|
function process(req, res, next) {
|
|
// we have to rebuild the body since we consumed in in the parser
|
|
if (req.body && Object.keys(req.body).length !== 0) {
|
|
const plainBody = JSON.stringify(req.body);
|
|
req.dockerRequest.setHeader('Content-Length', Buffer.byteLength(plainBody));
|
|
req.dockerRequest.end(plainBody);
|
|
} else if (!req.readable) {
|
|
req.dockerRequest.end();
|
|
} else {
|
|
req.pipe(req.dockerRequest, { end: true });
|
|
}
|
|
}
|
|
|
|
async function start() {
|
|
assert(gHttpServer === null, 'Already started');
|
|
|
|
const json = express.json({ strict: true });
|
|
|
|
// we protect container create as the app/admin can otherwise mount random paths (like the ghost file)
|
|
// protected other paths is done by preventing install/exec access of apps using docker addon
|
|
const router = new express.Router();
|
|
router.post('/:version/containers/create', containersCreate);
|
|
|
|
const proxyServer = express();
|
|
|
|
if (constants.TEST) {
|
|
proxyServer.use(function (req, res, next) {
|
|
debug('proxying: ' + req.method, req.url);
|
|
next();
|
|
});
|
|
}
|
|
|
|
proxyServer
|
|
.use((req, res , next) => {
|
|
// we store our route resources, like app,volumes,... in req.resources. Those are added in the load() routes
|
|
req.resources = {};
|
|
next();
|
|
})
|
|
.use(authorizeApp)
|
|
.use(attachDockerRequest)
|
|
.use(json)
|
|
.use(router)
|
|
.use(process)
|
|
.use(middleware.lastMile());
|
|
|
|
// disable slowloris prevention: https://github.com/nodejs/node/issues/47421
|
|
gHttpServer = http.createServer({ headersTimeout: 0, requestTimeout: 0 }, proxyServer);
|
|
|
|
// Overwrite the default 2min request timeout. This is required for large builds for example
|
|
gHttpServer.setTimeout(60 * 60 * 1000);
|
|
|
|
// eslint-disable-next-line no-unused-vars
|
|
gHttpServer.on('upgrade', function (req, client, head) {
|
|
// Create a new tcp connection to the TCP server
|
|
const remote = net.connect('/var/run/docker.sock', function () {
|
|
let upgradeMessage = req.method + ' ' + req.url + ' HTTP/1.1\r\n' +
|
|
`Host: ${req.headers.host}\r\n` +
|
|
'Connection: Upgrade\r\n' +
|
|
'Upgrade: tcp\r\n';
|
|
|
|
if (req.headers['content-type'] === 'application/json') {
|
|
// TODO we have to parse the immediate upgrade request body, but I don't know how
|
|
const plainBody = '{"Detach":false,"Tty":false}\r\n';
|
|
upgradeMessage += 'Content-Type: application/json\r\n';
|
|
upgradeMessage += `Content-Length: ${Buffer.byteLength(plainBody)}\r\n`;
|
|
upgradeMessage += '\r\n';
|
|
upgradeMessage += plainBody;
|
|
}
|
|
|
|
upgradeMessage += '\r\n';
|
|
|
|
// resend the upgrade event to the docker daemon, so it responds with the proper message through the pipes
|
|
remote.write(upgradeMessage);
|
|
|
|
// two-way pipes between client and docker daemon
|
|
client.pipe(remote).pipe(client);
|
|
});
|
|
});
|
|
|
|
debug(`start: listening on 172.18.0.1:${constants.DOCKER_PROXY_PORT}`);
|
|
await util.promisify(gHttpServer.listen.bind(gHttpServer))(constants.DOCKER_PROXY_PORT, '172.18.0.1');
|
|
}
|
|
|
|
async function stop() {
|
|
if (!gHttpServer) return;
|
|
|
|
await util.promisify(gHttpServer.close.bind(gHttpServer))();
|
|
gHttpServer = null;
|
|
}
|