diff options
author | Mateusz Gapski <mateuszx.gapski@intel.com> | 2020-07-30 12:01:29 +0300 |
---|---|---|
committer | Derick Montague <derick.montague@ibm.com> | 2020-08-28 22:59:16 +0300 |
commit | 75100469ab66851d84082b9651469901136f3d7e (patch) | |
tree | 68b0ad9e137b59c49eead11725dab773b0c4e80b /src | |
parent | 21d6de005f8e6cbf3a9dcbf02b604b6359e2ac00 (diff) | |
download | webui-vue-75100469ab66851d84082b9651469901136f3d7e.tar.xz |
Add VirtualMedia page
More info: https://github.com/openbmc/webui-vue/issues/7
Signed-off-by: Mateusz Gapski <mateuszx.gapski@intel.com>
Change-Id: I68f2074e77301c68c425f1e661988c751224b713
Diffstat (limited to 'src')
-rw-r--r-- | src/components/AppNavigation/AppNavigation.vue | 3 | ||||
-rw-r--r-- | src/locales/en-US.json | 18 | ||||
-rw-r--r-- | src/router/index.js | 8 | ||||
-rw-r--r-- | src/store/index.js | 4 | ||||
-rw-r--r-- | src/store/modules/Control/ControlStore.js | 2 | ||||
-rw-r--r-- | src/store/modules/Control/VirtualMediaStore.js | 80 | ||||
-rw-r--r-- | src/utilities/NBDServer.js | 290 | ||||
-rw-r--r-- | src/views/Control/VirtualMedia/VirtualMedia.vue | 153 | ||||
-rw-r--r-- | src/views/Control/VirtualMedia/index.js | 2 |
9 files changed, 556 insertions, 4 deletions
diff --git a/src/components/AppNavigation/AppNavigation.vue b/src/components/AppNavigation/AppNavigation.vue index 5101d82a..ef689d5f 100644 --- a/src/components/AppNavigation/AppNavigation.vue +++ b/src/components/AppNavigation/AppNavigation.vue @@ -84,6 +84,9 @@ > {{ $t('appNavigation.serverPowerOperations') }} </b-nav-item> + <b-nav-item to="/control/virtual-media"> + {{ $t('appNavigation.virtualMedia') }} + </b-nav-item> </b-collapse> </li> diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 97f4578c..01c38797 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -97,7 +97,8 @@ "serverLed": "@:appPageTitle.serverLed", "serverPowerOperations": "@:appPageTitle.serverPowerOperations", "snmpSettings": "@:appPageTitle.snmpSettings", - "sslCertificates": "@:appPageTitle.sslCertificates" + "sslCertificates": "@:appPageTitle.sslCertificates", + "virtualMedia": "@:appPageTitle.virtualMedia" }, "appPageTitle": { "changePassword": "Change password", @@ -120,7 +121,8 @@ "serverPowerOperations": "Server power operations", "snmpSettings": "SNMP settings", "sslCertificates": "SSL certificates", - "unauthorized": "Unauthorized" + "unauthorized": "Unauthorized", + "virtualMedia": "Virtual Media" }, "pageChangePassword": { "changePassword": "Change password", @@ -625,6 +627,18 @@ "pageUnauthorized": { "description": "The attempted action is not accessible from the logged in account. Contact your system administrator to check your privilege role." }, + "pageVirtualMedia": { + "configureConnection": "Configure Connection", + "virtualMediaSubTitleFirst": "Save image in a web browser", + "virtualMediaSubTitleSecond": "Load image from external server", + "defaultDeviceName": "Virtual media device", + "toast": { + "errorReadingFile": "Error reading file. Closing server.", + "serverRunning": "Server running", + "serverClosedSuccessfully": "Server closed successfully", + "serverClosedWithErrors": "Server closed with errors" + } + }, "countries": { "AF": "Afghanistan", "AL": "Albania", diff --git a/src/router/index.js b/src/router/index.js index d3bf742d..5db985cd 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -176,6 +176,14 @@ const routes = [ } }, { + path: '/control/virtual-media', + name: 'virtual-media', + component: () => import('@/views/Control/VirtualMedia'), + meta: { + title: 'appPageTitle.virtualMedia' + } + }, + { path: '/unauthorized', name: 'unauthorized', component: Unauthorized, diff --git a/src/store/index.js b/src/store/index.js index 2e7c97aa..3844511c 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -24,6 +24,7 @@ import ProcessorStore from './modules/Health/ProcessorStore'; import WebSocketPlugin from './plugins/WebSocketPlugin'; import DateTimeStore from './modules/Configuration/DateTimeSettingsStore'; +import VirtualMediaStore from './modules/Control/VirtualMediaStore'; Vue.use(Vuex); @@ -52,7 +53,8 @@ export default new Vuex.Store({ fan: FanStore, chassis: ChassisStore, bmc: BmcStore, - processors: ProcessorStore + processors: ProcessorStore, + virtualMedia: VirtualMediaStore }, plugins: [WebSocketPlugin] }); diff --git a/src/store/modules/Control/ControlStore.js b/src/store/modules/Control/ControlStore.js index 82dbdcc1..c06ff4fc 100644 --- a/src/store/modules/Control/ControlStore.js +++ b/src/store/modules/Control/ControlStore.js @@ -1,4 +1,4 @@ -import api from '../../api'; +import api from '@/store/api'; import i18n from '../../../i18n'; /** diff --git a/src/store/modules/Control/VirtualMediaStore.js b/src/store/modules/Control/VirtualMediaStore.js new file mode 100644 index 00000000..e01cfce1 --- /dev/null +++ b/src/store/modules/Control/VirtualMediaStore.js @@ -0,0 +1,80 @@ +import api from '../../api'; +import i18n from '@/i18n'; + +const VirtualMediaStore = { + namespaced: true, + state: { + proxyDevices: [], + legacyDevices: [], + connections: [] + }, + getters: { + proxyDevices: state => state.proxyDevices, + legacyDevices: state => state.legacyDevices + }, + mutations: { + setProxyDevicesData: (state, deviceData) => + (state.proxyDevices = deviceData), + setLegacyDevicesData: (state, deviceData) => + (state.legacyDevices = deviceData) + }, + actions: { + async getData({ commit }) { + const virtualMediaListEnabled = + process.env.VUE_APP_VIRTUAL_MEDIA_LIST_ENABLED === 'true' + ? true + : false; + if (!virtualMediaListEnabled) { + const device = { + id: i18n.t('pageVirtualMedia.defaultDeviceName'), + websocket: '/vm/0/0', + file: null, + transferProtocolType: 'OEM', + isActive: false + }; + commit('setProxyDevicesData', [device]); + return; + } + + return await api + .get('/redfish/v1/Managers/bmc/VirtualMedia') + .then(response => + response.data.Members.map(virtualMedia => virtualMedia['@odata.id']) + ) + .then(devices => api.all(devices.map(device => api.get(device)))) + .then(devices => { + const deviceData = devices.map(device => { + return { + id: device.data?.Id, + transferProtocolType: device.data?.TransferProtocolType, + websocket: device.data?.Oem?.OpenBMC?.WebSocketEndpoint + }; + }); + const proxyDevices = deviceData + .filter(d => d.transferProtocolType === 'OEM') + .map(device => { + return { + ...device, + file: null, + isActive: false + }; + }); + const legacyDevices = deviceData + .filter(d => !d.transferProtocolType) + .map(device => { + return { + ...device, + address: '' + }; + }); + commit('setProxyDevicesData', proxyDevices); + commit('setLegacyDevicesData', legacyDevices); + }) + .catch(error => { + console.log('Virtual Media:', error); + }); + } + } +}; + +export default VirtualMediaStore; diff --git a/src/utilities/NBDServer.js b/src/utilities/NBDServer.js new file mode 100644 index 00000000..7c0419ae --- /dev/null +++ b/src/utilities/NBDServer.js @@ -0,0 +1,290 @@ +/* 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; + +export default class NBDServer { + constructor(endpoint, file, id, token) { + this.socketStarted = () => {}; + this.socketClosed = () => {}; + this.errorReadingFile = () => {}; + 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, [token]); + 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.socketStarted(); + }; + this.stop = function() { + if (this.ws.readyState == 1) { + this.ws.close(); + this.state = NBD_STATE_UNKNOWN; + } + }; + this._on_ws_error = function(ev) { + console.log(`${endpoint} error: ${ev.error}`); + console.log(JSON.stringify(ev)); + }; + this._on_ws_close = function(ev) { + console.log( + `${endpoint} closed with code: ${ev.code} + reason: ${ev.reason}` + ); + console.log(JSON.stringify(ev)); + this.socketClosed(ev.code); + }; + /* websocket event handlers */ + this._on_ws_open = function() { + console.log(endpoint + ' 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 { + const 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; + // eslint-disable-next-line prettier/prettier + 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 resp1 = new ArrayBuffer(20); + var view1 = new DataView(resp1, 0, 20); + view1.setUint32(0, 0x0003e889); + view1.setUint32(4, 0x045565a9); + view1.setUint32(8, opt); + view1.setUint32(12, NBD_REP_ERR_UNSUP); + view1.setUint32(16, 0); + this.ws.send(resp1); + } + 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); + if (err == ENOSPC) { + this.errorReadingFile(); + this.stop(); + } + } + return consumed; + }; + this._handle_cmd_read = function(req) { + var offset; + // eslint-disable-next-line prettier/prettier + 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() { + 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) + }); + } +} diff --git a/src/views/Control/VirtualMedia/VirtualMedia.vue b/src/views/Control/VirtualMedia/VirtualMedia.vue new file mode 100644 index 00000000..a9a575d5 --- /dev/null +++ b/src/views/Control/VirtualMedia/VirtualMedia.vue @@ -0,0 +1,153 @@ +<template> + <b-container fluid="xl"> + <page-title /> + <b-row class="mb-4"> + <b-col md="12"> + <page-section + :section-title="$t('pageVirtualMedia.virtualMediaSubTitleFirst')" + > + <b-row> + <b-col v-for="(dev, $index) in proxyDevices" :key="$index" md="6"> + <b-form-group + :label="dev.id" + :label-for="dev.id" + label-class="bold" + > + <b-form-file + v-show="!dev.isActive" + :id="dev.id" + v-model="dev.file" + /> + <p v-if="dev.isActive">{{ dev.file.name }}</p> + </b-form-group> + <b-button + v-if="!dev.isActive" + variant="primary" + :disabled="!dev.file" + @click="startVM(dev)" + > + {{ 'Start' }} + </b-button> + <b-button + v-if="dev.isActive" + variant="primary" + :disabled="!dev.file" + @click="stopVM(dev)" + > + {{ 'Stop' }} + </b-button> + </b-col> + </b-row> + </page-section> + </b-col> + </b-row> + <b-row v-if="loadImageFromExternalServer" class="mb-4"> + <b-col md="12"> + <page-section + :section-title="$t('pageVirtualMedia.virtualMediaSubTitleSecond')" + > + <b-row> + <b-col + v-for="(device, $index) in legacyDevices" + :key="$index" + md="6" + > + <b-form-group + :label="device.id" + :label-for="device.id" + label-class="bold" + > + <b-button variant="primary" @click="configureConnection()"> + {{ $t('pageVirtualMedia.configureConnection') }} + </b-button> + + <b-button + variant="primary" + class="float-right" + :disabled="!device.address" + @click="startLegacy(device)" + > + {{ 'Start' }} + </b-button> + </b-form-group> + </b-col> + </b-row> + </page-section> + </b-col> + </b-row> + </b-container> +</template> + +<script> +import PageTitle from '@/components/Global/PageTitle'; +import PageSection from '@/components/Global/PageSection'; +import BVToastMixin from '@/components/Mixins/BVToastMixin'; +import LoadingBarMixin from '@/components/Mixins/LoadingBarMixin'; +import NbdServer from '@/utilities/NBDServer'; + +export default { + name: 'VirtualMedia', + components: { PageTitle, PageSection }, + mixins: [BVToastMixin, LoadingBarMixin], + data() { + return { + loadImageFromExternalServer: + process.env.VUE_APP_VIRTUAL_MEDIA_LIST_ENABLED === 'true' ? true : false + }; + }, + computed: { + proxyDevices() { + return this.$store.getters['virtualMedia/proxyDevices']; + }, + legacyDevices() { + return this.$store.getters['virtualMedia/legacyDevices']; + } + }, + created() { + if (this.proxyDevices.length > 0 || this.legacyDevices.length > 0) return; + this.startLoader(); + this.$store + .dispatch('virtualMedia/getData') + .finally(() => this.endLoader()); + }, + methods: { + startVM(device) { + const token = this.$store.getters['authentication/token']; + device.nbd = new NbdServer( + `wss://${window.location.host}${device.websocket}`, + device.file, + device.id, + token + ); + device.nbd.socketStarted = () => + this.successToast(this.$t('pageVirtualMedia.toast.serverRunning')); + device.nbd.errorReadingFile = () => + this.errorToast(this.$t('pageVirtualMedia.toast.errorReadingFile')); + device.nbd.socketClosed = code => { + if (code === 1000) + this.successToast( + this.$t('pageVirtualMedia.toast.serverClosedSuccessfully') + ); + else + this.errorToast( + this.$t('pageVirtualMedia.toast.serverClosedWithErrors') + ); + device.file = null; + device.isActive = false; + }; + + device.nbd.start(); + device.isActive = true; + }, + stopVM(device) { + device.nbd.stop(); + }, + startLegacy() { + console.log('starting legacy...'); + }, + configureConnection() { + this.warningToast('This option is unavialable. We are working on it.'); + } + } +}; +</script> diff --git a/src/views/Control/VirtualMedia/index.js b/src/views/Control/VirtualMedia/index.js new file mode 100644 index 00000000..4573e865 --- /dev/null +++ b/src/views/Control/VirtualMedia/index.js @@ -0,0 +1,2 @@ +import VirtualMedia from './VirtualMedia.vue'; +export default VirtualMedia; |