/** * Controller for virtual-media * * @module app/serverControl * @exports virtualMediaController * @name virtualMediaController */ window.angular && (function(angular) { 'use strict'; angular.module('app.serverControl').controller('virtualMediaController', [ '$scope', 'APIUtils', 'toastService', 'dataService', 'nbdServerService', function($scope, APIUtils, toastService, dataService, nbdServerService) { $scope.devices = []; // Only one Virtual Media WebSocket device is currently available. // Path is /vm/0/0. // TODO: Support more than 1 VM device, when backend support is added. var vmDevice = {}; // Hardcode to 0 since /vm/0/0. Last 0 is the device ID. // To support more than 1 device ID, replace with a call to get the // device IDs and names. vmDevice.id = 0; vmDevice.deviceName = 'Virtual media device'; findExistingConnection(vmDevice); $scope.devices.push(vmDevice); $scope.startVM = function(index) { $scope.devices[index].isActive = true; var file = $scope.devices[index].file; var id = $scope.devices[index].id; var host = dataService.getHost().replace('https://', ''); var server = new NBDServer('wss://' + host + '/vm/0/' + id, file, id); $scope.devices[index].nbdServer = server; nbdServerService.addConnection(id, server, file); server.start(); }; $scope.stopVM = function(index) { $scope.devices[index].isActive = false; var server = $scope.devices[index].nbdServer; server.stop(); }; $scope.resetFile = function(index) { document.getElementById('file-upload').value = ''; $scope.devices[index].file = ''; }; function findExistingConnection(vmDevice) { // Checks with existing connections kept in nbdServerService for an open // Websocket connection. var existingConnectionsMap = nbdServerService.getExistingConnections(); if (existingConnectionsMap.hasOwnProperty(vmDevice.id)) { // Open ws will have a ready state of 1 if (existingConnectionsMap[vmDevice.id].server.ws.readyState === 1) { vmDevice.isActive = true; vmDevice.file = existingConnectionsMap[vmDevice.id].file; vmDevice.nbdServer = existingConnectionsMap[vmDevice.id].server; } } return vmDevice; } } ]); })(angular); /* handshake flags */ const NBD_FLAG_FIXED_NEWSTYLE = 0x1; const NBD_FLAG_NO_ZEROES = 0x2; /* transmission flags */ const NBD_FLAG_HAS_FLAGS = 0x1; const NBD_FLAG_READ_ONLY = 0x2; /* option negotiation */ const NBD_OPT_EXPORT_NAME = 0x1; const NBD_REP_FLAG_ERROR = 0x1 << 31; const NBD_REP_ERR_UNSUP = NBD_REP_FLAG_ERROR | 1; /* command definitions */ const NBD_CMD_READ = 0; const NBD_CMD_WRITE = 1; const NBD_CMD_DISC = 2; const NBD_CMD_TRIM = 4; /* errno */ const EPERM = 1; const EIO = 5; const EINVAL = 22; const ENOSPC = 28; /* internal object state */ const NBD_STATE_UNKNOWN = 1; const NBD_STATE_OPEN = 2; const NBD_STATE_WAIT_CFLAGS = 3; const NBD_STATE_WAIT_OPTION = 4; const NBD_STATE_TRANSMISSION = 5; function NBDServer(endpoint, file, id) { this.file = file; this.id = id; this.endpoint = endpoint; this.ws = null; this.state = NBD_STATE_UNKNOWN; this.msgbuf = null; this.start = function() { this.ws = new WebSocket(this.endpoint); this.state = NBD_STATE_OPEN; this.ws.binaryType = 'arraybuffer'; this.ws.onmessage = this._on_ws_message.bind(this); this.ws.onopen = this._on_ws_open.bind(this); this.ws.onclose = this._on_ws_close.bind(this); this.ws.onerror = this._on_ws_error.bind(this); }; this.stop = function() { this.ws.close(); this.state = NBD_STATE_UNKNOWN; }; this._on_ws_error = function(ev) { console.log('vm/0/' + id + 'error: ' + ev); }; this._on_ws_close = function(ev) { console.log( 'vm/0/' + id + ' closed with code: ' + ev.code + ' reason: ' + ev.reason); }; /* websocket event handlers */ this._on_ws_open = function(ev) { console.log('vm/0/' + id + ' opened'); this.client = { flags: 0, }; this._negotiate(); }; this._on_ws_message = function(ev) { var data = ev.data; if (this.msgbuf == null) { this.msgbuf = data; } else { var tmp = new Uint8Array(this.msgbuf.byteLength + data.byteLength); tmp.set(new Uint8Array(this.msgbuf), 0); tmp.set(new Uint8Array(data), this.msgbuf.byteLength); this.msgbuf = tmp.buffer; } for (;;) { var handler = this.recv_handlers[this.state]; if (!handler) { console.log('no handler for state ' + this.state); this.stop(); break; } var consumed = handler(this.msgbuf); if (consumed < 0) { console.log( 'handler[state=' + this.state + '] returned error ' + consumed); this.stop(); break; } if (consumed == 0) { break; } if (consumed > 0) { if (consumed == this.msgbuf.byteLength) { this.msgbuf = null; break; } this.msgbuf = this.msgbuf.slice(consumed); } } }; this._negotiate = function() { var buf = new ArrayBuffer(18); var data = new DataView(buf, 0, 18); /* NBD magic: NBDMAGIC */ data.setUint32(0, 0x4e42444d); data.setUint32(4, 0x41474943); /* newstyle negotiation: IHAVEOPT */ data.setUint32(8, 0x49484156); data.setUint32(12, 0x454F5054); /* flags: fixed newstyle negotiation, no padding */ data.setUint16(16, NBD_FLAG_FIXED_NEWSTYLE | NBD_FLAG_NO_ZEROES); this.state = NBD_STATE_WAIT_CFLAGS; this.ws.send(buf); }; /* handlers */ this._handle_cflags = function(buf) { if (buf.byteLength < 4) { return 0; } var data = new DataView(buf, 0, 4); this.client.flags = data.getUint32(0); this.state = NBD_STATE_WAIT_OPTION; return 4; }; this._handle_option = function(buf) { if (buf.byteLength < 16) return 0; var data = new DataView(buf, 0, 16); if (data.getUint32(0) != 0x49484156 || data.getUint32(4) != 0x454F5054) { console.log('invalid option magic'); return -1; } var opt = data.getUint32(8); var len = data.getUint32(12); if (buf.byteLength < 16 + len) { return 0; } switch (opt) { case NBD_OPT_EXPORT_NAME: var n = 10; if (!(this.client.flags & NBD_FLAG_NO_ZEROES)) n += 124; var resp = new ArrayBuffer(n); var view = new DataView(resp, 0, 10); /* export size. */ var size = this.file.size; view.setUint32(0, Math.floor(size / (2 ** 32))); view.setUint32(4, size & 0xffffffff); /* transmission flags: read-only */ view.setUint16(8, NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY); this.ws.send(resp); this.state = NBD_STATE_TRANSMISSION; break; default: console.log('handle_option: Unsupported option: ' + opt); /* reject other options */ var resp = new ArrayBuffer(20); var view = new DataView(resp, 0, 20); view.setUint32(0, 0x0003e889); view.setUint32(4, 0x045565a9); view.setUint32(8, opt); view.setUint32(12, NBD_REP_ERR_UNSUP); view.setUint32(16, 0); this.ws.send(resp); } return 16 + len; }; this._create_cmd_response = function(req, rc, data = null) { var len = 16; if (data) len += data.byteLength; var resp = new ArrayBuffer(len); var view = new DataView(resp, 0, 16); view.setUint32(0, 0x67446698); view.setUint32(4, rc); view.setUint32(8, req.handle_msB); view.setUint32(12, req.handle_lsB); if (data) new Uint8Array(resp, 16).set(new Uint8Array(data)); return resp; }; this._handle_cmd = function(buf) { if (buf.byteLength < 28) { return 0; } var view = new DataView(buf, 0, 28); if (view.getUint32(0) != 0x25609513) { console.log('invalid request magic'); return -1; } var req = { flags: view.getUint16(4), type: view.getUint16(6), handle_msB: view.getUint32(8), handle_lsB: view.getUint32(12), offset_msB: view.getUint32(16), offset_lsB: view.getUint32(20), length: view.getUint32(24), }; /* we don't support writes, so nothing needs the data at present */ /* req.data = buf.slice(28); */ var err = 0; var consumed = 28; /* the command handlers return 0 on success, and send their * own response. Otherwise, a non-zero error code will be * used as a simple error response */ switch (req.type) { case NBD_CMD_READ: err = this._handle_cmd_read(req); break; case NBD_CMD_DISC: err = this._handle_cmd_disconnect(req); break; case NBD_CMD_WRITE: /* we also need length bytes of data to consume a write * request */ if (buf.byteLength < 28 + req.length) { return 0; } consumed += req.length; err = EPERM; break; case NBD_CMD_TRIM: err = EPERM; break; default: console.log('invalid command 0x' + req.type.toString(16)); err = EINVAL; } if (err) { console.log('error handle_cmd: ' + err); var resp = this._create_cmd_response(req, err); this.ws.send(resp); } return consumed; }; this._handle_cmd_read = function(req) { var offset; offset = (req.offset_msB * 2 ** 32) + req.offset_lsB; if (offset > Number.MAX_SAFE_INTEGER) return ENOSPC; if (offset + req.length > Number.MAX_SAFE_INTEGER) return ENOSPC; if (offset + req.length > file.size) return ENOSPC; var blob = this.file.slice(offset, offset + req.length); var reader = new FileReader(); reader.onload = (function(ev) { var reader = ev.target; if (reader.readyState != FileReader.DONE) return; var resp = this._create_cmd_response(req, 0, reader.result); this.ws.send(resp); }).bind(this); reader.onerror = (function(ev) { var reader = ev.target; console.log('error reading file: ' + reader.error); var resp = this._create_cmd_response(req, EIO); this.ws.send(resp); }).bind(this); reader.readAsArrayBuffer(blob); return 0; }; this._handle_cmd_disconnect = function(req) { this.stop(); return 0; }; this.recv_handlers = Object.freeze({ [NBD_STATE_WAIT_CFLAGS]: this._handle_cflags.bind(this), [NBD_STATE_WAIT_OPTION]: this._handle_option.bind(this), [NBD_STATE_TRANSMISSION]: this._handle_cmd.bind(this), }); }