diff --git a/config.example.yml b/config.example.yml index e3e6818..bef7b51 100644 --- a/config.example.yml +++ b/config.example.yml @@ -10,6 +10,7 @@ interface: 'public' port: 1 domain: 'db.foo.org' + ip: '8.8.8.8' ssl: false postgres: username: 'postgres' diff --git a/src/cmd.js b/src/cmd.js index b64aefc..ef1a137 100644 --- a/src/cmd.js +++ b/src/cmd.js @@ -4,10 +4,10 @@ const cp = require('child_process') const System = require('./system.js') -const run = script => +const run = (script, {stdio = undefined} = {}) => pipe( Either.tryCatch( - () => cp.spawnSync(script, { encoding: 'utf8', shell: true }), + () => cp.spawnSync(script, { encoding: 'utf8', shell: true, stdio }), id, ), Either.flatMap(o => (o.error ? Either.left(o.error) : Either.right(o))), diff --git a/src/config.js b/src/config.js index ba18c39..f4cf841 100644 --- a/src/config.js +++ b/src/config.js @@ -12,9 +12,7 @@ const parseSegmentLinuxUser = flow( 'linux_user' in o ? Either.right(o.linux_user) : Either.left( - new Error( - ['linux_user required', `in: ${yaml.stringify(o)}`].join('\n'), - ), + new Error(['linux_user required', yaml.stringify(o)].join('\n')), ), Either.flatMap(lx => 'username' in lx @@ -26,9 +24,7 @@ const parseSegmentLinuxUser = flow( }) : Either.left( new Error( - ['linux_user.username required', `in: ${yaml.stringify(lx)}`].join( - '\n', - ), + ['linux_user.username required', yaml.stringify(lx)].join('\n'), ), ), ), @@ -47,7 +43,7 @@ const parseSegmentLinuxUser = flow( new Error( [ `all paths in linux_user.persist must be subpaths of ${lx.homeDir}`, - `in: ${yaml.stringify(lx)}`, + yaml.stringify(lx), ].join('\n'), ), ), @@ -61,155 +57,165 @@ const parseSegmentLinuxUser = flow( })), ) -const parseSegmentPostgres = (cfg, linuxUser) => pipe( +const parseSegmentPostgres = (cfg, linuxUser) => + pipe( 'postgres' in cfg ? Either.right(cfg.postgres) : Either.left( - new Error( - ['postgres required', `in: ${yaml.stringify(cfg)}`].join('\n'), - ), + new Error(['postgres required', yaml.stringify(cfg)].join('\n')), ), - Either.map(pg => ({ - ...pg, - data_dir: pg.data_dir || './data', - })), - Either.map(pg => ({ - ...pg, + Either.map(pg => ({ + ...pg, + data_dir: pg.data_dir || './data', + })), + Either.map(pg => ({ + ...pg, data_dir: path.isAbsolute(pg.data_dir) ? pg.data_dir : path.join(linuxUser.homeDir, pg.data_dir.trim()), - })), - Either.flatMap(pg => + })), + Either.flatMap(pg => path.dirname(pg.data_dir).startsWith(linuxUser.homeDir) - ? Either.right(pg) - : Either.left( - new Error( - [ - `postgres.data_dir must be a subpath of ${linuxUser.homeDir}`, - `in: ${yaml.stringify(pg)}`, - ].join('\n'), - ), - ), - ), - Either.flatMap(pg => - 'username' in pg - ? Either.right(pg) - : Either.left( - new Error( - ['postgres.username required', `in: ${yaml.stringify(pg)}`].join( - '\n', + ? Either.right(pg) + : Either.left( + new Error( + [ + `postgres.data_dir must be a subpath of ${linuxUser.homeDir}`, + yaml.stringify(pg), + ].join('\n'), ), ), - ), - ), - Either.flatMap(pg => - 'password' in pg - ? Either.right(pg) - : Either.left( - new Error( - ['postgres.password required', `in: ${yaml.stringify(pg)}`].join( - '\n', + ), + Either.flatMap(pg => + 'username' in pg + ? Either.right(pg) + : Either.left( + new Error( + ['postgres.username required', yaml.stringify(pg)].join('\n'), ), ), - ), - ), - Either.map(pg => ({ - username: pg.username.trim(), - password: pg.password.trim(), - dataDir: pg.data_dir, - })), -) + ), + Either.flatMap(pg => + 'password' in pg + ? Either.right(pg) + : Either.left( + new Error( + ['postgres.password required', yaml.stringify(pg)].join('\n'), + ), + ), + ), + Either.map(pg => ({ + username: pg.username.trim(), + password: pg.password.trim(), + dataDir: pg.data_dir, + })), + ) -const parseSegmentNetwork = flow( - o => - 'network' in o - ? Either.right(o.network) - : Either.left( - new Error( - ['network required', `in: ${yaml.stringify(o)}`].join('\n'), +const parseSegmentNetwork = ({ http = true }, svc) => + pipe( + svc, + o => + 'network' in o + ? Either.right(o.network) + : Either.left( + new Error(['network required', yaml.stringify(o)].join('\n')), ), - ), - Either.flatMap(n => - ('interface' in n && n.interface === 'public') || n.interface === 'local' - ? Either.right(n) - : Either.left( - new Error( - [ - "network.interface required, and must be 'public' or 'local'", - `in: ${yaml.stringify(n)}`, - ].join('\n'), + Either.flatMap(n => + ('interface' in n && n.interface === 'public') || n.interface === 'local' + ? Either.right(n) + : Either.left( + new Error( + [ + "network.interface required, and must be 'public' or 'local'", + yaml.stringify(n), + ].join('\n'), + ), ), - ), - ), - Either.flatMap(n => - 'port' in n && typeof n.port === 'number' && Number.isInteger(n.port) - ? Either.right(n) - : Either.left( - new Error( - [ - 'network.port required and must be an integer', - `in: ${yaml.stringify(n)}`, - ].join('\n'), + ), + Either.flatMap(n => + 'port' in n && typeof n.port === 'number' && Number.isInteger(n.port) + ? Either.right(n) + : Either.left( + new Error( + [ + 'network.port required and must be an integer', + yaml.stringify(n), + ].join('\n'), + ), ), - ), - ), - Either.flatMap(n => - n.interface === 'public' || (n.interface === 'local' && !n.domain) - ? Either.right(n) - : Either.left( - new Error( - [ - "network.interface is 'local', network.domain must not be present.", - `in: ${yaml.stringify(n)}`, - ].join('\n'), + ), + Either.flatMap(n => + n.interface === 'public' || + (n.interface === 'local' && !n.domain && !n.ip) + ? Either.right(n) + : Either.left( + new Error( + [ + "network.interface is 'local', network.ip and network.domain must not be present.", + yaml.stringify(n), + ].join('\n'), + ), ), - ), - ), - Either.flatMap(n => - n.interface === 'local' || - (n.interface === 'public' && - 'domain' in n && - typeof n.domain === 'string' && - n.domain.length > 0) - ? Either.right(n) - : Either.left( - new Error( - [ - "network.interface is 'public', network.domain be set to a value.", - `in: ${yaml.stringify(n)}`, - ].join('\n'), + ), + Either.flatMap(n => + n.interface === 'local' || + (http && + n.interface === 'public' && + 'domain' in n && + typeof n.domain === 'string' && + n.domain.length > 0) || + (!http && + n.interface === 'public' && + 'domain' in n && + typeof n.domain === 'string' && + n.domain.length > 0 && + 'ip' in n && + typeof n.ip === 'string' && + n.ip.length > 0) + ? Either.right(n) + : Either.left( + new Error( + [ + `network.interface is 'public' and service uses ${ + http ? 'http' : 'tcp' + }; ${http ? 'network.domain' : 'network.domain and network.ip'} must be set.`, + yaml.stringify(n), + ].join('\n'), + ), ), - ), - ), - Either.flatMap(n => - 'ssl' in n && typeof n.ssl === 'boolean' - ? Either.right(n) - : Either.left( - new Error( - [ - 'network.ssl required and must be a bool', - `in: ${yaml.stringify(n)}`, - ].join('\n'), + ), + Either.flatMap(n => + 'ssl' in n && typeof n.ssl === 'boolean' + ? Either.right(n) + : Either.left( + new Error( + [ + 'network.ssl required and must be a bool', + yaml.stringify(n), + ].join('\n'), + ), ), - ), - ), - Either.map(n => ({ - ssl: n.ssl, - domain: (n.domain || '').trim(), - interface: n.interface, - interfaceIp: - n.interface === 'local' ? Net.localInterfaceIp : Net.publicInterfaceIp, - port: n.port, - })), -) + ), + Either.map(n => ({ + ssl: n.ssl, + domain: (n.domain || '').trim(), + ip: (n.ip || '').trim(), + interface: n.interface, + interfaceIp: + n.interface === 'local' ? Net.localInterfaceIp : Net.publicInterfaceIp, + port: n.port, + })), + ) const parseServiceDb = svc => pipe( Either.Do, Either.let('serviceType', () => 'db'), Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)), - Either.bind('network', () => parseSegmentNetwork(svc)), - Either.bind('postgres', ({linuxUser}) => parseSegmentPostgres(svc, linuxUser)), + Either.bind('network', () => parseSegmentNetwork({ http: false }, svc)), + Either.bind('postgres', ({ linuxUser }) => + parseSegmentPostgres(svc, linuxUser), + ), ) const parseServiceApi = svc => @@ -217,7 +223,7 @@ const parseServiceApi = svc => Either.Do, Either.let('serviceType', () => 'api'), Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)), - Either.bind('network', () => parseSegmentNetwork(svc)), + Either.bind('network', () => parseSegmentNetwork({ http: true }, svc)), ) const parseServiceUi = svc => @@ -225,14 +231,14 @@ const parseServiceUi = svc => Either.Do, Either.let('serviceType', () => 'ui'), Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)), - Either.bind('network', () => parseSegmentNetwork(svc)), + Either.bind('network', () => parseSegmentNetwork({ http: true }, svc)), ) const badService = svc => new Error( [ `top-level array elements must be records with a key named "db", "ui", or "api".`, - `in: ${yaml.stringify(svc)}`, + yaml.stringify(svc), ].join('\n'), ) diff --git a/src/index.js b/src/index.js index aa0f9c9..9eae656 100644 --- a/src/index.js +++ b/src/index.js @@ -18,23 +18,23 @@ pipe( svcs.reduce( (steps, svc) => svc.serviceType === 'db' ? [...steps, ...DbService.steps(svc)] : steps, - [System.step], + [System.step(svcs)], ), ), Either.flatMap(steps => - Array_.reverse(steps).reduce((a, step) => { + Array_.reverse(steps).reduce((res, step) => { if (step.down) { console.log(step.down.label) - return Either.flatMap(step.down.work)(a) + return Either.flatMap(step.down.work)(res) } else { - return a; + return res; } }, Either.right(steps)), ), Either.flatMap(steps => - steps.reduce((a, step) => { + steps.reduce((res, step) => { console.log(step.up.label) - return Either.flatMap(step.up.work)(a) + return Either.flatMap(step.up.work)(res) }, Either.right(steps)), ), Either.getOrElse(e => {throw e;}) diff --git a/src/service/db.js b/src/service/db.js index 5781965..8bfdd1b 100644 --- a/src/service/db.js +++ b/src/service/db.js @@ -13,8 +13,8 @@ const dockerCompose = cfg => // restart: always will start the container on system startup / reboot if // docker.service enabled (see ./linux-user.js) restart: 'always', - volumes: [`${cfg.postgres.dataDir}:/var/lib/postgres/data`], - ports: [`${cfg.network.interfaceIp}:${cfg.network.port}:5432`], + volumes: [`${cfg.postgres.dataDir}:/var/lib/postgresql/data`], + ports: [`${cfg.network.port}:5432`], environment: { POSTGRES_USER: cfg.postgres.username, POSTGRES_PASSWORD: cfg.postgres.password, diff --git a/src/service/linux-user.js b/src/service/linux-user.js index bcef90c..ebbeb2a 100644 --- a/src/service/linux-user.js +++ b/src/service/linux-user.js @@ -17,11 +17,12 @@ const userSessionsLinger = cfg => const persistedStateRestored = cfg => Cmd.run( [ + 'set -x', // if /tmp/home/foo exists, copy /tmp/home/foo/* to /home/foo - `if [[ -d ${path.join( + `if [ -d ${path.join( cfg.linuxUser.persistDir, cfg.linuxUser.homeDir, - )} ]]; then`, + )} ]; then`, ` cp -R ${path.join( cfg.linuxUser.persistDir, cfg.linuxUser.homeDir, @@ -30,6 +31,7 @@ const persistedStateRestored = cfg => ` chown -R ${cfg.linuxUser.username}:${cfg.linuxUser.username} ${cfg.linuxUser.homeDir}`, `fi`, ].join('\n'), + { stdio: 'inherit' }, ) const sshAllowed = cfg => @@ -81,6 +83,7 @@ const statePersisted = cfg => pipe( Cmd.run( [ + 'set -x', `mkdir -p ${path.join( cfg.linuxUser.persistDir, cfg.linuxUser.homeDir, @@ -91,6 +94,7 @@ const statePersisted = cfg => p => `mv ${p} ${path.join(cfg.linuxUser.persistDir, p)} || true`, ), ].join('\n'), + { stdio: 'inherit' }, ), ) diff --git a/src/system.js b/src/system.js index 025fc24..b7d6cbe 100644 --- a/src/system.js +++ b/src/system.js @@ -1,14 +1,63 @@ +const net = require('net') const Either = require('fp-ts/Either') -const { flow } = require('fp-ts/function') +const { pipe, flow } = require('fp-ts/function') const Cmd = () => require('./cmd.js') +const nginxConf = [ + 'worker_processes 1;', + '', + 'error_log logs/error.log debug;', + `log_format basic '$remote_addr [$time_local] '`, + ` '$protocol $status $bytes_sent $bytes_received '`, + ` '$session_time';`, + '', + 'access_log logs/access.log basic buffer=32k;', + '', + 'events {', + ' worker_connections 1024;', + '}', + '', + 'stream {', + ' include /etc/nginx/streams-enabled/*;', + '}', + '', + 'http {', + ' include mime.types;', + ' default_type application/octet-stream;', + '', + ' include /etc/nginx/sites-enabled/*;', + '', + ' sendfile on;', + ' keepalive_timeout 65;', + '}', +].join('\n') + +const nginxSystemdUnit = [ + '[Unit]', + 'Description=The NGINX HTTP and reverse proxy server', + 'After=syslog.target network-online.target remote-fs.target nss-lookup.target', + 'Wants=network-online.target', + '', + '[Service]', + 'Type=forking', + 'PIDFile=/usr/local/nginx/nginx.pid', + 'ExecStartPre=/usr/local/nginx/nginx -t', + 'ExecStart=/usr/local/nginx/nginx', + 'ExecReload=/usr/local/nginx/nginx -s reload', + 'ExecStop=/bin/kill -s QUIT $MAINPID', + 'PrivateTmp=true', + '', + '[Install]', + 'WantedBy=multi-user.target', +].join('\n') + const localSshKeyPath = '/root/.ssh/id_ed25519' const localSshPubKeyPath = '/root/.ssh/id_ed25519.pub' const pkgs = [ + 'openssl', 'man', - 'nginx', 'neovim', 'ca-certificates', 'gnupg', @@ -25,16 +74,16 @@ const pkgs = [ 'slirp4netns', ] -const apt = () => +const packagesInstalled = () => Cmd().run( [ - 'apt update -y', - 'apt upgrade -y', + 'apt-get update -y', + 'apt-get upgrade -y', `apt-get install -fy ${pkgs.join(' ')}`, ].join('\n'), ) -const docker = () => +const dockerInstalled = () => Cmd().run( [ 'install -m 0755 -d /etc/apt/keyrings', @@ -55,20 +104,214 @@ const localSshKeyCreated = () => const localSshKeyDeleted = () => Cmd().run(`rm ${localSshKeyPath} || true`) -const step = { +const firewallUp = cfgs => + Cmd().run( + [ + 'ufw default deny incoming', + 'ufw default allow outgoing', + 'ufw status verbose', + 'ufw allow ssh', + `ufw allow 'Nginx Full'`, + ...cfgs + .filter(svc => svc.serviceType === 'db' && svc.network.interface === 'public') + .map( + svc => + `ufw allow in to '${svc.network.ip}' port 5432 proto tcp comment ${svc.linuxUser.username}`, + ), + 'ufw --force enable', + ].join('\n'), + ) + +const nginxConfs = cfgs => + cfgs + .filter(cfg => cfg.network.interface === 'public') + .map(cfg => ({ + path: `/etc/nginx/${ + cfg.serviceType === 'db' ? 'streams' : 'sites' + }-available/${cfg.network.domain}`, + symlinks: [ + `/etc/nginx/${cfg.serviceType === 'db' ? 'streams' : 'sites'}-enabled/${ + cfg.network.domain + }`, + ], + contents: + cfg.serviceType === 'db' + ? [ + `upstream ${cfg.linuxUser.username}_pg {`, + ` server localhost:${cfg.network.port};`, + '}', + '', + 'server {', + ` listen ${net.isIP(cfg.network.ip) === 6 ? '[' + cfg.network.ip + ']' : cfg.network.ip}:5432;`, + ` proxy_pass ${cfg.linuxUser.username}_pg;`, + '}', + ].join('\n') + : [ + 'server {', + ` listen 80;`, + ` server_name ${cfg.network.domain};`, + '', + ' location / {', + ' client_max_body_size 512M;', + ` proxy_pass http://localhost:${cfg.network.port};`, + ' proxy_set_header Host $host;', + ' proxy_set_header X-Real-IP $remote_addr;', + ' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;', + ' proxy_set_header X-Forwarded-Proto $scheme;', + ' }', + '}', + ].join('\n'), + })) + +const nginxInstalled = () => + pipe( + Cmd().run('nginx -V 2>&1'), + Either.map(e => ({ shouldInstall: !e.stdout.includes('--with-stream') })), + Either.orElse(e => { + try { + const { stdout } = JSON.parse(e.message) + return stdout.includes('not found') + ? Either.right({ shouldInstall: true }) + : Either.left(e) + } catch (e) { + return Either.left(e) + } + }), + Either.flatMap(({ shouldInstall }) => + !shouldInstall + ? Either.right() + : Cmd().run( + [ + 'set -x', + 'echo "nginx needs the stream module, building from source... (this will take a while)"', + 'sleep 2', + 'rm *.tar.gz || true', + // pcre + 'wget -q github.com/PCRE2Project/pcre2/releases/download/pcre2-10.42/pcre2-10.42.tar.gz', + 'tar -zxf pcre2-10.42.tar.gz', + 'cd pcre2-10.42', + './configure', + 'make 1>/dev/null', + 'make install 1>/dev/null', + 'cd ..', + // openssl + 'wget -q https://www.openssl.org/source/openssl-1.1.1u.tar.gz', + 'tar -zxf openssl-1.1.1u.tar.gz', + 'cd openssl-1.1.1u', + './config --prefix=/usr 1>/dev/null', + 'make 1>/dev/null', + 'make install 1>/dev/null', + 'cd ..', + // zlib + 'wget -q http://zlib.net/zlib-1.2.13.tar.gz', + 'tar -zxf zlib-1.2.13.tar.gz', + 'cd zlib-1.2.13', + './configure 1>/dev/null', + 'make 1>/dev/null', + 'make install 1>/dev/null', + 'cd ..', + // nginx + 'wget https://nginx.org/download/nginx-1.25.1.tar.gz', + 'tar -zxf nginx-1.25.1.tar.gz', + 'cd nginx-1.25.1', + [ + './configure', + '--sbin-path=/usr/local/nginx/nginx', + '--conf-path=/usr/local/nginx/nginx.conf', + '--pid-path=/usr/local/nginx/nginx.pid', + '--with-pcre=../pcre2-10.42', + '--with-zlib=../zlib-1.2.13', + '--with-stream', + '--with-threads', + '--with-http_ssl_module', + '--with-pcre-jit', + '--with-http_stub_status_module', + '--with-http_realip_module', + '--with-http_auth_request_module', + '--with-http_v2_module', + '--with-http_dav_module', + '--with-http_slice_module', + '--with-http_addition_module', + '--with-http_gunzip_module', + '--with-http_gzip_static_module', + '--with-http_sub_module', + '--with-debug', + '1>/dev/null', + ].join(' '), + 'make 1>/dev/null', + 'make install 1>/dev/null', + // nginx.conf + 'cat <<"NGINXCONF" > /usr/local/nginx/nginx.conf', + nginxConf, + 'NGINXCONF', + // systemd + 'cat <<"NGINXUNIT" > /lib/systemd/system/nginx.service', + nginxSystemdUnit, + 'NGINXUNIT', + 'systemctl enable nginx', + 'rm /usr/sbin/nginx || true', + 'ln /usr/local/nginx/nginx /usr/sbin/nginx', + ].join('\n'), + { stdio: 'inherit' }, + ), + ), + ) + +const nginxConfsCreated = cfgs => + nginxConfs(cfgs).reduce( + (res, { path, symlinks, contents }) => + Either.flatMap(() => + Cmd().run( + [ + `cat <<"EOCONF" > ${path}`, + contents, + 'EOCONF', + ...symlinks.map(ln => `ln -s ${path} ${ln}`), + ].join('\n'), + ), + )(res), + Either.right(undefined), + ) + +const nginxConfsDeleted = cfgs => + nginxConfs(cfgs).reduce( + (res, { path, symlinks }) => + Either.flatMap(() => + Cmd().run( + [ + `rm ${path} || true`, + ...symlinks.map(ln => `rm ${ln} || true`), + ].join('\n'), + ), + )(res), + Either.right(undefined), + ) + +const nginxStarted = () => Cmd().run('systemctl restart nginx') + +const step = cfgs => ({ up: { label: 'system: setup', work: flow( Either.right, - Either.tap(apt), - Either.tap(docker), + Either.tap(packagesInstalled), + Either.tap(dockerInstalled), + Either.tap(() => firewallUp(cfgs)), Either.tap(localSshKeyCreated), + Either.tap(nginxInstalled), + Either.tap(() => nginxConfsCreated(cfgs)), + Either.tap(nginxStarted), + // Either.tap(sslCertsInstalled), ), }, down: { label: 'system: teardown', - work: flow(Either.right, Either.tap(localSshKeyDeleted)), + work: flow( + Either.right, + Either.tap(localSshKeyDeleted), + Either.tap(() => nginxConfsDeleted(cfgs)), + ), }, -} +}) module.exports = { step, localSshKeyPath, localSshPubKeyPath }