view rlgwebd.js @ 158:9961a538c00e

rlgwebd.js: get rid of numerical game identifiers. Games will be indentified by gamename/username pairs. This will allow better interoperability with dgamelaunch. Polling clients are no longer supported; the code remnants need to be removed. The reaper() function will likely crash. Unexpectedly, the WebSocket client still works well enough to play. Watching and listing current games are probably broken.
author John "Elwin" Edwards
date Thu, 01 Jan 2015 15:56:22 -0500
parents e7f809f06c5c
children a613380ffdc2
line wrap: on
line source

#!/usr/bin/env node

var http = require('http');
var net = require('net');
var url = require('url');
var path = require('path');
var fs = require('fs');
var events = require('events');
var child_process = require('child_process');
// Dependencies
var posix = require("posix");
var pty = require("pty.js");
var WebSocketServer = require("websocket").server;

/* Configuration variables */
// The first file is NOT in the chroot.
var ctlsocket = "/var/local/rlgwebd/ctl";
var httpPort = 8080;
var chrootDir = "/var/dgl/";
var dropToUser = "rodney";
var serveStaticRoot = "/var/www/"; // inside the chroot
var playtimeout = 3600000; // Idle time before games are autosaved, in ms

/* Data on the games available. */
var games = {
  "rogue3": {
    "name": "Rogue V3",
    "uname": "rogue3",
    "suffix": ".r3sav",
    "path": "/usr/bin/rogue3",
    "clear": new Buffer([27, 91, 72, 27, 91, 50, 74]) // CSI H CSI 2J
  },
  "rogue4": {
    "name": "Rogue V4",
    "uname": "rogue4",
    "suffix": ".r4sav",
    "path": "/usr/bin/rogue4",
    "clear": new Buffer([27, 91, 72, 27, 91, 50, 74]) // CSI H CSI 2J
  },
  "rogue5": {
    "name": "Rogue V5",
    "uname": "rogue5",
    "suffix": ".r5sav",
    "path": "/usr/bin/rogue5",
    "clear": new Buffer([27, 91, 72, 27, 91, 50, 74]) // CSI H CSI 2J
  },
  "srogue": {
    "name": "Super-Rogue",
    "uname": "srogue",
    "suffix": ".srsav",
    "path": "/usr/bin/srogue",
    "clear": new Buffer([27, 91, 72, 27, 91, 50, 74]) // CSI H CSI 2J
  },
  "arogue5": {
    "name": "Advanced Rogue 5",
    "uname": "arogue5",
    "suffix": ".ar5sav",
    "path": "/usr/bin/arogue5",
    "clear": new Buffer([27, 91, 72, 27, 91, 50, 74]) // CSI H CSI 2J
  }
};

/* Global state */
var logins = {};
var sessions = {};
var clients = {};
var dglgames = {};
var allowlogin = true;
var gamemux = new events.EventEmitter();

/* Constructor.  A TermSession handles a pty and the game running on it.
 *   game: (String) Name of the game to launch.
 *   lkey: (String, key) The user's id, a key into logins.
 *   dims: (Array [Number, Number]) Height and width of the pty.
 *   handlers: (Object) Key-value pairs, event names and functions to
 *           install to handle them.
 *  Events:
 *   "open": Emitted on startup.  Parameters: success (Boolean)
 *   "data": Data generated by child. Parameters: buf (Buffer)
 *   "exit": Child terminated. Parameters: none
 */
function TermSession(game, lkey, dims, handlers) {
  var ss = this;
  /* Subclass EventEmitter to do the hard work. */
  events.EventEmitter.call(this);
  for (var evname in handlers)
    this.on(evname, handlers[evname]);
  /* Don't launch anything that's not a real game. */
  if (game in games) {
    this.game = games[game];
  }
  else {
    this.emit('open', false);
    return;
  }
  if (lkey in logins) {
    this.key = lkey;
    this.pname = logins[lkey].name;
  }
  else {
    this.emit('open', false);
    return;
  }
  /* Grab a spot in the sessions table. */
  sessions[this.game.uname + "/" + this.pname] = this;
  /* Set up the sizes. */
  this.w = Math.floor(Number(dims[1]));
  if (!(this.w > 0 && this.w < 256))
    this.w = 80;
  this.h = Math.floor(Number(dims[0]));
  if (!(this.h > 0 && this.h < 256))
    this.h = 24;
  /* Environment. */
  var childenv = {};
  for (var key in process.env) {
    childenv[key] = process.env[key];
  }
  var args = ["-n", this.pname];
  var spawnopts = {"env": childenv, "cwd": "/", "rows": this.h, "cols": this.w,
                   "name": "xterm-256color"};
  this.term = pty.spawn(this.game.path, args, spawnopts);
  tslog("%s playing %s (pid %d)", this.pname, this.game.uname, this.term.pid);
  this.emit('open', true, this.game.uname, this.pname);
  gamemux.emit('begin', this.game.uname, this.pname);
  /* Set up the lockfile and ttyrec */
  var ts = timestamp();
  var progressdir = path.join("/dgldir/inprogress", this.game.uname);
  this.lock = path.join(progressdir, this.pname + ":node:" + ts + ".ttyrec");
  var lmsg = this.term.pid.toString() + '\n' + this.h + '\n' + this.w + '\n'; 
  fs.writeFile(this.lock, lmsg, "utf8"); 
  var ttyrec = path.join("/dgldir/ttyrec", this.pname, this.game.uname, 
               ts + ".ttyrec");
  this.record = fs.createWriteStream(ttyrec, { mode: 0664 });
  /* Holds the output since the last screen clear, so watchers can begin
   * with a complete screen. */
  this.framebuf = new Buffer(1024);
  this.frameoff = 0;
  logins[lkey].sessions.push(this.game.uname + "/" + this.pname);
  /* END setup */
  function ttyrec_chunk(datastr) {
    var ts = new Date();
    var buf = new Buffer(datastr);
    var chunk = new Buffer(buf.length + 12);
    /* TTYREC headers */
    chunk.writeUInt32LE(Math.floor(ts.getTime() / 1000), 0);
    chunk.writeUInt32LE(1000 * (ts.getTime() % 1000), 4);
    chunk.writeUInt32LE(buf.length, 8);
    buf.copy(chunk, 12);
    ss.record.write(chunk);
    ss.framepush(buf);
    ss.emit('data', buf);
  }
  this.term.on("data", ttyrec_chunk);
  this.framepush = function(chunk) {
    /* If this chunk resets the screen, discard what preceded it. */
    if (bufncmp(chunk, this.game.clear, this.game.clear.length)) {
      this.framebuf = new Buffer(1024);
      this.frameoff = 0;
    }
    /* Make sure there's space. */
    while (this.framebuf.length < chunk.length + this.frameoff) {
      var nbuf = new Buffer(this.framebuf.length * 2);
      this.framebuf.copy(nbuf, 0, 0, this.frameoff);
      this.framebuf = nbuf;
      if (this.framebuf.length > 65536) {
        tslog("Warning: Game %d frame buffer at %d bytes", this.tag(), 
                this.framebuf.length);
      }
    }
    chunk.copy(this.framebuf, this.frameoff);
    this.frameoff += chunk.length;
  };
  this.write = function(data) {
    this.term.write(data);
  };
  this.tag = function() {
    return this.game.uname + "/" + this.pname;
  };
  // Teardown.
  this.term.on("exit", function () {
    var tag = ss.tag();
    fs.unlink(ss.lock);
    ss.record.end();
    ss.emit('exit');
    gamemux.emit('end', ss.game.uname, ss.pname);
    delete sessions[tag];
    tslog("Game %s ended.", tag);
  });
  this.close = function () {
    if (this.tag() in sessions)
      this.term.kill('SIGHUP');
  };
}
TermSession.prototype = new events.EventEmitter();

function Watcher(session) {
  var ss = this; // that
  this.session = session;
  this.alive = true;
  /* State for messaging. */
  this.nsend = 0;
  this.sendQ = [];
  /* Get a place in the table. */
  this.id = randkey(2);
  while (this.id in clients) {
    this.id = randkey(2);
  }
  clients[this.id] = this;
  /* Recreate the current screen state from the session's buffer. */
  this.sendQ.push({"t": "d", "n": this.nsend++, 
        "d": session.framebuf.toString("hex", 0, session.frameoff)});
  function dataH(buf) {
    var reply = {};
    reply.t = "d";
    reply.n = ss.nsend++;
    reply.d = buf.toString("hex");
    ss.sendQ.push(reply);
  }
  function exitH() {
    ss.alive = false;
    ss.sendQ.push({"t": "q"});
  }
  session.on('data', dataH);
  session.on('exit', exitH);
  this.read = function() {
    /* Returns an array of all outstanding messages, empty if none. */
    var temp = this.sendQ;
    this.sendQ = [];
    /* Clean up if finished. */
    if (!this.alive) {
      delete clients[this.id];
    }
    return temp;
  };
  this.quit = function() {
    this.session.removeListener('data', dataH);
    this.session.removeListener('exit', exitH);
    delete clients[this.id];
  };
}

function Player(gamename, lkey, dims, callback) {
  var ss = this;
  this.alive = false;
  /* State for messaging. */
  this.nsend = 0;
  this.nrecv = 0;
  this.sendQ = [];
  this.recvQ = []
  this.Qtimeout = null;
  /* Get a place in the table. */
  this.id = randkey(2);
  while (this.id in clients) {
    this.id = randkey(2);
  }
  clients[this.id] = this;

  this.read = function() {
    var temp = this.sendQ;
    this.sendQ = [];
    /* Clean up if finished. */
    if (!this.alive) {
      clearTimeout(this.Qtimeout);
      delete clients[this.id];
    }
    return temp;
  };
  this.write = function (data, n) {
    if (!this.alive || typeof (n) != "number") {
      return;
    }
    var oindex = n - this.nrecv;
    if (oindex === 0) {
      this.session.write(data);
      this.nrecv++;
      var next;
      while ((next = this.recvQ.shift()) !== undefined) {
        this.session.write(next);
        this.nrecv++;
      }
      if (this.recvQ.length == 0 && this.Qtimeout) {
        clearTimeout(this.Qtimeout);
        this.Qtimeout = null;
      }
    }
    else if (oindex > 0 && oindex <= 1024) {
      tslog("Client %s: Stashing message %d at %d", this.id,  n, oindex - 1);
      this.recvQ[oindex - 1] = data;
      if (!this.Qtimeout) {
        var nextn = this.nrecv + this.recvQ.length + 1;
        this.Qtimeout = setTimeout(this.flushQ, 30000, this, nextn);
      }
    }
    /* Otherwise, discard it */
    return;
  };
  this.flushQ = function (client, n) {
    /* Callback for when an unreceived message times out.
     * n is the first empty space that will not be given up on. */
    if (!client.alive || client.nrecv >= n)
      return;
    client.nrecv++;
    var next;
    /* Clear the queue up to n */
    while (client.nrecv < n) {
      next = client.recvQ.shift();
      if (next !== undefined)
        client.session.write(next);
      client.nrecv++;
    }
    /* Clear out anything that's ready. */
    while ((next = client.recvQ.shift()) !== undefined) {
      client.session.write(next);
      client.nrecv++;
    }
    /* Now set another timeout if necessary. */
    if (client.recvQ.length != 0) {
      var nextn = client.nrecv + client.recvQ.length + 1;
      client.Qtimeout = setTimeout(client.flushQ, 30000, client, nextn);
    }
    tslog("Flushing queue for player %s", player.id);
  };
  this.reset = function () {
    /* To be called when the game is taken over. */
    if (this.Qtimeout) {
      clearTimeout(this.Qtimeout);
      this.Qtimeout = null;
    }
    for (var i = 0; i < this.recvQ.length; i++) {
      if (this.recvQ[i] !== undefined) {
        this.session.write(this.recvQ[i]);
      }
    }
    this.recvQ = [];
    this.nrecv = 0;
    this.nsend = 0;
    this.sendQ = [{"t": "d", "n": this.nsend++, 
        "d": this.session.framebuf.toString("hex", 0, this.session.frameoff)}];
  };
  this.quit = function() {
    if (this.alive)
      this.session.close();
  };
  function openH(success, tag) {
    if (success) {
      ss.alive = true;
      ss.session = sessions[tag];
      ss.h = sessions[tag].h;
      ss.w = sessions[tag].w;
    }
    callback(ss, success);
  }
  function dataH(chunk) {
    var reply = {};
    reply.t = "d";
    reply.n = ss.nsend++;
    reply.d = chunk.toString("hex");
    ss.sendQ.push(reply);
  }
  function exitH() {
    ss.alive = false;
    ss.sendQ.push({"t": "q"});
  }
  var handlers = {'open': openH, 'data': dataH, 'exit': exitH};
  this.session = new TermSession(gamename, lkey, dims, handlers);
}

// Also known as WebSocketAndTermSessionClosureGlueFactory
function wsWatcher(conn, session) {
  var ss = this; // is this even needed?
  var dataH = function(buf) {
    conn.sendUTF(JSON.stringify({"t": "d", "d": buf.toString("hex")}));
  };
  var exitH = function() {
    if (conn.connected)
      conn.close();
  }
  session.on('data', dataH);
  session.on('exit', exitH);
  conn.on('close', function(code, desc) {
    session.removeListener('data', dataH);
    session.removeListener('exit', exitH);
    if (session.tag() in sessions)
      tslog("A WebSocket watcher has left game %d", session.tag());
  });
  conn.sendUTF(JSON.stringify({
            "t": "w", "w": session.w, "h": session.h, 
            "p": session.pname, "g": session.game.uname
  }));
  conn.sendUTF(JSON.stringify({"t": "d",
        "d": session.framebuf.toString("hex", 0, session.frameoff)}));
}

function wsPlay(wsReq, game, lkey, dims) {
  var conn;
  var session;
  /* Listeners on the WebSocket */
  function messageH(message) {
    var parsedMsg = getMsgWS(message);
    if (parsedMsg.t == 'q') {
      session.close();
    }
    else if (parsedMsg.t == 'd') {
      var hexstr = parsedMsg.d.replace(/[^0-9a-f]/gi, "");
      if (hexstr.length % 2 != 0) {
        hexstr = hexstr.slice(0, -1);
      }
      var keybuf = new Buffer(hexstr, "hex");
      session.write(keybuf);
    }
  }
  function closeH() {
    session.close();
  }
  /* These listen on the TermSession. */
  function openH(success, gname, pname) {
    if (success) {
      var tag = gname + "/" + pname;
      var reply = {"t": "s", "tag": tag, "w": sessions[tag].w, "h": 
                   sessions[tag].h, "p": pname, "g": gname};
      conn = wsReq.accept(null, wsReq.origin);
      conn.sendUTF(JSON.stringify(reply));
      conn.on('message', messageH);
      conn.on('close', closeH);
    }
    else {
      wsReq.reject(500, errorcodes[5]);
      tslog("Unable to allocate TTY for %s", game);
    }
  }
  function dataH(chunk) {
    var msg = {};
    msg.t = "d";
    msg.d = chunk.toString("hex");
    conn.sendUTF(JSON.stringify(msg));
  }
  function exitH() {
    if (conn.connected)
      conn.sendUTF(JSON.stringify({"t": "q"}));
    conn.close();
    session.removeListener('open', openH);
    session.removeListener('data', dataH);
    session.removeListener('exit', exitH);
  }
  var handlers = {'open': openH, 'data': dataH, 'exit': exitH};
  session = new TermSession(game, lkey, dims, handlers);
}

function wsStart(wsReq) {
  var playmatch = wsReq.resourceURL.pathname.match(/^\/play\/([^\/]*)$/);
  if (!playmatch[1] || !(playmatch[1] in games)) {
    wsReq.reject(404, errorcodes[2]);
    return;
  }
  var gname = playmatch[1];
  if (!allowlogin) {
    wsReq.reject(404, errorcodes[6]);
    return;
  }
  if (!("key" in wsReq.resourceURL.query)) {
    wsReq.reject(404, "No key given.");
    return;
  }
  var lkey = wsReq.resourceURL.query["key"];
  if (!(lkey in logins)) {
    wsReq.reject(404, errorcodes[1]);
    return;
  }
  var pname = logins[lkey].name;
  var dims = [wsReq.resourceURL.query.h, wsReq.resourceURL.query.w];
  function progcallback(err, fname) {
    if (fname) {
      wsReq.reject(404, errorcodes[4]);
      tslog("%s is already playing %s", pname, gname);
    }
    else
      wsPlay(wsReq, gname, lkey, dims);
  };
  checkprogress(pname, games[gname], progcallback, []);
}

/* Some functions which check whether a player is currently playing or 
 * has a saved game.  Maybe someday they will provide information on 
 * the game. */
function checkprogress(user, game, callback, args) {
  var progressdir = path.join("/dgldir/inprogress", game.uname);
  fs.readdir(progressdir, function(err, files) {
    if (err) {
      args.unshift(err, null);
      callback.apply(null, args);
      return;
    }
    var fre = RegExp("^" + user + ":");
    for (var i = 0; i < files.length; i++) {
      if (files[i].match(fre)) {
        args.unshift(null, files[i]);
        callback.apply(null, args);
        return;
      }
    }
    args.unshift(null, false);
    callback.apply(null, args);
  });
}

function checksaved(user, game, callback, args) {
  var savedirc = game.uname + "save";
  var basename = String(pwent.uid) + "-" + user + game.suffix;
  var savefile = path.join("/var/games/roguelike", savedirc, basename);
  fs.exists(savefile, function (exist) {
    args.unshift(exist);
    callback.apply(null, args);
  });
}

function playerstatus(user, callback) {
  var sdata = {};
  function finishp() {
    for (var gname in games) {
      if (!(gname in sdata))
        return;
    }
    callback(sdata);
  }
  function regsaved(exists, game) {
    if (exists)
      sdata[game.uname] = "s";
    else
      sdata[game.uname] = "0";
    finishp();
  }
  function regactive(err, filename, game) {
    if (!err && filename) {
      if (filename.match(/^[^:]*:node:/))
        sdata[game.uname] = "p";
      else
        sdata[game.uname] = "d";
      finishp();
    }
    else
      checksaved(user, game, regsaved, [game]);
  }
  for (var gname in games) {
    checkprogress(user, games[gname], regactive, [games[gname]]);
  }
}

/* A few utility functions */
function timestamp() {
  dd = new Date();
  sd = dd.toISOString();
  sd = sd.slice(0, sd.indexOf("."));
  return sd.replace("T", ".");
}

function randkey(words) {
  if (!words || !(words > 0))
    words = 1;
  function rand32() {
    rnum = Math.floor(Math.random() * 65536 * 65536);
    hexstr = rnum.toString(16);
    while (hexstr.length < 8)
      hexstr = "0" + hexstr;
    return hexstr;
  }
  var key = "";
  for (var i = 0; i < words; i++)
    key += rand32();
  return key;
}

/* Compares two buffers, returns true for equality up to index n */
function bufncmp(buf1, buf2, n) {
  if (!Buffer.isBuffer(buf1) || !Buffer.isBuffer(buf2))
    return false;
  for (var i = 0; i < n; i++) {
    if (i == buf1.length && i == buf2.length)
      return true;
    if (i == buf1.length || i == buf2.length)
      return false;
    if (buf1[i] != buf2[i])
      return false;
  }
  return true;
}

function tslog() {
  arguments[0] = new Date().toISOString() + ": " + String(arguments[0]);
  console.log.apply(console, arguments);
}

/* 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 getMsg(posttext) {
  var jsonobj;
  if (!posttext)
    return {};
  try {
    jsonobj = JSON.parse(posttext);
  }
  catch (e) {
    if (e instanceof SyntaxError)
      return {};
  }
  if (typeof(jsonobj) != "object")
    return {};
  return jsonobj;
}

function getMsgWS(msgObj) {
  if (msgObj.type != "utf8")
    return {};
  return getMsg(msgObj.utf8Data);
}

/* FIXME sessid removal */
function reaper() {
  return; // TODO figure out if this function is useful
  var now = new Date();
  function reapcheck(session) {
    fs.fstat(session.record.fd, function (err, stats) {
      if (!err && now - stats.mtime > playtimeout) {
        tslog("Reaping session %s", session.sessid);
        /* Dissociate it with its login name. */
        var sn = logins[session.key].sessions.indexOf(session.sessid);
        if (sn >= 0) {
          logins[session.key].sessions.splice(sn, 1);
        }
        /* Shut it down. */
        session.close();
      }
    });
  }
  for (var sessid in sessions) {
    reapcheck(sessions[sessid]);
  }
  for (var lkey in logins) {
    if (logins[lkey].sessions.length > 0) {
      /* Check for games that have terminated normally, and remove them. */
      var expired = [];
      var targarray = logins[lkey].sessions;
      /* Let's not find out what happens if you modify an array
       * you're iterating through. */
      for (var i = 0; i < targarray.length; i++) {
        if (!(targarray[i] in sessions))
          expired.push(targarray[i]);
      }
      if (expired.length > 0) {
        for (var j = 0; j < expired.length; j++) {
          targarray.splice(targarray.indexOf(expired[j]), 1);
        }
      }
    }
  }
}

function login(req, res, formdata) {
  if (!allowlogin) {
    sendError(res, 6, null, false);
    return;
  }
  if (!("name" in formdata)) {
    sendError(res, 2, "Username not given.", false);
    return;
  }
  else if (!("pw" in formdata)) {
    sendError(res, 2, "Password not given.", false);
    return;
  }
  var username = String(formdata["name"]);
  var password = String(formdata["pw"]);
  function checkit(code, signal) {
    /* Checks the exit status, see sqlickrypt.c for details. */
    if (code != 0) {
      sendError(res, 3);
      if (code == 1)
        tslog("Password check failed for user %s", username);
      else if (code == 2)
        tslog("Attempted login by nonexistent user %s", username);
      else
        tslog("Login failed: sqlickrypt error %d", code);
      return;
    }
    var lkey = randkey(2);
    while (lkey in logins)
      lkey = randkey(2);
    logins[lkey] = {"name": username, "ts": new Date(), "sessions": []};
    res.writeHead(200, {'Content-Type': 'application/json'});
    var reply = {"t": "l", "k": lkey, "u": username};
    res.write(JSON.stringify(reply));
    res.end();
    tslog("%s has logged in (key %s)", username, lkey);
    return;
  }
  /* Launch the sqlickrypt utility to check the password. */
  var pwchecker = child_process.spawn("/bin/sqlickrypt", ["check"]);
  pwchecker.on("exit", checkit);
  pwchecker.stdin.end(username + '\n' + password + '\n', "utf8");
  return;
}

function startgame(req, res, formdata) {
  if (!allowlogin) {
    sendError(res, 6, null);
    return;
  }
  if (!("key" in formdata)) {
    sendError(res, 2, "No key given.");
    return;
  }
  else if (!("game" in formdata)) {
    sendError(res, 2, "No game specified.");
    return;
  }
  var lkey = String(formdata["key"]);
  if (!(lkey in logins)) {
    sendError(res, 1, null);
    return;
  }
  var username = logins[lkey].name;
  var gname = formdata["game"];
  // If dims are not given or invalid, the constructor will handle it.
  var dims = [formdata["h"], formdata["w"]];
  if (!(gname in games)) {
    sendError(res, 2, "No such game: " + gname);
    tslog("Request for nonexistant game \"%s\"", gname);
    return;
  }
  // A callback to pass to the game-in-progress checker.
  var launch = function(err, fname) {
    var nodematch = new RegExp("^" + username + ":node:");
    if (fname && (fname.match(nodematch) === null)) {
      /* It's being played in dgamelaunch. */
      sendError(res, 4, "dgamelaunch");
      tslog("%s is already playing %s", username, gname);
      return;
    }
    // Game starting has been approved.
    var respondlaunch = function(nclient, success) {
      if (success) {
        res.writeHead(200, {'Content-Type': 'application/json'});
        var reply = {"t": "s", "id": nclient.id, "w": nclient.w, "h": 
                     nclient.h, "p": username, "g": gname};
        res.write(JSON.stringify(reply));
        res.end();
      }
      else {
        sendError(res, 5, "Failed to open TTY");
        tslog("Unable to allocate TTY for %s", gname);
      }
    };
    if (fname) {
      for (var cid in clients) {
        cli = clients[cid];
        if ((cli instanceof Player) && 
             cli.session.pname == username &&
             cli.session.game.uname == gname) {
          cli.reset();
          respondlaunch(cli, true);
          tslog("Game %d has been taken over.", cli.session.sessid);
          return;
        }
      }
      /* If there's no player, it's a WebSocket game, and shouldn't be 
       * seized. */
      sendError(res, 4, "WebSocket");
    }
    else {
      new Player(gname, lkey, dims, respondlaunch);
    }
  };
  checkprogress(username, games[gname], launch, []);
}

function watch(req, res, formdata) {
  if (!("g" in formdata) | !("p" in formdata)) {
    sendError(res, 2, "Game or player not given");
    return;
  }
  if (!(formdata.g in games)) {
    sendError(res, 2, "No such game: " + formdata.g);
    return;
  }
  var tag = formdata.g = "/" + formdata.p;
  if (!(tag in sessions)) {
    sendError(res, 7);
    return;
  }
  var session = sessions[tag];
  var watch = new Watcher(session);
  var reply = {"t": "w", "w": session.w, "h": session.h, 
               "p": session.pname, "g": session.game.uname};
  res.writeHead(200, {'Content-Type': 'application/json'});
  res.write(JSON.stringify(reply));
  res.end();
  tslog("Game %d is being watched", tag);
}

/* Sets things up for a new user, like dgamelaunch's commands[register] */
function regsetup(username) {
  function regsetup_l2(err) {
    for (var g in games) {
      fs.mkdir(path.join("/dgldir/ttyrec", username, games[g].uname), 0755);
    }
  }
  fs.mkdir(path.join("/dgldir/userdata", username), 0755);
  fs.mkdir(path.join("/dgldir/ttyrec/", username), 0755, regsetup_l2);
}

function register(req, res, formdata) {
  var uname, passwd, email;
  if (typeof (formdata.name) != "string" || formdata.name === "") {
    sendError(res, 2, "No name given.");
    return;
  }
  else
    uname = formdata["name"];
  if (typeof (formdata.pw) != "string" || formdata.pw === "") {
    sendError(res, 2, "No password given.");
    return;
  }
  else
    passwd = formdata["pw"];
  if (typeof (formdata.email) != "string" || formdata.email === "") {
    /* E-mail is optional */
    email = "nobody@nowhere.not";
  }
  else
    email = formdata["email"];
  function checkreg(code, signal) {
    if (code === 0) {
      var lkey = randkey(2);
      while (lkey in logins)
        lkey = randkey(2);
      logins[lkey] = {"name": uname, "ts": new Date(), "sessions": []};
      var reply = {"t": "r", "k": lkey, "u": uname};
      res.writeHead(200, {'Content-Type': 'application/json'});
      res.write(JSON.stringify(reply));
      res.end();
      tslog("Added new user: %s", uname);
      regsetup(uname);
    }
    else if (code == 4) {
      sendError(res, 2, "Invalid characters in name or email.");
      tslog("Attempted registration: %s %s", uname, email);
    }
    else if (code == 1) {
      sendError(res, 2, "Username " + uname + " is already being used.");
      tslog("Attempted duplicate registration: %s", uname);
    }
    else {
      sendError(res, 0, null);
      tslog("sqlickrypt register failed with code %d", code);
    }
  }
  var child_adder = child_process.spawn("/bin/sqlickrypt", ["register"]);
  child_adder.on("exit", checkreg);
  child_adder.stdin.end(uname + '\n' + passwd + '\n' + email + '\n', "utf8");
  return;
}

/* Ends the game, obviously.  Less obviously, stops watching the game if
 * the client is a Watcher instead of a Player. */
function endgame(client, res) {
  if (!client.alive) {
    sendError(res, 7, null, true);
    return;
  }
  client.quit();
  // Give things some time to happen.
  if (client instanceof Player)
    setTimeout(readFeed, 200, client, res);
  else
    readFeed(client, res);
  return;
}

/* Stops a running game if the request has the proper key. */
/* TODO does this still work? */
function stopgame(res, formdata) {
  if (!("key" in formdata) || !(formdata["key"] in logins)) {
    sendError(res, 1);
    return;
  }
  var pname = logins[formdata["key"]].name;
  if (!("g" in formdata) || !(formdata["g"] in games)) {
    sendError(res, 2, "No such game.");
    return;
  }
  var gname = formdata["g"];
  function checkback(err, fname) {
    if (!fname) {
      sendError(res, 7);
      return;
    }
    var fullfile = path.join("/dgldir/inprogress", gname, fname);
    fs.readFile(fullfile, "utf8", function(err, fdata) {
      if (err) {
        sendError(res, 7);
        return;
      }
      var pid = parseInt(fdata.split('\n')[0], 10);
      try {
        process.kill(pid, 'SIGHUP');
      }
      catch (err) {
        /* If the PID is invalid, the lockfile is stale. */
        if (err.code == "ESRCH") {
          var nodere = RegExp("^" + pname + ":node:");
          if (fname.match(nodere)) {
            fs.unlink(fullfile);
          }
        }
      }
      /* The response doesn't mean that the game is gone.  The only way
       * to make sure a dgamelaunch-supervised game is over would be to
       * poll fname until it disappears. */
      res.writeHead(200, {'Content-Type': 'application/json'});
      res.write(JSON.stringify({"t": "q"}));
      res.end();
    });
  }
  checkprogress(pname, games[gname], checkback, []);
}

/* TODO remove polling */
function findClient(formdata, playersOnly) {
  if (typeof(formdata) != "object")
    return null;
  if ("id" in formdata) {
    var id = formdata["id"];
    if (id in clients && (!playersOnly || clients[id] instanceof Player)) {
      return clients[id];
    }
  }
  return null;
}

function startProgressWatcher() {
  var watchdirs = [];
  for (var gname in games) {
    watchdirs.push(path.join("/dgldir/inprogress", gname));
  }
  var subproc = child_process.spawn("/bin/watcher", watchdirs);
  subproc.stdout.setEncoding('utf8');
  subproc.stdout.on('data', function (chunk) {
    var fname = chunk.slice(2, -1);
    var filere = /.*\/([^\/]*)\/([^\/:]*):(node:)?(.*)/;
    var matchresult = fname.match(filere);
    if (!matchresult || matchresult[3])
      return;
    var gname = matchresult[1];
    var pname = matchresult[2];
    var tag = gname + "/" + pname;
    if (chunk[0] == "E") {
      tslog("DGL: %s is playing %s: %s", pname, gname, fname)
      dglgames[tag] = fname;
    }
    else if (chunk[0] == "C") {
      tslog("DGL: %s started playing %s: %s", pname, gname, fname)
      dglgames[tag] = fname;
    }
    else if (chunk[0] == "D") {
      tslog("DGL: %s finished playing %s: %s", pname, gname, fname)
      delete dglgames[tag];
    }
    else {
      tslog("Watcher says: %s", chunk)
    }
  });
  subproc.stdout.resume();
  return subproc;
}

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; charset=utf-8";
    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) {
      fs.readFile(realname, function (error, data) {
        if (error) {
          res.writeHead(500, {});
          res.end();
        }
        else {
          res.writeHead(200, resheaders);
          if (req.method != 'HEAD')
            res.write(data);
          res.end();
        }
      });
    }
    else {
      send404(res, nname, req.method == 'HEAD');
    }
  });
  return;
}

/* TODO remove polling */
function readFeed(client, res) {
  if (!client) {
    sendError(res, 7, null, true);
    return;
  }
  var msgs = client.read();
  if (!allowlogin && !msgs.length) {
    sendError(res, 6, null, true);
    return;
  }
  res.writeHead(200, { "Content-Type": "application/json" });
  res.write(JSON.stringify(msgs));
  res.end();
}

/* TODO simplify by storing timestamps instead of callin stat() */
function getStatus(callback) {
  var now = new Date();
  var statusinfo = {"s": allowlogin, "g": []};
  function idleset(n, idletime) {
    if (n >= 0 && n < statusinfo.g.length) {
      statusinfo.g[n].i = idletime;
    }
    for (var j = 0; j < statusinfo.g.length; j++) {
      if (!("i" in statusinfo.g[j]))
        return;
    }
    callback(statusinfo);
  }
  for (var tag in sessions) {
    var gamedesc = {};
    gamedesc["tag"] = tag;
    gamedesc["p"] = sessions[tag].pname;
    gamedesc["g"] = sessions[tag].game.uname;
    statusinfo["g"].push(gamedesc);
  }
  statusinfo["dgl"] = [];
  for (var tag in dglgames) {
    statusinfo["dgl"].push(tag);
  }
  if (statusinfo.g.length == 0) {
    callback(statusinfo);
    return;
  }
  function makecallback(i) {
    return function (err, stats) {
      if (err)
        idleset(i, null);
      else
        idleset(i, now - stats.mtime);
    }
  }
  for (var i = 0; i < statusinfo.g.length; i++) {
    /* fd sometimes isn't a number, presumably when the file isn't open yet. */
    /* FIXME sessid -> tag */
    var tag = statusinfo.g[i].tag;
    if (tag in sessions && typeof(sessions[tag].record.fd) == 'number') {
      fs.fstat(sessions[tag].record.fd, makecallback(i));
    }
    else {
      idleset(i, null);
    }
  }
}

function statusmsg(req, res) {
  function respond(info) {
    res.writeHead(200, { "Content-Type": "application/json" });
    if (req.method != 'HEAD')
      res.write(JSON.stringify(info));
    res.end();
  }
  getStatus(respond);
}

function pstatusmsg(req, res) {
  if (req.method == 'HEAD') {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end();
    return;
  }
  var target = url.parse(req.url).pathname;
  var pmatch = target.match(/^\/pstatus\/(.*)/);
  if (pmatch && pmatch[1])
    var pname = pmatch[1];
  else {
    sendError(res, 2, "No name given.");
    return;
  }
  var reply = {"name": pname};
  playerstatus(pname, function (pdata) {
    reply["stat"] = pdata;
    res.writeHead(200, { "Content-Type": "application/json" });
    res.write(JSON.stringify(reply));
    res.end();
  });
}

function getuinfo(req, res) {
  var urlobj = url.parse(req.url, true);
  var query = urlobj.query;
  if (!("key" in query) || !(query["key"] in logins)) {
    sendError(res, 1);
    return;
  }
  var match = urlobj.pathname.match(/^\/[^\/]*\/(.*)/);
  if (!match || !match[1]) {
    send404(res, urlobj.pathname, req.method == 'HEAD');
    return;
  }
  var which = match[1];
  var name = logins[query["key"]].name;
  var reply = { "u": name };
  function send() {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.write(JSON.stringify(reply));
    res.end();
  }
  if (which == "pw") {
    /* Don't actually divulge passwords. */
    reply["pw"] = "";
    send();
  }
  else if (which == "email") {
    var address;
    function finish(code, signal) {
      if (code != 0) {
        tslog("sqlickrypt: %d with name %s", code, name);
        sendError(res, 2);
      }
      else {
        reply["email"] = address;
        send();
      }
    }
    var subproc = child_process.spawn("/bin/sqlickrypt", ["getmail"]);
    subproc.stdout.on("data", function (data) {
      address = data.toString().replace(/\n/g, "");
    });
    subproc.on("exit", finish);
    subproc.stdin.end(name + '\n', "utf8");
  }
  else {
    send404(res, urlobj.pathname, req.method == 'HEAD');
    return;
  }
}

function setuinfo(req, res, postdata) {
  var urlobj = url.parse(req.url, true);
  var query = urlobj.query;
  if (!("key" in query) || !(query["key"] in logins)) {
    sendError(res, 1);
    return;
  }
  var name = logins[query["key"]].name;
  var match = urlobj.pathname.match(/^\/[^\/]*\/(.*)/);
  if (!match || !match[1]) {
    send404(res, urlobj.pathname, true);
    return;
  }
  var which = match[1];
  if (!("v" in postdata)) {
    sendError(res, 2, "No value provided");
    return;
  }
  if (which == "email" || which == "pw") {
    var args;
    if (which == "email")
      args = ["setmail"];
    else
      args = ["setpw"];
    var child = child_process.execFile("/bin/sqlickrypt", args, 
                  function (err, stdout, stderr) {
      if (err) {
        tslog("Could not set %s: sqlickrypt error %d", which, err.code);
        sendError(res, 2);
      }
      else {
        tslog("User %s has changed %s", name, which);
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify({"t": "t"}));
      }
    });
    child.stdin.end(name + "\n" + postdata.v + "\n", "utf8");
  }
  else {
    send404(res, urlobj.pathname, true);
  }
}

var errorcodes = [ "Generic Error", "Not logged in", "Invalid data", 
        "Login failed", "Already playing", "Game launch failed",
        "Server shutting down", "Game not in progress" ];

function sendError(res, ecode, msg, box) {
  res.writeHead(200, { "Content-Type": "application/json" });
  var edict = {"t": "E"};
  if (!(ecode < errorcodes.length && ecode > 0))
    ecode = 0;
  edict["c"] = ecode;
  edict["s"] = errorcodes[ecode];
  if (msg)
    edict["s"] += ": " + msg;
  if (box)
    res.write(JSON.stringify([edict]));
  else
    res.write(JSON.stringify(edict));
  res.end();
}

function send404(res, path, nopage) {
  res.writeHead(404, {"Content-Type": "text/html; charset=utf-8"});
  if (!nopage) {
    res.write("<html><head><title>" + path + "</title></head>\n<body><h1>"
              + path + " Not Found</h1></body></html>\n");
  }
  res.end();
}

function webHandler(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() {
    formdata = getMsg(reqbody);
    var target = url.parse(req.url).pathname;
    /* First figure out if the client is POSTing to a command interface. */
    if (req.method == 'POST') {
      if (target == '/feed') {
        var client = findClient(formdata, false);
        if (!client) {
          sendError(res, 7, null, true);
          return;
        }
        if (formdata.t == "q") {
          /* The client wants to terminate the process. */
          endgame(client, res);
          return; // endgame() calls readFeed() itself.
        }
        else if (formdata.t == "d" && typeof(formdata.d) == "string") {
          if (!(client instanceof Player)) {
            sendError(res, 7, "Watching", true);
            return;
          }
          /* process the keys */
          var hexstr = formdata.d.replace(/[^0-9a-f]/gi, "");
          if (hexstr.length % 2 != 0) {
            sendError(res, 2, "incomplete byte", true);
            return;
          }
          var keybuf = new Buffer(hexstr, "hex");
          client.write(keybuf, formdata.n);
        }
        readFeed(client, res);
      }
      else if (target == "/login") {
        login(req, res, formdata);
      }
      else if (target == "/addacct") {
        register(req, res, formdata);
      }
      else if (target == "/play") {
        startgame(req, res, formdata);
      }
      else if (target == "/watch") {
        watch(req, res, formdata);
      }
      else if (target == "/quit") {
        stopgame(res, formdata);
      }
      else if (target.match(/^\/uinfo\//)) {
        setuinfo(req, res, formdata);
      }
      else {
        res.writeHead(405, resheaders);
        res.end();
      }
    }
    else if (req.method == 'GET' || req.method == 'HEAD') {
      if (target == '/feed') {
        if (req.method == 'HEAD') {
          res.writeHead(200, {"Content-Type": "application/json"});
          res.end();
        }
        else
          sendError(res, 7, null, true);
        return;
      }
      else if (target == '/status') {
        statusmsg(req, res);
      }
      else if (target.match(/^\/uinfo\//)) {
        getuinfo(req, res);
      }
      else if (target.match(/^\/pstatus\//)) {
        pstatusmsg(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);

}

function wsHandler(wsRequest) {
  var watchmatch = wsRequest.resource.match(/^\/watch\/(.*)$/);
  var playmatch = wsRequest.resource.match(/^\/play\//);
  if (watchmatch !== null) {
    if (!(watchmatch[1] in sessions)) {
      wsRequest.reject(404, errorcodes[7]);
      return;
    }
    var tsession = sessions[watchmatch[1]];
    var conn = wsRequest.accept(null, wsRequest.origin);
    new wsWatcher(conn, tsession);
    tslog("Game %d is being watched via WebSockets", tsession.tag());
  }
  else if (playmatch !== null) {
    wsStart(wsRequest);
  }
  else if (wsRequest.resourceURL.pathname == "/status") {
    var conn = wsRequest.accept(null, wsRequest.origin);
    var tell = function () {
      getStatus(function (info) {
        info["t"] = "t";
        conn.sendUTF(JSON.stringify(info));
      });
    }
    var beginH = function (gname, pname) {
      conn.sendUTF(JSON.stringify({"t": "b", "p": pname, "g": gname}));
    };
    var listH = function (list) {
      conn.sendUTF(JSON.stringify(list));
    };
    var endH = function (gname, pname) {
      conn.sendUTF(JSON.stringify({"t": "e", "p": pname, "g": gname}));
    };
    gamemux.on('begin', beginH);
    gamemux.on('list', listH);
    gamemux.on('end', endH);
    conn.on('message', tell);
    conn.on('close', function () {
      gamemux.removeListener('begin', beginH);
      gamemux.removeListener('list', listH);
      gamemux.removeListener('end', endH);
    });
    tell();
  }
  else
    wsRequest.reject(404, "No such resource.");
}

function pushStatus() {
  getStatus(function(info) {
    info["t"] = "t";
    gamemux.emit('list', info);
  });
}

function shutdown () {
  httpServer.close();
  httpServer.removeAllListeners('request');
  ctlServer.close();
  tslog("Shutting down...");
  process.exit();
}

function consoleHandler(chunk) {
  var msg = chunk.toString().split('\n')[0];
  if (msg == "quit") {
    allowlogin = false;
    tslog("Disconnecting...");
    for (var tag in sessions) {
      sessions[tag].close();
    }
    progressWatcher.stdin.end("\n");
    setTimeout(shutdown, 2000);
  }
}

process.on("exit", function () {
  for (var tag in sessions) {
    sessions[tag].term.kill('SIGHUP');
  }
  tslog("Quitting...");
  return;
});

/* Initialization STARTS HERE */
process.env["TERM"] = "xterm-256color";

if (process.getuid() != 0) {
  tslog("Not running as root, cannot chroot.");
  process.exit(1);
}

var httpServer; // declare here so shutdown() can find it
var wsServer;
var progressWatcher;

var pwent; 
try {
  pwent = posix.getpwnam(dropToUser);
}
catch (err) {
  tslog("Could not drop to user %s: user does not exist", dropToUser);
  process.exit(1);
}

/* This could be nonblocking, but nothing else can start yet anyway. */
if (fs.existsSync(ctlsocket)) {
  fs.unlinkSync(ctlsocket);
}

/* Open the control socket before chrooting where it can't be found */
var ctlServer = net.createServer(function (sock) {
  sock.on('data', consoleHandler);
});
ctlServer.listen(ctlsocket, function () {
  /* rlgwebd.js now assumes that it has been started by the rlgwebd shell
   * script, or some other method that detaches it and sets up stdio. */
  /* chroot and drop permissions.  posix.chroot() does chdir() itself. */
  try {
    posix.chroot(chrootDir);
  }
  catch (err) {
    tslog("chroot to %s failed: %s", chrootDir, err);
    process.exit(1);
  }
  try {
    // drop gid first, that requires UID=0
    process.setgid(pwent.gid);
    process.setuid(pwent.uid);
  }
  catch (err) {
    tslog("Could not drop permissions: %s", err);
    process.exit(1);
  }
  httpServer = http.createServer(webHandler);
  httpServer.listen(httpPort);
  tslog('rlgwebd running on port %d', httpPort); 
  setInterval(reaper, playtimeout / 4);
  wsServer = new WebSocketServer({"httpServer": httpServer});
  wsServer.on("request", wsHandler);
  tslog('WebSockets are online'); 
  progressWatcher = startProgressWatcher();
  setInterval(pushStatus, 4000);
});