summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.env.intel3
-rw-r--r--src/components/AppNavigation/AppNavigation.vue3
-rw-r--r--src/locales/en-US.json18
-rw-r--r--src/router/index.js8
-rw-r--r--src/store/index.js4
-rw-r--r--src/store/modules/Control/ControlStore.js2
-rw-r--r--src/store/modules/Control/VirtualMediaStore.js80
-rw-r--r--src/utilities/NBDServer.js290
-rw-r--r--src/views/Control/VirtualMedia/VirtualMedia.vue153
-rw-r--r--src/views/Control/VirtualMedia/index.js2
10 files changed, 558 insertions, 5 deletions
diff --git a/.env.intel b/.env.intel
index 731a1253..74c880e2 100644
--- a/.env.intel
+++ b/.env.intel
@@ -1,4 +1,5 @@
NODE_ENV=production
VUE_APP_ENV_NAME="intel"
VUE_APP_COMPANY_NAME="intel"
-VUE_APP_SUBSCRIBE_SOCKET_DISABLED="true" \ No newline at end of file
+VUE_APP_SUBSCRIBE_SOCKET_DISABLED="true"
+VUE_APP_VIRTUAL_MEDIA_LIST_ENABLED="true" \ No newline at end of file
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;