diff --git a/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/ipmi.json b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/ipmi.json new file mode 100644 index 0000000000..02b1bb7dd8 --- /dev/null +++ b/guacamole-ext/src/main/resources/org/apache/guacamole/protocols/ipmi.json @@ -0,0 +1,246 @@ +{ + "name" : "ipmi", + "connectionForms" : [ + + { + "name" : "network", + "fields" : [ + { + "name" : "hostname", + "type" : "TEXT" + }, + { + "name" : "port", + "type" : "NUMERIC" + }, + { + "name" : "timeout", + "type" : "NUMERIC" + } + ] + }, + + { + "name" : "authentication", + "fields" : [ + { + "name" : "username", + "type" : "USERNAME" + }, + { + "name" : "password", + "type" : "PASSWORD" + }, + { + "name" : "k-g", + "type" : "PASSWORD" + }, + { + "name" : "privilege-level", + "type" : "ENUM", + "options" : [ "", "user", "operator", "admin" ] + } + ] + }, + + { + "name" : "security", + "fields" : [ + { + "name" : "cipher-suite", + "type" : "ENUM", + "options" : [ "", "3", "17" ] + }, + { + "name" : "encryption-policy", + "type" : "ENUM", + "options" : [ "", "required", "preferred", "none" ] + }, + { + "name" : "workaround-flags", + "type" : "ENUM", + "options" : [ "", "supermicro", "intel", "sun", "dell", "hpe", "lenovo" ] + } + ] + }, + + { + "name" : "serial-over-lan", + "fields" : [ + { + "name" : "sol-payload-instance", + "type" : "NUMERIC" + }, + { + "name" : "keepalive-interval", + "type" : "NUMERIC" + } + ] + }, + + { + "name" : "power", + "fields" : [ + { + "name" : "power-on-connect", + "type" : "ENUM", + "options" : [ "", "none", "on", "reset", "cycle" ] + }, + { + "name" : "boot-device", + "type" : "ENUM", + "options" : [ "", "none", "pxe", "disk", "cdrom", "bios" ] + }, + { + "name" : "boot-device-persistent", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "display", + "fields" : [ + { + "name" : "color-scheme", + "type" : "TERMINAL_COLOR_SCHEME", + "options" : [ "", "black-white", "gray-black", "green-black", "white-black" ] + }, + { + "name" : "font-name", + "type" : "TEXT" + }, + { + "name" : "font-size", + "type" : "ENUM", + "options" : [ "", "8", "9", "10", "11", "12", "14", "18", "24", "30", "36", "48", "60", "72", "96" ] + }, + { + "name" : "scrollback", + "type" : "NUMERIC" + }, + { + "name" : "read-only", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "clipboard", + "fields" : [ + { + "name" : "disable-copy", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "disable-paste", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "behavior", + "fields" : [ + { + "name" : "backspace", + "type" : "ENUM", + "options" : [ "", "127", "8" ] + }, + { + "name" : "terminal-type", + "type" : "ENUM", + "options" : [ "", "xterm", "xterm-256color", "vt220", "vt100", "ansi", "linux" ] + } + ] + }, + + { + "name" : "typescript", + "fields" : [ + { + "name" : "typescript-path", + "type" : "TEXT" + }, + { + "name" : "typescript-name", + "type" : "TEXT" + }, + { + "name" : "create-typescript-path", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "typescript-write-existing", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + }, + + { + "name" : "recording", + "fields" : [ + { + "name" : "recording-path", + "type" : "TEXT" + }, + { + "name" : "recording-name", + "type" : "TEXT" + }, + { + "name" : "recording-exclude-output", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "recording-exclude-mouse", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "recording-include-keys", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "recording-include-clipboard", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "create-recording-path", + "type" : "BOOLEAN", + "options" : [ "true" ] + }, + { + "name" : "recording-write-existing", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + } + + ], + "sharingProfileForms" : [ + + { + "name" : "display", + "fields" : [ + { + "name" : "read-only", + "type" : "BOOLEAN", + "options" : [ "true" ] + } + ] + } + + ] +} diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacIpmiControl.js b/guacamole/src/main/frontend/src/app/client/directives/guacIpmiControl.js new file mode 100644 index 0000000000..45666deebb --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/directives/guacIpmiControl.js @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * A directive which provides out-of-band chassis management (power control, + * chassis identify, System Event Log, serial break) for connections using the + * IPMI protocol. Intended to live within the client menu. Console I/O flows + * over the normal terminal; all chassis state and commands are mediated by + * ipmiControlService over the dedicated "ipmi-control" pipe stream. + */ +angular.module('client').directive('guacIpmiControl', [function guacIpmiControl() { + + const directive = { + restrict: 'E', + replace: true, + templateUrl: 'app/client/templates/guacIpmiControl.html' + }; + + directive.scope = { + + /** + * The ManagedClient of the IPMI connection to control. + * + * @type ManagedClient + */ + client : '=' + + }; + + directive.controller = ['$scope', '$injector', + function guacIpmiControlController($scope, $injector) { + + // Required services + const ipmiControlService = $injector.get('ipmiControlService'); + + /** + * The set of power actions which are potentially disruptive and thus + * require explicit confirmation before being sent to the BMC. + * + * @type Object. + */ + const DESTRUCTIVE = { + 'power-off' : true, + 'power-cycle' : true, + 'hard-reset' : true, + 'diagnostic-interrupt' : true + }; + + /** + * The shared chassis state for the current client, or null if no + * client is available yet. + * + * @type Object + */ + $scope.state = null; + + /** + * The power action awaiting confirmation, or null if none is pending. + * + * @type String + */ + $scope.pending = null; + + /** + * The translation-key suffix (uppercase, underscore-separated) of the + * pending destructive action, or null if none is pending. + * + * @type String + */ + $scope.pendingKey = null; + + /** + * Requests a fresh, authoritative power/health state from the BMC. + */ + $scope.refresh = function refresh() { + ipmiControlService.send($scope.client, 'refresh-status'); + }; + + /** + * Requests the current System Event Log. + */ + $scope.readSel = function readSel() { + if ($scope.state) + $scope.state.sel = ''; + ipmiControlService.send($scope.client, 'read-sel'); + }; + + /** + * Activates the chassis identify LED. + */ + $scope.identify = function identify() { + ipmiControlService.send($scope.client, 'identify'); + }; + + /** + * Sends a serial break over the active SOL session. + */ + $scope.sendBreak = function sendBreak() { + ipmiControlService.send($scope.client, 'send-break'); + }; + + /** + * Invokes the given power action, first prompting for confirmation if + * the action is potentially disruptive. + * + * @param {String} action + * The power action to invoke (e.g. "power-on", "hard-reset"). + */ + $scope.power_action = function power_action(action) { + if (DESTRUCTIVE[action]) { + $scope.pending = action; + $scope.pendingKey = action.toUpperCase().replace(/-/g, '_'); + } + else + ipmiControlService.send($scope.client, action); + }; + + /** + * Confirms and sends the currently pending destructive action. + */ + $scope.confirm = function confirm() { + const action = $scope.pending; + $scope.pending = null; + $scope.pendingKey = null; + if (action) + ipmiControlService.send($scope.client, action); + }; + + /** + * Cancels the currently pending destructive action. + */ + $scope.cancel = function cancel() { + $scope.pending = null; + $scope.pendingKey = null; + }; + + // Bind to the shared chassis state as soon as a client is available + $scope.$watch('client', function clientChanged(client) { + if (client && client.client) + $scope.state = ipmiControlService.getState(client); + }); + + }]; + + return directive; + +}]); diff --git a/guacamole/src/main/frontend/src/app/client/directives/guacIpmiStatus.js b/guacamole/src/main/frontend/src/app/client/directives/guacIpmiStatus.js new file mode 100644 index 0000000000..c61a0b6069 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/directives/guacIpmiStatus.js @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * A lightweight, always-visible chassis power/health indicator for IPMI + * connections. It is a thin read-only view over ipmiControlService, allowing + * operators to see the host power state at a glance without opening the menu, + * where the full chassis controls live. + */ +angular.module('client').directive('guacIpmiStatus', [function guacIpmiStatus() { + + const directive = { + restrict: 'E', + replace: true, + templateUrl: 'app/client/templates/guacIpmiStatus.html' + }; + + directive.scope = { + + /** + * The ManagedClient of the IPMI connection whose state is shown. + * + * @type ManagedClient + */ + client : '=' + + }; + + directive.controller = ['$scope', '$injector', + function guacIpmiStatusController($scope, $injector) { + + // Required services + const ipmiControlService = $injector.get('ipmiControlService'); + + /** + * The shared chassis state for the current client, or null if no + * client is available yet. + * + * @type Object + */ + $scope.state = null; + + // Bind to the shared chassis state as soon as a client is available. + // Accessing the state also initializes the control channel if the menu + // panel has not already done so. + $scope.$watch('client', function clientChanged(client) { + if (client && client.client) + $scope.state = ipmiControlService.getState(client); + }); + + }]; + + return directive; + +}]); diff --git a/guacamole/src/main/frontend/src/app/client/services/ipmiControlService.js b/guacamole/src/main/frontend/src/app/client/services/ipmiControlService.js new file mode 100644 index 0000000000..44f756cac5 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/services/ipmiControlService.js @@ -0,0 +1,201 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * A service which maintains the out-of-band chassis state (power, SOL health, + * System Event Log, last command result) for IPMI connections, communicating + * with the guacd IPMI module over the dedicated "ipmi-control" pipe stream. + * + * State is held per ManagedClient and persists independently of any directive + * lifecycle, so both the always-visible status badge and the (menu-scoped, + * repeatedly destroyed) control panel can be thin views over a single source + * of truth. + */ +angular.module('client').factory('ipmiControlService', ['$rootScope', '$injector', + function ipmiControlService($rootScope, $injector) { + + // Required services + const ManagedClient = $injector.get('ManagedClient'); + + /** + * The name of the bidirectional control pipe stream, matching + * GUAC_IPMI_CONTROL_PIPE_NAME on the server. + * + * @constant + * @type String + */ + const PIPE_NAME = 'ipmi-control'; + + const service = {}; + + /** + * Per-ManagedClient chassis state, keyed by ManagedClient id. + * + * @type Object. + */ + const states = {}; + + /** + * The outbound control writer for each ManagedClient, keyed by id and + * created lazily on first command. + * + * @type Object. + */ + const writers = {}; + + /** + * A monotonically increasing counter used to generate unique command + * identifiers so results can be correlated with their requests. + * + * @type Number + */ + let nextId = 0; + + /** + * Applies the given inbound control message to the state of the given + * client, within an Angular digest. + * + * @param {Object} state + * The chassis state object to update. + * + * @param {Object} msg + * The parsed inbound JSON message. + */ + const handleMessage = function handleMessage(state, msg) { + $rootScope.$evalAsync(function applyMessage() { + + // Any recognized inbound message concludes the outstanding command + state.busy = false; + + switch (msg.type) { + + case 'state': + if (msg.power) { + state.power = msg.power; + // A power value means an authoritative status query + // completed; record when it was last checked. + state.lastUpdated = new Date(); + } + if (msg.health) state.health = msg.health; + break; + + case 'result': + state.message = msg.message; + state.messageError = (msg.ok === false); + break; + + case 'sel': + state.sel = msg.error ? ('[' + msg.error + ']') : (msg.text || ''); + break; + + } + }); + }; + + /** + * Returns the chassis state object for the given ManagedClient, creating + * and initializing it (registering the inbound pipe handler and requesting + * an authoritative status) on first access. + * + * @param {ManagedClient} client + * The IPMI ManagedClient whose chassis state is desired. + * + * @returns {Object} + * The chassis state object for the given client. + */ + service.getState = function getState(client) { + + const id = client.id; + + if (!states[id]) { + + const state = { + power : 'unknown', + health : 'sol-disconnected', + sel : null, + message : null, + messageError : false, + busy : false, + lastUpdated : null + }; + states[id] = state; + + // Register the inbound control handler and request an initial + // authoritative state as soon as the underlying client exists + if (client.client) { + ManagedClient.registerDeferredPipeHandler(client, PIPE_NAME, + function pipeHandler(stream) { + const reader = new Guacamole.StringReader(stream); + let received = ''; + reader.ontext = function ontext(text) { received += text; }; + reader.onend = function onend() { + try { handleMessage(state, JSON.parse(received)); } + catch (ignore) { /* ignore malformed messages */ } + }; + }); + service.send(client, 'refresh-status'); + } + + } + + return states[id]; + + }; + + /** + * Sends the given command to the given client over the control pipe, + * generating a unique correlation id and marking the client busy. + * + * @param {ManagedClient} client + * The IPMI ManagedClient to command. + * + * @param {String} command + * The command to send (e.g. "power-on", "identify", "read-sel"). + */ + service.send = function send(client, command) { + + const guacClient = client && client.client; + if (!guacClient) + return; + + const id = client.id; + + // Lazily open the outbound control pipe for this client + if (!writers[id]) { + const stream = guacClient.createPipeStream('application/json', PIPE_NAME); + writers[id] = new Guacamole.StringWriter(stream); + } + + writers[id].sendText(JSON.stringify({ + type : 'command', + command : command, + id : 'c' + (nextId++) + }) + '\n'); + + const state = states[id]; + if (state) { + state.busy = true; + state.message = null; + } + + }; + + return service; + +}]); diff --git a/guacamole/src/main/frontend/src/app/client/styles/ipmi-control.css b/guacamole/src/main/frontend/src/app/client/styles/ipmi-control.css new file mode 100644 index 0000000000..db46767257 --- /dev/null +++ b/guacamole/src/main/frontend/src/app/client/styles/ipmi-control.css @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.ipmi-control .ipmi-status { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5em; + margin-bottom: 0.5em; +} + +/* Always-visible chassis power indicator overlaid on the display */ +#ipmi-status-overlay { + position: absolute; + top: 0.5em; + right: 0.5em; + z-index: 10; + pointer-events: none; +} + +#ipmi-status-overlay .ipmi-status-indicator { + opacity: 0.85; + display: flex; + gap: 0.3em; + align-items: center; +} + +#ipmi-status-overlay .ipmi-power-badge, +#ipmi-status-overlay .ipmi-health-badge { + display: inline-block; + padding: 0.15em 0.6em; + border-radius: 0.75em; + font-size: 0.8em; + font-weight: bold; + text-transform: uppercase; + color: white; + background: #888; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + +#ipmi-status-overlay .ipmi-power-on { background: #2e7d32; } +#ipmi-status-overlay .ipmi-power-off { background: #b71c1c; } +#ipmi-status-overlay .ipmi-power-unknown { background: #888; } +#ipmi-status-overlay .ipmi-health-connected { background: #1565c0; } +#ipmi-status-overlay .ipmi-health-disconnected { background: #b71c1c; } + +.ipmi-control .ipmi-power-badge, +.ipmi-control .ipmi-health-badge { + display: inline-block; + padding: 0.15em 0.6em; + border-radius: 0.75em; + font-size: 0.85em; + font-weight: bold; + text-transform: uppercase; + color: white; + background: #888; +} + +.ipmi-control .ipmi-power-on { background: #2e7d32; } +.ipmi-control .ipmi-power-off { background: #b71c1c; } +.ipmi-control .ipmi-power-unknown { background: #888; } +.ipmi-control .ipmi-health-connected { background: #1565c0; } +.ipmi-control .ipmi-health-disconnected { background: #b71c1c; } + +.ipmi-control .ipmi-refresh, +.ipmi-control .ipmi-working { + margin-left: auto; +} + +/* In-progress affordance shown while a chassis command is outstanding */ +.ipmi-control .ipmi-working { + display: inline-flex; + align-items: center; + gap: 0.4em; + font-size: 0.85em; + color: #555; +} + +.ipmi-control .ipmi-spinner { + width: 0.9em; + height: 0.9em; + border: 2px solid rgba(0, 0, 0, 0.25); + border-top-color: #555; + border-radius: 50%; + animation: ipmi-spin 0.7s linear infinite; +} + +@keyframes ipmi-spin { + to { transform: rotate(360deg); } +} + +.ipmi-control .ipmi-last-checked { + margin: -0.25em 0 0.5em 0; + font-size: 0.75em; + color: #888; +} + +.ipmi-control .ipmi-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35em; + margin: 0.5em 0; +} + +.ipmi-control .ipmi-actions button { + flex: 1 1 45%; +} + +.ipmi-control button.ipmi-danger { + border-color: #b71c1c; + color: #b71c1c; +} + +.ipmi-control .ipmi-message { + margin: 0.5em 0; + font-size: 0.9em; +} + +.ipmi-control .ipmi-message-error { + color: #b71c1c; + font-weight: bold; +} + +.ipmi-control .ipmi-confirm { + border: 1px solid #b71c1c; + border-radius: 0.25em; + padding: 0.5em; + margin: 0.5em 0; +} + +.ipmi-control .ipmi-confirm-action { + font-weight: bold; + margin: 0.25em 0 0.5em 0; +} + +.ipmi-control .ipmi-sel { + margin-top: 0.5em; +} + +.ipmi-control .ipmi-sel pre { + max-height: 12em; + overflow: auto; + background: rgba(0, 0, 0, 0.05); + border: 1px solid rgba(0, 0, 0, 0.2); + padding: 0.5em; + font-size: 0.75em; + white-space: pre; +} diff --git a/guacamole/src/main/frontend/src/app/client/templates/client.html b/guacamole/src/main/frontend/src/app/client/templates/client.html index 66dca3a770..1bb14dd05e 100644 --- a/guacamole/src/main/frontend/src/app/client/templates/client.html +++ b/guacamole/src/main/frontend/src/app/client/templates/client.html @@ -44,7 +44,12 @@
{{'CLIENT.TEXT_CLIENT_STATUS_UNSTABLE' | translate}}
- + + +
+ +
+