// Cronicle API Layer // Copyright (c) 2015 Joseph Huckaby // Released under the MIT License var fs = require('fs'); var assert = require("assert"); var async = require('async'); var Class = require("pixl-class"); var Tools = require("pixl-tools"); module.exports = Class.create({ __mixins: [ require('./api/config.js'), require('./api/category.js'), require('./api/group.js'), require('./api/plugin.js'), require('./api/event.js'), require('./api/job.js'), require('./api/admin.js'), require('./api/apikey.js') ], api_ping: function(args, callback) { // hello callback({ code: 0 }); }, api_echo: function(args, callback) { // for testing: adds 1 second delay, echoes everything back setTimeout( function() { callback({ code: 0, query: args.query || {}, params: args.params || {}, files: args.files || {} }); }, 1000 ); }, api_check_user_exists: function(args, callback) { // checks if username is taken (used for showing green checkmark on form) var self = this; var query = args.query; var path = 'users/' + this.usermgr.normalizeUsername(query.username); if (!this.requireParams(query, { username: this.usermgr.usernameMatch }, callback)) return; // do not cache this API response this.forceNoCacheResponse(args); this.storage.get(path, function(err, user) { callback({ code: 0, user_exists: !!user }); } ); }, api_status: function(args, callback) { // simple status, used by monitoring tools var tick_age = 0; var now = Tools.timeNow(); if (this.lastTick) tick_age = now - this.lastTick; // do not cache this API response this.forceNoCacheResponse(args); var data = { code: 0, version: this.server.__version, node: process.version, hostname: this.server.hostname, ip: this.server.ip, pid: process.pid, now: now, uptime: Math.floor( now - (this.server.started || now) ), last_tick: this.lastTick || now, tick_age: tick_age, cpu: process.cpuUsage(), mem: process.memoryUsage() }; callback(data); // self-check: if tick_age is over 60 seconds, log a level 1 debug alert if (tick_age > 60) { var msg = "EMERGENCY: Tick age is over 60 seconds (" + Math.floor(tick_age) + "s) -- Server should be restarted immediately."; this.logDebug(1, msg, data); // JH 2018-08-28 Commenting this out for now, because an unsecured API should not have the power to cause an internal restart. // This kind of thing should be handled by external monitoring tools. // this.restartLocalServer({ reason: msg }); } }, forceNoCacheResponse: function(args) { // make sure this response isn't cached, ever args.response.setHeader( 'Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate' ); args.response.setHeader( 'Expires', 'Thu, 01 Jan 1970 00:00:00 GMT' ); }, getServerBaseAPIURL: function(hostname, ip) { // construct fully-qualified URL to API on specified hostname // use proper protocol and ports as needed var api_url = ''; if (ip && !this.server.config.get('server_comm_use_hostnames')) hostname = ip; if (this.web.config.get('https') && this.web.config.get('https_force')) { api_url = 'https://' + hostname; if (this.web.config.get('https_port') != 443) api_url += ':' + this.web.config.get('https_port'); } else { api_url = 'http://' + hostname; if (this.web.config.get('http_port') != 80) api_url += ':' + this.web.config.get('http_port'); } api_url += this.api.config.get('base_uri'); return api_url; }, validateOptionalParams: function(params, rules, callback) { // vaildate optional params given rule set assert( arguments.length == 3, "Wrong number of arguments to validateOptionalParams" ); for (var key in rules) { if (key in params) { var rule = rules[key]; var type_regexp = rule[0]; var value_regexp = rule[1]; var value = params[key]; var type_value = typeof(value); if (!type_value.match(type_regexp)) { this.doError('api', "Malformed parameter type: " + key + " (" + type_value + ")", callback); return false; } else if (!value.toString().match(value_regexp)) { this.doError('api', "Malformed parameter value: " + key, callback); return false; } } } return true; }, requireValidEventData: function(event, callback) { // make sure params contains valid event data (optional params) // otherwise throw an API error and return false // used by create_event, update_event, run_event and update_job APIs var RE_TYPE_STRING = /^(string)$/, RE_TYPE_BOOL = /^(boolean|number)$/, RE_TYPE_NUM = /^(number)$/, RE_ALPHANUM = /^\w+$/, RE_POS_INT = /^\d+$/, RE_BOOL = /^(\d+|true|false)$/; var rules = { algo: [RE_TYPE_STRING, RE_ALPHANUM], api_key: [RE_TYPE_STRING, RE_ALPHANUM], catch_up: [RE_TYPE_BOOL, RE_BOOL], category: [RE_TYPE_STRING, RE_ALPHANUM], chain: [RE_TYPE_STRING, /^\w*$/], chain_error: [RE_TYPE_STRING, /^\w*$/], cpu_limit: [RE_TYPE_NUM, RE_POS_INT], cpu_sustain: [RE_TYPE_NUM, RE_POS_INT], created: [RE_TYPE_NUM, RE_POS_INT], detached: [RE_TYPE_BOOL, RE_BOOL], enabled: [RE_TYPE_BOOL, RE_BOOL], id: [RE_TYPE_STRING, RE_ALPHANUM], log_max_size: [RE_TYPE_NUM, RE_POS_INT], max_children: [RE_TYPE_NUM, RE_POS_INT], memory_limit: [RE_TYPE_NUM, RE_POS_INT], memory_sustain: [RE_TYPE_NUM, RE_POS_INT], modified: [RE_TYPE_NUM, RE_POS_INT], multiplex: [RE_TYPE_BOOL, RE_BOOL], notes: [RE_TYPE_STRING, /.*/], notify_fail: [RE_TYPE_STRING, /.*/], notify_success: [RE_TYPE_STRING, /.*/], plugin: [RE_TYPE_STRING, RE_ALPHANUM], queue: [RE_TYPE_BOOL, RE_BOOL], queue_max: [RE_TYPE_NUM, RE_POS_INT], retries: [RE_TYPE_NUM, RE_POS_INT], retry_delay: [RE_TYPE_NUM, RE_POS_INT], stagger: [RE_TYPE_NUM, RE_POS_INT], target: [RE_TYPE_STRING, /^[\w\-\.]+$/], timeout: [RE_TYPE_NUM, RE_POS_INT], timezone: [RE_TYPE_STRING, /.*/], title: [RE_TYPE_STRING, /\S/], username: [RE_TYPE_STRING, /^[\w\-\.]+$/], web_hook: [RE_TYPE_STRING, /(^$|https?\:\/\/\S+)/i] }; if (!this.validateOptionalParams(event, rules, callback)) return false; // make sure title doesn't contain HTML metacharacters if (event.title && event.title.match(/[<>]/)) { this.doError('api', "Malformed title parameter: Cannot contain HTML metacharacters", callback); return false; } // params if (("params" in event) && (typeof(event.params) != 'object')) { this.doError('api', "Malformed event parameter: params (must be object)", callback); return false; } // timing (can be falsey, or object) if (event.timing) { if (typeof(event.timing) != 'object') { this.doError('api', "Malformed event parameter: timing (must be object)", callback); return false; } // check timing keys, should all be arrays of ints var timing = event.timing; for (var key in timing) { if (!key.match(/^(years|months|days|weekdays|hours|minutes)$/)) { this.doError('api', "Unknown event timing parameter: " + key, callback); return false; } var values = timing[key]; if (!Tools.isaArray(values)) { this.doError('api', "Malformed event timing parameter: " + key + " (must be array)", callback); return false; } for (var idx = 0, len = values.length; idx < len; idx++) { var value = values[idx]; if (typeof(value) != 'number') { this.doError('api', "Malformed event timing parameter: " + key + " (must be array of numbers)", callback); return false; } if ((key == 'years') && (value < 1)) { this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); return false; } if ((key == 'months') && ((value < 1) || (value > 12))) { this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); return false; } if ((key == 'days') && ((value < 1) || (value > 31))) { this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); return false; } if ((key == 'weekdays') && ((value < 0) || (value > 6))) { this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); return false; } if ((key == 'hours') && ((value < 0) || (value > 23))) { this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); return false; } if ((key == 'minutes') && ((value < 0) || (value > 59))) { this.doError('api', "Malformed event timing parameter: " + key + " (value out of range: " + value + ")", callback); return false; } } } } // timing return true; }, requireValidUser: function(session, user, callback) { // make sure user and session are valid // otherwise throw an API error and return false if (session && (session.type == 'api')) { // session is simulated, created by API key if (!user) { return this.doError('api', "Invalid API Key: " + session.api_key, callback); } if (!user.active) { return this.doError('api', "API Key is disabled: " + session.api_key, callback); } return true; } // api key if (!session) { return this.doError('session', "Session has expired or is invalid.", callback); } if (!user) { return this.doError('user', "User not found: " + session.username, callback); } if (!user.active) { return this.doError('user', "User account is disabled: " + session.username, callback); } return true; }, requireAdmin: function(session, user, callback) { // make sure user and session are valid, and user is an admin // otherwise throw an API error and return false if (!this.requireValidUser(session, user, callback)) return false; if (session.type == 'api') { // API Keys cannot be admins return this.doError('api', "API Key cannot use administrator features", callback); } if (!user.privileges.admin) { return this.doError('user', "User is not an administrator: " + session.username, callback); } return true; }, hasPrivilege: function(user, priv_id) { // return true if user has privilege, false otherwise if (user.privileges.admin) return true; // admins can do everything if (user.privileges[priv_id]) return true; return false; }, requirePrivilege: function(user, priv_id, callback) { // make sure user has the specified privilege // otherwise throw an API error and return false if (this.hasPrivilege(user, priv_id)) return true; if (user.key) { return this.doError('api', "API Key ('"+user.title+"') does not have the required privileges to perform this action ("+priv_id+").", callback); } else { return this.doError('user', "User '"+user.username+"' does not have the required account privileges to perform this action ("+priv_id+").", callback); } }, requireCategoryPrivilege: function(user, cat_id, callback) { // make sure user has the specified category privilege // otherwise throw an API error and return false if (user.privileges.admin) return true; // admins can do everything if (!user.privileges.cat_limit) return true; // user is not limited to categories var priv_id = 'cat_' + cat_id; return this.requirePrivilege(user, priv_id, callback); }, requireGroupPrivilege: function(args, user, grp_id, callback) { // make sure user has the specified server group privilege // otherwise throw an API error and return false if (user.privileges.admin) return true; // admins can do everything if (!user.privileges.grp_limit) return true; // user is not limited to groups var priv_id = 'grp_' + grp_id; var result = this.hasPrivilege(user, priv_id); if (result) return true; // user may have targeted an individual server, so find its groups if (!args.server_groups) return false; // no groups loaded? hmmm... var groups = args.server_groups.filter( function(group) { return grp_id.match( group.regexp ); } ); // we just need one group to match, then the user has permission to target the server for (var idx = 0, len = groups.length; idx < len; idx++) { priv_id = 'grp_' + groups[idx].id; if (this.hasPrivilege(user, priv_id, callback)) return true; } // user does not have group privilege if (user.key) { return this.doError('api', "API Key ('"+user.title+"') does not have the required privileges to perform this action ("+priv_id+").", callback); } else { return this.doError('user', "User '"+user.username+"' does not have the required account privileges to perform this action ("+priv_id+").", callback); } }, requireMaster: function(args, callback) { // make sure we are the master server // otherwise throw an API error and return false if (this.multi.master) return true; var status = "200 OK"; var headers = {}; if (this.multi.masterHostname) { // we know who master is, so let's give the client a hint status = "302 Found"; var url = ''; if (this.web.config.get('https') && this.web.config.get('https_force')) { url = 'https://' + (this.server.config.get('server_comm_use_hostnames') ? this.multi.masterHostname : this.multi.masterIP); if (this.web.config.get('https_port') != 443) url += ':' + this.web.config.get('https_port'); } else { url = 'http://' + (this.server.config.get('server_comm_use_hostnames') ? this.multi.masterHostname : this.multi.masterIP); if (this.web.config.get('http_port') != 80) url += ':' + this.web.config.get('http_port'); } url += args.request.url; headers['Location'] = url; } var msg = "This API call can only be invoked on the master server."; // this.logError( 'master', msg ); callback( { code: 'master', description: msg }, status, headers ); return false; }, getClientInfo: function(args, params) { // proxy over to user module // var info = this.usermgr.getClientInfo(args, params); var info = null; if (params) info = Tools.copyHash(params, true); else info = {}; info.ip = args.ip; info.headers = args.request.headers; // augment with our own additions if (args.admin_user) info.username = args.admin_user.username; else if (args.user) { if (args.user.key) { // API Key info.api_key = args.user.key; info.api_title = args.user.title; } else { info.username = args.user.username; } } return info; }, loadSession: function(args, callback) { // Load user session or validate API Key var self = this; var session_id = args.cookies['session_id'] || args.request.headers['x-session-id'] || args.params.session_id || args.query.session_id; if (session_id) { this.storage.get('sessions/' + session_id, function(err, session) { if (err) return callback(err, null, null); // also load user self.storage.get('users/' + self.usermgr.normalizeUsername(session.username), function(err, user) { if (err) return callback(err, null, null); // set type to discern this from API Key sessions session.type = 'user'; // get session_id out of args.params, so it doesn't interfere with API calls delete args.params.session_id; // pass both session and user to callback callback(null, session, user); } ); } ); return; } // no session found, look for API Key var api_key = args.request.headers['x-api-key'] || args.params.api_key || args.query.api_key; if (!api_key) return callback( new Error("No Session ID or API Key could be found"), null, null ); this.storage.listFind( 'global/api_keys', { key: api_key }, function(err, item) { if (err) return callback(new Error("API Key is invalid: " + api_key), null, null); // create simulated session and user objects var session = { type: 'api', api_key: api_key }; var user = item; // get api_key out of args.params, so it doesn't interfere with API calls delete args.params.api_key; // pass both "session" and "user" to callback callback(null, session, user); } ); return; }, requireParams: function(params, rules, callback) { // proxy over to user module assert( arguments.length == 3, "Wrong number of arguments to requireParams" ); return this.usermgr.requireParams(params, rules, callback); }, doError: function(code, msg, callback) { // proxy over to user module assert( arguments.length == 3, "Wrong number of arguments to doError" ); return this.usermgr.doError( code, msg, callback ); } });