msimerson/Haraka

View on GitHub
bin/haraka

Summary

Maintainability
Test Coverage
#!/usr/bin/env node

// this script takes inspiration from:
// https://github.com/visionmedia/express/blob/master/bin/express
// https://github.com/tnantoka/LooseLeaf/blob/master/bin/looseleaf

const fs   = require('fs');
const net  = require('net');
const nopt = require('nopt');
const path = require('path');
const os   = require('os');

const utils   = require('haraka-utils');
const sprintf = require('sprintf-js').sprintf;
const base = path.join(__dirname, '..');
const ver  = JSON.parse(fs.readFileSync(`${base}/package.json`, 'utf8')).version;
const knownOpts = {
    "version":     Boolean,
    "verbose":     Boolean,
    "help":        [String, null],
    "configs":     path,
    "install":     path,
    "list":        Boolean,
    "plugin":      Array,
    "force":       Boolean,
    "qlist":       Boolean,
    "qstat":       Boolean,
    "qempty":      Boolean,
    "qunstick":    [String, null],
    "graceful":    Boolean,
    "order":       Boolean,
    "test":        [String, Array],
    "ip":          String,
    "helo":        String,
    "ehlo":        String,
    "envfrom":     String,
    "envrcpt":     [String, Array],
    "message":     path,
    "dump-mime":   Boolean,
    "dump-stream": Boolean,
    "skip-deny":   Boolean,
    "set-relay":   Boolean,
}
const shortHands = {
    "v": ["--version"],
    "h": ["--help"],
    "c": ["--configs"],
    "i": ["--install"],
    "l": ["--list"],
    "p": ["--plugin"],
    "f": ["--force"],
    "o": ["--order"],
    "t": ["--test"],
}
const parsed = nopt(knownOpts, shortHands, process.argv, 2);

const usage = [
    "\033[32;40mHaraka.js\033[0m — A Node.js Email Server project",
    "Usage: haraka [options] [path]",
    "Options:",
    "\t-v, --version \t\tOutputs version number",
    "\t-h, --help    \t\tOutputs this help message",
    "\t-h NAME       \t\tShows help for NAME",
    "\t-c, --configs \t\tPath to your config directory",
    "\t-i, --install \t\tCopies the default configs to a specified dir",
    "\t-l, --list    \t\tList the plugins bundled with Haraka",
    "\t-p, --plugin  \t\tGenerate a new plugin with the given name",
    "\t-f, --force   \t\tForce overwriting of old files",
    "\t--qlist       \t\tList the outbound queue",
    "\t--qstat       \t\tGet statistics on the outbound queue",
    "\t--qunstick    \t\tUnstick (force delivery) for a given domain",
    "\t-o, --order   \t\tShow all registered plugins and their run order",
    "\t-t PLUGIN     \t\tPlugin test mode",
    "\t--------------- PLUGIN TEST MODE OPTIONS (all optional) --------------",
    "\t--ip IP       \t\tIP address to use",
    "\t--helo HELO   \t\tHELO to use",
    "\t--ehlo EHLO   \t\tEHLO to use",
    "\t--envfrom FROM\t\tMAIL FROM to use",
    "\t--envfrom TO  \t\tRCPT TO(s) to use",
    "\t--message FILE\t\tMessage file to use",
    "\t--dump-mime   \t\tDump the MIME structure and body text",
    "\t--dump-stream \t\tDump the MessageStream to stdout",
    "\t--skip-deny   \t\tContinue running hooks after DENY/DENYSOFT",
    "\t--set-relay   \t\tSet connection.relaying",
].join('\n');


function listPlugins (b, dir) {

    if (!dir) { dir = "plugins/"; }

    let plist = `${dir}\n`;
    const subdirs = [];
    const gl = path.join((b ? b : base), dir);
    const pd = fs.readdirSync(gl);

    pd.forEach(function (p) {
        const stat = fs.statSync(`${gl}/${p}`);
        if (stat.isFile() && ~p.search('.js')) {
            plist += `\t${p.replace('.js', '')}\n`;
        }
        else if (stat.isDirectory()) {
            subdirs.push(`${dir + p}/`);
        }
    });

    subdirs.forEach(function (s) {
        plist += `\n${listPlugins(b, s)}`;
    });

    return plist;
}


// Show message when create
function create (dirPath) {
    console.log(`\x1b[32mcreate\x1b[0m: ${dirPath}`);
}

// Warning messsage
function warning (msg) {
    console.error(`\x1b[31mwarning\x1b[0m: ${msg}`);
}

function fail (msg) {
    console.error(`\x1b[31merror\x1b[0m: ${msg}`);
    process.exit(-1);
}

// Make directory if NOT exist
function mkDir (dstPath) {
    try {
        if (fs.statSync(dstPath).isDirectory()) return;
    }
    catch (ignore) {}

    try {
        fs.mkdirSync(dstPath, { recursive: true });
        create(dstPath);
    }
    catch (e) {
        // File exists
        console.error(e);
        if (e.errno == 17) {
            warning(e.message);
        }
        else {
            throw e;
        }
    }
}

// Copy directory
function copyDir (srcPath, dstPath) {

    mkDir(dstPath);
    const files = fs.readdirSync(srcPath);

    for (let i=0; i < files.length; i++) {

        // Ignore ".*"
        if (/^\./.test(files[i])) continue;

        const srcFile = path.join(srcPath, files[i]);
        const dstFile = path.join(dstPath, files[i]);

        const srcStat = fs.statSync(srcFile);

        // Recursive call If direcotory
        if (srcStat.isDirectory()) {
            copyDir(srcFile, dstFile);
        }
        // Copy to dstPath if file
        else if (srcStat.isFile()) {
            // NOT overwrite file
            copyFile(srcFile, dstFile);
        }
    }
}

function copyFile (srcFile, dstFile) {

    try {
        if (fs.statSync(dstFile).isFile()) {
            warning(`EEXIST, File exists '${dstFile}'`);
            return;
        }
        throw `EEXIST but not a file: '${dstFile}'`;
    }
    catch (e) {
        // File NOT exists
        if (e.code == 'ENOENT') {
            mkDir(path.dirname(dstFile));
            fs.writeFileSync(dstFile, fs.readFileSync(srcFile));
            create(dstFile)
        }
        else {
            console.log(`copy ${srcFile} to ${dstFile}`);
            throw e;
        }
    }
}

function setupHostname (confPath) {
    mkDir(confPath);

    const hostname = `${os.hostname()}${os.EOL}`;

    ['me','host_list'].forEach(f => {
        const cfPath = path.join(confPath, f);

        try { if (fs.statSync(cfPath).isFile()) return; }
        catch (ignore) { }

        try { fs.writeFileSync(cfPath, hostname); }
        catch (err) { warning(`Unable to write to config/${f}: ${err.message}`); }
    })
}

function setupBaseConfig (confPath) {
    copyFile(path.join(base, 'config', 'smtp.ini'), path.join(confPath, 'smtp.ini'));
    copyFile(path.join(base, 'config', 'log.ini'),  path.join(confPath, 'log.ini'));
    copyFile(path.join(base, 'config', 'plugins'),  path.join(confPath, 'plugins'));
    copyDir(path.join(base, 'config', 'dkim'), path.join(confPath, 'dkim'));
}

const readme = [
    "Haraka",
    "",
    "Congratulations on creating a new installation of Haraka.",
    "",
    "This directory contains two key directories for how Haraka will function:",
    "",
    " - config",
    "           This directory contains configuration files for Haraka. The",
    "           directory contains the default configuration. You probably want",
    "           to modify some files in here, particularly `smtp.ini`.",
    " - plugins",
    "           This directory contains custom plugins which you write to run in",
    "           Haraka. The plugins which ship with Haraka are still available",
    "           to use.",
    " - docs/plugins",
    "           This directory contains documentation for your plugins.",
    "",
    "Documentation for Haraka is available via `haraka -h <name> where the name",
    "is either the name of a plugin (without the .js extension) or the name of",
    "a core Haraka module, such as `Connection` or `Transaction`.",
    "",
    "To get documentation on writing a plugin type `haraka -h Plugins`.",
    "",
].join("\n");

const packageJson = JSON.stringify({
    "name": "haraka_local",
    "description": "An SMTP Server",
    "version": "0.0.1",
    "dependencies": {},
    "repository": "",
    "license": "MIT"
}, null, 2);

const plugin_src = [
    "// %plugin%",
    "",
    "// documentation via: haraka -c %config% -h plugins/%plugin%",
    "",
    "// Put your plugin code here",
    "// type: `haraka -h Plugins` for documentation on how to create a plugin",
    "",
].join("\n");

const plugin_doc = [
    "%plugin%",
    "========",
    "",
    "Describe what your plugin does here.",
    "",
    "Configuration",
    "-------------",
    "",
    "* `config/some_file` - describe what effect this config file has",
    "",
].join("\n");

function createFile (filePath, data, info) {
    try {
        if (fs.existsSync(filePath) && !parsed.force) {
            throw `${filePath} already exists`;
        }
        mkDir(path.dirname(filePath));
        const fd = fs.openSync(filePath, 'w');
        const output = data.replace(/%(\w+)%/g, function (i, m1) { return info[m1] });
        fs.writeSync(fd, output, null);
    }
    catch (e) {
        warning(`Unable to create file: ${e}`);
    }
}

let config;
let logger;
let outbound;
let plugins;
if (parsed.version) {
    console.log(`\x1B[32;40mHaraka.js\x1B[0m — Version: ${ver}`);
}
else if (parsed.list) {
    console.log(`\x1B[32;40m*global\x1B[0m\n${ listPlugins() }`);
    if (parsed.configs) {
        console.log(`\x1B[32;40m*local\x1B[0m\n${ listPlugins(parsed.configs) }`);
    }
}
else if (parsed.help) {
    if (parsed.help === 'true') {
        console.log(usage);
    }
    else {
        let md_path;
        const md_paths = [
            path.join(base, 'docs', `${parsed.help}.md`),
            path.join(base, 'docs', 'plugins', `${parsed.help}.md`),
            path.join(base, 'docs', 'deprecated', `${parsed.help}.md`),
            path.join(base, 'node_modules', `haraka-plugin-${parsed.help}`, 'README.md'),
        ];
        if (parsed.configs) {
            md_paths.unshift(path.join(parsed.configs, 'docs', 'plugins', `${parsed.help}.md`));
            md_paths.unshift(path.join(parsed.configs, 'docs', `${parsed.help}.md`));
        }
        for (let i=0, j=md_paths.length; i<j; i++) {
            const _md_path = md_paths[i];
            if (fs.existsSync(_md_path)) {
                md_path = [_md_path];
                break;
            }
        }
        if (!md_path) {
            warning(`No documentation found for: ${parsed.help}`);
            process.exit();
        }
        let pager = 'less';
        const spawn = require('child_process').spawn;
        if (process.env.PAGER) {
            const pager_split = process.env.PAGER.split(/ +/);
            pager = pager_split.shift();
            md_path = pager_split.concat(md_path);
        }

        const less  = spawn( pager, md_path, { stdio: 'inherit' } );
        less.on('exit', function () {
            process.exit(0);
        });
    }
}
else if (parsed.configs && parsed.plugin) {
    const js_path = path.join(parsed.configs, 'plugins', `${parsed.plugin}.js`);
    createFile(js_path,
        plugin_src, { plugin: parsed.plugin, config: parsed.configs });

    const doc_path = path.join(parsed.configs, 'docs', 'plugins', `${parsed.plugin}.md`);
    createFile(doc_path,
        plugin_doc, { plugin: parsed.plugin, config: parsed.configs });

    console.log(`Plugin ${parsed.plugin} created`);
    console.log(`Now edit javascript in:    ${js_path}`);
    console.log(`Add the plugin to config:  ${path.join(parsed.configs, 'config', 'plugins')}`);
    console.log(`And edit documentation in: ${doc_path}`);
}
else if (parsed.qlist) {
    if (!parsed.configs) {
        fail("qlist option requires config path");
    }
    process.env.HARAKA = parsed.configs;
    logger = require(path.join(base, "logger"));
    if (!parsed.verbose)
        logger.log = function () {} // disable logging for this
    outbound = require(path.join(base, "outbound"));
    outbound.list_queue(function (err, qlist) {
        qlist.forEach(function (todo) {
            console.log(sprintf("Q: %s rcpt:%d from:%s domain:%s", todo.file, todo.rcpt_to.length, todo.mail_from.toString(), todo.domain));
        });
        process.exit();
    });
}
else if (parsed.qstat) {
    if (!parsed.configs) {
        fail("qstat option requires config path");
    }
    process.env.HARAKA = parsed.configs;
    logger = require(path.join(base, "logger"));
    if (!parsed.verbose)
        logger.log = function () {} // disable logging for this
    outbound = require(path.join(base, "outbound"));
    outbound.stat_queue(function (err, stats) {
        console.log(stats);
        process.exit();
    });
}
else if (parsed.qunstick) {
    if (!parsed.configs) {
        fail("qunstick option requires config path");
    }
    const domain = parsed.qunstick.toLowerCase();
    process.env.HARAKA = parsed.configs;
    logger = require(path.join(base, "logger"));
    if (!parsed.verbose)
        logger.log = function () {} // disable logging for this
    const cb = function () {
        process.exit();
    };
    if (domain == 'true') {
        send_internal_command('flushQueue', cb);
    }
    else {
        send_internal_command(`flushQueue ${domain}`, cb);
    }
}
else if (parsed.graceful) {
    if (!parsed.configs) {
        fail("graceful option requires config path");
    }
    process.env.HARAKA = parsed.configs;
    logger = require(path.join(base, "logger"));
    if (!parsed.verbose)
        logger.log = function () {} // disable logging for this
    config = require('haraka-config');
    if (!config.get("smtp.ini").main.nodes) {
        console.log("Graceful restart not possible without `nodes` value in smtp.ini");
        process.exit();
    }
    else {
        send_internal_command('gracefulRestart', function () {
            process.exit();
        });
    }
}
else if (parsed.qempty) {
    if (!parsed.configs) {
        fail("qempty option requires config path");
    }
    fail("qempty is unimplemented");
}
else if (parsed.order) {
    if (!parsed.configs) {
        fail("order option requires config path");
    }
    process.env.HARAKA = parsed.configs;
    try {
        require.paths.push(path.join(process.env.HARAKA, 'node_modules'));
    }
    catch (e) {
        process.env.NODE_PATH = process.env.NODE_PATH ?
            (`${process.env.NODE_PATH}:${path.join(process.env.HARAKA, 'node_modules')}`)
            :
            (path.join(process.env.HARAKA, 'node_modules'));
        require('module')._initPaths(); // Horrible hack
    }
    logger = require(path.join(base, "logger"));
    if (!parsed.verbose)
        logger.log = function () {} // disable logging for this
    plugins = require(path.join(base, "plugins"));
    plugins.load_plugins();
    console.log('');
    const hooks = Object.keys(plugins.registered_hooks);
    for (let h = 0; h < hooks.length; h++) {
        const hook = hooks[h];
        console.log(sprintf('%\'--80s', `Hook: ${hook} `));
        console.log(sprintf('%-35s %-35s %-4s %-3s', 'Plugin', 'Method', 'Prio', 'T/O'));
        console.log(sprintf("%'-80s",''));
        for (let p=0; p<plugins.registered_hooks[hook].length; p++) {
            const item = plugins.registered_hooks[hook][p];
            console.log(sprintf('%-35s %-35s %4d %3d', item.plugin, item.method, item.priority, item.timeout));
        }
        console.log('');
    }
    process.exit();
}
else if (parsed.test) {
    if (!parsed.configs) {
        fail("test option requires config path");
    }
    process.env.HARAKA = parsed.configs;
    try {
        require.paths.push(path.join(process.env.HARAKA, 'node_modules'));
    }
    catch (e) {
        process.env.NODE_PATH = process.env.NODE_PATH ?
            (`${process.env.NODE_PATH}:${path.join(process.env.HARAKA, 'node_modules')}`)
            :
            (path.join(process.env.HARAKA, 'node_modules'));
        require('module')._initPaths(); // Horrible hack
    }
    logger = require(path.join(base, "logger"));
    logger.loglevel = logger.levels.PROTOCOL;

    // Attempt to load message early
    let msg;
    if (parsed.message) {
        try {
            msg = fs.readFileSync(parsed.message);
        }
        catch (e) {
            logger.logcrit(e.message);
            logger.dump_logs(1);
        }
    }

    plugins = require(path.join(base, "plugins"));
    plugins.server = { notes: {} };
    plugins.load_plugins((parsed.test && parsed.test[0] !== 'all') ? parsed.test : null);
    const Connection = require(path.join(base, "connection"));
    // var Transaction = require(path.join(base, "transaction"));
    const Address = require('address-rfc2821').Address;
    const Notes = require('haraka-notes');
    const constants = require('haraka-constants');
    const client = {
        remoteAddress: ((parsed.ip) ? parsed.ip : '1.2.3.4'),
        remotePort: 1234,
        destroy () {},
        on (event, done) {},
        end () {
            process.exit();
        },
        write (buf) {},
        resume () {},
        pause () {},
    }
    const server = {
        address () {
            return { port: 25, family: 'ipv4', address: '127.0.0.1' };
        },
        cfg  : require('haraka-config').get('smtp.ini'),
        notes: new Notes(),
    }
    const connection = Connection.createConnection(client, server, server.cfg);
    if (parsed['set-relay']) connection.relaying = true;

    const run_next_hook = function () {
        const args = Array.prototype.slice.call(arguments);
        const code = args.shift();
        if (!parsed['skip-deny'] && code !== constants.ok && code !== constants.cont) {
            plugins.run_hooks('quit', connection);
        }
        else {
            plugins.run_hooks.apply(this, args);
        }
    }

    connection.connect_respond = function () {
        let helo = 'test.example.com';
        let mode = 'ehlo';
        if (parsed.ehlo) {
            helo = parsed.ehlo;
        }
        else if (parsed.helo) {
            helo = parsed.helo;
            mode = 'helo';
        }
        connection.hello.host = helo;
        run_next_hook(arguments[0], mode, connection, helo);
    }
    connection.helo_respond = connection.ehlo_respond = function () {
        const args = arguments;
        const mail = new Address(((parsed.envfrom) ? parsed.envfrom : 'test@example.com'));
        connection.init_transaction(function () {
            connection.transaction.mail_from = mail;
            run_next_hook(args[0], 'mail', connection, [mail, null]);
        });
    }
    connection.mail_respond = function () {
        const rcpt = new Address(((parsed.envrcpt) ? parsed.envrcpt : 'test@example.com'));
        this.transaction.rcpt_to.push(rcpt);
        run_next_hook(arguments[0], 'rcpt', connection, [rcpt, null]);
    }
    connection.rcpt_respond = function () {
        connection.transaction.parse_body = true;
        run_next_hook(arguments[0], 'data', connection);
    }
    connection.data_respond = function () {
        const args = arguments;
        // Add data to stream
        if (msg) {
            let buf = msg;
            let offset;
            while ((offset = utils.indexOfLF(buf)) !== -1) {
                const line = buf.slice(0, offset+1);
                if (buf.length > offset) {
                    buf = buf.slice(offset+1);
                }
                connection.transaction.add_data(line);
                connection.transaction.data_bytes += line.length;
            }
        }
        else {
            // Add empty data to initialize message_stream
            connection.transaction.add_data('');
        }
        connection.data_done(function () {
            run_next_hook(args[0], 'data_post', connection);
        });
    }
    connection.data_post_respond = function () {
        const args = arguments;
        // Dump MIME structure and decoded body text?
        function dump_mime_structure (body) {
            console.log(`Found MIME part ${body.ct}`);
            console.log(body.bodytext);
            for (let m=0,l=body.children.length; m < l; m++) {
                dump_mime_structure(body.children[m]);
            }
        }
        if (parsed['dump-mime']) {
            dump_mime_structure(connection.transaction.body);
        }
        if (parsed['dump-stream']) {
            console.log('STREAM:');
            connection.transaction.message_stream.on('end', function () {
                run_next_hook(args[0], 'queue', connection);
            });
            connection.transaction.message_stream.pipe(process.stdout);
        }
        else {
            run_next_hook(args[0], 'queue', connection);
        }
    }
    connection.queue_respond = function () {
        run_next_hook(arguments[0], 'queue_ok', connection);
    }
    connection.queue_ok_respond = function () {
        run_next_hook(arguments[0], 'quit', connection);
    }
}
else if (parsed.configs) {
    const haraka_path = path.join(base, 'haraka.js');

    const base_dir = process.argv[3];
    const err_msg = `Did you install a Haraka config? (haraka -i ${base_dir })`;
    if (!fs.existsSync(base_dir)) {
        fail( `No such directory: ${base_dir}\n${err_msg}` );
    }

    const smtp_ini_path = path.join(base_dir,'config','smtp.ini');
    const smtp_json = path.join(base_dir,'config','smtp.json');
    const smtp_yaml = path.join(base_dir,'config','smtp.yaml');
    if (!fs.existsSync(smtp_ini_path) && !fs.existsSync(smtp_json) && !fs.existsSync(smtp_yaml)) {
        fail( `No smtp.ini at: ${smtp_ini_path}\n${err_msg}` );
    }

    process.argv[1] = haraka_path;
    process.env.HARAKA = parsed.configs;
    require(haraka_path);
}
else if (parsed.install) {
    const pa = parsed.install;
    mkDir(parsed.install);
    mkDir(path.join(pa, 'plugins'));
    mkDir(path.join(pa, 'docs'));
    mkDir(path.join(pa, 'config'));
    createFile(path.join(pa, 'README'), readme);
    createFile(path.join(pa, 'package.json'), packageJson);
    const bytes = require('crypto').randomBytes(32);
    createFile(path.join(pa, 'config', 'internalcmd_key'), bytes.toString('hex'));
    setupHostname(path.join(pa, 'config'));
    setupBaseConfig(path.join(pa, 'config'));
}
else {
    console.log("\033[31;40mError\033[0m: Undefined or erroneous arguments\n");
    console.log(usage);
}

function send_internal_command (cmd, done) {
    config = require('haraka-config');
    const key = config.get("internalcmd_key");
    const smtp_ini = config.get("smtp.ini");
    const listen_addrs = require(path.join(base, "server")).get_listen_addrs(smtp_ini.main);
    const hp = /^\[?([^\]]+)\]?:(\d+)$/.exec(listen_addrs[0]);
    if (!hp) throw "No listen address in smtp.ini";
    // console.log("Connecting to " + listen_addrs[0]);
    const sock = net.connect(hp[2], hp[1], function () {
        sock.once('data', function (data) {
            // this is the greeting. Ignore it...
            sock.write(`INTERNALCMD ${key ? (`key:${key} `) : ''}${cmd}\r\n`);
            sock.once('data', function (data2) {
                console.log(data2.toString().replace(/\r?\n$/, ''));
                sock.write('QUIT\r\n');
                sock.once('data', function (data3) {
                    sock.end();
                })
            });
        });
    });
    sock.on('end', done);
}