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