Mercurial > hg > rlgwebd
view rlgwebd.js @ 49:423ef87ddc9b
RLG-Web: delay removing TermSessions until the client is informed.
Add a TermSession.sendq flag that indicates whether a type q message
has been sent to the client. Don't immediately destroy the TermSession
on child exit if the message hasn't been sent.
This is an ugly hack until the TermSession class is separated into an
EventEmitter to handle the PTY and a listening object that handles
communication with the client. That will also allow other clients to
watch the game.
author | John "Elwin" Edwards <elwin@sdf.org> |
---|---|
date | Mon, 11 Jun 2012 09:15:33 -0700 |
parents | 27b7f0c8b9f0 |
children | 96815eae4ebe |
line wrap: on
line source
#!/usr/bin/env node // If you can't quite trust node to find it on its own var localModules = '/usr/local/lib/node_modules/'; var http = require('http'); var net = require('net'); var url = require('url'); var path = require('path'); var fs = require('fs'); var child_process = require('child_process'); var daemon = require(path.join(localModules, "daemon")); /* Configuration variables */ // These first two files are NOT in the chroot. var ctlsocket = "/var/local/rlgwebd/ctl"; var logfile = "/var/local/rlgwebd/log"; var httpPort = 8080; var chrootDir = "/var/dgl/"; var dropToUID = 501; var dropToGID = 501; 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": "/bin/rogue3" }, "rogue4": { "name": "Rogue V4", "uname": "rogue4", "suffix": ".r4sav", "path": "/bin/rogue4" }, "rogue5": { "name": "Rogue V5", "uname": "rogue5", "suffix": ".r5sav", "path": "/bin/rogue5" }, "srogue": { "name": "Super-Rogue", "uname": "srogue", "suffix": ".srsav", "path": "/bin/srogue" } }; /* Global state */ var logins = {}; var sessions = {}; var allowlogin = true; /* Constructor for TermSessions. Note that it opens the terminal and * adds itself to the sessions dict. It currently assumes the user has * been authenticated. */ /* TODO take a callback, or emit success/err events. */ function TermSession(game, user, dims, lkey) { /* First make sure starting the game will work. */ if (game in games) { this.game = games[game]; } else { // TODO: throw an exception instead return null; } this.player = String(user); this.key = lkey; /* This order seems to best avoid race conditions... */ this.alive = false; // A kludge until TermSession is rewritten to handle real watching. this.sendq = false; this.sessid = randkey(2); while (this.sessid in sessions) { this.sessid = randkey(2); } /* Grab a spot in the sessions table. */ sessions[this.sessid] = this; /* State for messaging. */ this.nsend = 0; this.nrecv = 0; this.msgQ = [] this.Qtimeout = null; /* 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]; } childenv["PTYHELPER"] = String(this.h) + "x" + String(this.w); /* TODO handle tty-opening errors */ /* TODO make argument-finding into a method */ args = [this.game.path, "-n", user.toString()]; this.child = child_process.spawn("/bin/ptyhelper", args, {"env": childenv}); var ss = this; this.alive = true; this.data = []; /* Set up the lockfile and ttyrec */ var ts = timestamp(); var progressdir = "/dgldir/inprogress-" + this.game.uname; this.lock = path.join(progressdir, this.player + ":node:" + ts + ".ttyrec"); var lmsg = this.child.pid.toString() + '\n' + this.w + '\n' + this.h + '\n'; fs.writeFile(this.lock, lmsg, "utf8"); var ttyrec = path.join("/dgldir/ttyrec", this.player, this.game.uname, ts + ".ttyrec"); this.record = fs.createWriteStream(ttyrec, { mode: 0664 }); /* END setup */ function ttyrec_chunk(buf) { var ts = new Date(); 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.data.push(chunk); ss.record.write(chunk); } this.child.stdout.on("data", ttyrec_chunk); this.child.stderr.on("data", ttyrec_chunk); this.child.on("exit", function (code, signal) { ss.exitcode = (code != null ? code : 255); ss.alive = false; fs.unlink(ss.lock); /* Wait for all the data to get collected */ setTimeout(ss.cleanup, 1000); }); this.write = function (data, n) { if (!this.alive || typeof (n) != "number") { return; } //console.log("Got message " + n); var oindex = n - this.nrecv; if (oindex === 0) { //console.log("Writing message " + n); this.child.stdin.write(data); this.nrecv++; var next; while ((next = this.msgQ.shift()) !== undefined) { //console.log("Writing message " + this.nrecv); this.child.stdin.write(next); this.nrecv++; } if (this.msgQ.length == 0 && this.Qtimeout) { clearTimeout(this.Qtimeout); this.Qtimeout = null; } } else if (oindex > 0 && oindex <= 1024) { tslog("Stashing message %d at %d", n, oindex - 1); this.msgQ[oindex - 1] = data; if (!this.Qtimeout) { var nextn = this.nrecv + this.msgQ.length + 1; this.Qtimeout = setTimeout(this.flushQ, 30000, this, nextn); } } /* Otherwise, discard it */ return; }; this.flushQ = function (session, n) { /* Callback for when an unreceived message times out. * n is the first empty space that will not be given up on. */ if (!session.alive) return; session.nrecv++; var next; /* Clear the queue up to n */ while (session.nrecv < n) { next = session.msgQ.shift(); if (next !== undefined) session.child.stdin.write(next); session.nrecv++; } /* Clear out anything that's ready. */ while ((next = session.msgQ.shift()) !== undefined) { session.child.stdin.write(next); session.nrecv++; } /* Now set another timeout if necessary. */ if (session.msgQ.length != 0) { var nextn = session.nrecv + session.msgQ.length + 1; session.Qtimeout = setTimeout(session.flushQ, 30000, session, nextn); } tslog("Flushing queue for session %s", session.sessid); }; 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 += this.data[i].length - 12; var nbuf = new Buffer(pos); var tptr; pos = 0; while (this.data.length > 0) { tptr = this.data.shift(); tptr.copy(nbuf, pos, 12); pos += tptr.length - 12; } return nbuf; }; this.close = function () { if (this.alive) this.child.kill('SIGHUP'); }; this.cleanup = function () { /* Call this when the child is dead. */ if (ss.alive) return; ss.record.end(); /* Give the client a chance to read any leftover data. */ if (ss.data.length > 0 || !ss.sendq) setTimeout(ss.remove, 8000); else ss.remove(); }; this.remove = function () { var id = ss.sessid; delete sessions[id]; tslog("Session %s removed.", id); }; } function checkprogress(user, game, callback, args) { var progressdir = "/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(dropToUID) + "-" + user + game.suffix; var savefile = path.join("/var/games/roguelike", savedirc, basename); path.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) { sdata[game.uname] = "p"; 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; } 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 reaper() { var now = new Date(); function reapcheck(session) { if (!session.alive) return; fs.fstat(session.record.fd, function (err, stats) { if (!err && now - stats.mtime > playtimeout) { tslog("Reaping %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); if (now - logins[session.key].ts > playtimeout) logins[session.key].ts = new Date(now - playtimeout); } /* Shut it down. */ session.close(); } }); } for (var sessid in sessions) { reapcheck(sessions[sessid]); } /* HELPME this is about as clever as I can code, so I can't tell whether * there are any bugs. */ for (var lkey in logins) { if (logins[lkey].sessions.length == 0) { /* A login with no current games can be killed for inactivity. */ if (now - logins[lkey].ts > playtimeout * 4) { tslog("Login for %s (key %s) timed out", logins[lkey].name, lkey); delete logins[lkey]; } } else { /* Check for games that have terminated normally, and update * the timestamp. */ 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) { logins[lkey].ts = new Date(now); 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); return; } if (!("name" in formdata)) { sendError(res, 2, "Username not given."); return; } else if (!("pw" in formdata)) { sendError(res, 2, "Password not given."); 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; } else { logins[lkey].ts = new Date(); } var username = logins[lkey].name; var gname = formdata["game"]; 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) { if (fname) { sendError(res, 4, null); tslog("%s is already playing %s", username, gname); return; } // Game starting has been approved. var nsession = new TermSession(gname, username, dims, lkey); if (nsession) { res.writeHead(200, {'Content-Type': 'application/json'}); var reply = {"t": "l", "id": nsession.sessid, "w": nsession.w, "h": nsession.h}; res.write(JSON.stringify(reply)); res.end(); tslog("%s playing %s (key %s, pid %d)", username, gname, nsession.sessid, nsession.child.pid); logins[lkey].sessions.push(nsession.sessid); } else { sendError(res, 5, "Failed to open TTY"); tslog("Unable to allocate TTY for %s", gname); } } checkprogress(username, games[gname], launch, []); } /* 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; } function endgame(term, res) { if (!term.alive) { sendError(res, 7, null); return; } term.close(); var resheaders = {'Content-Type': 'application/json'}; res.writeHead(200, resheaders); res.write(JSON.stringify({"t": "q"})); res.end(); term.sendq = true; return; } function findTermSession(formdata) { if (typeof(formdata) != "object") return null; if ("id" in formdata) { var sessid = formdata["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); path.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 { res.writeHead(404, resheaders); if (req.method != 'HEAD') { 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) { if (term) { var reply = {}; var result = term.read(); if (result == null) { if (term.alive) reply.t = "n"; else { if (allowlogin) { reply.t = "q"; term.sendq = true; } else { sendError(res, 6, null); return; } } } else { reply.t = "d"; reply.n = term.nsend++; reply.d = result.toString("hex"); } res.writeHead(200, { "Content-Type": "application/json" }); res.write(JSON.stringify(reply)); res.end(); } else { sendError(res, 7, null); } } function statusmsg(req, res) { var reply = {"s": allowlogin, "g": []}; for (var sessid in sessions) { if (sessions[sessid].alive) { var gamedesc = {}; gamedesc["p"] = sessions[sessid].player; gamedesc["g"] = sessions[sessid].game.name; reply["g"].push(gamedesc); } } res.writeHead(200, { "Content-Type": "application/json" }); if (req.method != 'HEAD') res.write(JSON.stringify(reply)); res.end(); } 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(); }); } 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) { 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; res.write(JSON.stringify(edict)); 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; var cterm = findTermSession(formdata); /* First figure out if the client is POSTing to a command interface. */ if (req.method == 'POST') { if (target == '/feed') { if (!cterm) { sendError(res, 7, null); return; } if (formdata.t == "q") { /* The client wants to terminate the process. */ endgame(cterm, res); } 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, "incomplete byte"); return; } keybuf = new Buffer(hexstr, "hex"); /* TODO OoO correction */ cterm.write(keybuf, formdata.n); } readFeed(res, cterm); } 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 { 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(); return; } if (!cterm) { sendError(res, 7, null); return; } readFeed(res, cterm); } else if (target == '/status') { statusmsg(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 */