1/**
2 * Controller for virtual-media
3 *
4 * @module app/serverControl
5 * @exports virtualMediaController
6 * @name virtualMediaController
7 */
8
9window.angular && (function(angular) {
10  'use strict';
11
12  angular.module('app.serverControl').controller('virtualMediaController', [
13    '$scope', 'APIUtils', 'toastService', 'dataService', 'nbdServerService',
14    function($scope, APIUtils, toastService, dataService, nbdServerService) {
15      $scope.devices = [];
16
17      // Only one Virtual Media WebSocket device is currently available.
18      // Path is /vm/0/0.
19      // TODO: Support more than 1 VM device, when backend support is added.
20      var vmDevice = {};
21      // Hardcode to 0 since /vm/0/0. Last 0 is the device ID.
22      // To support more than 1 device ID, replace with a call to get the
23      // device IDs and names.
24      vmDevice.id = 0;
25      vmDevice.deviceName = 'Virtual media device';
26      findExistingConnection(vmDevice);
27      $scope.devices.push(vmDevice);
28
29      $scope.startVM = function(index) {
30        $scope.devices[index].isActive = true;
31        var file = $scope.devices[index].file;
32        var id = $scope.devices[index].id;
33        var host = dataService.getHost().replace('https://', '');
34        var server = new NBDServer('wss://' + host + '/vm/0/' + id, file, id);
35        $scope.devices[index].nbdServer = server;
36        nbdServerService.addConnection(id, server, file);
37        server.start();
38      };
39      $scope.stopVM = function(index) {
40        $scope.devices[index].isActive = false;
41        var server = $scope.devices[index].nbdServer;
42        server.stop();
43      };
44
45      $scope.resetFile = function(index) {
46        document.getElementById('file-upload').value = '';
47        $scope.devices[index].file = '';
48      };
49
50      function findExistingConnection(vmDevice) {
51        // Checks with existing connections kept in nbdServerService for an open
52        // Websocket connection.
53        var existingConnectionsMap = nbdServerService.getExistingConnections();
54        if (existingConnectionsMap.hasOwnProperty(vmDevice.id)) {
55          // Open ws will have a ready state of 1
56          if (existingConnectionsMap[vmDevice.id].server.ws.readyState === 1) {
57            vmDevice.isActive = true;
58            vmDevice.file = existingConnectionsMap[vmDevice.id].file;
59            vmDevice.nbdServer = existingConnectionsMap[vmDevice.id].server;
60          }
61        }
62        return vmDevice;
63      }
64    }
65  ]);
66})(angular);
67
68/* handshake flags */
69const NBD_FLAG_FIXED_NEWSTYLE = 0x1;
70const NBD_FLAG_NO_ZEROES = 0x2;
71
72/* transmission flags */
73const NBD_FLAG_HAS_FLAGS = 0x1;
74const NBD_FLAG_READ_ONLY = 0x2;
75
76/* option negotiation */
77const NBD_OPT_EXPORT_NAME = 0x1;
78const NBD_REP_FLAG_ERROR = 0x1 << 31;
79const NBD_REP_ERR_UNSUP = NBD_REP_FLAG_ERROR | 1;
80
81/* command definitions */
82const NBD_CMD_READ = 0;
83const NBD_CMD_WRITE = 1;
84const NBD_CMD_DISC = 2;
85const NBD_CMD_TRIM = 4;
86
87/* errno */
88const EPERM = 1;
89const EIO = 5;
90const EINVAL = 22;
91const ENOSPC = 28;
92
93/* internal object state */
94const NBD_STATE_UNKNOWN = 1;
95const NBD_STATE_OPEN = 2;
96const NBD_STATE_WAIT_CFLAGS = 3;
97const NBD_STATE_WAIT_OPTION = 4;
98const NBD_STATE_TRANSMISSION = 5;
99
100function NBDServer(endpoint, file, id) {
101  this.file = file;
102  this.id = id;
103  this.endpoint = endpoint;
104  this.ws = null;
105  this.state = NBD_STATE_UNKNOWN;
106  this.msgbuf = null;
107
108  this.start = function() {
109    this.ws = new WebSocket(this.endpoint);
110    this.state = NBD_STATE_OPEN;
111    this.ws.binaryType = 'arraybuffer';
112    this.ws.onmessage = this._on_ws_message.bind(this);
113    this.ws.onopen = this._on_ws_open.bind(this);
114    this.ws.onclose = this._on_ws_close.bind(this);
115    this.ws.onerror = this._on_ws_error.bind(this);
116  };
117
118  this.stop = function() {
119    this.ws.close();
120    this.state = NBD_STATE_UNKNOWN;
121  };
122
123  this._on_ws_error = function(ev) {
124    console.log('vm/0/' + id + 'error: ' + ev);
125  };
126
127  this._on_ws_close = function(ev) {
128    console.log(
129        'vm/0/' + id + ' closed with code: ' + ev.code +
130        ' reason: ' + ev.reason);
131  };
132
133  /* websocket event handlers */
134  this._on_ws_open = function(ev) {
135    console.log('vm/0/' + id + ' opened');
136    this.client = {
137      flags: 0,
138    };
139    this._negotiate();
140  };
141
142  this._on_ws_message = function(ev) {
143    var data = ev.data;
144
145    if (this.msgbuf == null) {
146      this.msgbuf = data;
147    } else {
148      var tmp = new Uint8Array(this.msgbuf.byteLength + data.byteLength);
149      tmp.set(new Uint8Array(this.msgbuf), 0);
150      tmp.set(new Uint8Array(data), this.msgbuf.byteLength);
151      this.msgbuf = tmp.buffer;
152    }
153
154    for (;;) {
155      var handler = this.recv_handlers[this.state];
156      if (!handler) {
157        console.log('no handler for state ' + this.state);
158        this.stop();
159        break;
160      }
161
162      var consumed = handler(this.msgbuf);
163      if (consumed < 0) {
164        console.log(
165            'handler[state=' + this.state + '] returned error ' + consumed);
166        this.stop();
167        break;
168      }
169
170      if (consumed == 0) {
171        break;
172      }
173
174      if (consumed > 0) {
175        if (consumed == this.msgbuf.byteLength) {
176          this.msgbuf = null;
177          break;
178        }
179        this.msgbuf = this.msgbuf.slice(consumed);
180      }
181    }
182  };
183
184  this._negotiate = function() {
185    var buf = new ArrayBuffer(18);
186    var data = new DataView(buf, 0, 18);
187
188    /* NBD magic: NBDMAGIC */
189    data.setUint32(0, 0x4e42444d);
190    data.setUint32(4, 0x41474943);
191
192    /* newstyle negotiation: IHAVEOPT */
193    data.setUint32(8, 0x49484156);
194    data.setUint32(12, 0x454F5054);
195
196    /* flags: fixed newstyle negotiation, no padding */
197    data.setUint16(16, NBD_FLAG_FIXED_NEWSTYLE | NBD_FLAG_NO_ZEROES);
198
199    this.state = NBD_STATE_WAIT_CFLAGS;
200    this.ws.send(buf);
201  };
202
203  /* handlers */
204  this._handle_cflags = function(buf) {
205    if (buf.byteLength < 4) {
206      return 0;
207    }
208
209    var data = new DataView(buf, 0, 4);
210    this.client.flags = data.getUint32(0);
211
212    this.state = NBD_STATE_WAIT_OPTION;
213    return 4;
214  };
215
216  this._handle_option = function(buf) {
217    if (buf.byteLength < 16) return 0;
218
219    var data = new DataView(buf, 0, 16);
220    if (data.getUint32(0) != 0x49484156 || data.getUint32(4) != 0x454F5054) {
221      console.log('invalid option magic');
222      return -1;
223    }
224
225    var opt = data.getUint32(8);
226    var len = data.getUint32(12);
227
228
229    if (buf.byteLength < 16 + len) {
230      return 0;
231    }
232
233    switch (opt) {
234      case NBD_OPT_EXPORT_NAME:
235        var n = 10;
236        if (!(this.client.flags & NBD_FLAG_NO_ZEROES)) n += 124;
237        var resp = new ArrayBuffer(n);
238        var view = new DataView(resp, 0, 10);
239        /* export size. */
240        var size = this.file.size;
241        view.setUint32(0, Math.floor(size / (2 ** 32)));
242        view.setUint32(4, size & 0xffffffff);
243        /* transmission flags: read-only */
244        view.setUint16(8, NBD_FLAG_HAS_FLAGS | NBD_FLAG_READ_ONLY);
245        this.ws.send(resp);
246
247        this.state = NBD_STATE_TRANSMISSION;
248        break;
249
250      default:
251        console.log('handle_option: Unsupported option: ' + opt);
252        /* reject other options */
253        var resp = new ArrayBuffer(20);
254        var view = new DataView(resp, 0, 20);
255        view.setUint32(0, 0x0003e889);
256        view.setUint32(4, 0x045565a9);
257        view.setUint32(8, opt);
258        view.setUint32(12, NBD_REP_ERR_UNSUP);
259        view.setUint32(16, 0);
260        this.ws.send(resp);
261    }
262
263    return 16 + len;
264  };
265
266  this._create_cmd_response = function(req, rc, data = null) {
267    var len = 16;
268    if (data) len += data.byteLength;
269    var resp = new ArrayBuffer(len);
270    var view = new DataView(resp, 0, 16);
271    view.setUint32(0, 0x67446698);
272    view.setUint32(4, rc);
273    view.setUint32(8, req.handle_msB);
274    view.setUint32(12, req.handle_lsB);
275    if (data) new Uint8Array(resp, 16).set(new Uint8Array(data));
276    return resp;
277  };
278
279  this._handle_cmd = function(buf) {
280    if (buf.byteLength < 28) {
281      return 0;
282    }
283
284    var view = new DataView(buf, 0, 28);
285
286    if (view.getUint32(0) != 0x25609513) {
287      console.log('invalid request magic');
288      return -1;
289    }
290
291    var req = {
292      flags: view.getUint16(4),
293      type: view.getUint16(6),
294      handle_msB: view.getUint32(8),
295      handle_lsB: view.getUint32(12),
296      offset_msB: view.getUint32(16),
297      offset_lsB: view.getUint32(20),
298      length: view.getUint32(24),
299    };
300
301    /* we don't support writes, so nothing needs the data at present */
302    /* req.data = buf.slice(28); */
303
304    var err = 0;
305    var consumed = 28;
306
307    /* the command handlers return 0 on success, and send their
308     * own response. Otherwise, a non-zero error code will be
309     * used as a simple error response
310     */
311    switch (req.type) {
312      case NBD_CMD_READ:
313        err = this._handle_cmd_read(req);
314        break;
315
316      case NBD_CMD_DISC:
317        err = this._handle_cmd_disconnect(req);
318        break;
319
320      case NBD_CMD_WRITE:
321        /* we also need length bytes of data to consume a write
322         * request */
323        if (buf.byteLength < 28 + req.length) {
324          return 0;
325        }
326        consumed += req.length;
327        err = EPERM;
328        break;
329
330      case NBD_CMD_TRIM:
331        err = EPERM;
332        break;
333
334      default:
335        console.log('invalid command 0x' + req.type.toString(16));
336        err = EINVAL;
337    }
338
339    if (err) {
340      console.log('error handle_cmd: ' + err);
341      var resp = this._create_cmd_response(req, err);
342      this.ws.send(resp);
343    }
344
345    return consumed;
346  };
347
348  this._handle_cmd_read = function(req) {
349    var offset;
350
351    offset = (req.offset_msB * 2 ** 32) + req.offset_lsB;
352
353    if (offset > Number.MAX_SAFE_INTEGER) return ENOSPC;
354
355    if (offset + req.length > Number.MAX_SAFE_INTEGER) return ENOSPC;
356
357    if (offset + req.length > file.size) return ENOSPC;
358
359    var blob = this.file.slice(offset, offset + req.length);
360    var reader = new FileReader();
361
362    reader.onload = (function(ev) {
363                      var reader = ev.target;
364                      if (reader.readyState != FileReader.DONE) return;
365                      var resp =
366                          this._create_cmd_response(req, 0, reader.result);
367                      this.ws.send(resp);
368                    }).bind(this);
369
370    reader.onerror = (function(ev) {
371                       var reader = ev.target;
372                       console.log('error reading file: ' + reader.error);
373                       var resp = this._create_cmd_response(req, EIO);
374                       this.ws.send(resp);
375                     }).bind(this);
376    reader.readAsArrayBuffer(blob);
377
378    return 0;
379  };
380
381  this._handle_cmd_disconnect = function(req) {
382    this.stop();
383    return 0;
384  };
385
386  this.recv_handlers = Object.freeze({
387    [NBD_STATE_WAIT_CFLAGS]: this._handle_cflags.bind(this),
388    [NBD_STATE_WAIT_OPTION]: this._handle_option.bind(this),
389    [NBD_STATE_TRANSMISSION]: this._handle_cmd.bind(this),
390  });
391}
392