import apps from './apps.js'; import assert from 'node:assert'; import constants from './constants.js'; import express from 'express'; import debugModule from 'debug'; import http from 'node:http'; import { HttpError } from '@cloudron/connect-lastmile'; import middleware from './middleware/index.js'; import net from 'node:net'; import path from 'node:path'; import paths from './paths.js'; import safe from 'safetydance'; import util from 'node:util'; import volumes from './volumes.js'; const debug = debugModule('box:dockerproxy'); 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; } export default { start, stop };