fix: nginx, ufw, state persistence

This commit is contained in:
Orion Kindel 2023-06-17 12:19:23 -05:00
parent 09ee1d3507
commit b68d3fc144
Signed by untrusted user who does not match committer: orion
GPG Key ID: 6D4165AE4C928719
7 changed files with 411 additions and 157 deletions

View File

@ -10,6 +10,7 @@
interface: 'public' interface: 'public'
port: 1 port: 1
domain: 'db.foo.org' domain: 'db.foo.org'
ip: '8.8.8.8'
ssl: false ssl: false
postgres: postgres:
username: 'postgres' username: 'postgres'

View File

@ -4,10 +4,10 @@ const cp = require('child_process')
const System = require('./system.js') const System = require('./system.js')
const run = script => const run = (script, {stdio = undefined} = {}) =>
pipe( pipe(
Either.tryCatch( Either.tryCatch(
() => cp.spawnSync(script, { encoding: 'utf8', shell: true }), () => cp.spawnSync(script, { encoding: 'utf8', shell: true, stdio }),
id, id,
), ),
Either.flatMap(o => (o.error ? Either.left(o.error) : Either.right(o))), Either.flatMap(o => (o.error ? Either.left(o.error) : Either.right(o))),

View File

@ -12,9 +12,7 @@ const parseSegmentLinuxUser = flow(
'linux_user' in o 'linux_user' in o
? Either.right(o.linux_user) ? Either.right(o.linux_user)
: Either.left( : Either.left(
new Error( new Error(['linux_user required', yaml.stringify(o)].join('\n')),
['linux_user required', `in: ${yaml.stringify(o)}`].join('\n'),
),
), ),
Either.flatMap(lx => Either.flatMap(lx =>
'username' in lx 'username' in lx
@ -26,9 +24,7 @@ const parseSegmentLinuxUser = flow(
}) })
: Either.left( : Either.left(
new Error( new Error(
['linux_user.username required', `in: ${yaml.stringify(lx)}`].join( ['linux_user.username required', yaml.stringify(lx)].join('\n'),
'\n',
),
), ),
), ),
), ),
@ -47,7 +43,7 @@ const parseSegmentLinuxUser = flow(
new Error( new Error(
[ [
`all paths in linux_user.persist must be subpaths of ${lx.homeDir}`, `all paths in linux_user.persist must be subpaths of ${lx.homeDir}`,
`in: ${yaml.stringify(lx)}`, yaml.stringify(lx),
].join('\n'), ].join('\n'),
), ),
), ),
@ -61,13 +57,12 @@ const parseSegmentLinuxUser = flow(
})), })),
) )
const parseSegmentPostgres = (cfg, linuxUser) => pipe( const parseSegmentPostgres = (cfg, linuxUser) =>
pipe(
'postgres' in cfg 'postgres' in cfg
? Either.right(cfg.postgres) ? Either.right(cfg.postgres)
: Either.left( : Either.left(
new Error( new Error(['postgres required', yaml.stringify(cfg)].join('\n')),
['postgres required', `in: ${yaml.stringify(cfg)}`].join('\n'),
),
), ),
Either.map(pg => ({ Either.map(pg => ({
...pg, ...pg,
@ -86,7 +81,7 @@ const parseSegmentPostgres = (cfg, linuxUser) => pipe(
new Error( new Error(
[ [
`postgres.data_dir must be a subpath of ${linuxUser.homeDir}`, `postgres.data_dir must be a subpath of ${linuxUser.homeDir}`,
`in: ${yaml.stringify(pg)}`, yaml.stringify(pg),
].join('\n'), ].join('\n'),
), ),
), ),
@ -96,9 +91,7 @@ const parseSegmentPostgres = (cfg, linuxUser) => pipe(
? Either.right(pg) ? Either.right(pg)
: Either.left( : Either.left(
new Error( new Error(
['postgres.username required', `in: ${yaml.stringify(pg)}`].join( ['postgres.username required', yaml.stringify(pg)].join('\n'),
'\n',
),
), ),
), ),
), ),
@ -107,9 +100,7 @@ const parseSegmentPostgres = (cfg, linuxUser) => pipe(
? Either.right(pg) ? Either.right(pg)
: Either.left( : Either.left(
new Error( new Error(
['postgres.password required', `in: ${yaml.stringify(pg)}`].join( ['postgres.password required', yaml.stringify(pg)].join('\n'),
'\n',
),
), ),
), ),
), ),
@ -118,16 +109,16 @@ const parseSegmentPostgres = (cfg, linuxUser) => pipe(
password: pg.password.trim(), password: pg.password.trim(),
dataDir: pg.data_dir, dataDir: pg.data_dir,
})), })),
) )
const parseSegmentNetwork = flow( const parseSegmentNetwork = ({ http = true }, svc) =>
pipe(
svc,
o => o =>
'network' in o 'network' in o
? Either.right(o.network) ? Either.right(o.network)
: Either.left( : Either.left(
new Error( new Error(['network required', yaml.stringify(o)].join('\n')),
['network required', `in: ${yaml.stringify(o)}`].join('\n'),
),
), ),
Either.flatMap(n => Either.flatMap(n =>
('interface' in n && n.interface === 'public') || n.interface === 'local' ('interface' in n && n.interface === 'public') || n.interface === 'local'
@ -136,7 +127,7 @@ const parseSegmentNetwork = flow(
new Error( new Error(
[ [
"network.interface required, and must be 'public' or 'local'", "network.interface required, and must be 'public' or 'local'",
`in: ${yaml.stringify(n)}`, yaml.stringify(n),
].join('\n'), ].join('\n'),
), ),
), ),
@ -148,35 +139,47 @@ const parseSegmentNetwork = flow(
new Error( new Error(
[ [
'network.port required and must be an integer', 'network.port required and must be an integer',
`in: ${yaml.stringify(n)}`, yaml.stringify(n),
].join('\n'), ].join('\n'),
), ),
), ),
), ),
Either.flatMap(n => Either.flatMap(n =>
n.interface === 'public' || (n.interface === 'local' && !n.domain) n.interface === 'public' ||
(n.interface === 'local' && !n.domain && !n.ip)
? Either.right(n) ? Either.right(n)
: Either.left( : Either.left(
new Error( new Error(
[ [
"network.interface is 'local', network.domain must not be present.", "network.interface is 'local', network.ip and network.domain must not be present.",
`in: ${yaml.stringify(n)}`, yaml.stringify(n),
].join('\n'), ].join('\n'),
), ),
), ),
), ),
Either.flatMap(n => Either.flatMap(n =>
n.interface === 'local' || n.interface === 'local' ||
(n.interface === 'public' && (http &&
n.interface === 'public' &&
'domain' in n && 'domain' in n &&
typeof n.domain === 'string' && typeof n.domain === 'string' &&
n.domain.length > 0) 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.right(n)
: Either.left( : Either.left(
new Error( new Error(
[ [
"network.interface is 'public', network.domain be set to a value.", `network.interface is 'public' and service uses ${
`in: ${yaml.stringify(n)}`, http ? 'http' : 'tcp'
}; ${http ? 'network.domain' : 'network.domain and network.ip'} must be set.`,
yaml.stringify(n),
].join('\n'), ].join('\n'),
), ),
), ),
@ -188,7 +191,7 @@ const parseSegmentNetwork = flow(
new Error( new Error(
[ [
'network.ssl required and must be a bool', 'network.ssl required and must be a bool',
`in: ${yaml.stringify(n)}`, yaml.stringify(n),
].join('\n'), ].join('\n'),
), ),
), ),
@ -196,20 +199,23 @@ const parseSegmentNetwork = flow(
Either.map(n => ({ Either.map(n => ({
ssl: n.ssl, ssl: n.ssl,
domain: (n.domain || '').trim(), domain: (n.domain || '').trim(),
ip: (n.ip || '').trim(),
interface: n.interface, interface: n.interface,
interfaceIp: interfaceIp:
n.interface === 'local' ? Net.localInterfaceIp : Net.publicInterfaceIp, n.interface === 'local' ? Net.localInterfaceIp : Net.publicInterfaceIp,
port: n.port, port: n.port,
})), })),
) )
const parseServiceDb = svc => const parseServiceDb = svc =>
pipe( pipe(
Either.Do, Either.Do,
Either.let('serviceType', () => 'db'), Either.let('serviceType', () => 'db'),
Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)), Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)),
Either.bind('network', () => parseSegmentNetwork(svc)), Either.bind('network', () => parseSegmentNetwork({ http: false }, svc)),
Either.bind('postgres', ({linuxUser}) => parseSegmentPostgres(svc, linuxUser)), Either.bind('postgres', ({ linuxUser }) =>
parseSegmentPostgres(svc, linuxUser),
),
) )
const parseServiceApi = svc => const parseServiceApi = svc =>
@ -217,7 +223,7 @@ const parseServiceApi = svc =>
Either.Do, Either.Do,
Either.let('serviceType', () => 'api'), Either.let('serviceType', () => 'api'),
Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)), Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)),
Either.bind('network', () => parseSegmentNetwork(svc)), Either.bind('network', () => parseSegmentNetwork({ http: true }, svc)),
) )
const parseServiceUi = svc => const parseServiceUi = svc =>
@ -225,14 +231,14 @@ const parseServiceUi = svc =>
Either.Do, Either.Do,
Either.let('serviceType', () => 'ui'), Either.let('serviceType', () => 'ui'),
Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)), Either.bind('linuxUser', () => parseSegmentLinuxUser(svc)),
Either.bind('network', () => parseSegmentNetwork(svc)), Either.bind('network', () => parseSegmentNetwork({ http: true }, svc)),
) )
const badService = svc => const badService = svc =>
new Error( new Error(
[ [
`top-level array elements must be records with a key named "db", "ui", or "api".`, `top-level array elements must be records with a key named "db", "ui", or "api".`,
`in: ${yaml.stringify(svc)}`, yaml.stringify(svc),
].join('\n'), ].join('\n'),
) )

View File

@ -18,23 +18,23 @@ pipe(
svcs.reduce( svcs.reduce(
(steps, svc) => (steps, svc) =>
svc.serviceType === 'db' ? [...steps, ...DbService.steps(svc)] : steps, svc.serviceType === 'db' ? [...steps, ...DbService.steps(svc)] : steps,
[System.step], [System.step(svcs)],
), ),
), ),
Either.flatMap(steps => Either.flatMap(steps =>
Array_.reverse(steps).reduce((a, step) => { Array_.reverse(steps).reduce((res, step) => {
if (step.down) { if (step.down) {
console.log(step.down.label) console.log(step.down.label)
return Either.flatMap(step.down.work)(a) return Either.flatMap(step.down.work)(res)
} else { } else {
return a; return res;
} }
}, Either.right(steps)), }, Either.right(steps)),
), ),
Either.flatMap(steps => Either.flatMap(steps =>
steps.reduce((a, step) => { steps.reduce((res, step) => {
console.log(step.up.label) console.log(step.up.label)
return Either.flatMap(step.up.work)(a) return Either.flatMap(step.up.work)(res)
}, Either.right(steps)), }, Either.right(steps)),
), ),
Either.getOrElse(e => {throw e;}) Either.getOrElse(e => {throw e;})

View File

@ -13,8 +13,8 @@ const dockerCompose = cfg =>
// restart: always will start the container on system startup / reboot if // restart: always will start the container on system startup / reboot if
// docker.service enabled (see ./linux-user.js) // docker.service enabled (see ./linux-user.js)
restart: 'always', restart: 'always',
volumes: [`${cfg.postgres.dataDir}:/var/lib/postgres/data`], volumes: [`${cfg.postgres.dataDir}:/var/lib/postgresql/data`],
ports: [`${cfg.network.interfaceIp}:${cfg.network.port}:5432`], ports: [`${cfg.network.port}:5432`],
environment: { environment: {
POSTGRES_USER: cfg.postgres.username, POSTGRES_USER: cfg.postgres.username,
POSTGRES_PASSWORD: cfg.postgres.password, POSTGRES_PASSWORD: cfg.postgres.password,

View File

@ -17,11 +17,12 @@ const userSessionsLinger = cfg =>
const persistedStateRestored = cfg => const persistedStateRestored = cfg =>
Cmd.run( Cmd.run(
[ [
'set -x',
// if /tmp/home/foo exists, copy /tmp/home/foo/* to /home/foo // 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.persistDir,
cfg.linuxUser.homeDir, cfg.linuxUser.homeDir,
)} ]]; then`, )} ]; then`,
` cp -R ${path.join( ` cp -R ${path.join(
cfg.linuxUser.persistDir, cfg.linuxUser.persistDir,
cfg.linuxUser.homeDir, cfg.linuxUser.homeDir,
@ -30,6 +31,7 @@ const persistedStateRestored = cfg =>
` chown -R ${cfg.linuxUser.username}:${cfg.linuxUser.username} ${cfg.linuxUser.homeDir}`, ` chown -R ${cfg.linuxUser.username}:${cfg.linuxUser.username} ${cfg.linuxUser.homeDir}`,
`fi`, `fi`,
].join('\n'), ].join('\n'),
{ stdio: 'inherit' },
) )
const sshAllowed = cfg => const sshAllowed = cfg =>
@ -81,6 +83,7 @@ const statePersisted = cfg =>
pipe( pipe(
Cmd.run( Cmd.run(
[ [
'set -x',
`mkdir -p ${path.join( `mkdir -p ${path.join(
cfg.linuxUser.persistDir, cfg.linuxUser.persistDir,
cfg.linuxUser.homeDir, cfg.linuxUser.homeDir,
@ -91,6 +94,7 @@ const statePersisted = cfg =>
p => `mv ${p} ${path.join(cfg.linuxUser.persistDir, p)} || true`, p => `mv ${p} ${path.join(cfg.linuxUser.persistDir, p)} || true`,
), ),
].join('\n'), ].join('\n'),
{ stdio: 'inherit' },
), ),
) )

View File

@ -1,14 +1,63 @@
const net = require('net')
const Either = require('fp-ts/Either') 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 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 localSshKeyPath = '/root/.ssh/id_ed25519'
const localSshPubKeyPath = '/root/.ssh/id_ed25519.pub' const localSshPubKeyPath = '/root/.ssh/id_ed25519.pub'
const pkgs = [ const pkgs = [
'openssl',
'man', 'man',
'nginx',
'neovim', 'neovim',
'ca-certificates', 'ca-certificates',
'gnupg', 'gnupg',
@ -25,16 +74,16 @@ const pkgs = [
'slirp4netns', 'slirp4netns',
] ]
const apt = () => const packagesInstalled = () =>
Cmd().run( Cmd().run(
[ [
'apt update -y', 'apt-get update -y',
'apt upgrade -y', 'apt-get upgrade -y',
`apt-get install -fy ${pkgs.join(' ')}`, `apt-get install -fy ${pkgs.join(' ')}`,
].join('\n'), ].join('\n'),
) )
const docker = () => const dockerInstalled = () =>
Cmd().run( Cmd().run(
[ [
'install -m 0755 -d /etc/apt/keyrings', 'install -m 0755 -d /etc/apt/keyrings',
@ -55,20 +104,214 @@ const localSshKeyCreated = () =>
const localSshKeyDeleted = () => Cmd().run(`rm ${localSshKeyPath} || true`) 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: { up: {
label: 'system: setup', label: 'system: setup',
work: flow( work: flow(
Either.right, Either.right,
Either.tap(apt), Either.tap(packagesInstalled),
Either.tap(docker), Either.tap(dockerInstalled),
Either.tap(() => firewallUp(cfgs)),
Either.tap(localSshKeyCreated), Either.tap(localSshKeyCreated),
Either.tap(nginxInstalled),
Either.tap(() => nginxConfsCreated(cfgs)),
Either.tap(nginxStarted),
// Either.tap(sslCertsInstalled),
), ),
}, },
down: { down: {
label: 'system: teardown', 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 } module.exports = { step, localSshKeyPath, localSshPubKeyPath }