Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/tty.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ process.title = 'tty.js';

var tty = require('../');

var conf = tty.config.readConfig()
var conf = tty.config.readConfig(['/etc/tty.js.d/', process.env.HOME+'/.config/tty.js', process.env.TTYJS_CONFD])
, app = tty.createServer(conf);

app.listen();
128 changes: 96 additions & 32 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

var path = require('path')
, fs = require('fs')
, logger = require('./logger');
, logger = require('./logger')
, vm = require('vm');

/**
* Options
Expand All @@ -14,50 +15,119 @@ var path = require('path')
var options;

/**
* Read Config
* Read configuration files from given directories.
*
* For each directory, read all configuration files with .json extension or
* execute all configuration scripts with .js extension and merge results
* into single configuration object at top level. If file passed instead of
* directory, just read it as .json file.
*
* JSON configuration example:
* <pre>{ shell: '/bin/bash' }</pre>
*
* Configuration script example:
* <pre> shell = function(tty, options) { return [ '/bin/bash' ]; };</pre>
*/

function readConfig(file) {
function readConfig(dirs) {
var home = process.env.HOME
, conf = {}
, dir
, json;

if (file || options.config) {
file = path.resolve(process.cwd(), file || options.config);
dir = path.dirname(file);
json = options.config;
} else {
dir = process.env.TTYJS_PATH || path.join(home, '.tty.js');
json = path.join(dir, 'config.json');
// Convert argument to array, for clarity
if(typeof(dirs) === 'string') {
dirs = [ dirs ];
} else if (typeof(dirs)==='undefined') {
dirs = [];
}

if (exists(dir) && exists(json)) {
if (!fs.statSync(dir).isDirectory()) {
json = dir;
dir = home;
dirs.forEach(function(dir) {
//*DEBUG*/logger.log("Configuration directory: \""+dir+"\".");

// Skip undefined values numbers and objects
if(typeof(dir) === 'undefined') { return; }
if(typeof(dir) !== 'string') {
logger.error("Unexepected argument: expected string (path to configuration directory or file), got: \""+typeof(dir)+"\". Argument value: \""+dir+"\".");
return;
}

conf = JSON.parse(fs.readFileSync(json, 'utf8'));
} else {
if (!exists(dir)) {
fs.mkdirSync(dir, 0700);
// Skip non-directories
if(!fs.existsSync(dir)) {
//*DEBUG*/logger.log("Configuration directory \""+dir+"\" is not exists, skipping.");
return;
}

fs.writeFileSync(json, JSON.stringify(conf, null, 2));
fs.chmodSync(json, 0600);
}
// If file given, then read it directly
var dirStats = fs.statSync(dir);
if(dirStats.isFile()) {
try {
// Read file and merge it with previous settings
logger.log("Reading file \""+file+"\" from directory \""+dir+"\".");
merge(conf, JSON.parse(fs.readFileSync(file, 'utf8')));
} catch(e) {
logger.error("Cannot read configuration file \""+file+"\": ", e.stack);
}

return;
}

if(!dirStats.isDirectory()) {
logger.error("Expected a directory to read configuration files from: \""+dir+"\". Check file system.");
return;
}

// expose paths
conf.dir = dir;
conf.json = json;
//*DEBUG*/logger.log("Reading configuration files from directory \""+dir+"\".");
try {
fs.readdirSync(dir).sort().forEach(function(file) {
if(typeof(file)!=='string') { return; }
file = path.join(dir, file);

var result = {};

// If file has .json extension
if(endsWith(file, ".json")) {
try {
// Read file, parse it, and merge it with previous settings
//*DEBUG*/logger.log("Reading configuration file \""+file+"\".");
merge(conf, JSON.parse(fs.readFileSync(file, 'utf8')));
} catch(e) {
logger.error("Cannot read configuration file \""+file+"\": ",e.stack);
return;
}
} else if(endsWith(file, ".js")) {
try {
//*DEBUG*/logger.log("Executing configuration script \""+file+"\".");

// Put logger into scope to allow functions to produce log messages
conf.logger = logger;

// Read file and execute it in scope of previous configuration to merge with.
vm.runInNewContext(fs.readFileSync(file, 'utf8'), conf, file);
} catch(e) {
logger.error("Cannot execute configuration script \""+file+"\": ", e.stack);
return;
}
} else {
//*DEBUG*/logger.log("File \""+file+"\" has incorrect extension (not .json, nor .js). Skipping.");
return;
}

});
} catch(e) {
logger.error("Cannot read configuration files from directory \""+dir+"\": ", e.stack);
}
});

// flag
conf.__read = true;

return checkConfig(conf);
}

function endsWith(string, suffix) {
return string.indexOf(suffix, string.length - suffix.length) !== -1;
};

function checkConfig(conf) {
if (typeof conf === 'string') {
return readConfig(conf);
Expand Down Expand Up @@ -102,13 +172,7 @@ function checkConfig(conf) {
conf.hostname; // '0.0.0.0'

// shell, process name
if (conf.shell && ~conf.shell.indexOf('/')) {
conf.shell = path.resolve(conf.dir, conf.shell);
}
conf.shell = conf.shell || process.env.SHELL || 'sh';

// arguments to shell, if they exist
conf.shellArgs = conf.shellArgs || [];
conf.shell = conf.shell || process.env.SHELL || '/bin/sh';

// static directory
conf.static = tryResolve(conf.dir, conf.static || 'static');
Expand Down
122 changes: 110 additions & 12 deletions lib/tty.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ Server.prototype.handleConnection = function(socket) {
// XXX Possibly wrap socket events from inside Session
// constructor, and do: session.on('create')
// or session.on('create term').
socket.on('create', function(cols, rows, func) {
return session.handleCreate(cols, rows, func);
socket.on('create', function(cols, rows, options, func) {
return session.handleCreate(cols, rows, options, func);
});

socket.on('data', function(id, data) {
Expand Down Expand Up @@ -421,12 +421,115 @@ Session.prototype.sync = function() {
this.socket.emit('sync', terms);
};

Session.prototype.handleCreate = function(cols, rows, func) {

/*
* Generate shell command using configuration and options.
*
* Configuration options are:
* <ul>
*
* <li> shell - string or array or function returning string or
* array. Examples: "sh", [ "/bin/bash" ],
* function(options) { return [ '/bin/bash' ] }. It affects "local",
* "ssh", and "docker" protocols and "remoteHost" option.
*
* <li> ${protocol}Shell - function, which should generate shell command
* for user supplied protocol. Function receives single argument -
* user supplied options. Function must return array.
*
* <li> remoteHostCommand - function, which should generate command to connect to remote host and execute
*
*</ul>
*
* User supplied options are:
* <ul>
*
* <li> protocol - protocol to use. Supported protocols are "local" (for
* su to local user), "ssh" (for ssh-ing as an user to a remote host),
* docker (for entering a running container), or any other
* protocol which has ${protocol}Shell() function defined in configuration.
*
* <li> remoteHost - address of remote host to execute shell
* at via ssh or via an other method defined
* in configuration using function remoteHostCommand().
*
* </ul>
*/
Session.prototype.prepareShellCommand = function Session_prepareShellCommand(conf, options) {

// For backward compatibility: get shell command and arguments from configuration
var shell = typeof conf.shell === 'function'
? conf.shell(this, options)
: conf.shell;

if(typeof(shell) === 'string') {
shell = [ shell ];
}

options = options || {};

// Chose shell using options
if(options.protocol) {
try {
switch(options.protocol) {
// "local" means local user
case 'local':
shell = conf.localUserShell? conf.localUserShell(options) : [ 'su', '--login', options.user || 'root' ,
'--shell=' + shell[0] ].concat( (shell.length>1)? [ '--command='+shell.slice(1).join(' ') ] : [] );
break;

// ssh means access a remote host via ssh. See also "remoteHost" option.
//
// If host at remote IP will be reinstalled, then it key will be different,
// so ssh will complain about that.
case 'ssh':
shell = conf.sshShell? conf.sshShell(options) : [ 'ssh', '-t', ((options.user)? options.user : 'root') + '@' + options.host ].concat(shell);
break;

// docker means access to a docker container at a host. If tty.js
// itself will be ran in a container, then "remoteHost" option will be
// required for this protocol to work.
case 'docker':
shell = conf.dockerShell? conf.dockerShell(options) : [ 'docker', 'exec', '-it', options.container ].concat(shell);
break;

default:
// For unknown protocol, try to call protocol handler from configuration with name "${protocol}Shell".
shell = conf[options.protocol+"Shell"]();
break;
}
} catch(e) {
this.error("Cannot create shell for protocol: \""+options.protocol+"\". Options: ", options, ", error: ", e);
return "/bin/false"; // Safe default
}
}

if(options.remoteHost) {
try {
// Execute shell at remote host using ssh with host key checking disabled.
// The reason for that is that user hosts can be reinstalled often using
shell = (conf.remoteHostCommand)? conf.remoteHostCommand(options.remoteHost, shell, options) :
[ 'ssh', '-t', '-q', '-o', 'StrictHostKeyChecking no', '-o', 'UserKnownHostsFile=/dev/null', '-l', 'root', options.remoteHost ].concat(shell);
} catch(e) {
this.error("Cannot create command to execute shell on remote host: \""+options.remoteHost+"\". Options: ", options, ", error: ", e);
return "/bin/false"; // Safe default
}
}

//*DEBUG*/this.log("conf.shell: ", conf.shell, ", options: ", options, ", shell: ", shell);

return shell;
}

// Options are for prepareShellCommand() function.
Session.prototype.handleCreate = function(cols, rows, options, func) {
var self = this
, terms = this.terms
, conf = this.server.conf
, socket = this.socket;

//*DEBUG*/this.log('Options: ', options);

var len = Object.keys(terms).length
, term
, id;
Expand All @@ -436,15 +539,10 @@ Session.prototype.handleCreate = function(cols, rows, func) {
return func({ error: 'Terminal limit.' });
}

var shell = typeof conf.shell === 'function'
? conf.shell(this)
: conf.shell;

var shellArgs = typeof conf.shellArgs === 'function'
? conf.shellArgs(this)
: conf.shellArgs;
// Get path to shell binary from configuration
var shellCmd = this.prepareShellCommand(conf, options);

term = pty.fork(shell, shellArgs, {
term = pty.fork(shellCmd[0], shellCmd.slice(1), {
name: conf.termName,
cols: cols,
rows: rows,
Expand Down Expand Up @@ -478,7 +576,7 @@ Session.prototype.handleCreate = function(cols, rows, func) {
return func(null, {
id: id,
pty: term.pty,
process: sanitize(conf.shell)
process: sanitize(shellCmd[0])
});
};

Expand Down
42 changes: 39 additions & 3 deletions static/index.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<!doctype html>
<!DOCTYPE html>
<title>tty.js</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="user.css">
Expand All @@ -13,11 +13,47 @@ <h1>tty.js</h1>
<p>Click the tilde with a modifier to close the window.</p>
</div>

<button id="open">Open Terminal</button>
<button id="lights">Light Switch</button>
<ul>
<li><button onclick="new tty.Window(false, {title:'localhost'})">Open</button> local terminal
<li><button onclick="new tty.Window(false, {title:'root@localhost',protocol:'local', user:'root'})">Open</button> local terminal for user root
<li><button onclick="new tty.Window(false, {title:'root@localhost', protocol:'ssh', user:'root', host:'localhost'})">Open</button> ssh terminal to root@localhost
<li><button onclick="new tty.Window(false, {title:'foo', protocol:'docker', container:'foo'})">Open</button> terminal to docker container "foo"
<li><button onclick="new tty.Window(false, {title:'bar', protocol:'docker', container:'bar', remoteHost:'localhost'})">Open</button> terminal to docker container "bar" via ssh
</ul>

<script src="socket.io/socket.io.js"></script>
<script src="term.js"></script>
<script src="options.js"></script>
<script src="tty.js"></script>
<script src="user.js"></script>
<script>
(function() {
var on = Terminal.on;
var off = Terminal.off;

function load() {
if (load.done) return;
load.done = true;

off(document, 'load', load);
off(document, 'DOMContentLoaded', load);

if (document.location.pathname) {
// "/foo/bar/baz" -> "/foo/bar/socket.io"
var parts = document.location.pathname.split('/')
, base = parts.slice(0, parts.length - 1).join('/') + '/'
, resource = base.substring(1) + 'socket.io';

window.socket = io.connect(null, { resource: resource });
} else {
window.socket = io.connect();
}

tty.open(window.socket);
}

on(document, 'load', load);
on(document, 'DOMContentLoaded', load);
setTimeout(load, 200);
})();
</script>
Loading