Mercurial > hg > rlgwebd
view rlgwebd.js @ 165:59e62710cbb5
rlgwebd.js: prevent races when reading ttyrecs.
DglSession objects read a 12-byte TTYREC header, extract therefrom the
length of the data chunk, and then read the data. In between these two
reads, the file watcher could trigger another readchunk() invocation,
which might attempt to read a header from the beginning of the data
chunk. This usually results in expecting a data chunk of several GB
and failing to create a Buffer for it.
The race is remedied by setting a flag on the DglSession object
whenever readchunk() is called, clearing it when both reads complete,
and refusing to read if it is already set.
author | John "Elwin" Edwards |
---|---|
date | Wed, 07 Jan 2015 13:18:35 -0500 |
parents | 3a97e4ee50f0 |
children | fba1b34e7554 |
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 /* 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 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 */ this.lasttime = new Date(); var ts = timestamp(this.lasttime); 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; /* END setup */ function ttyrec_chunk(datastr) { ss.lasttime = new Date(); var buf = new Buffer(datastr); var chunk = new Buffer(buf.length + 12); /* TTYREC headers */ chunk.writeUInt32LE(Math.floor(ss.lasttime.getTime() / 1000), 0); chunk.writeUInt32LE(1000 * (ss.lasttime.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 %s 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 DglSession(filename) { var ss = this; events.EventEmitter.call(this); var pathcoms = filename.split('/'); this.gname = pathcoms[pathcoms.length - 2]; if (!(this.gname in games)) { ss.emit('open', false); return; } var basename = pathcoms[pathcoms.length - 1]; var firstsep = basename.indexOf(':'); this.pname = basename.slice(0, firstsep); var fname = basename.slice(firstsep + 1); this.ttyrec = path.join("/dgldir/ttyrec", this.pname, this.gname, fname); /* Flag to prevent multiple handlers from reading simultaneously and * getting into a race. */ this.reading = false; this.framebuf = new Buffer(1024); this.frameoff = 0; this.framepush = function(chunk) { /* If this chunk resets the screen, discard what preceded it. */ var cgame = games[this.gname]; if (bufncmp(chunk, cgame.clear, cgame.clear.length)) { tslog("DGL %s: clearing frame", ss.tag()); 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: DGL %s frame buffer at %d bytes", this.tag(), this.framebuf.length); } } chunk.copy(this.framebuf, this.frameoff); this.frameoff += chunk.length; }; this.readchunk = function () { if (this.reading) return; this.reading = true; var header = new Buffer(12); fs.read(ss.fd, header, 0, 12, null, function (err, n, buf) { /* Stop recursion if end of file has been reached. */ if (err || n < 12) { ss.reading = false; return; } var datalen = buf.readUInt32LE(8); //tslog("Allocating %d bytes", datalen); var databuf = new Buffer(datalen); fs.read(ss.fd, databuf, 0, datalen, null, function (err, n, buf) { ss.reading = false; if (err || n < datalen) { return; } /* Process the data */ ss.framepush(buf); ss.emit("data", buf); tslog("DGL %s: %d bytes", ss.tag(), buf.length); /* Recurse. */ ss.readchunk(); }); }); }; fs.readFile(filename, {encoding: "utf8"}, function (err, data) { if (err) { ss.emit('open', false); return; } var lines = data.split('\n'); ss.h = Number(lines[1]); ss.w = Number(lines[2]); fs.open(ss.ttyrec, "r", function(err, fd) { if (err) { ss.emit('open', false); } else { ss.fd = fd; ss.emit('open', true); tslog("DGL %s: open", ss.tag()); ss.readchunk(); ss.watcher = fs.watch(ss.ttyrec, function (ev, finame) { if (ev == "change") ss.readchunk(); }); } }); }); this.tag = function () { return this.gname + "/" + this.pname; }; this.close = function () { this.watcher.close() /* Ensure all data is handled before quitting. */ this.readchunk(); fs.close(this.fd); this.emit("close"); tslog("DGL %s: closed", ss.tag()); }; } DglSession.prototype = new events.EventEmitter(); // 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 %s", 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) { if (!(dd instanceof Date)) { 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); } 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()}; 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; } /* 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()}; 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; } /* Stops a running game if the request has the proper key. */ 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, []); } 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] = new DglSession(fname); } else if (chunk[0] == "C") { tslog("DGL: %s started playing %s: %s", pname, gname, fname) dglgames[tag] = new DglSession(fname); } else if (chunk[0] == "D") { tslog("DGL: %s finished playing %s: %s", pname, gname, fname) dglgames[tag].close(); 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; } /* Currently, this doesn't do anything blocking, but keep the callback */