redis-commander
Version:
Redis web-based management tool written in node.js
766 lines (706 loc) • 27.5 kB
JavaScript
#!/usr/bin/env node
'use strict';
const yargs = require('yargs');
const Redis = require('ioredis');
const isEqual = require('lodash.isequal');
const myUtils = require('../lib/util');
const fs = require('fs');
// fix the cwd to project base dir for browserify and config loading
const path = require('path');
process.chdir( path.join(__dirname, '..') );
process.env.ALLOW_CONFIG_MUTATIONS = true;
const config = require('config');
const connectionWrapper = require('../lib/connections');
let redisConnections;
const args = yargs
.alias('h', 'help')
.alias('h', '?')
.options('redis-port', {
type: 'number',
describe: 'The port to find redis on.'
})
.options('redis-host', {
type: 'string',
describe: 'The host to find redis on.'
})
.options('redis-socket', {
type: 'string',
describe: 'The unix-socket to find redis on.'
})
.options('redis-username', {
type: 'string',
describe: 'The redis username.'
})
.options('redis-password', {
type: 'string',
describe: 'The redis password.'
})
.options('redis-db', {
type: 'number',
describe: 'The redis database.'
})
.options('redis-optional', {
type: 'boolean',
describe: 'Set to true if no permanent auto-reconnect shall be done if server is down.',
default: false
})
.options('sentinel-port', {
type: 'number',
describe: 'The port to find sentinel on.'
})
.options('sentinel-host', {
type: 'string',
describe: 'The host to find sentinel on.'
})
.options('sentinels', {
type: 'string',
describe: 'Comma separated list of sentinels with host:port.'
})
.options('sentinel-name', {
type: 'string',
describe: 'The sentinel group name to use.'
})
.options('sentinel-username', {
type: 'string',
describe: 'The sentinel username to use.'
})
.options('sentinel-password', {
type: 'string',
describe: 'The sentinel password to use.'
})
.options('clusters', {
type: 'string',
describe: 'Comma separated list of redis cluster server with host:port.'
})
.options('is-cluster', { // names-with-dash are automatically converted to namesWithDash too
type: 'boolean',
describe: 'Flag to use parameter from redis-host and redis-port as Redis cluster member',
default: false
})
.options('cluster-no-tls-validation', {
type: 'boolean',
describe: 'Flag to disable tls host name validation within cluster node communication (needed for AWS)',
default: false
})
.options('redis-tls', {
type: 'boolean',
describe: 'Use TLS for connection to redis server. Required for TLS connections.',
default: false
})
.options('redis-tls-ca-cert', {
type: 'string',
describe: 'Use PEM-style CA certificate key for connection to redis server. Requires "redis-tls=true"',
})
.options('redis-tls-ca-cert-file', {
type: 'string',
describe: 'File path to PEM-style CA certificate key for connection to redis server. Requires "redis-tls=true", Overrides "redis-tls-ca-cert" if set too.',
})
.options('redis-tls-cert', {
type: 'string',
describe: 'Use PEM-style public key for connection to redis server. Requires "redis-tls=true"',
})
.options('redis-tls-cert-file', {
type: 'string',
describe: 'File path to PEM-style public key for connection to redis server. Requires "redis-tls=true", Overrides "redis-tls-cert" if set too.',
})
.options('redis-tls-key', {
type: 'string',
describe: 'Use PEM-style private key for connection to redis server. Requires "redis-tls=true"',
})
.options('redis-tls-key-file', {
type: 'string',
describe: 'File path PEM-style private key for connection to redis server. Requires "redis-tls=true", Overrides "redis-tls-key" if set too.',
})
.options('redis-tls-server-name', {
type: 'string',
describe: 'Server name to confirm client connection. Server name for the SNI (Server Name Indication) TLS extension. Requires "redis-tls=true"',
})
.options('sentinel-tls', {
type: 'boolean',
describe: 'Enable TLS for sentinel mode. If no special "sentinel-tls-*" option is defined the redis TLS settings are reused ("redis-tls-*"). Required for TLS sentinel connections.',
default: false
})
.options('sentinel-tls-ca-cert', {
type: 'string',
describe: 'Use PEM-style CA certificate key for connection to sentinel. Requires "sentinel-tls=true"',
})
.options('sentinel-tls-ca-cert-file', {
type: 'string',
describe: 'File path to PEM-style CA certificate key for connection to sentinel. Requires "sentinel-tls=true", Overrides "sentinel-tls-ca-cert" if set too.',
})
.options('sentinel-tls-cert', {
type: 'string',
describe: 'Use PEM-style public key for connection to sentinel. Requires "sentinel-tls=true"',
})
.options('sentinel-tls-cert-file', {
type: 'string',
describe: 'File path to PEM-style public key for connection to sentinel. Requires "sentinel-tls=true", Overrides "sentinel-tls-cert" if set too.',
})
.options('sentinel-tls-key', {
type: 'string',
describe: 'Use PEM-style private key for connection to sentinel. Requires "sentinel-tls=true"',
})
.options('sentinel-tls-key-file', {
type: 'string',
describe: 'File path to PEM-style private key for connection to sentinel. Requires "sentinel-tls=true", Overrides "sentinel-tls-key" if set too.',
})
.options('sentinel-tls-server-name', {
type: 'string',
describe: 'Server name to confirm client connection. Server name for the SNI (Server Name Indication) TLS extension. Requires "sentinel-tls=true"',
})
.options('insecure-certificate', {
type: 'boolean',
describe: 'Disable certificate check for all certificates (Redis, Sentinel, Cluster). Should not be used in production!',
default: false
})
.options('noload', {
alias: 'nl',
type: 'boolean',
describe: 'Do not load connections from config.'
})
.options('clear-config', {
alias: 'cc',
type: 'boolean',
describe: 'Clear configuration file.'
})
.options('migrate-config', {
type: 'boolean',
describe: 'Migrate old configuration file in $HOME to new style.'
})
.options('test', {
alias: 't',
type: 'boolean',
describe: 'Test final configuration (file, env-vars, command line).'
})
.options('open', {
// open local web-browser to connect to web ui on startup of server daemon too
type: 'boolean',
default: false,
describe: 'Open web-browser with Redis-Commander.'
})
// following cli params have equivalent within config file as default
.options('redis-label', {
type: 'string',
describe: 'The label to display for the connection.',
default: config.get('redis.defaultLabel')
})
.options('read-only', {
type: 'boolean',
describe: 'Start app in read-only mode.',
default: config.get('redis.readOnly')
})
.options('http-auth-username', {
alias: "http-u",
type: 'string',
describe: 'The http authorisation username.',
default: config.get('server.httpAuth.username')
})
.options('http-auth-password', {
alias: "http-p",
type: 'string',
describe: 'The http authorisation password.',
default: config.get('server.httpAuth.password')
})
.options('http-auth-password-hash', {
alias: "http-h",
type: 'string',
describe: 'The http authorisation password hash.',
default: config.get('server.httpAuth.passwordHash')
})
.options('address', {
alias: 'a',
type: 'string',
describe: 'The address to run the server on.',
default: config.get('server.address')
})
.options('port', {
alias: 'p',
type: 'number',
describe: 'The port to run the server on.',
default: config.get('server.port')
})
.options('url-prefix', {
alias: 'u',
type: 'string',
describe: 'The url prefix to respond on.',
default: config.get('server.urlPrefix'),
})
.options('trust-proxy', {
type: 'boolean',
describe: 'App is run behind proxy (enable Express "trust proxy").',
default: config.get('server.trustProxy')
})
.options('max-hash-field-size', {
type: 'number',
describe: 'The max number of bytes for a hash field before you must click to view it.',
default: config.get('ui.maxHashFieldSize'),
})
.options('nosave', {
alias: 'ns',
type: 'boolean',
describe: 'Do not save new connections to config file.',
default: config.get('noSave'),
})
.options('no-log-data', {
// through no- this is a negated param, if set args[log-data]=true
// internal handling of yargs is different to nosave (without "-")
type: 'boolean',
describe: 'Do not log data values from redis store.',
default: config.get('noLogData')
})
.options('folding-char', {
alias: 'fc',
type: 'string',
describe: 'Character to fold keys at for tree view.',
default: config.get('ui.foldingChar')
})
.options('root-pattern', {
alias: 'rp',
type: 'string',
describe: 'Default root pattern for redis keys.',
default: config.get('redis.rootPattern')
})
.options('use-scan', {
alias: 'sc',
type: 'boolean',
describe: 'Use SCAN instead of KEYS.',
default: config.get('redis.useScan')
})
.options('scan-count', {
type: 'number',
describe: 'The size of each separate scan.',
default: config.get('redis.scanCount'),
})
.check(function(value) {
switch (value['folding-char']) {
case '&':
case '?':
case '*':
throw new Error('Characters &, ? and * are not allowed for param folding-char!');
}
// parser special handling of params starting with "no-"
// it adds new field without "no-" and set this to "false"
if (typeof value['log-data'] !== 'undefined') value['no-log-data'] = !value['log-data'];
// now write back all values into config object to overwrite defaults with cli params
config.noSave = value['nosave'];
config.noLogData = value['no-log-data'];
config.ui.foldingChar = value['folding-char'];
config.ui.maxHashFieldSize = value['max-hash-field-size'];
config.redis.useScan = value['use-scan'];
config.redis.readOnly = value['read-only'];
config.redis.scanCount = value['scan-count'];
config.redis.rootPattern = value['root-pattern'];
config.redis.defaultLabel = value['redis-label'];
config.server.address = value['address'];
config.server.port = value['port'];
config.server.urlPrefix = value['url-prefix'];
config.server.trustProxy = value['trust-proxy'];
config.server.httpAuth.username = value['http-auth-username'];
config.server.httpAuth.password = value['http-auth-password'];
config.server.httpAuth.passwordHash = value['http-auth-password-hash'];
return true;
})
.usage('Usage: $0 [options]')
.wrap(yargs.terminalWidth())
.argv;
if (args.help) {
yargs.help();
return process.exit(-1);
}
// var to distinguish between commands that exit right after doing some stuff
// and other to startup http server
let startServer = true;
if(args['migrate-config']) {
startServer = false;
myUtils.migrateDeprecatedConfig(function() {
process.exit();
});
}
if(args['clear-config']) {
startServer = false;
myUtils.deleteDeprecatedConfig(function(err) {
if (err) {
console.log('Failed to delete existing deprecated config file.');
}
});
myUtils.deleteConfig('local', function(err) {
if (err) {
console.log('Failed to delete existing local.json config file.');
}
myUtils.deleteConfig('connections', function(err2) {
if (err2) {
console.log('Failed to delete existing local-<hostname>.json config file.');
}
// now restart app to reload config files and reapply env vars and cli params
const spawn = require('child_process').spawn;
let processArgs = process.argv.slice(1);
processArgs.splice(processArgs.indexOf('--clear-config'), 1);
const subprocess = spawn(process.argv[0], processArgs, {detached: true});
subprocess.unref();
process.exit();
});
});
}
if(args['test']) {
startServer = false;
try {
myUtils.validateConfig();
console.log('Configuration created from files, env-vars and command line is valid.');
process.exit(0);
}
catch(e) {
console.error(e.message);
process.exit(2);
}
}
if (startServer) {
console.log(`Starting with NODE_ENV=${process.env.NODE_ENV} and config NODE_APP_INSTANCE=${process.env.NODE_APP_INSTANCE}`);
// check if old deprecated config exists and merge into current one
if (myUtils.hasDeprecatedConfig()) {
console.log('==================================================================================================');
console.log('DEPRECATION WARNING: Old style configuration file found at ' + myUtils.getDeprecatedConfigPath());
console.log(' Please delete file or migrate to new format calling app with "--migrate-config" parameter');
console.log('==================================================================================================');
myUtils.getDeprecatedConfig(function(err, oldConfig) {
// old config only contains some ui parameters or connection definitions
config.ui = config.util.extendDeep(config.ui, oldConfig.ui);
if (Array.isArray(oldConfig.connections) && oldConfig.connections.length > 0) {
oldConfig.connections.forEach(function(cfg) {
if (!connectionWrapper.containsConnection(config.connections, cfg)) {
config.connections.push(cfg);
}
});
}
startAllConnections();
});
}
else {
startAllConnections();
}
}
// ==============================================
// end main program / special cli param handling
// functions below...
/** function to check command line arguments given if the contain vaid informations for an redis connection
* Some params like port and db are check if they are valid values (if set), otherwise the entire program will exit
* with an error message.
*
* @param {object} argList object of params as given on command line as parsed by yargs
* @return {null|object} returns "null" if no usable connection data are found, an object to feed into redis client
* otherwise to create a new connection.
*/
function createConnectionObjectFromArgs(argList) {
// check if ports and dbIndex given are valid, must be done here as redis connection params from cli are not added
// to config object and tested later together with everything else from config
let checkPortInvalid = function(portString, paramName) {
if (portString) {
if (Number.isNaN(portString) || !Number.isInteger(Number(portString))) {
console.error(`value given for "${paramName}" is invalid - must be an integer number`);
return true;
}
else if (Number(portString) < 1 || Number(portString) > 65535) {
console.error(`value given for "${paramName}" is invalid - must be an integer number between 1 and 65535`);
return true
}
}
return false;
};
let checkDbIndexInvalid = function(dbString, paramName) {
if (dbString) {
if (Number.isNaN(dbString) || !Number.isInteger(Number(dbString))) {
console.error(`value given for "${paramName}" is invalid - must be an integer number`);
return true;
}
else if (Number(dbString) < 0) {
console.error(`value given for "${paramName}" is invalid - must be an positiv integer number`);
return true
}
}
return false;
};
// sometimes redis_port is automatically set to something like 'tcp://10.2.3.4:6379'
// parse this and update args accordingly
if (typeof argList['redis-port'] === 'string' && argList['redis-port'].startsWith('tcp://')) {
console.log('Found long tcp port descriptor with hostname in redis-port param, parse this as host and port value');
let parts = argList['redis-port'].split(':');
argList['redis-port'] = parts[2];
argList['redis-host'] = parts[1].substring(2);
}
// now some validity checks - exits on failure with error message
if (checkPortInvalid(argList['redis-port'], 'redis-port') ||
checkPortInvalid(argList['sentinel-port'], 'sentinel-port') ||
checkDbIndexInvalid(argList['redis-db'], 'redis-db')) {
process.exit(1)
}
// now create connection object if enough params are set
let connObj = null;
if (argList['clusters'] || argList['sentinel-host'] || argList['sentinels'] || argList['redis-host'] || argList['redis-port'] || argList['redis-socket']
|| argList['redis-username'] || argList['redis-password'] || argList['redis-db']) {
let db = parseInt(argList['redis-db']);
connObj = {
label: config.get('redis.defaultLabel'),
dbIndex: Number.isNaN(db) ? 0 : db,
username: argList['redis-username'] || null,
password: argList['redis-password'] || '',
connectionName: config.get('redis.connectionName'),
optional: argList['redis-optional'],
clusterNoTlsValidation: argList['clusterNoTlsValidation']
};
if (argList['redis-socket']) {
connObj.path = argList['redis-socket'];
}
else {
connObj.host = argList['redis-host'] || 'localhost';
connObj.port = argList['redis-port'] || 6379;
connObj.port = parseInt(connObj.port);
connObj.isCluster = argList['is-cluster'];
connObj.sentinelUsername = argList['sentinel-username'] || null;
connObj.sentinelPassword = argList['sentinel-password'] || '';
if (argList['sentinels']) {
connObj.sentinels = myUtils.parseRedisServerList('--sentinels', argList['sentinels']);
connObj.sentinelName = myUtils.getRedisSentinelGroupName(argList['sentinel-name']);
}
else if (argList['sentinel-host']) {
connObj.sentinels = myUtils.parseRedisServerList('--sentinel-host or --sentinel-port',
argList['sentinel-host'] + ':' + argList['sentinel-port']);
connObj.sentinelName = myUtils.getRedisSentinelGroupName(argList['sentinel-name']);
}
else if (argList['clusters']) {
connObj.clusters = myUtils.parseRedisServerList('--clusters', argList['clusters']);
}
}
if (argList['redis-tls']) {
// either basic tls support some special certs set and added to the tls config object
connObj.tls = {};
if (argList['redis-tls-ca-cert-file'] || argList['redis-tls-ca-cert']
|| argList['redis-tls-cert-file'] || argList['redis-tls-cert']
|| argList['redis-tls-key-file'] || argList['redis-tls-key']
|| argList['redis-tls-server-name']) {
if (argList['redis-tls-ca-cert-file']) {
connObj.tls.ca = fs.readFileSync(argList['redis-tls-ca-cert-file']);
}
else if (argList['redis-tls-ca-cert']) {
connObj.tls.ca = argList['redis-tls-ca-cert'];
}
if (argList['redis-tls-cert-file']) {
connObj.tls.cert = fs.readFileSync(argList['redis-tls-cert-file']);
}
else if (argList['redis-tls-cert']) {
connObj.tls.cert = argList['redis-tls-cert'];
}
if (argList['redis-tls-key-file']) {
connObj.tls.key = fs.readFileSync(argList['redis-tls-key-file']);
}
else if (argList['redis-tls-key']) {
connObj.tls.key = argList['redis-tls-key'];
}
if (argList['redis-tls-server-name']) {
connObj.tls.servername = argList['redis-tls-server-name'];
}
}
if (argList['insecure-certificate']) {
connObj.tls.rejectUnauthorized = false;
}
}
// either set 'sentinel-tls' to a boolean value to reuse same tls settings as defined for Redis server
// for Sentinel connections too
// or use 'sentinel-tls' with optional 'sentinel-tls-*' settings to define some independent tls settings and
// certificates to use and not reuse config for Redis server
if (argList['sentinel-tls']) {
connObj.enableTLSForSentinelMode = true;
// either basic tls or complex tls support for sentinels, same meaning as for redis server itself
connObj.sentinelTLS = {};
if (argList['sentinel-tls-ca-cert-file'] || argList['sentinel-tls-ca-cert']
|| argList['sentinel-tls-cert-file'] || argList['sentinel-tls-cert']
|| argList['sentinel-tls-key-file'] || argList['sentinel-tls-key']
|| argList['sentinel-tls-server-name']) {
if (argList['sentinel-tls-ca-cert-file']) {
connObj.sentinelTLS.ca = fs.readFileSync(argList['sentinel-tls-ca-cert-file']);
}
else if (argList['sentinel-tls-ca-cert']) {
connObj.sentinelTLS.ca = argList['sentinel-tls-ca-cert'];
}
if (argList['sentinel-tls-cert-file']) {
connObj.sentinelTLS.cert = fs.readFileSync(argList['sentinel-tls-cert-file']);
}
else if (argList['sentinel-tls-cert']) {
connObj.sentinelTLS.cert = argList['sentinel-tls-cert'];
}
if (argList['sentinel-tls-key-file']) {
connObj.sentinelTLS.key = fs.readFileSync(argList['sentinel-tls-key-file']);
}
else if (argList['sentinel-tls-key']) {
connObj.sentinelTLS.key = argList['sentinel-tls-key'];
}
if (argList['sentinel-tls-server-name']) {
connObj.sentinelTLS.servername = argList['sentinel-tls-server-name'];
}
}
else {
// fallback if no special sentinel settings are defined - reuse redis one
connObj.sentinelTLS = connObj.tls;
if (argList['insecure-certificate']) {
connObj.sentinelTLS.rejectUnauthorized = false;
}
}
}
}
return connObj;
}
/** function to start all confugred connections from config and command line
*/
function startAllConnections() {
try {
myUtils.validateConfig();
}
catch(e) {
console.error(e.message);
process.exit(2);
}
// create new singleton object to hold all connections
redisConnections = connectionWrapper.setConnectionList([]);
// redefine keys method before connections are started
if (config.get('redis.useScan')) {
console.log('Using scan instead of keys');
const keysCallbackFunc = function(that, pattern, cb) {
let keys = [];
let scanCB = function(err, res) {
if (err) {
switch (typeof cb) {
case 'function':
cb(err);
break;
case 'object': // promise
cb.reject(err);
break;
default:
console.log('ERROR in redefined "keys" function to use "scan" instead without callback: ' +
(err.message ? err.message : JSON.stringify(err)));
}
}
else {
let count = res[0], curKeys = res[1];
keys = keys.concat(curKeys);
if (Number(count) === 0) {
switch (typeof cb) {
case 'function':
cb(null, keys);
break;
case 'object':
cb.resolve(keys);
break;
default:
console.log('ERROR in redefined "keys" function to use "scan" instead - no callback given!');
}
}
else {
that.scan(count, 'MATCH', pattern, 'COUNT', config.get('redis.scanCount'), scanCB);
}
}
};
return that.scan(0, 'MATCH', pattern, 'COUNT', config.get('redis.scanCount'), scanCB);
}
Object.defineProperty(Redis.prototype, 'keys', {
value: function(pattern, cb) {
if (!cb) {
const that = this;
cb = new Promise(function(resolve, reject) {
keysCallbackFunc(that, pattern, {resolve: resolve, reject: reject});
});
return cb;
}
keysCallbackFunc(this, pattern, cb);
}
});
}
// first connection from cli params (redis-host, redis-port, ...)
// second default connections from config object (to allow override of pw changed and so on)
let client;
let newDefault = createConnectionObjectFromArgs(args);
if (newDefault) {
client = myUtils.createRedisClient(newDefault);
redisConnections.push(client);
redisConnections.setUpConnection(client);
// now check if this one is already part of default connections
// update it if needed
let configChanged = false;
let oldDefault = connectionWrapper.findConnection(config.connections, newDefault);
if (!oldDefault) {
config.connections.push(newDefault);
configChanged = true;
}
else {
// remove connectionId from newDefaults to allow comparison, otherwise non-equal every time
delete newDefault.connectionId;
if (!isEqual(oldDefault, newDefault)) {
connectionWrapper.replaceConnection(config.connections, oldDefault, newDefault);
configChanged = true;
}
}
if (configChanged && !config.get('noSave')) {
myUtils.saveConnections(config,function (err) {
if (err) {
console.log("Problem saving connection config.");
console.error(err);
}
});
}
}
else if (config.connections.length === 0) {
// fallback to localhost if nothing else configured
client = myUtils.createRedisClient({label: config.get('redis.defaultLabel')});
redisConnections.push(client);
redisConnections.setUpConnection(client);
}
// now start all default connections (if not same as one given via command line)...
startDefaultConnections(config.connections, function (err) {
if (err) {
console.log(err);
process.exit();
}
return startWebApp();
});
// wait a bit before starting browser to let http server start
if (args['open']) {
setTimeout(function() {
let address = '127.0.0.1';
if (config.get('server.address') !== '0.0.0.0' && config.get('server.address') !== '::') {
address = config.get('server.address');
}
require('opener')('http://' + address + ':' + config.get('server.port'));
}, 1000);
}
}
function startDefaultConnections (connections, callback) {
if (connections && Array.isArray(connections)) {
connections.forEach(function (connection) {
if (!redisConnections.containsConnection(connection)) {
let client = myUtils.createRedisClient(connection);
redisConnections.push(client);
redisConnections.setUpConnection(client);
}
});
}
return callback(null);
}
function startWebApp () {
let urlPrefix = config.get('server.urlPrefix');
console.log("No Save: " + config.get('noSave'));
let app = require('../lib/app');
let appInstance = app(redisConnections);
appInstance.listen(config.get('server.port'), config.get('server.address'), function() {
console.log(`listening on ${config.get('server.address')}:${config.get('server.port')}`);
// default ip 0.0.0.0 and ipv6 equivalent cannot be opened with browser, need different one
// may search for first non-localhost address of server instead of 127.0.0.1...
let address = '127.0.0.1';
if (config.get('server.address') !== '0.0.0.0' && config.get('server.address') !== '::') {
address = config.get('server.address');
}
let msg = `access with browser at http://${address}:${config.get('server.port')}`;
if (urlPrefix) {
console.log(`using url prefix ${urlPrefix}`);
msg += urlPrefix;
}
console.log(msg);
});
}