Verified Commit 10a677e1 authored by Aral Balkan's avatar Aral Balkan
Browse files

Now using systemd for startup daemon

parents 72a4b614 1b8443a5
......@@ -2,3 +2,4 @@ node_modules
dist-iws
web-server.zip
dist
......@@ -17,21 +17,13 @@ const childProcess = require('child_process')
console.log(`\n ⚙ Indie Web Server: building native binaries for version ${version}`)
const linuxVersionPath = `dist-iws/linux/${version}`
const macOSVersionPath = `dist-iws/macos/${version}`
const linuxVersionPath = `dist/linux/${version}`
const macOSVersionPath = `dist/macos/${version}`
fs.mkdirSync(linuxVersionPath, {recursive: true})
fs.mkdirSync(macOSVersionPath, {recursive: true})
async function build () {
//
// Zip the source.
//
console.log(' • Zipping up the source for inclusion in the binary…')
const mainSourceDirectory = path.join(__dirname, '..')
childProcess.execSync(String.raw`rm -f web-server.zip && zip web-server.zip * -x \*.git\* \*dist-iws\* -r`, {env: process.env, cwd: mainSourceDirectory})
//
// Build.
//
......@@ -40,7 +32,7 @@ async function build () {
input: 'bin/web-server.js',
output: `${linuxVersionPath}/web-server`,
target: 'linux-x64-10.15.3',
resources: ['package.json', 'bin/daemon.js', 'web-server.zip']
resources: ['package.json', 'bin/daemon.js']
})
console.log(' • Building macOS version…')
......@@ -49,7 +41,7 @@ async function build () {
input: 'bin/web-server.js',
output: `${macOSVersionPath}/web-server`,
target: 'mac-x64-10.15.3',
resources: ['package.json', 'bin/daemon.js', 'web-server.zip']
resources: ['package.json', 'bin/daemon.js']
})
//
......@@ -58,6 +50,7 @@ async function build () {
console.log(' • Zipping binaries…')
const zipFileName = `${version}.zip`
const mainSourceDirectory = path.join(__dirname, '..')
const linuxVersionWorkingDirectory = path.join(mainSourceDirectory, linuxVersionPath)
const macOSVersionWorkingDirectory = path.join(mainSourceDirectory, macOSVersionPath)
......@@ -98,10 +91,6 @@ async function build () {
console.log(' • Skipped copy of binaries to Indie Web Site as could not find the local working copy.')
}
console.log(' • Cleaning up…')
childProcess.execSync('rm -f web-server.zip', {env: process.env, cwd: mainSourceDirectory})
console.log('\n 😁👍 Done!\n')
}
......
......@@ -8,7 +8,7 @@
const args = process.argv.slice(2)
if (args.length !== 1) {
console.log('\n 💕😈💕 Indie Web Server Daemon\n\n Please use via web-server --live instead.\n')
console.log('\n 💕😈💕 Indie Web Server Daemon\n\n Please use via $ web-server on\n')
process.exit(1)
}
......
......@@ -5,23 +5,22 @@ const path = require('path')
const ansi = require('ansi-escape-sequences')
const webServer = require('../index.js')
const pm2 = require('pm2')
const childProcess = require('child_process')
const arguments = require('minimist')(process.argv.slice(2), {boolean: true})
//
// When run as a regular Node script, the source directory is our parent
// directory (web-server.js resides in the <sourceDirectory>/bin directory).
// However, when run as a standalone executable using Nexe, we currently have
// to bundle the source code in the executable and copy it from the virtual
// filesystem of the binary to the external file system in order to run the
// pm2 process manager using execSync.
//
// For more information, please see the following issues in the Nexe repo:
//
// https://github.com/nexe/nexe/issues/605
// https://github.com/nexe/nexe/issues/607
//
// //
// // When run as a regular Node script, the source directory is our parent
// // directory (web-server.js resides in the <sourceDirectory>/bin directory).
// // However, when run as a standalone executable using Nexe, we currently have
// // to bundle the source code in the executable and copy it from the virtual
// // filesystem of the binary to the external file system in order to run the
// // pm2 process manager using execSync.
// //
// // For more information, please see the following issues in the Nexe repo:
// //
// // https://github.com/nexe/nexe/issues/605
// // https://github.com/nexe/nexe/issues/607
// //
const runtime = {
isNode: process.argv0 === 'node',
isBinary: process.argv0 === 'web-server'
......@@ -29,55 +28,6 @@ const runtime = {
let sourceDirectory = path.resolve(__dirname, '..')
if (runtime.isBinary) {
// This is the directory that will house a copy of the source code.
// Scripts and other processes are launched from here so that they work
// properly when Indie Web Server is wrapped into native binaries using Nexe.
sourceDirectory = path.join(os.homedir(), '.indie-web-server')
// This is the directory that we will copy the source code to (as a single
// zip file, before unzipping it into sourceDirectory.)
const zipFilePath = path.join(os.homedir(), 'web-server.zip')
// If the external directory (and, thereby, the external copy of our
// bundled source code) doesn’t exist, copy it over and unzip it.
if (!fs.existsSync(sourceDirectory)) {
try {
//
// Note: we are copying the node_modules.zip file using fs.readFileSync()
// ===== and fs.writeFileSync() instead of fs.copyFileSync() as the latter
// does not work currently in binaries that are compiled with
// Nexe (tested with version 3.1.0). See these issues for more details:
//
// https://github.com/nexe/nexe/issues/605 (red herring)
// https://github.com/nexe/nexe/issues/607 (actual issue)
//
// fs.copyFileSync(internalZipFilePath, zipFilePath)
//
const internalZipFilePath = path.join(__dirname, '../web-server.zip')
const webServerZip = fs.readFileSync(internalZipFilePath, 'binary')
fs.writeFileSync(zipFilePath, webServerZip, 'binary')
// Unzip the node_modules
const options = {
env: process.env,
stdio: 'inherit' // Display output.
}
// Unzip the node_modules directory to the external directory.
childProcess.execSync(`unzip ${zipFilePath} -d ${sourceDirectory}`)
} catch (error) {
console.log(' 💥 Failed to copy Indie Web Server source code to external directory.', error)
process.exit(1)
}
}
}
// The path that we expect the PM2 process manager’s source code to reside
// at in the external directory.
const pm2Path = path.join(sourceDirectory, 'node_modules/pm2/bin/pm2')
// At this point, regardless of whether we are running as a regular Node script or
// as a standalone executable created with Nexe, all paths should be correctly set.
......@@ -89,32 +39,33 @@ const command = {
isHelp: (arguments.h || arguments.help || positionalArguments.length > 2 || firstPositionalArgument === 'help'),
isVersion: (arguments.version || arguments.v || firstPositionalArgument === 'version'),
isTest: (arguments.test || firstPositionalArgument === 'test'),
isOn: (arguments.on || firstPositionalArgument === 'on'),
isOff: (arguments.off || firstPositionalArgument === 'off'),
isMonitor: (arguments.monitor || firstPositionalArgument === 'monitor'),
isEnable: (arguments.enable || firstPositionalArgument === 'enable'),
isDisable: (arguments.disable || firstPositionalArgument === 'disable'),
isLogs: (arguments.logs || firstPositionalArgument === 'logs'),
isInfo: (arguments.info || firstPositionalArgument === 'info')
isStatus: (arguments.status || firstPositionalArgument === 'status'),
//isDev: is handled below.
}
// If we didn’t match a command, we default to dev.
const didMatchCommand = Object.values(command).reduce((p,n) => p || n)
command.isDev = (arguments.dev || firstPositionalArgument === 'dev' || !didMatchCommand)
const firstPositionalArgumentDidMatchCommand = ['version', 'help', 'test', 'on', 'off', 'monitor', 'logs', 'info'].reduce((p, n) => p || (firstPositionalArgument === n), false)
const firstPositionalArgumentDidMatchCommand = ['version', 'help', 'test', 'enable', 'disable', 'logs', 'status'].reduce((p, n) => p || (firstPositionalArgument === n), false)
// Help / usage instructions.
if (command.isHelp) {
const usageCommand = `${clr('command', 'green')}`
const usageFolderToServe = clr('folder', 'cyan')
const usageOptions = clr('options', 'yellow')
const usageVersion = `${clr('version', 'green')}`
const usageHelp = `${clr('help', 'green')}`
const usageDev = `${clr('dev', 'green')}`
const usageTest = `${clr('test', 'green')}`
const usageOn = `${clr('on', 'green')}`
const usageOff = `${clr('off', 'green')}`
const usageMonitor = `${clr('monitor', 'green')}`
const usageEnable = `${clr('enable', 'green')}`
const usageDisable = `${clr('disable', 'green')}`
const usageLogs = `${clr('logs', 'green')}`
const usageInfo = `${clr('info', 'green')}`
const usageStatus = `${clr('status', 'green')}`
const usagePort = `${clr('--port', 'yellow')}=${clr('N', 'cyan')}`
const usage = `
......@@ -123,7 +74,7 @@ if (command.isHelp) {
${clr('web-server', 'bold')} [${usageCommand}] [${usageFolderToServe}] [${usageOptions}]
${usageCommand}\t${usageVersion} | ${usageHelp} | ${usageDev} | ${usageTest} | ${usageOn} | ${usageOff} | ${usageMonitor} | ${usageLogs} | ${usageInfo}
${usageCommand}\t${usageVersion} | ${usageHelp} | ${usageDev} | ${usageTest} | ${usageEnable} | ${usageDisable} | ${usageLogs} | ${usageStatus}
${usageFolderToServe}\tPath of folder to serve (defaults to current folder).
${usageOptions}\tSettings that alter server characteristics.
......@@ -132,16 +83,13 @@ if (command.isHelp) {
${usageVersion}\tDisplay version and exit.
${usageHelp}\t\tDisplay this help screen and exit.
${usageDev}\t\tLaunch server as regular process with locally-trusted certificates.
${usageTest}\t\tLaunch server as regular process with globally-trusted certificates.
${usageOn}\t\tLaunch server as startup daemon with globally-trusted certificates.
${usageDev}\t\tStart server as regular process with locally-trusted certificates.
${usageTest}\t\tStart server as regular process with globally-trusted certificates.
When server is on, you can also use:
${usageOff}\t\tTurn server off and remove it from startup items.
${usageMonitor}\tMonitor server state.
${usageEnable}\tStart server as daemon with globally-trusted certificates and add to startup.
${usageDisable}\tStop server and remove from startup.
${usageLogs}\t\tDisplay and tail server logs.
${usageInfo}\t\tDisplay detailed server information.
${usageStatus}\tDisplay detailed server information (press ‘q’ to exit).
If ${usageCommand} is omitted, behaviour defaults to ${usageDev}.
......@@ -154,207 +102,159 @@ if (command.isHelp) {
process.exit()
}
// Version.
if (command.isVersion) {
console.log(webServer.version())
process.exit()
}
// Monitor (pm2 proxy).
if (command.isMonitor) {
// Launch pm2 monit.
const options = {
env: process.env,
stdio: 'inherit' // Display output.
}
try {
childProcess.execSync(`sudo ${pm2Path} monit`, options)
} catch (error) {
console.log(`\n 👿 Failed to launch the process monitor.\n`)
process.exit(1)
}
process.exit(0)
}
// Logs (pm2 proxy).
if (command.isLogs) {
// Launch pm2 logs.
const options = {
env: process.env,
stdio: 'inherit' // Display output.
}
try {
childProcess.execSync(`sudo ${pm2Path} logs web-server`, options)
} catch (error) {
console.log(`\n 👿 Failed to get the logs.\n`)
process.exit(1)
}
process.exit(0)
}
// Info (pm2 proxy).
if (command.isInfo) {
// Launch pm2 logs.
const options = {
env: process.env,
stdio: 'inherit' // Display output.
}
try {
childProcess.execSync(`sudo ${pm2Path} show web-server`, options)
} catch (error) {
console.log(`\n 👿 Failed to show detailed information on the web server.\n`)
process.exit(1)
}
process.exit(0)
}
// Offline (pm2 proxy for unstartup + delete)
if (command.isOff) {
const options = {
env: process.env,
stdio: 'pipe' // Suppress output.
}
// Do some cleanup, display a success message and exit.
function success () {
// Try to reset permissions on pm2 so that future uses of pm2 proxies via web-server
// in this session will not require sudo.
//
// Execute requested command.
//
switch (true) {
// Version
case command.isVersion:
console.log(webServer.version())
process.exit()
break
// Logs (proxy: journalctl --follow --unit web-server)
case command.isLogs:
ensureJournalctl()
childProcess.spawn('journalctl', ['--follow', '--unit', 'web-server'], {env: process.env, stdio: 'inherit'})
break
// Status (proxy: systemctl status web-server)
case command.isStatus:
ensureSystemctl()
childProcess.spawn('systemctl', ['status', 'web-server'], {env: process.env, stdio: 'inherit'})
break
// Off (turn off the server daemon and remove it from startup items).
case command.isDisable:
ensureRoot('disable')
ensureSystemctl()
try {
childProcess.execSync('sudo chown $(whoami):$(whoami) /home/$(whoami)/.pm2/rpc.sock /home/$(whoami)/.pm2/pub.sock', options)
childProcess.execSync('sudo systemctl disable web-server', {env: process.env})
childProcess.execSync('sudo systemctl stop web-server', {env: process.env})
} catch (error) {
console.log(`\n 👿 Warning: could not reset permissions on pm2.`)
console.error(error, '\n 👿 Error: Could not disable web server.\n')
process.exit(1)
}
break
// Default: run the server (either for development, testing (test), or production (on))
default:
// If no path is passed, serve the current folder (i.e., called with just web-server)
// If there is a path, serve that.
let pathToServe = '.'
if ((command.isDev || command.isTest || command.isEnable) && positionalArguments.length === 2) {
// e.g., web-server on path-to-serve
pathToServe = secondPositionalArgument
} else if (!firstPositionalArgumentDidMatchCommand && (command.isDev || command.isTest || command.isEnable) && positionalArguments.length === 1) {
// e.g., web-server --on path-to-serve
pathToServe = firstPositionalArgument
} else if (command.isDev && positionalArguments.length === 1) {
// i.e., web-server path-to-serve
pathToServe = firstPositionalArgument
}
// All’s good.
console.log(`\n 😈 Server is offline and removed from startup items.\n`)
process.exit(0)
}
// Is the server running?
try {
childProcess.execSync(`sudo ${pm2Path} show web-server`, options)
} catch (error) {
console.log(`\n 👿 Server is not running as a live daemon; nothing to take offline.\n`)
process.exit(1)
}
// If a port is specified, use it. Otherwise use the default port (443).
let port = 443
if (arguments.port !== undefined) {
port = parseInt(arguments.port)
}
// Try to remove from startup items.
try {
childProcess.execSync(`sudo ${pm2Path} unstartup`, options)
} catch (error) {
console.log(`\n 👿 Could not remove the server from startup items.\n`)
process.exit(1)
}
// If a test server is specified, use it.
let global = false
if (command.isTest) {
global = true
}
// If the server was started as a startup item, unstartup will also
// kill the process. Check again to see if the server is running.
try {
childProcess.execSync(`sudo ${pm2Path} show web-server`, options)
} catch (error) {
success()
}
if (!fs.existsSync(pathToServe)) {
console.log(` 🤔 Error: could not find path ${pathToServe}\n`)
process.exit(1)
}
// The server is still on (it was not started as a startup item). Use
// pm2 delete to remove it.
try {
childProcess.execSync(`sudo ${pm2Path} delete web-server`, options)
} catch (error) {
console.log(`\n 👿 Could not delete the server daemon.\n`)
process.exit(1)
}
//
// Launch as startup daemon or regular process?
//
if (command.isEnable) {
//
// Launch as startup daemon.
//
success()
}
ensureRoot('enable')
ensureSystemctl()
// If no path is passed, serve the current folder (i.e., called with just web-server)
// If there is a path, serve that.
let pathToServe = '.'
if ((command.isDev || command.isTest || command.isOn) && positionalArguments.length === 2) {
// e.g., web-server on path-to-serve
pathToServe = secondPositionalArgument
} else if (!firstPositionalArgumentDidMatchCommand && (command.isDev || command.isTest || command.isOn) && positionalArguments.length === 1) {
// e.g., web-server --on path-to-serve
pathToServe = firstPositionalArgument
} else if (command.isDev && positionalArguments.lenght === 1) {
// i.e., web-server path-to-serve
pathToServe = firstPositionalArgument
}
//
// Create the systemd service unit.
//
const binaryExecutable = '/usr/local/bin/web-server'
const nodeExecutable = `node ${path.join(sourceDirectory, 'bin/web-server.js')}`
const executable = runtime.isBinary ? binaryExecutable : nodeExecutable
// If a port is specified, use it. Otherwise use the default port (443).
let port = 443
if (arguments.port !== undefined) {
port = parseInt(arguments.port)
}
const absolutePathToServe = path.resolve(pathToServe)
// If a test server is specified, use it.
let global = false
if (command.isTest) {
global = true
}
// Get the regular account name (i.e, the unprivileged account that is
// running the current process via sudo).
const accountUID = parseInt(process.env.SUDO_UID)
if (!accountUID) {
console.log(`\n 👿 Error: could not get account ID.\n`)
process.exit(1)
}
if (!fs.existsSync(pathToServe)) {
console.log(` 🤔 Error: could not find path ${pathToServe}\n`)
process.exit(1)
}
let accountName
try {
// Courtesy: https://www.unix.com/302402784-post4.html
accountName = childProcess.execSync(`awk -v val=${accountUID} -F ":" '$3==val{print $1}' /etc/passwd`).toString()
} catch (error) {
console.error(error, '\n 👿 Error: could not get account name.\n')
process.exit(1)
}
// If on is specified, run as a daemon using the pm2 process manager.
// Otherwise, start the server as a regular process.
if (command.isOn) {
const unit = `[Unit]
Description=Indie Web Server
Documentation=https://ind.ie/web-server/
After=network.target
StartLimitIntervalSec=0
pm2.connect((error) => {
if (error) {
console.log(error)
process.exit(1)
}
[Service]
Type=simple
User=${accountName}
Environment=PATH=/sbin:/usr/bin:/usr/local/bin
Environment=NODE_ENV=production
RestartSec=1
Restart=always
pm2.start({
script: path.join(sourceDirectory, 'bin/daemon.js'),
args: pathToServe,
name: 'web-server',
autorestart: true
}, (error, processObj) => {
if (error) {
throw error
}
ExecStart=${executable} test ${absolutePathToServe}
console.log(`${webServer.version()}\n 😈 Launched as daemon on https://${os.hostname()} serving ${pathToServe}\n`)
[Install]
WantedBy=multi-user.target
`
//
// Run the script that tells the process manager to add the server to launch at startup
// as a separate process with sudo privileges.
//
const options = {
env: process.env,
stdio: 'pipe' // Suppress output.
}
// Save the systemd service unit.
fs.writeFileSync('/etc/systemd/system/web-server.service', unit, 'utf-8')
// Enable and start the systemd service.
try {
const output = childProcess.execSync(`sudo ${pm2Path} startup`, options)
// Enable.
childProcess.execSync('sudo systemctl enable web-server', {env: process.env})
console.log(` 😈 Installed for auto-launch at startup.\n`)
// Start.
childProcess.execSync('sudo systemctl start web-server', {env: process.env})
console.log(`${webServer.version()}\n 😈 Launched as daemon on https://${os.hostname()} serving ${pathToServe}\n`)
} catch (error) {
console.log(` 👿 Failed to add server for auto-launch at startup.\n`)
pm2.disconnect()
console.error(error, `\n 👿 Error: could not enable web server.\n`)
process.exit(1)
}
console.log(` 😈 Installed for auto-launch at startup.\n`)
// Disconnect from the pm2 daemon. This will also exit the script.
pm2.disconnect()
})
})
} else {
//
// Start a regular server process.
//
webServer.serve({
path: pathToServe,
port,
global
})
} else {
//
// Start a regular server process.
//
webServer.serve({
path: pathToServe,
port,
global
})
}
break
}
//
......@@ -366,3 +266,33 @@ if (command.isOn) {
function clr (text, color) {
return process.stdout.isTTY ? ansi.format(text, color) : text
}
// Ensure we have root privileges and exit if we don’t.
function ensureRoot (commandName) {
if (process.getuid() !== 0) {
const nodeSyntax = `sudo node bin/webserver.js ${commandName}`
const binarySyntax = `sudo web-server ${commandName}`
console.log(`\n 👿 Error: Requires root. Please try again with ${runtime.isNode ? nodeSyntax : binarySyntax}\n`)
process.exit(1)
}
}
// Ensure systemctl exists.
function ensureSystemctl () {
try {
childProcess.execSync('which systemctl', {env: process.env})
} catch (error) {
console.error(error, '\n 👿 Error: Could not find systemctl.\n')
process.exit(1)
}
}
// Ensure systemctl exists.
function ensureJournalctl () {
try {
childProcess.execSync('which journalctl', {env: process.env})
} catch (error) {
console.error(error, '\n 👿 Error: Could not find journalctl.\n')
process.exit(1)
}
}