diff --git a/package-lock.json b/package-lock.json index 93bdf60..94308d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "domcloud-bridge", - "version": "0.58.0", + "version": "0.59.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "domcloud-bridge", - "version": "0.58.0", + "version": "0.59.0", "license": "MIT", "dependencies": { "cli": "^1.0.1", @@ -15,7 +15,8 @@ "fast-xml-parser": "^4.3.6", "nginx-conf": "^2.1.0", "proper-lockfile": "^4.1.2", - "shelljs": "^0.8.5" + "shelljs": "^0.8.5", + "yaml": "^2.5.1" }, "devDependencies": { "@types/cli": "^0.11.25", @@ -1088,6 +1089,17 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } } }, "dependencies": { @@ -1892,6 +1904,11 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==" } } } diff --git a/package.json b/package.json index f1e1226..b7b3c0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "domcloud-bridge", - "version": "0.58.0", + "version": "0.59.0", "description": "Deployment runner for DOM Cloud", "main": "app.js", "engines": { @@ -25,7 +25,8 @@ "fast-xml-parser": "^4.3.6", "nginx-conf": "^2.1.0", "proper-lockfile": "^4.1.2", - "shelljs": "^0.8.5" + "shelljs": "^0.8.5", + "yaml": "^2.5.1" }, "devDependencies": { "@types/cli": "^0.11.25", diff --git a/src/executor/docker.js b/src/executor/docker.js index 319aade..1bdf888 100644 --- a/src/executor/docker.js +++ b/src/executor/docker.js @@ -1,11 +1,18 @@ import { + cat, executeLock, spawnSudoUtil, } from '../util.js'; import { existsSync } from 'fs'; +import { nginxExec } from './nginx.js'; +import path from 'path'; +import * as yaml from 'yaml'; +import { ShellString } from 'shelljs'; + +const tmpFile = path.join(process.cwd(), '/.tmp/compose') class DockerExecutor { - LOGINLINGERDIR = '/var/lib/systemd/linger'; + LOGINLINGERDIR = '/var/lib/systemd/linger'; constructor() { if (process.env.LOGINLINGERDIR) { this.LOGINLINGERDIR = process.env.LOGINLINGERDIR; @@ -49,6 +56,114 @@ class DockerExecutor { return "Updated for docker"; }); } + generateRandomIPv4() { + // Generate random octets (numbers between 0 and 255) + const octet1 = Math.trunc(Math.random() * 256); + const octet2 = Math.trunc(Math.random() * 256); + const octet3 = Math.trunc(Math.random() * 256); + + // Construct the address with the "10." prefix + const ipAddress = `10.${octet1}.${octet2}.${octet3}`; + return ipAddress; + } + /** + * + * @param {any} services + * @param {string} domain + */ + async rewriteServices(services, domain) { + // get IP data from nginx + const nginxNode = await nginxExec.get(domain); + const nginx = nginxExec.extractInfo(nginxNode, domain); + let nginxChanged = false; + if (!nginx.docker_ip) { + nginx.docker_ip = this.generateRandomIPv4(); + nginxChanged = true; + } + var exposedPorts = []; + // rewrite all ips + for (const [name, service] of Object.entries(services)) { + if (typeof service !== 'object' || !service.ports) continue; + if (!Array.isArray(service.ports) || service.ports.length == 0) throw new Error("Invalid ports format in service: " + name); + for (let i = 0; i < service.ports.length; i++) { + var port = service.ports[i]; + var conf = { + target: 0, + host_ip: nginx.docker_ip, + protocol: 'tcp', + published: "" + Math.trunc(Math.random() * 30000 + 1025), + } + if (typeof port === 'string') { + if (/^\d+$/.test(port)) { + conf.target = parseInt(port); + } else if (/^\d+:\d+$/.test(port)) { + const [src, dst] = port.split(":"); + conf.target = parseInt(dst); + conf.published = src; + } else if (/^127.0.0.1:\d+:\d+$/.test(port)) { + const [_, src, dst] = port.split(":"); + conf.target = parseInt(dst); + conf.published = src; + } else { + throw new Error("Unknown ports format: " + name); + } + } + exposedPorts.push(conf.published); + service.ports[i] = conf; + } + } + // nginx replace port docker + let matchedConf = nginx.config.locations?.find(x => x.match == '/'); + if (!matchedConf) { + if (!nginx.config.locations) + nginx.config.locations = []; + matchedConf = { match: '/' } + nginx.config.locations.push(matchedConf); + nginxChanged = true; + } + let proxyPass = matchedConf.proxy_pass + ""; + if (!proxyPass || !proxyPass.startsWith('docker:') || exposedPorts.includes(proxyPass.replace(/^docker:/, ''))) { + if (exposedPorts.length == 0) { + throw new Error("There are no exposed ports! Need atleast one to forward it into NGINX"); + } + matchedConf.proxy_pass = "docker:" + exposedPorts[exposedPorts.length - 1]; + nginxChanged = true; + } + if (nginxChanged) { + await nginxExec.setDirect(domain, nginx); + } + return services; + } + /** + * + * @param {any} services + * @param {string} home + * @param {string} domain + * @return {Promise} + */ + async executeServices(services, home, domain) { + let filename = path.join(home, 'docker-compose.yml'); + if (typeof services === 'string') { + filename = path.join(home, services); + // cat from file + services = yaml.parse(await executeLock('compose', () => { + return new Promise((resolve, reject) => { + spawnSudoUtil('COMPOSE_GET', [filename]).then(() => { + resolve(cat(tmpFile)); + }).catch(reject); + }); + })).services; + } + services = this.rewriteServices(services, domain); + let composeFile = yaml.stringify({ services }); + await executeLock('compose', () => { + return new Promise((resolve, reject) => { + ShellString(composeFile).to(filename) + spawnSudoUtil('COMPOSE_SET', [filename]).then(resolve).catch(reject); + }); + }); + return composeFile; + } } export const dockerExec = new DockerExecutor(); diff --git a/src/executor/nginx.js b/src/executor/nginx.js index 7a19d12..b6c8e71 100644 --- a/src/executor/nginx.js +++ b/src/executor/nginx.js @@ -40,7 +40,12 @@ class NginxExecutor { } else if (key === "proxy_pass") { if (config[key] === "unit") { node._add(key, unitProxy); - } else if (/^http:\/\/(10|127)\.\d+\.\d+\.\d+:\d+(\$\d+|\/.+)?$/) { + } else if (/^docker:(\d+)$/.test(config[key]) && info.docker_ip) { + let port = parseInt(config[key].substring(7)); + if (port > 0x400 && port < 0xFFFF) { + node._add(key, `http://${info.docker_ip}:${port}`); + } + } else if (/^http:\/\/(10|127)\.\d+\.\d+\.\d+:\d+(\$.+|\/.+)?$/.test(config[key])) { node._add(key, config[key]); } } else { @@ -93,7 +98,7 @@ class NginxExecutor { } } } - if (config.fastcgi) { + if (config.fastcgi && info.fcgi) { node._add("location", "~ \\.php" + (config.fastcgi == 'always' ? "(/|$)" : "$")); var n = node.location[node.location.length - 1]; switch (config.fastcgi) { @@ -261,6 +266,21 @@ class NginxExecutor { } return null; } + const findDockerIp = (l) => { + if (l.proxy_pass && l.proxy_pass[0]) { + if (/^http:\/\/10\.\d+\.\d+\.\d+:\d+$/.test(l.proxy_pass[0]._value)) { + return new URL(l.proxy_pass[0]._value).hostname; + } + } + if (l.location) { + for (const ll of l.location) { + var r = findFastCgi(ll); + if (r) + return r; + } + } + return null; + } const data = { ssl: 0, // binary of 1 = HTTP, 2 = HTTPS http: 1, // http version (1 or 2) @@ -270,6 +290,7 @@ class NginxExecutor { root: null, user: null, fcgi: null, + docker_ip: null, free: false, access_log: null, error_log: null, @@ -301,6 +322,7 @@ class NginxExecutor { data.ssl_certificate_key = node.ssl_certificate_key ? node.ssl_certificate_key[0]._value : `/home/${data.user}/ssl.key`; data.fcgi = findFastCgi(node); + data.docker_ip = findDockerIp(node); data.config = extractLocations(node, `/home/${data.user}/`); delete data.config.match; delete data.config.alias; diff --git a/src/executor/runnersub.js b/src/executor/runnersub.js index f77c14a..c35a7e6 100644 --- a/src/executor/runnersub.js +++ b/src/executor/runnersub.js @@ -8,9 +8,10 @@ import { namedExec } from "./named.js"; import { nginxExec } from "./nginx.js"; import { virtualminExec } from "./virtualmin.js"; import { unitExec } from "./unit.js"; +import { dockerExec } from "./docker.js"; /** - * @param {{source: any;features: any;commands: any;nginx: any;unit: any;envs: any,directory:any, root:any}} config + * @param {{source: any;features: any;commands: any;services: any;nginx: any;unit: any;envs: any,directory:any, root:any}} config * @param {{[x: string]: any}} domaindata * @param {string} subdomain * @param {{(cmd: string, write?: boolean): Promise}} sshExec @@ -343,7 +344,7 @@ export async function runConfigSubdomain(config, domaindata, subdomain, sshExec, } }; - if (config.source || config.commands) { + if (config.source || config.commands || config.services) { await sshExec(`shopt -s dotglob`, false); await sshExec(`export DOMAIN='${subdomain}'`, false); // enable managing systemd for linger user @@ -366,6 +367,16 @@ export async function runConfigSubdomain(config, domaindata, subdomain, sshExec, delete config.root; } + if (config.services) { + await writeLog("$> Applying docker compose services"); + await dockerExec.executeServices(config.services, subdomaindata['Home directory'] + '/public_html', subdomain); + if (typeof config.services == 'string') { + await sshExec(`docker compose up --build -f ` + config.services); + } else { + await sshExec(`docker compose up --build`); + } + } + if (Array.isArray(config.features)) { await writeLog("$> Applying features"); for (const feature of config.features) { diff --git a/sudoutil.js b/sudoutil.js index f0bfea2..b44e4ac 100755 --- a/sudoutil.js +++ b/sudoutil.js @@ -50,6 +50,7 @@ const env = Object.assign({}, { NGINX_TMP: path.join(__dirname, '.tmp/nginx'), UNIT_SOCKET: '/var/run/unit/control.sock', UNIT_TMP: path.join(__dirname, '.tmp/unit'), + COMPOSE_TMP: path.join(__dirname, '.tmp/compose'), IPTABLES_PATH: '/etc/sysconfig/iptables', IPTABLES_OUT: '/etc/sysconfig/iptables', IPTABLES_SAVE: 'iptables-save', @@ -112,6 +113,15 @@ switch (cli.args.shift()) { rm(DEST + '.bak'); exec(`${env.NGINX_BIN} -s reload`); exit(0); + case 'COMPOSE_GET': + arg = cli.args.shift(); + cat(arg).to(env.COMPOSE_TMP); + fixOwner(env.COMPOSE_TMP); + exit(0); + case 'COMPOSE_SET': + arg = cli.args.shift(); + cat(env.COMPOSE_TMP).to(arg); + exit(0); case 'UNIT_GET': arg = cli.args.shift(); var unit = spawn('curl', ['--unix-socket', env.UNIT_SOCKET, 'http://localhost' + arg], {