view webtty.js @ 143:f1676e81c80a

sqlickrypt: add support for salted SHA-512 passwords, and fix NULL bug. Passwords will now be securely encrypted with random salt. Also avoid storing NULL in the database, because that makes dgamelaunch segfault.
author John "Elwin" Edwards
date Sun, 20 Oct 2013 21:19:13 -0700
parents 789c094675f4
children c4a32007d2dc
line wrap: on
line source

#!/usr/bin/env node

var localModules = '/usr/lib/node_modules/';
var http = require('http');
var url = require('url');
var path = require('path');
var fs = require('fs');
var pty = require(path.join(localModules, "pty.js"));
var child_process = require("child_process");
var webSocketServer = require(path.join(localModules, "websocket")).server;

var serveStaticRoot = fs.realpathSync(".");
var sessions = {};
var sessionsWS = {};

var env_dontuse = {"TMUX": true, "TMUX_PANE": true};

/* Constructor for TermSessions.  Note that it opens the terminal and 
 * adds itself to the sessions dict. 
 */
function TermSessionWS(conn, h, w) {
  var ss = this;
  /* Set up the sizes. */
  w = Math.floor(Number(w));
  if (!(w > 0 && w < 256))
    w = 80;
  this.w = w;
  h = Math.floor(Number(h));
  if (!(h > 0 && h < 256))
    h = 25;
  this.h = h;
  this.conn = conn;
  /* Customize the environment. */
  var childenv = {};
  for (var key in process.env) {
    if (!(key in env_dontuse))
      childenv[key] = process.env[key];
  }
  var spawnopts = {"env": childenv, "cwd": process.env["HOME"], 
                   "rows": this.h, "cols": this.w};
  this.term = pty.spawn("bash", [], spawnopts);
  this.alive = true;
  this.term.on("data", function (datastr) {
    var buf = new Buffer(datastr);
    if (ss.conn.connected)
      ss.conn.sendUTF(JSON.stringify({"t": "d", "d": buf.toString("hex")}));
  });
  this.term.on("exit", function () {
    ss.alive = false;
    /* Wait for all the data to get collected */
    setTimeout(ss.cleanup, 1000);
  });
  this.conn.on("message", function (msg) {
    try {
      var msgObj = JSON.parse(msg.utf8Data);
    }
    catch (e) {
      return;
    }
    if (msgObj.t == "d") {
      var hexstr = msgObj["d"].replace(/[^0-9a-f]/gi, "");
      if (hexstr.length % 2 != 0) {
        return;
      }
      var keybuf = new Buffer(hexstr, "hex");
      ss.term.write(keybuf);
    }
  });
  this.conn.on("close", function (msg) {
    if (ss.alive)
      ss.term.kill('SIGHUP');
    console.log("WebSocket connection closed.");
  });
  this.cleanup = function () {
    /* Call this when the child is dead. */
    if (ss.alive)
      return;
    if (ss.conn.connected) {
      ss.conn.sendUTF(JSON.stringify({"t": "q"}));
    }
  };
  this.conn.sendUTF(JSON.stringify({"t": "l", "w": w, "h": h}));
  console.log("New WebSocket connection.");
}

function TermSession(sessid, h, w) {
  /* Set up the sizes. */
  w = Math.floor(Number(w));
  if (!(w > 0 && w < 256))
    w = 80;
  this.w = w;
  h = Math.floor(Number(h));
  if (!(h > 0 && h < 256))
    h = 25;
  this.h = h;
  /* Customize the environment. */
  var childenv = {};
  for (var key in process.env) {
    if (!(key in env_dontuse))
      childenv[key] = process.env[key];
  }
  var spawnopts = {"env": childenv, "cwd": process.env["HOME"], 
                   "rows": this.h, "cols": this.w};
  this.term = pty.spawn("bash", [], spawnopts);
  var ss = this;
  /* Eventually we'll need to make sure the sessid isn't in use yet. */
  this.sessid = sessid;
  this.alive = true;
  this.data = []; // Buffer for the process' output.
  this.nsend = 0; // Number to use for the next message sent.
  this.nrecv = 0; // Number expected on the next message received.
  this.msgQ = []; // Queue for messages that arrived out of order.
  this.term.on("data", function (buf) {
    ss.data.push(buf);
  });
  this.term.on("exit", function () {
    ss.alive = false;
    /* Wait for all the data to get collected */
    setTimeout(ss.cleanup, 1000);
  });
  this.write = function (data, n) {
    if (!this.alive) {
      /* Throw some kind of exception? */
      return;
    }
    if (n !== this.nrecv) {
      console.log("Session " + this.sessid + ": Expected message " + this.nrecv + ", got " + n);
    }
    this.nrecv = n + 1;
    this.term.write(data);
  };
  this.read = function () {
    if (this.data.length == 0)
      return null;
    var pos = 0;
    var i = 0;
    for (i = 0; i < this.data.length; i++)
      pos += Buffer.byteLength(this.data[i]);
    var nbuf = new Buffer(pos);
    var tptr;
    pos = 0;
    while (this.data.length > 0) {
      tptr = new Buffer(this.data.shift());
      tptr.copy(nbuf, pos);
      pos += tptr.length;
    }
    return nbuf;
  };
  this.close = function () {
    if (this.alive)
      this.term.kill('SIGHUP');
  };
  this.cleanup = function () {
    /* Call this when the child is dead. */
    if (this.alive)
      return;
    /* Give the client a chance to read any leftover data. */
    if (ss.data.length > 0)
      setTimeout(ss.remove, 8000);
    else
      ss.remove();
  };
  this.remove = function () {
    delete sessions[ss.sessid];
    console.log("Session " + this.sessid + " removed.");
  };
  sessions[sessid] = this;
}

function randkey() {
  rnum = Math.floor(Math.random() * 65536 * 65536);
  hexstr = rnum.toString(16);
  while (hexstr.length < 8)
    hexstr = "0" + hexstr;
  return hexstr;
}

/* Returns a list of the cookies in the request, obviously. */
function getCookies(req) {
  cookies = [];
  if ("cookie" in req.headers) {
    cookstrs = req.headers["cookie"].split("; ");
    for (var i = 0; i < cookstrs.length; i++) {
      eqsign = cookstrs[i].indexOf("=");
      if (eqsign > 0) {
        name = cookstrs[i].slice(0, eqsign).toLowerCase();
        val = cookstrs[i].slice(eqsign + 1);
        cookies[name] = val;
      }
      else if (eqsign < 0)
        cookies[cookstrs[i]] = null;
    }
  }
  return cookies;
}

function urlDec(encstr) {
  var decstr = "";
  var tnum;
  for (var i = 0; i < encstr.length; i++)
  {
    if (encstr.charAt(i) == "+")
      decstr += " ";
    else if (encstr.charAt(i) == "%")
    {
      tnum = Number("0x" + encstr.slice(i + 1, 2));
      if (!isNaN(tnum) && tnum >= 0)
        decstr += String.fromCharCode(tnum);
      i += 2;
    }
    else
      decstr += encstr.charAt(i);
  }
  return decstr;
}

/* Returns the contents of a form */
function getFormValues(formtext) {
  var jsonobj;
  try {
    jsonobj = JSON.parse(formtext);
  } catch (e) {
    if (e instanceof SyntaxError)
      return null;
  }
  return jsonobj;
}

function login(req, res, formdata) {
  var resheaders = {'Content-Type': 'text/plain'};
  var sessid = randkey();
  /* The TermSession constructor will check these thoroughly too, but 
   * you can't be too suspicious of client-supplied data. */
  var w = 80;
  var h = 25;
  var t;
  if ("w" in formdata) {
    t = Math.floor(Number(formdata["w"]));
    if (t > 0 && t < 256)
      w = t;
  }
  if ("h" in formdata) {
    t = Math.floor(Number(formdata["h"]));
    if (t > 0 && t < 256)
      h = t;
  }
  var nsession = new TermSession(sessid, h, w);
  resheaders["Set-Cookie"] = "ID=" + sessid;
  res.writeHead(200, resheaders);
  var logindict = {"login": true, "id": sessid, "w": w, "h": h};
  res.write(JSON.stringify(logindict));
  res.end();
  console.log("Started new session with key " + sessid + ", pid " + nsession.term.pid);
  return;
}

function findTermSession(req) {
  var cookies = getCookies(req);
  if ("id" in cookies) {
    var sessid = cookies["id"];
    if (sessid in sessions) {
      return sessions[sessid];
    }
  }
  return null;
}

function serveStatic(req, res, fname) {
  var nname = path.normalize(fname);
  if (nname == "" || nname == "/")
    nname = "index.html";
  if (nname.match(/\/$/))
    path.join(nname, "index.html"); /* it was a directory */
  var realname = path.join(serveStaticRoot, nname);
  var extension = path.extname(realname);
  fs.exists(realname, function (exists) {
    var resheaders = {};
    if (!exists || !extension || extension == ".html")
      resheaders["Content-Type"] = "text/html";
    else if (extension == ".png")
      resheaders["Content-Type"] = "image/png";
    else if (extension == ".css")
      resheaders["Content-Type"] = "text/css";
    else if (extension == ".js")
      resheaders["Content-Type"] = "text/javascript";
    else if (extension == ".svg")
      resheaders["Content-Type"] = "image/svg+xml";
    else
      resheaders["Content-Type"] = "application/octet-stream";
    if (exists) {
      /* Not nice, not sensible.  First see if it's readable, then respond
       * 200 or 500.  Don't throw nasty errors. */
      res.writeHead(200, resheaders);
      fs.readFile(realname, function (error, data) {
        if (error) throw error;
        res.write(data);
        res.end();
      });
    }
    else {
      res.writeHead(404, resheaders);
      res.write("<html><head><title>" + nname + "</title></head>\n<body><h1>" + nname + " Not Found</h1></body></html>\n");
      res.end();
    }
  });
  return;
}

function readFeed(res, term) {
  res.writeHead(200, { "Content-Type": "text/plain" });
  if (term) {
    var answer = {};
    var result = term.read();
    if (result == null) {
      answer["t"] = "n";
    }
    else {
      answer["t"] = "d";
      answer["d"] = result.toString("hex");
      answer["n"] = term.nsend++;
    }
    res.write(JSON.stringify(answer));
    res.end();
  }
  else {
    sendError(res, 1);
  }
}

var errorcodes = [ "Generic Error", "Not logged in", "Invalid data" ];

function sendError(res, ecode) {
  res.writeHead(200, { "Content-Type": "text/plain" });
  if (!(ecode >= 0 && ecode < errorcodes.length))
    ecode = 0;
  res.write(JSON.stringify({"t": "E", "c": ecode, "s": errorcodes[ecode]}));
  res.end();
}

function handler(req, res) {
  /* default headers for the response */
  var resheaders = {'Content-Type': 'text/html'};
  /* The request body will be added to this as it arrives. */
  var reqbody = "";
  var formdata;

  /* Register a listener to get the body. */
  function moredata(chunk) {
    reqbody += chunk;
  }
  req.on('data', moredata);

  /* This will send the response once the whole request is here. */
  function respond() {
    var target = url.parse(req.url).pathname;
    var cterm = findTermSession(req);
    /* First figure out if the client is POSTing to a command interface. */
    if (req.method == 'POST') {
      formdata = getFormValues(reqbody);
      if (target == '/feed') {
        if (!cterm) {
          sendError(res, 1);
          return;
        }
        if (formdata["t"] == "q") {
          /* The client wants to quit. */
          // FIXME need to send a message back to the client
          cterm.close();
        }
        else if (formdata["t"] == "d" && typeof(formdata["d"]) == "string") {
          /* process the keys */
          hexstr = formdata["d"].replace(/[^0-9a-f]/gi, "");
          if (hexstr.length % 2 != 0) {
            sendError(res, 2);
            return;
          }
          keybuf = new Buffer(hexstr, "hex");
          cterm.write(keybuf, formdata["n"]);
        }
        readFeed(res, cterm);
      }
      else if (target == "/login") {
        login(req, res, formdata);
      }
      else {
        res.writeHead(405, resheaders);
        res.end();
      }
    }
    else if (req.method == 'GET' || req.method == 'HEAD') {
      if (target == '/feed') {
        if (!cterm) {
          sendError(res, 1);
          return;
        }
        readFeed(res, cterm);
      }
      /* Default page, create a new term */
      /* FIXME New term not created anymore, is a special case still needed? */
      else if (target == '/') {
        serveStatic(req, res, "/");
      }
      else /* Go look for it in the filesystem */
        serveStatic(req, res, target);
    }
    else { /* Some other method */
      res.writeHead(501, resheaders);
      res.write("<html><head><title>501</title></head>\n<body><h1>501 Not Implemented</h1></body></html>\n");
      res.end();
    }
    return;
  }
  req.on('end', respond);

}

process.on("exit", function () {
  for (var sessid in sessions) {
    if (sessions[sessid].alive)
      sessions[sessid].term.kill('SIGHUP');
  }
  console.log("Quitting...");
  return;
});

function wsRespond(req) {
  var w, h, conn;
  if (req.resourceURL.pathname == "/sock") {
    w = parseInt(req.resourceURL.query.w);
    if (isNaN(w) || w <= 0 || w > 256)
      w = 80;
    h = parseInt(req.resourceURL.query.h);
    if (isNaN(h) || h <= 0 || h > 256)
      h = 25;
    conn = req.accept(null, req.origin);
    new TermSessionWS(conn, h, w);
  }
  else {
    req.reject(404, "No such resource.");
  }
}

/* The pty.js module doesn't wait for the processes it spawns, so they 
 * become zombies, which leads to unpleasantness when the system runs
 * out of process table entries.  But if the child_process module is 
 * initialized and a child spawned, node will continue waiting for any
 * children.
 * Someday, some developer will get the bright idea of tracking how many
 * processes the child_process module has spawned, and not waiting if 
 * it's zero.  Until then, the following useless line will protect us 
 * from the zombie hordes.
 * Figuring this out was almost as interesting as the Rogue bug where 
 * printf debugging altered whether the high score list was checked.
 */
child_process.spawn("/bin/true");

process.env["TERM"] = "xterm-256color";
var webServer = http.createServer(handler);
webServer.listen(8080, "127.0.0.1");
console.log('Server running at http://127.0.0.1:8080/'); 
var wsServer = new webSocketServer({"httpServer": webServer});
wsServer.on("request", wsRespond);
console.log('WebSockets online');