From 928972f7676425039d0ff482e2086b6201b7a047 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Sat, 27 Sep 2025 08:54:27 +0530 Subject: [PATCH] extension/proxmox: add console access for instances (#11601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces console access support for instances deployed using Orchestrator Extensions, available via either VNC or a direct URL. - CloudStack queries the extension using the getconsole action. - For VNC-based access, the extension must return host/port/ticket details. CloudStack then forwards these to the Console Proxy VM (CPVM) in the instance’s zone. It is assumed that the CPVM can reach the specified host and port. - For direct URL access, the extension returns a console URL with the protocol set to `direct`. The URL is then provided directly to the user. - The built-in Proxmox Orchestrator Extension now supports console access via VNC. The extension calls the Proxmox API to fetch console details and returns them in the required format. Also, adds changes to send caller details to the extension payload. ``` # cat /var/lib/cloudstack/management/extensions/Proxmox/02b650f6-bb98-49cb-8cac-82b7a78f43a2.json | jq { "caller": { "roleid": "6b86674b-7e61-11f0-ba77-1e00c8000158", "rolename": "Root Admin", "name": "admin", "roletype": "Admin", "id": "93567ed9-7e61-11f0-ba77-1e00c8000158", "type": "ADMIN" }, "virtualmachineid": "126f4562-1f0f-4313-875e-6150cabeb72f", ... ``` Documentation PR: https://github.com/apache/cloudstack-documentation/pull/560 --------- Signed-off-by: Abhishek Kumar --- .../apache/cloudstack/api/ApiConstants.java | 1 + .../CreateConsoleEndpointCmd.java | 5 + .../agent/api/GetExternalConsoleAnswer.java | 68 +++ .../agent/api/GetExternalConsoleCommand.java | 53 ++ .../agent/api/RunCustomActionCommand.java | 12 +- .../cloud/hypervisor/ExternalProvisioner.java | 4 + extensions/HyperV/hyperv.py | 6 +- extensions/Proxmox/proxmox.sh | 105 +++- .../extensions/manager/ExtensionsManager.java | 4 + .../manager/ExtensionsManagerImpl.java | 61 ++- .../manager/ExtensionsManagerImplTest.java | 168 +++++- .../ExternalPathPayloadProvisioner.java | 130 +++-- .../external/resource/ExternalResource.java | 11 + .../ExternalPathPayloadProvisionerTest.java | 285 +++++++++- .../external/provisioner/provisioner.sh | 18 +- .../com/cloud/consoleproxy/AgentHookBase.java | 9 +- .../com/cloud/server/ManagementServer.java | 4 + .../cloud/server/ManagementServerImpl.java | 27 +- .../servlet/ConsoleProxyClientParam.java | 6 + .../cloud/servlet/ConsoleProxyServlet.java | 19 +- .../ConsoleAccessManagerImpl.java | 344 ++++++++---- .../server/ManagementServerImplTest.java | 14 + .../ConsoleAccessManagerImplTest.java | 508 +++++++++++++++++- .../com/cloud/consoleproxy/ConsoleProxy.java | 7 +- .../consoleproxy/ConsoleProxyClientParam.java | 18 +- .../ConsoleProxyHttpHandlerHelper.java | 3 + .../ConsoleProxyNoVNCHandler.java | 2 + .../consoleproxy/ConsoleProxyNoVncClient.java | 11 +- .../cloud/consoleproxy/vnc/NoVncClient.java | 5 +- .../consoleproxy/vnc/network/NioSocket.java | 36 +- ui/src/components/view/ActionButton.vue | 4 +- 31 files changed, 1714 insertions(+), 234 deletions(-) create mode 100644 core/src/main/java/com/cloud/agent/api/GetExternalConsoleAnswer.java create mode 100644 core/src/main/java/com/cloud/agent/api/GetExternalConsoleCommand.java diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index 6c84e54b2d1..f5861c257a1 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -80,6 +80,7 @@ public class ApiConstants { public static final String BYTES_WRITE_RATE_MAX = "byteswriteratemax"; public static final String BYTES_WRITE_RATE_MAX_LENGTH = "byteswriteratemaxlength"; public static final String BYPASS_VLAN_OVERLAP_CHECK = "bypassvlanoverlapcheck"; + public static final String CALLER = "caller"; public static final String CAPACITY = "capacity"; public static final String CATEGORY = "category"; public static final String CAN_REVERT = "canrevert"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/consoleproxy/CreateConsoleEndpointCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/consoleproxy/CreateConsoleEndpointCmd.java index 63b47e163b6..b84f8ce3489 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/consoleproxy/CreateConsoleEndpointCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/consoleproxy/CreateConsoleEndpointCmd.java @@ -35,6 +35,7 @@ import org.apache.cloudstack.consoleproxy.ConsoleAccessManager; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.utils.consoleproxy.ConsoleAccessUtils; import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.ObjectUtils; import javax.inject.Inject; import java.util.Map; @@ -86,6 +87,10 @@ public class CreateConsoleEndpointCmd extends BaseCmd { } private ConsoleEndpointWebsocketResponse createWebsocketResponse(ConsoleEndpoint endpoint) { + if (ObjectUtils.allNull(endpoint.getWebsocketHost(), endpoint.getWebsocketPort(), endpoint.getWebsocketPath(), + endpoint.getWebsocketToken(), endpoint.getWebsocketExtra())) { + return null; + } ConsoleEndpointWebsocketResponse wsResponse = new ConsoleEndpointWebsocketResponse(); wsResponse.setHost(endpoint.getWebsocketHost()); wsResponse.setPort(endpoint.getWebsocketPort()); diff --git a/core/src/main/java/com/cloud/agent/api/GetExternalConsoleAnswer.java b/core/src/main/java/com/cloud/agent/api/GetExternalConsoleAnswer.java new file mode 100644 index 00000000000..e913d6f0d3a --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/GetExternalConsoleAnswer.java @@ -0,0 +1,68 @@ +// 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. + +package com.cloud.agent.api; + +public class GetExternalConsoleAnswer extends Answer { + + private String url; + private String host; + private Integer port; + @LogLevel(LogLevel.Log4jLevel.Off) + private String password; + private String protocol; + private boolean passwordOneTimeUseOnly; + + public GetExternalConsoleAnswer(Command command, String details) { + super(command, false, details); + } + + public GetExternalConsoleAnswer(Command command, String url, String host, Integer port, String password, + boolean passwordOneTimeUseOnly, String protocol) { + super(command, true, ""); + this.url = url; + this.host = host; + this.port = port; + this.password = password; + this.passwordOneTimeUseOnly = passwordOneTimeUseOnly; + this.protocol = protocol; + } + + public String getUrl() { + return url; + } + + public String getHost() { + return host; + } + + public Integer getPort() { + return port; + } + + public String getPassword() { + return password; + } + + public String getProtocol() { + return protocol; + } + + public boolean isPasswordOneTimeUseOnly() { + return passwordOneTimeUseOnly; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/GetExternalConsoleCommand.java b/core/src/main/java/com/cloud/agent/api/GetExternalConsoleCommand.java new file mode 100644 index 00000000000..fc2134f631f --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/GetExternalConsoleCommand.java @@ -0,0 +1,53 @@ +// 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. + +package com.cloud.agent.api; + +import com.cloud.agent.api.to.VirtualMachineTO; + +public class GetExternalConsoleCommand extends Command { + String vmName; + VirtualMachineTO vm; + protected boolean executeInSequence; + + public GetExternalConsoleCommand(String vmName, VirtualMachineTO vm) { + this.vmName = vmName; + this.vm = vm; + this.executeInSequence = false; + } + + public String getVmName() { + return this.vmName; + } + + public void setVirtualMachine(VirtualMachineTO vm) { + this.vm = vm; + } + + public VirtualMachineTO getVirtualMachine() { + return vm; + } + + @Override + public boolean executeInSequence() { + return executeInSequence; + } + + public void setExecuteInSequence(boolean executeInSequence) { + this.executeInSequence = executeInSequence; + } +} diff --git a/core/src/main/java/com/cloud/agent/api/RunCustomActionCommand.java b/core/src/main/java/com/cloud/agent/api/RunCustomActionCommand.java index 36489ad4fa5..113073ac5ee 100644 --- a/core/src/main/java/com/cloud/agent/api/RunCustomActionCommand.java +++ b/core/src/main/java/com/cloud/agent/api/RunCustomActionCommand.java @@ -21,10 +21,12 @@ package com.cloud.agent.api; import java.util.Map; +import com.cloud.agent.api.to.VirtualMachineTO; + public class RunCustomActionCommand extends Command { String actionName; - Long vmId; + VirtualMachineTO vmTO; Map parameters; public RunCustomActionCommand(String actionName) { @@ -36,12 +38,12 @@ public class RunCustomActionCommand extends Command { return actionName; } - public Long getVmId() { - return vmId; + public VirtualMachineTO getVmTO() { + return vmTO; } - public void setVmId(Long vmId) { - this.vmId = vmId; + public void setVmTO(VirtualMachineTO vmTO) { + this.vmTO = vmTO; } public Map getParameters() { diff --git a/engine/components-api/src/main/java/com/cloud/hypervisor/ExternalProvisioner.java b/engine/components-api/src/main/java/com/cloud/hypervisor/ExternalProvisioner.java index a22ea421113..c574a8be017 100644 --- a/engine/components-api/src/main/java/com/cloud/hypervisor/ExternalProvisioner.java +++ b/engine/components-api/src/main/java/com/cloud/hypervisor/ExternalProvisioner.java @@ -18,6 +18,8 @@ package com.cloud.hypervisor; import java.util.Map; +import com.cloud.agent.api.GetExternalConsoleAnswer; +import com.cloud.agent.api.GetExternalConsoleCommand; import com.cloud.agent.api.HostVmStateReportEntry; import com.cloud.agent.api.PrepareExternalProvisioningAnswer; import com.cloud.agent.api.PrepareExternalProvisioningCommand; @@ -57,5 +59,7 @@ public interface ExternalProvisioner extends Manager { Map getHostVmStateReport(long hostId, String extensionName, String extensionRelativePath); + GetExternalConsoleAnswer getInstanceConsole(String hostGuid, String extensionName, String extensionRelativePath, GetExternalConsoleCommand cmd); + RunCustomActionAnswer runCustomAction(String hostGuid, String extensionName, String extensionRelativePath, RunCustomActionCommand cmd); } diff --git a/extensions/HyperV/hyperv.py b/extensions/HyperV/hyperv.py index 8ae2c7ff797..c9b1d4da77e 100755 --- a/extensions/HyperV/hyperv.py +++ b/extensions/HyperV/hyperv.py @@ -25,7 +25,7 @@ import winrm def fail(message): - print(json.dumps({"error": message})) + print(json.dumps({"status": "error", "error": message})) sys.exit(1) @@ -220,6 +220,9 @@ class HyperVManager: fail(str(e)) succeed({"status": "success", "message": "Instance deleted"}) + def get_console(self): + fail("Operation not supported") + def suspend(self): self.run_ps(f'Suspend-VM -Name "{self.data["vmname"]}"') succeed({"status": "success", "message": "Instance suspended"}) @@ -283,6 +286,7 @@ def main(): "reboot": manager.reboot, "delete": manager.delete, "status": manager.status, + "getconsole": manager.get_console, "suspend": manager.suspend, "resume": manager.resume, "listsnapshots": manager.list_snapshots, diff --git a/extensions/Proxmox/proxmox.sh b/extensions/Proxmox/proxmox.sh index 7f363a6b9a0..23f30311e2b 100755 --- a/extensions/Proxmox/proxmox.sh +++ b/extensions/Proxmox/proxmox.sh @@ -18,7 +18,7 @@ parse_json() { local json_string="$1" - echo "$json_string" | jq '.' > /dev/null || { echo '{"error":"Invalid JSON input"}'; exit 1; } + echo "$json_string" | jq '.' > /dev/null || { echo '{"status": "error", "error": "Invalid JSON input"}'; exit 1; } local -A details while IFS="=" read -r key value; do @@ -112,9 +112,14 @@ call_proxmox_api() { curl_opts+=(-d "$data") fi - #echo curl "${curl_opts[@]}" "https://${url}:8006/api2/json${path}" >&2 response=$(curl "${curl_opts[@]}" "https://${url}:8006/api2/json${path}") + local status=$? + if [[ $status -ne 0 ]]; then + echo "{\"errors\":{\"curl\":\"API call failed with status $status: $(echo "$response" | jq -Rsa . | jq -r .)\"}}" + return $status + fi echo "$response" + return 0 } wait_for_proxmox_task() { @@ -129,7 +134,7 @@ wait_for_proxmox_task() { local now now=$(date +%s) if (( now - start_time > timeout )); then - echo '{"error":"Timeout while waiting for async task"}' + echo '{"status": "error", "error":"Timeout while waiting for async task"}' exit 1 fi @@ -139,7 +144,7 @@ wait_for_proxmox_task() { if [[ -z "$status_response" || "$status_response" == *'"errors":'* ]]; then local msg msg=$(echo "$status_response" | jq -r '.message // "Unknown error"') - echo "{\"error\":\"$msg\"}" + echo "{\"status\": \"error\", \"error\": \"$msg\"}" exit 1 fi @@ -285,6 +290,86 @@ status() { echo "{\"status\": \"success\", \"power_state\": \"$powerstate\"}" } +get_node_host() { + check_required_fields node + local net_json host + + if ! net_json="$(call_proxmox_api GET "/nodes/${node}/network")"; then + echo "" + return 1 + fi + + # Prefer a static non-bridge IP + host="$(echo "$net_json" | jq -r ' + .data + | map(select( + (.type // "") != "bridge" and + (.type // "") != "bond" and + (.method // "") == "static" and + ((.address // .cidr // "") != "") + )) + | map(.address // (.cidr | split("/")[0])) + | .[0] // empty + ' 2>/dev/null)" + + # Fallback: first interface with a CIDR + if [[ -z "$host" ]]; then + host="$(echo "$net_json" | jq -r ' + .data + | map(select((.cidr // "") != "")) + | map(.cidr | split("/")[0]) + | .[0] // empty + ' 2>/dev/null)" + fi + + echo "$host" +} + + get_console() { + check_required_fields node vmid + + local api_resp port ticket + if ! api_resp="$(call_proxmox_api POST "/nodes/${node}/qemu/${vmid}/vncproxy")"; then + echo "$api_resp" | jq -c '{status:"error", error:(.errors.curl // (.errors|tostring))}' + exit 1 + fi + + port="$(echo "$api_resp" | jq -re '.data.port // empty' 2>/dev/null || true)" + ticket="$(echo "$api_resp" | jq -re '.data.ticket // empty' 2>/dev/null || true)" + + if [[ -z "$port" || -z "$ticket" ]]; then + jq -n --arg raw "$api_resp" \ + '{status:"error", error:"Proxmox response missing port/ticket", upstream:$raw}' + exit 1 + fi + + # Derive host from node’s network info + local host + host="$(get_node_host)" + if [[ -z "$host" ]]; then + jq -n --arg msg "Could not determine host IP for node $node" \ + '{status:"error", error:$msg}' + exit 1 + fi + + jq -n \ + --arg host "$host" \ + --arg port "$port" \ + --arg password "$ticket" \ + --argjson passwordonetimeuseonly true \ + '{ + status: "success", + message: "Console retrieved", + console: { + host: $host, + port: $port, + password: $password, + passwordonetimeuseonly: $passwordonetimeuseonly, + protocol: "vnc" + } + }' + } + list_snapshots() { snapshot_response=$(call_proxmox_api GET "/nodes/${node}/qemu/${vmid}/snapshot") echo "$snapshot_response" | jq ' @@ -356,7 +441,12 @@ parameters_file="$2" wait_time=$3 if [[ -z "$action" || -z "$parameters_file" ]]; then - echo '{"error":"Missing required arguments"}' + echo '{"status": "error", "error": "Missing required arguments"}' + exit 1 +fi + +if [[ ! -r "$parameters_file" ]]; then + echo '{"status": "error", "error": "File not found or unreadable"}' exit 1 fi @@ -396,6 +486,9 @@ case $action in status) status ;; + getconsole) + get_console + ;; ListSnapshots) list_snapshots ;; @@ -409,7 +502,7 @@ case $action in delete_snapshot ;; *) - echo '{"error":"Invalid action"}' + echo '{"status": "error", "error": "Invalid action"}' exit 1 ;; esac diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java index 82174872e87..1b1a175c597 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManager.java @@ -44,10 +44,12 @@ import org.apache.cloudstack.framework.extensions.api.UpdateCustomActionCmd; import org.apache.cloudstack.framework.extensions.api.UpdateExtensionCmd; import org.apache.cloudstack.framework.extensions.command.ExtensionServerActionBaseCommand; +import com.cloud.agent.api.Answer; import com.cloud.host.Host; import com.cloud.org.Cluster; import com.cloud.utils.Pair; import com.cloud.utils.component.Manager; +import com.cloud.vm.VirtualMachine; public interface ExtensionsManager extends Manager { @@ -93,4 +95,6 @@ public interface ExtensionsManager extends Manager { final ExtensionResourceMap.ResourceType resourceType, final Map details); void updateExtensionResourceMapDetails(final long extensionResourceMapId, final Map details); + + Answer getInstanceConsole(VirtualMachine vm, Host host); } diff --git a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java index 5abf0f424a7..9af5cb69739 100644 --- a/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java +++ b/framework/extensions/src/main/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImpl.java @@ -104,8 +104,10 @@ import org.apache.commons.lang3.StringUtils; import com.cloud.agent.AgentManager; import com.cloud.agent.api.Answer; import com.cloud.agent.api.Command; +import com.cloud.agent.api.GetExternalConsoleCommand; import com.cloud.agent.api.RunCustomActionAnswer; import com.cloud.agent.api.RunCustomActionCommand; +import com.cloud.agent.api.to.VirtualMachineTO; import com.cloud.alert.AlertManager; import com.cloud.cluster.ClusterManager; import com.cloud.cluster.ManagementServerHostVO; @@ -141,6 +143,8 @@ import com.cloud.utils.db.TransactionCallbackWithException; import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VirtualMachineProfile; +import com.cloud.vm.VirtualMachineProfileImpl; import com.cloud.vm.VmDetailConstants; import com.cloud.vm.dao.VMInstanceDao; @@ -472,6 +476,29 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana executorService.shutdown(); } + protected Map getCallerDetails() { + Account caller = CallContext.current().getCallingAccount(); + if (caller == null) { + return null; + } + Map callerDetails = new HashMap<>(); + callerDetails.put(ApiConstants.ID, caller.getUuid()); + callerDetails.put(ApiConstants.NAME, caller.getAccountName()); + if (caller.getType() != null) { + callerDetails.put(ApiConstants.TYPE, caller.getType().name()); + } + Role role = roleService.findRole(caller.getRoleId()); + if (role == null) { + return callerDetails; + } + callerDetails.put(ApiConstants.ROLE_ID, role.getUuid()); + callerDetails.put(ApiConstants.ROLE_NAME, role.getName()); + if (role.getRoleType() != null) { + callerDetails.put(ApiConstants.ROLE_TYPE, role.getRoleType().name()); + } + return callerDetails; + } + protected Map> getExternalAccessDetails(Map actionDetails, long hostId, ExtensionResourceMap resourceMap) { Map> externalDetails = new HashMap<>(); @@ -493,6 +520,10 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana if (MapUtils.isNotEmpty(extensionDetails)) { externalDetails.put(ApiConstants.EXTENSION, extensionDetails); } + Map callerDetails = getCallerDetails(); + if (MapUtils.isNotEmpty(callerDetails)) { + externalDetails.put(ApiConstants.CALLER, callerDetails); + } return externalDetails; } @@ -1323,11 +1354,13 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana clusterId = host.getClusterId(); } else if (entity instanceof VirtualMachine) { VirtualMachine virtualMachine = (VirtualMachine)entity; - runCustomActionCommand.setVmId(virtualMachine.getId()); if (!Hypervisor.HypervisorType.External.equals(virtualMachine.getHypervisorType())) { logger.error("Invalid {} specified as VM resource for running {}", entity, customActionVO); throw new InvalidParameterValueException(error); } + VirtualMachineProfile vmProfile = new VirtualMachineProfileImpl(virtualMachine); + VirtualMachineTO virtualMachineTO = virtualMachineManager.toVmTO(vmProfile); + runCustomActionCommand.setVmTO(virtualMachineTO); Pair clusterAndHostId = virtualMachineManager.findClusterAndHostIdForVm(virtualMachine, false); clusterId = clusterAndHostId.first(); hostId = clusterAndHostId.second(); @@ -1369,6 +1402,13 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana actionResourceType, entity)); Map> externalDetails = getExternalAccessDetails(allDetails.first(), hostId, extensionResource); + Map vmExternalDetails = null; + if (runCustomActionCommand.getVmTO() != null) { + vmExternalDetails = runCustomActionCommand.getVmTO().getExternalDetails(); + } + if (MapUtils.isNotEmpty(vmExternalDetails)) { + externalDetails.put(ApiConstants.VIRTUAL_MACHINE, vmExternalDetails); + } runCustomActionCommand.setParameters(parameters); runCustomActionCommand.setExternalDetails(externalDetails); runCustomActionCommand.setWait(customActionVO.getTimeout()); @@ -1517,6 +1557,25 @@ public class ExtensionsManagerImpl extends ManagerBase implements ExtensionsMana extensionResourceMapDetailsDao.saveDetails(detailsList); } + @Override + public Answer getInstanceConsole(VirtualMachine vm, Host host) { + Extension extension = getExtensionForCluster(host.getClusterId()); + if (extension == null || !Extension.Type.Orchestrator.equals(extension.getType()) || + !Extension.State.Enabled.equals(extension.getState())) { + logger.error("No enabled orchestrator {} found for the {} while trying to get console for {}", + extension == null ? "extension" : extension, host, vm); + return new Answer(null, false, + String.format("No enabled orchestrator extension found for the host: %s", host.getName())); + } + VirtualMachineProfile vmProfile = new VirtualMachineProfileImpl(vm); + VirtualMachineTO virtualMachineTO = virtualMachineManager.toVmTO(vmProfile); + GetExternalConsoleCommand cmd = new GetExternalConsoleCommand(vm.getInstanceName(), virtualMachineTO); + Map> externalAccessDetails = + getExternalAccessDetails(host, virtualMachineTO.getExternalDetails()); + cmd.setExternalDetails(externalAccessDetails); + return agentMgr.easySend(host.getId(), cmd); + } + @Override public Long getExtensionIdForCluster(long clusterId) { ExtensionResourceMapVO map = extensionResourceMapDao.findByResourceIdAndType(clusterId, diff --git a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java index fcceb16523e..bee597550a0 100644 --- a/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java +++ b/framework/extensions/src/test/java/org/apache/cloudstack/framework/extensions/manager/ExtensionsManagerImplTest.java @@ -92,7 +92,6 @@ import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockedStatic; -import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; @@ -102,6 +101,7 @@ import com.cloud.agent.AgentManager; import com.cloud.agent.api.Answer; import com.cloud.agent.api.Command; import com.cloud.agent.api.RunCustomActionAnswer; +import com.cloud.agent.api.to.VirtualMachineTO; import com.cloud.alert.AlertManager; import com.cloud.cluster.ClusterManager; import com.cloud.cluster.ManagementServerHostVO; @@ -111,6 +111,7 @@ import com.cloud.dc.dao.ClusterDao; import com.cloud.exception.AgentUnavailableException; import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.OperationTimedoutException; +import com.cloud.host.Host; import com.cloud.host.dao.HostDao; import com.cloud.host.dao.HostDetailsDao; import com.cloud.hypervisor.ExternalProvisioner; @@ -120,6 +121,7 @@ import com.cloud.serializer.GsonHelper; import com.cloud.storage.dao.VMTemplateDao; import com.cloud.user.Account; import com.cloud.utils.Pair; +import com.cloud.utils.UuidUtils; import com.cloud.utils.db.EntityManager; import com.cloud.utils.db.SearchBuilder; import com.cloud.utils.db.SearchCriteria; @@ -524,11 +526,15 @@ public class ExtensionsManagerImplTest { when(hostDetailsDao.findDetails(hostId)).thenReturn(null); when(extensionResourceMapDetailsDao.listDetailsKeyPairs(2L, true)).thenReturn(Collections.emptyMap()); when(extensionDetailsDao.listDetailsKeyPairs(3L, true)).thenReturn(map); - Map> result = extensionsManager.getExternalAccessDetails(map, hostId, resourceMap); - assertTrue(result.containsKey(ApiConstants.ACTION)); - assertFalse(result.containsKey(ApiConstants.HOST)); - assertFalse(result.containsKey(ApiConstants.RESOURCE_MAP)); - assertTrue(result.containsKey(ApiConstants.EXTENSION)); + try (MockedStatic ignored = mockStatic(CallContext.class)) { + mockCallerRole(RoleType.Admin); + Map> result = extensionsManager.getExternalAccessDetails(map, hostId, resourceMap); + assertTrue(result.containsKey(ApiConstants.ACTION)); + assertFalse(result.containsKey(ApiConstants.HOST)); + assertFalse(result.containsKey(ApiConstants.RESOURCE_MAP)); + assertTrue(result.containsKey(ApiConstants.EXTENSION)); + assertTrue(result.containsKey(ApiConstants.CALLER)); + } } @Test(expected = CloudRuntimeException.class) @@ -1281,12 +1287,17 @@ public class ExtensionsManagerImplTest { } private void mockCallerRole(RoleType roleType) { - CallContext callContextMock = Mockito.mock(CallContext.class); + CallContext callContextMock = mock(CallContext.class); when(CallContext.current()).thenReturn(callContextMock); Account accountMock = mock(Account.class); + when(accountMock.getAccountName()).thenReturn("testAccount"); + when(accountMock.getUuid()).thenReturn(UUID.randomUUID().toString()); + when(accountMock.getType()).thenReturn(RoleType.Admin.equals(roleType) ? Account.Type.ADMIN : Account.Type.NORMAL); when(accountMock.getRoleId()).thenReturn(1L); Role role = mock(Role.class); when(role.getRoleType()).thenReturn(roleType); + when(role.getUuid()).thenReturn("role-uuid-1"); + when(role.getName()).thenReturn(roleType.name() + "Role"); when(roleService.findRole(1L)).thenReturn(role); when(callContextMock.getCallingAccount()).thenReturn(accountMock); } @@ -1882,4 +1893,147 @@ public class ExtensionsManagerImplTest { Extension result = extensionsManager.getExtensionForCluster(clusterId); assertNull(result); } + + @Test + public void getInstanceConsole_whenValid() { + Extension extension = mock(Extension.class); + when(extension.getType()).thenReturn(Extension.Type.Orchestrator); + when(extension.getState()).thenReturn(Extension.State.Enabled); + when(extensionsManager.getExtensionForCluster(anyLong())).thenReturn(extension); + VirtualMachine vm = mock(VirtualMachine.class); + Host host = mock(Host.class); + when(host.getClusterId()).thenReturn(1L); + Answer expectedAnswer = mock(Answer.class); + when(virtualMachineManager.toVmTO(any())).thenReturn(mock(VirtualMachineTO.class)); + when(agentMgr.easySend(anyLong(), any())).thenReturn(expectedAnswer); + Answer result = extensionsManager.getInstanceConsole(vm, host); + assertNotNull(result); + assertEquals(expectedAnswer, result); + } + + @Test + public void getInstanceConsole_whenNullExtension() { + when(extensionsManager.getExtensionForCluster(anyLong())).thenReturn(null); + VirtualMachine vm = mock(VirtualMachine.class); + Host host = mock(Host.class); + when(host.getClusterId()).thenReturn(1L); + Answer result = extensionsManager.getInstanceConsole(vm, host); + assertNotNull(result); + assertFalse(result.getResult()); + } + + @Test + public void getInstanceConsole_whenNullExtensionNotOrchestrator() { + Extension extension = mock(Extension.class); + when(extensionsManager.getExtensionForCluster(anyLong())).thenReturn(extension); + VirtualMachine vm = mock(VirtualMachine.class); + Host host = mock(Host.class); + when(host.getClusterId()).thenReturn(1L); + Answer result = extensionsManager.getInstanceConsole(vm, host); + assertNotNull(result); + assertFalse(result.getResult()); + } + + @Test + public void getInstanceConsole_whenNullExtensionNotEnabled() { + Extension extension = mock(Extension.class); + when(extension.getType()).thenReturn(Extension.Type.Orchestrator); + when(extension.getState()).thenReturn(Extension.State.Disabled); + when(extensionsManager.getExtensionForCluster(anyLong())).thenReturn(extension); + VirtualMachine vm = mock(VirtualMachine.class); + Host host = mock(Host.class); + when(host.getClusterId()).thenReturn(1L); + Answer result = extensionsManager.getInstanceConsole(vm, host); + assertNotNull(result); + assertFalse(result.getResult()); + } + + @Test + public void getInstanceConsole_whenAgentManagerFails() { + Extension extension = mock(Extension.class); + when(extension.getType()).thenReturn(Extension.Type.Orchestrator); + when(extension.getState()).thenReturn(Extension.State.Enabled); + when(extensionsManager.getExtensionForCluster(anyLong())).thenReturn(extension); + VirtualMachine vm = mock(VirtualMachine.class); + Host host = mock(Host.class); + when(host.getClusterId()).thenReturn(1L); + when(virtualMachineManager.toVmTO(any())).thenReturn(mock(VirtualMachineTO.class)); + when(agentMgr.easySend(anyLong(), any())).thenReturn(null); + Answer result = extensionsManager.getInstanceConsole(vm, host); + assertNull(result); + } + + @Test + public void getExternalAccessDetailsReturnsExpectedDetails() { + Host host = mock(Host.class); + when(host.getId()).thenReturn(100L); + when(host.getClusterId()).thenReturn(1L); + Map vmDetails = Map.of("key1", "value1", "key2", "value2"); + ExtensionResourceMapVO resourceMapVO = mock(ExtensionResourceMapVO.class); + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)) + .thenReturn(resourceMapVO); + doReturn(new HashMap<>()).when(extensionsManager).getExternalAccessDetails(null, 100L, resourceMapVO); + Map> result = extensionsManager.getExternalAccessDetails(host, vmDetails); + assertNotNull(result); + assertNotNull(result.get(ApiConstants.VIRTUAL_MACHINE)); + assertEquals(vmDetails, result.get(ApiConstants.VIRTUAL_MACHINE)); + } + + @Test + public void getExternalAccessDetailsReturnsExpectedNullDetails() { + Host host = mock(Host.class); + when(host.getId()).thenReturn(101L); + when(host.getClusterId()).thenReturn(1L); + Map vmDetails = null; + ExtensionResourceMapVO resourceMapVO = mock(ExtensionResourceMapVO.class); + when(extensionResourceMapDao.findByResourceIdAndType(1L, ExtensionResourceMap.ResourceType.Cluster)) + .thenReturn(resourceMapVO); + doReturn(new HashMap<>()).when(extensionsManager).getExternalAccessDetails(null, 101L, resourceMapVO); + Map> result = extensionsManager.getExternalAccessDetails(host, vmDetails); + assertNotNull(result); + assertNull(result.get(ApiConstants.VIRTUAL_MACHINE)); + } + + @Test + public void getCallerDetailsReturnsExpectedDetailsForValidCaller() { + try (MockedStatic ignored = mockStatic(CallContext.class)) { + mockCallerRole(RoleType.Admin); + Map result = extensionsManager.getCallerDetails(); + assertNotNull(result); + assertTrue(UuidUtils.isUuid(result.get(ApiConstants.ID))); + assertEquals("testAccount", result.get(ApiConstants.NAME)); + assertEquals("ADMIN", result.get(ApiConstants.TYPE)); + assertEquals("role-uuid-1", result.get(ApiConstants.ROLE_ID)); + assertEquals("AdminRole", result.get(ApiConstants.ROLE_NAME)); + assertEquals("Admin", result.get(ApiConstants.ROLE_TYPE)); + } + } + + @Test + public void getCallerDetailsReturnsNullWhenCallerIsNull() { + CallContext callContext = mock(CallContext.class); + when(callContext.getCallingAccount()).thenReturn(null); + try (MockedStatic mockedCallContext = mockStatic(CallContext.class)) { + mockedCallContext.when(CallContext::current).thenReturn(callContext); + Map result = extensionsManager.getCallerDetails(); + assertNull(result); + } + } + + @Test + public void getCallerDetailsReturnsDetailsWithoutRoleWhenRoleIsNull() { + try (MockedStatic ignored = mockStatic(CallContext.class)) { + mockCallerRole(RoleType.User); + when(roleService.findRole(1L)).thenReturn(null); + Map result = extensionsManager.getCallerDetails(); + assertNotNull(result); + assertTrue(UuidUtils.isUuid(result.get(ApiConstants.ID))); + assertEquals("testAccount", result.get(ApiConstants.NAME)); + assertEquals("NORMAL", result.get(ApiConstants.TYPE)); + assertNull(result.get(ApiConstants.ROLE_ID)); + assertNull(result.get(ApiConstants.ROLE_NAME)); + assertNull(result.get(ApiConstants.ROLE_TYPE)); + } + } + } diff --git a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java index 5a1632ce977..6cec5181de6 100644 --- a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisioner.java @@ -54,8 +54,11 @@ import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; import org.apache.cloudstack.utils.security.DigestHelper; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.ObjectUtils; import com.cloud.agent.AgentManager; +import com.cloud.agent.api.GetExternalConsoleAnswer; +import com.cloud.agent.api.GetExternalConsoleCommand; import com.cloud.agent.api.HostVmStateReportEntry; import com.cloud.agent.api.PrepareExternalProvisioningAnswer; import com.cloud.agent.api.PrepareExternalProvisioningCommand; @@ -85,13 +88,11 @@ import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.utils.json.JsonMergeUtil; import com.cloud.utils.script.Script; import com.cloud.vm.UserVmVO; -import com.cloud.vm.VMInstanceVO; import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachineProfile; import com.cloud.vm.VirtualMachineProfileImpl; import com.cloud.vm.VmDetailConstants; import com.cloud.vm.dao.UserVmDao; -import com.cloud.vm.dao.VMInstanceDao; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -114,9 +115,6 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter @Inject HostDao hostDao; - @Inject - VMInstanceDao vmInstanceDao; - @Inject HypervisorGuruManager hypervisorGuruManager; @@ -140,6 +138,10 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter protected Map loadAccessDetails(Map> externalDetails, VirtualMachineTO virtualMachineTO) { Map modifiedDetails = new HashMap<>(); + if (MapUtils.isNotEmpty(externalDetails) && externalDetails.containsKey(ApiConstants.CALLER)) { + modifiedDetails.put(ApiConstants.CALLER, externalDetails.get(ApiConstants.CALLER)); + externalDetails.remove(ApiConstants.CALLER); + } if (MapUtils.isNotEmpty(externalDetails)) { modifiedDetails.put(ApiConstants.EXTERNAL_DETAILS, externalDetails); } @@ -208,6 +210,22 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter logger.info("Extensions data directory path: {}", extensionsDataDirectory); } + protected VirtualMachineTO getVirtualMachineTO(VirtualMachine vm) { + if (vm == null) { + return null; + } + final HypervisorGuru hvGuru = hypervisorGuruManager.getGuru(Hypervisor.HypervisorType.External); + VirtualMachineProfile profile = new VirtualMachineProfileImpl(vm); + return hvGuru.implement(profile); + } + + protected String getSanitizedJsonStringForLog(String json) { + if (StringUtils.isBlank(json)) { + return json; + } + return json.replaceAll("(\"password\"\\s*:\\s*\")([^\"]*)(\")", "$1****$3"); + } + private String getServerProperty(String name) { Properties props = propertiesRef.get(); if (props == null) { @@ -289,12 +307,20 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter } } + protected String getExtensionConfigureError(String extensionName, String hostName) { + StringBuilder sb = new StringBuilder("Extension: ").append(extensionName).append(" not configured"); + if (StringUtils.isNotBlank(hostName)) { + sb.append(" for host: ").append(hostName); + } + return sb.toString(); + } + @Override - public PrepareExternalProvisioningAnswer prepareExternalProvisioning(String hostGuid, + public PrepareExternalProvisioningAnswer prepareExternalProvisioning(String hostName, String extensionName, String extensionRelativePath, PrepareExternalProvisioningCommand cmd) { String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); if (StringUtils.isEmpty(extensionPath)) { - return new PrepareExternalProvisioningAnswer(cmd, false, "Extension not configured"); + return new PrepareExternalProvisioningAnswer(cmd, false, getExtensionConfigureError(extensionName, hostName)); } VirtualMachineTO vmTO = cmd.getVirtualMachineTO(); String vmUUID = vmTO.getUuid(); @@ -322,11 +348,11 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter } @Override - public StartAnswer startInstance(String hostGuid, String extensionName, String extensionRelativePath, + public StartAnswer startInstance(String hostName, String extensionName, String extensionRelativePath, StartCommand cmd) { String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); if (StringUtils.isEmpty(extensionPath)) { - return new StartAnswer(cmd, "Extension not configured"); + return new StartAnswer(cmd, getExtensionConfigureError(extensionName, hostName)); } VirtualMachineTO virtualMachineTO = cmd.getVirtualMachine(); Map accessDetails = loadAccessDetails(cmd.getExternalDetails(), virtualMachineTO); @@ -366,11 +392,11 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter } @Override - public StopAnswer stopInstance(String hostGuid, String extensionName, String extensionRelativePath, + public StopAnswer stopInstance(String hostName, String extensionName, String extensionRelativePath, StopCommand cmd) { String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); if (StringUtils.isEmpty(extensionPath)) { - return new StopAnswer(cmd, "Extension not configured", false); + return new StopAnswer(cmd, getExtensionConfigureError(extensionName, hostName), false); } logger.debug("Executing stop command on the external provisioner"); VirtualMachineTO virtualMachineTO = cmd.getVirtualMachine(); @@ -387,13 +413,12 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter } @Override - public RebootAnswer rebootInstance(String hostGuid, String extensionName, String extensionRelativePath, + public RebootAnswer rebootInstance(String hostName, String extensionName, String extensionRelativePath, RebootCommand cmd) { String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); if (StringUtils.isEmpty(extensionPath)) { - return new RebootAnswer(cmd, "Extension not configured", false); + return new RebootAnswer(cmd, getExtensionConfigureError(extensionName, hostName), false); } - logger.debug("Executing reboot command using IPMI in the external provisioner"); VirtualMachineTO virtualMachineTO = cmd.getVirtualMachine(); String vmUUID = virtualMachineTO.getUuid(); logger.debug("Executing reboot command in the external system for the VM {}", vmUUID); @@ -408,11 +433,11 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter } @Override - public StopAnswer expungeInstance(String hostGuid, String extensionName, String extensionRelativePath, + public StopAnswer expungeInstance(String hostName, String extensionName, String extensionRelativePath, StopCommand cmd) { String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); if (StringUtils.isEmpty(extensionPath)) { - return new StopAnswer(cmd, "Extension not configured", false); + return new StopAnswer(cmd, getExtensionConfigureError(extensionName, hostName), false); } VirtualMachineTO virtualMachineTO = cmd.getVirtualMachine(); String vmUUID = virtualMachineTO.getUuid(); @@ -456,24 +481,65 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter } @Override - public RunCustomActionAnswer runCustomAction(String hostGuid, String extensionName, + public GetExternalConsoleAnswer getInstanceConsole(String hostName, String extensionName, + String extensionRelativePath, GetExternalConsoleCommand cmd) { + String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); + if (StringUtils.isEmpty(extensionPath)) { + return new GetExternalConsoleAnswer(cmd, getExtensionConfigureError(extensionName, hostName)); + } + VirtualMachineTO virtualMachineTO = cmd.getVirtualMachine(); + String vmUUID = virtualMachineTO.getUuid(); + logger.debug("Executing getconsole command in the external system for the VM {}", vmUUID); + Map accessDetails = loadAccessDetails(cmd.getExternalDetails(), virtualMachineTO); + Pair result = getInstanceConsoleOnExternalSystem(extensionName, extensionPath, vmUUID, + accessDetails, cmd.getWait()); + if (result == null) { + return new GetExternalConsoleAnswer(cmd, "No response from external system"); + } + String output = result.second(); + if (!result.first()) { + return new GetExternalConsoleAnswer(cmd, output); + } + logger.debug("Received console details from the external system: {}", + getSanitizedJsonStringForLog(output)); + try { + JsonObject jsonObj = JsonParser.parseString(output).getAsJsonObject(); + JsonObject consoleObj = jsonObj.has("console") ? jsonObj.getAsJsonObject("console") : null; + if (consoleObj == null) { + logger.error("Missing console object in external console output: {}", + getSanitizedJsonStringForLog(output)); + return new GetExternalConsoleAnswer(cmd, "Missing console object in output"); + } + String url = consoleObj.has("url") ? consoleObj.get("url").getAsString() : null; + String host = consoleObj.has("host") ? consoleObj.get("host").getAsString() : null; + Integer port = consoleObj.has("port") ? Integer.valueOf(consoleObj.get("port").getAsString()) : null; + String password = consoleObj.has("password") ? consoleObj.get("password").getAsString() : null; + boolean passwordOneTimeUseOnly = consoleObj.has("passwordonetimeuseonly") && + consoleObj.get("passwordonetimeuseonly").getAsBoolean(); + String protocol = consoleObj.has("protocol") ? consoleObj.get("protocol").getAsString() : null; + if (url == null && ObjectUtils.anyNull(host, port)) { + logger.error("Missing required fields in external console output: {}", + getSanitizedJsonStringForLog(output)); + return new GetExternalConsoleAnswer(cmd, "Missing required fields in output"); + } + return new GetExternalConsoleAnswer(cmd, url, host, port, password, passwordOneTimeUseOnly, protocol); + } catch (RuntimeException e) { + logger.error("Failed to parse output for getInstanceConsole: {}", e.getMessage(), e); + return new GetExternalConsoleAnswer(cmd, "Failed to parse output"); + } + } + + @Override + public RunCustomActionAnswer runCustomAction(String hostName, String extensionName, String extensionRelativePath, RunCustomActionCommand cmd) { String extensionPath = getExtensionCheckedPath(extensionName, extensionRelativePath); if (StringUtils.isEmpty(extensionPath)) { - return new RunCustomActionAnswer(cmd, false, "Extension not configured"); + return new RunCustomActionAnswer(cmd, false, getExtensionConfigureError(extensionName, hostName)); } final String actionName = cmd.getActionName(); final Map parameters = cmd.getParameters(); - logger.debug("Executing custom action '{}' in the external provisioner", actionName); - VirtualMachineTO virtualMachineTO = null; - if (cmd.getVmId() != null) { - VMInstanceVO vm = vmInstanceDao.findById(cmd.getVmId()); - final HypervisorGuru hvGuru = hypervisorGuruManager.getGuru(Hypervisor.HypervisorType.External); - VirtualMachineProfile profile = new VirtualMachineProfileImpl(vm); - virtualMachineTO = hvGuru.implement(profile); - } logger.debug("Executing custom action '{}' in the external system", actionName); - Map accessDetails = loadAccessDetails(cmd.getExternalDetails(), virtualMachineTO); + Map accessDetails = loadAccessDetails(cmd.getExternalDetails(), cmd.getVmTO()); accessDetails.put(ApiConstants.ACTION, actionName); if (MapUtils.isNotEmpty(parameters)) { accessDetails.put(ApiConstants.PARAMETERS, parameters); @@ -659,9 +725,7 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter private VirtualMachine.PowerState getVmPowerState(UserVmVO userVmVO, Map> accessDetails, String extensionName, String extensionPath) { - final HypervisorGuru hvGuru = hypervisorGuruManager.getGuru(Hypervisor.HypervisorType.External); - VirtualMachineProfile profile = new VirtualMachineProfileImpl(userVmVO); - VirtualMachineTO virtualMachineTO = hvGuru.implement(profile); + VirtualMachineTO virtualMachineTO = getVirtualMachineTO(userVmVO); accessDetails.put(ApiConstants.VIRTUAL_MACHINE, virtualMachineTO.getExternalDetails()); Map modifiedDetails = loadAccessDetails(accessDetails, virtualMachineTO); String vmUUID = userVmVO.getUuid(); @@ -718,6 +782,12 @@ public class ExternalPathPayloadProvisioner extends ManagerBase implements Exter String.format("Failed to get the instance power status %s on external system", vmUUID), filename); } + public Pair getInstanceConsoleOnExternalSystem(String extensionName, String filename, + String vmUUID, Map accessDetails, int wait) { + return executeExternalCommand(extensionName, "getconsole", accessDetails, wait, + String.format("Failed to get the instance console %s on external system", vmUUID), filename); + } + public Pair executeExternalCommand(String extensionName, String action, Map accessDetails, int wait, String errorLogPrefix, String file) { try { diff --git a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/resource/ExternalResource.java b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/resource/ExternalResource.java index ab70c880a81..02747350dd6 100644 --- a/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/resource/ExternalResource.java +++ b/plugins/hypervisors/external/src/main/java/org/apache/cloudstack/hypervisor/external/resource/ExternalResource.java @@ -35,6 +35,8 @@ import com.cloud.agent.api.CheckNetworkAnswer; import com.cloud.agent.api.CheckNetworkCommand; import com.cloud.agent.api.CleanupNetworkRulesCmd; import com.cloud.agent.api.Command; +import com.cloud.agent.api.GetExternalConsoleAnswer; +import com.cloud.agent.api.GetExternalConsoleCommand; import com.cloud.agent.api.GetHostStatsAnswer; import com.cloud.agent.api.GetHostStatsCommand; import com.cloud.agent.api.GetVmStatsCommand; @@ -162,6 +164,8 @@ public class ExternalResource implements ServerResource { return execute((StopCommand) cmd); } else if (cmd instanceof RebootCommand) { return execute((RebootCommand) cmd); + } else if (cmd instanceof GetExternalConsoleCommand) { + return execute((GetExternalConsoleCommand) cmd); } else if (cmd instanceof PrepareExternalProvisioningCommand) { return execute((PrepareExternalProvisioningCommand) cmd); } else if (cmd instanceof GetHostStatsCommand) { @@ -273,6 +277,13 @@ public class ExternalResource implements ServerResource { return externalProvisioner.rebootInstance(guid, extensionName, extensionRelativePath, cmd); } + public GetExternalConsoleAnswer execute(GetExternalConsoleCommand cmd) { + if (isExtensionDisconnected() || isExtensionNotEnabled() || isExtensionPathNotReady()) { + return new GetExternalConsoleAnswer(cmd, logAndGetExtensionNotConnectedOrDisabledError()); + } + return externalProvisioner.getInstanceConsole(guid, extensionName, extensionRelativePath, cmd); + } + public PrepareExternalProvisioningAnswer execute(PrepareExternalProvisioningCommand cmd) { if (isExtensionDisconnected() || isExtensionNotEnabled() || isExtensionPathNotReady()) { return new PrepareExternalProvisioningAnswer(cmd, false, logAndGetExtensionNotConnectedOrDisabledError()); diff --git a/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java index 8c63a20fa31..d0a396f7a94 100644 --- a/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java +++ b/plugins/hypervisors/external/src/test/java/org/apache/cloudstack/hypervisor/external/provisioner/ExternalPathPayloadProvisionerTest.java @@ -65,6 +65,8 @@ import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.test.util.ReflectionTestUtils; +import com.cloud.agent.api.GetExternalConsoleAnswer; +import com.cloud.agent.api.GetExternalConsoleCommand; import com.cloud.agent.api.HostVmStateReportEntry; import com.cloud.agent.api.PrepareExternalProvisioningAnswer; import com.cloud.agent.api.PrepareExternalProvisioningCommand; @@ -88,11 +90,10 @@ import com.cloud.utils.Pair; import com.cloud.utils.PropertiesUtil; import com.cloud.utils.script.Script; import com.cloud.vm.UserVmVO; -import com.cloud.vm.VMInstanceVO; import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachineProfile; +import com.cloud.vm.VmDetailConstants; import com.cloud.vm.dao.UserVmDao; -import com.cloud.vm.dao.VMInstanceDao; @RunWith(MockitoJUnitRunner.class) public class ExternalPathPayloadProvisionerTest { @@ -107,9 +108,6 @@ public class ExternalPathPayloadProvisionerTest { @Mock private HostDao hostDao; - @Mock - private VMInstanceDao vmInstanceDao; - @Mock private HypervisorGuruManager hypervisorGuruManager; @@ -208,6 +206,20 @@ public class ExternalPathPayloadProvisionerTest { assertEquals("test-vm", result.get(ApiConstants.VIRTUAL_MACHINE_NAME)); } + @Test + public void testLoadAccessDetails_WithCaller() { + Map> externalDetails = new HashMap<>(); + externalDetails.put(ApiConstants.EXTENSION, Map.of("key1", "value1")); + externalDetails.put(ApiConstants.CALLER, Map.of("key2", "value2")); + Map result = provisioner.loadAccessDetails(externalDetails, null); + + assertNotNull(result); + assertNotNull(result.get(ApiConstants.EXTERNAL_DETAILS)); + assertNotNull(((Map) result.get(ApiConstants.EXTERNAL_DETAILS)).get(ApiConstants.EXTENSION)); + assertNotNull(result.get(ApiConstants.CALLER)); + assertNull(result.get(VmDetailConstants.CLOUDSTACK_VM_DETAILS)); + } + @Test public void testGetExtensionCheckedPathValidFile() { String result = provisioner.getExtensionCheckedPath("test-extension", "test-extension.sh"); @@ -317,7 +329,7 @@ public class ExternalPathPayloadProvisionerTest { .executeExternalCommand(anyString(), anyString(), anyMap(), anyInt(), anyString(), anyString()); PrepareExternalProvisioningAnswer answer = provisioner.prepareExternalProvisioning( - "host-guid", "test-extension", "test-extension.sh", cmd); + "host-name", "test-extension", "test-extension.sh", cmd); assertTrue(answer.getResult()); assertEquals("test-net-uuid", answer.getVirtualMachineTO().getNics()[0].getNetworkUuid()); @@ -329,11 +341,14 @@ public class ExternalPathPayloadProvisionerTest { public void testPrepareExternalProvisioning_ExtensionNotConfigured() { PrepareExternalProvisioningCommand cmd = mock(PrepareExternalProvisioningCommand.class); + String extensionName = "test-extension"; + String hostName = "host-name"; PrepareExternalProvisioningAnswer answer = provisioner.prepareExternalProvisioning( - "host-guid", "test-extension", "nonexistent.sh", cmd); + hostName, extensionName, "nonexistent.sh", cmd); assertFalse(answer.getResult()); - assertEquals("Extension not configured", answer.getDetails()); + assertNotNull(answer); + assertEquals(String.format("Extension: %s not configured for host: %s", extensionName, hostName), answer.getDetails()); } @Test @@ -348,7 +363,7 @@ public class ExternalPathPayloadProvisionerTest { doReturn(new Pair<>(true, "{\"status\": \"success\", \"message\": \"Instance started\"}")).when(provisioner) .executeExternalCommand(anyString(), anyString(), anyMap(), anyInt(), anyString(), anyString()); - StartAnswer answer = provisioner.startInstance("host-guid", "test-extension", "test-extension.sh", cmd); + StartAnswer answer = provisioner.startInstance("host-name", "test-extension", "test-extension.sh", cmd); assertTrue(answer.getResult()); Mockito.verify(logger).debug("Starting VM test-uuid on the external system"); @@ -369,7 +384,7 @@ public class ExternalPathPayloadProvisionerTest { doReturn(new Pair<>(true, "{\"status\": \"success\", \"message\": \"Instance started\"}")).when(provisioner) .executeExternalCommand(anyString(), anyString(), anyMap(), anyInt(), anyString(), anyString()); - StartAnswer answer = provisioner.startInstance("host-guid", "test-extension", "test-extension.sh", cmd); + StartAnswer answer = provisioner.startInstance("host-name", "test-extension", "test-extension.sh", cmd); assertTrue(answer.getResult()); Mockito.verify(logger).debug("Deploying VM test-uuid on the external system"); @@ -387,7 +402,7 @@ public class ExternalPathPayloadProvisionerTest { doReturn(new Pair<>(false, "{\"error\": \"Instance failed to start\"}")).when(provisioner) .executeExternalCommand(anyString(), anyString(), anyMap(), anyInt(), anyString(), anyString()); - StartAnswer answer = provisioner.startInstance("host-guid", "test-extension", "test-extension.sh", cmd); + StartAnswer answer = provisioner.startInstance("host-name", "test-extension", "test-extension.sh", cmd); assertFalse(answer.getResult()); assertEquals("{\"error\": \"Instance failed to start\"}", answer.getDetails()); @@ -406,7 +421,7 @@ public class ExternalPathPayloadProvisionerTest { doReturn(new Pair<>(true, "success")).when(provisioner) .executeExternalCommand(anyString(), anyString(), anyMap(), anyInt(), anyString(), anyString()); - StopAnswer answer = provisioner.stopInstance("host-guid", "test-extension", "test-extension.sh", cmd); + StopAnswer answer = provisioner.stopInstance("host-name", "test-extension", "test-extension.sh", cmd); assertTrue(answer.getResult()); } @@ -423,7 +438,7 @@ public class ExternalPathPayloadProvisionerTest { doReturn(new Pair<>(true, "success")).when(provisioner) .executeExternalCommand(anyString(), anyString(), anyMap(), anyInt(), anyString(), anyString()); - RebootAnswer answer = provisioner.rebootInstance("host-guid", "test-extension", "test-extension.sh", cmd); + RebootAnswer answer = provisioner.rebootInstance("host-name", "test-extension", "test-extension.sh", cmd); assertTrue(answer.getResult()); } @@ -440,7 +455,7 @@ public class ExternalPathPayloadProvisionerTest { doReturn(new Pair<>(true, "success")).when(provisioner) .executeExternalCommand(anyString(), anyString(), anyMap(), anyInt(), anyString(), anyString()); - StopAnswer answer = provisioner.expungeInstance("host-guid", "test-extension", "test-extension.sh", cmd); + StopAnswer answer = provisioner.expungeInstance("host-name", "test-extension", "test-extension.sh", cmd); assertTrue(answer.getResult()); } @@ -489,19 +504,11 @@ public class ExternalPathPayloadProvisionerTest { when(cmd.getParameters()).thenReturn(new HashMap<>()); when(cmd.getExternalDetails()).thenReturn(new HashMap<>()); when(cmd.getWait()).thenReturn(30); - when(cmd.getVmId()).thenReturn(1L); - - VMInstanceVO vm = mock(VMInstanceVO.class); - when(vmInstanceDao.findById(anyLong())).thenReturn(vm); - - when(hypervisorGuruManager.getGuru(Hypervisor.HypervisorType.External)).thenReturn(hypervisorGuru); - VirtualMachineTO vmTO = mock(VirtualMachineTO.class); - when(hypervisorGuru.implement(any(VirtualMachineProfile.class))).thenReturn(vmTO); doReturn(new Pair<>(true, "success")).when(provisioner) .executeExternalCommand(anyString(), anyString(), anyMap(), anyInt(), anyString(), anyString()); - RunCustomActionAnswer answer = provisioner.runCustomAction("host-guid", "test-extension", "test-extension.sh", cmd); + RunCustomActionAnswer answer = provisioner.runCustomAction("host-name", "test-extension", "test-extension.sh", cmd); assertTrue(answer.getResult()); Mockito.verify(logger).debug("Executing custom action '{}' in the external system", "test-action"); @@ -747,4 +754,236 @@ public class ExternalPathPayloadProvisionerTest { VirtualMachine.PowerState result = provisioner.parsePowerStateFromResponse(vm, response); assertEquals(VirtualMachine.PowerState.PowerOn, result); } + + @Test + public void getVirtualMachineTOReturnsNullWhenVmIsNull() { + VirtualMachineTO result = provisioner.getVirtualMachineTO(null); + assertNull(result); + } + + @Test + public void getVirtualMachineTOReturnsValidTOWhenVmIsNotNull() { + VirtualMachine vm = mock(VirtualMachine.class); + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + when(hypervisorGuruManager.getGuru(Hypervisor.HypervisorType.External)).thenReturn(hypervisorGuru); + when(hypervisorGuru.implement(any(VirtualMachineProfile.class))).thenReturn(vmTO); + VirtualMachineTO result = provisioner.getVirtualMachineTO(vm); + assertNotNull(result); + assertEquals(vmTO, result); + Mockito.verify(hypervisorGuruManager).getGuru(Hypervisor.HypervisorType.External); + Mockito.verify(hypervisorGuru).implement(any(VirtualMachineProfile.class)); + } + + @Test + public void getInstanceConsoleReturnsAnswerWhenConsoleDetailsAreValid() { + GetExternalConsoleCommand cmd = mock(GetExternalConsoleCommand.class); + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + when(cmd.getVirtualMachine()).thenReturn(vmTO); + when(vmTO.getUuid()).thenReturn("test-uuid"); + + Map accessDetails = new HashMap<>(); + when(provisioner.loadAccessDetails(any(), eq(vmTO))).thenReturn(accessDetails); + + String validOutput = "{\"console\":{\"host\":\"127.0.0.1\",\"port\":5900,\"password\":\"pass\",\"protocol\":\"vnc\"}}"; + doReturn(new Pair<>(true, validOutput)).when(provisioner) + .getInstanceConsoleOnExternalSystem(anyString(), anyString(), anyString(), anyMap(), anyInt()); + + GetExternalConsoleAnswer result = provisioner.getInstanceConsole("host-name", "test-extension", "test-extension.sh", cmd); + + assertNotNull(result); + assertEquals("127.0.0.1", result.getHost()); + Integer port = 5900; + assertEquals(port, result.getPort()); + assertEquals("pass", result.getPassword()); + assertEquals("vnc", result.getProtocol()); + } + + @Test + public void getInstanceConsoleReturnsErrorWhenExtensionNotConfigured() { + GetExternalConsoleCommand cmd = mock(GetExternalConsoleCommand.class); + when(provisioner.getExtensionCheckedPath(anyString(), anyString())).thenReturn(null); + + String extensionName = "test-extension"; + String hostName = "host-name"; + GetExternalConsoleAnswer result = provisioner.getInstanceConsole(hostName, + extensionName, "test-extension.sh", cmd); + + assertNotNull(result); + assertEquals(String.format("Extension: %s not configured for host: %s", extensionName, hostName), result.getDetails()); + } + + @Test + public void getInstanceConsoleReturnsErrorWhenExternalSystemFails() { + GetExternalConsoleCommand cmd = mock(GetExternalConsoleCommand.class); + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + when(cmd.getVirtualMachine()).thenReturn(vmTO); + when(vmTO.getUuid()).thenReturn("test-uuid"); + + doReturn(new Pair<>(false, "External system error")).when(provisioner) + .getInstanceConsoleOnExternalSystem(anyString(), anyString(), anyString(), anyMap(), anyInt()); + + GetExternalConsoleAnswer result = provisioner.getInstanceConsole("host-name", "test-extension", "test-extension.sh", cmd); + + assertNotNull(result); + assertEquals("External system error", result.getDetails()); + } + + @Test + public void getInstanceConsoleReturnsErrorWhenConsoleObjectIsMissing() { + GetExternalConsoleCommand cmd = mock(GetExternalConsoleCommand.class); + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + when(cmd.getVirtualMachine()).thenReturn(vmTO); + when(vmTO.getUuid()).thenReturn("test-uuid"); + + String invalidOutput = "{\"invalid_key\":\"value\"}"; + doReturn(new Pair<>(true, invalidOutput)).when(provisioner) + .getInstanceConsoleOnExternalSystem(anyString(), anyString(), anyString(), anyMap(), anyInt()); + + GetExternalConsoleAnswer result = provisioner.getInstanceConsole("host-name", "test-extension", "test-extension.sh", cmd); + + assertNotNull(result); + assertEquals("Missing console object in output", result.getDetails()); + } + + @Test + public void getInstanceConsoleReturnsErrorWhenRequiredFieldsAreMissing() { + GetExternalConsoleCommand cmd = mock(GetExternalConsoleCommand.class); + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + when(cmd.getVirtualMachine()).thenReturn(vmTO); + when(vmTO.getUuid()).thenReturn("test-uuid"); + + String incompleteOutput = "{\"console\":{\"host\":\"127.0.0.1\"}}"; + doReturn(new Pair<>(true, incompleteOutput)).when(provisioner) + .getInstanceConsoleOnExternalSystem(anyString(), anyString(), anyString(), anyMap(), anyInt()); + + GetExternalConsoleAnswer result = provisioner.getInstanceConsole("host-name", "test-extension", "test-extension.sh", cmd); + + assertNotNull(result); + assertEquals("Missing required fields in output", result.getDetails()); + } + + @Test + public void getInstanceConsoleReturnsErrorWhenOutputParsingFails() { + GetExternalConsoleCommand cmd = mock(GetExternalConsoleCommand.class); + VirtualMachineTO vmTO = mock(VirtualMachineTO.class); + when(cmd.getVirtualMachine()).thenReturn(vmTO); + when(vmTO.getUuid()).thenReturn("test-uuid"); + + String malformedOutput = "{console:invalid}"; + doReturn(new Pair<>(true, malformedOutput)).when(provisioner) + .getInstanceConsoleOnExternalSystem(anyString(), anyString(), anyString(), anyMap(), anyInt()); + + GetExternalConsoleAnswer result = provisioner.getInstanceConsole("host-name", "test-extension", "test-extension.sh", cmd); + + assertNotNull(result); + assertEquals("Failed to parse output", result.getDetails()); + } + + @Test + public void getInstanceConsoleOnExternalSystemReturnsSuccessWhenCommandExecutesSuccessfully() { + String extensionName = "test-extension"; + String filename = "test-script.sh"; + String vmUUID = "test-vm-uuid"; + Map accessDetails = new HashMap<>(); + int wait = 30; + + doReturn(new Pair<>(true, "Console details")).when(provisioner) + .executeExternalCommand(eq(extensionName), eq("getconsole"), eq(accessDetails), eq(wait), anyString(), eq(filename)); + + Pair result = provisioner.getInstanceConsoleOnExternalSystem(extensionName, filename, vmUUID, accessDetails, wait); + + assertTrue(result.first()); + assertEquals("Console details", result.second()); + } + + @Test + public void getInstanceConsoleOnExternalSystemReturnsFailureWhenCommandFails() { + String extensionName = "test-extension"; + String filename = "test-script.sh"; + String vmUUID = "test-vm-uuid"; + Map accessDetails = new HashMap<>(); + int wait = 30; + + doReturn(new Pair<>(false, "Failed to get console")).when(provisioner) + .executeExternalCommand(eq(extensionName), eq("getconsole"), eq(accessDetails), eq(wait), anyString(), eq(filename)); + + Pair result = provisioner.getInstanceConsoleOnExternalSystem(extensionName, filename, vmUUID, accessDetails, wait); + + assertFalse(result.first()); + assertEquals("Failed to get console", result.second()); + } + + @Test + public void getInstanceConsoleOnExternalSystemHandlesNullResponseGracefully() { + String extensionName = "test-extension"; + String filename = "test-script.sh"; + String vmUUID = "test-vm-uuid"; + Map accessDetails = new HashMap<>(); + int wait = 30; + + doReturn(null).when(provisioner) + .executeExternalCommand(eq(extensionName), eq("getconsole"), eq(accessDetails), eq(wait), anyString(), eq(filename)); + + Pair result = provisioner.getInstanceConsoleOnExternalSystem(extensionName, filename, vmUUID, accessDetails, wait); + + assertNull(result); + } + + @Test + public void getSanitizedJsonStringForLogReturnsNullWhenInputIsNull() { + String result = provisioner.getSanitizedJsonStringForLog(null); + assertNull(result); + } + + @Test + public void getSanitizedJsonStringForLogReturnsEmptyWhenInputIsEmpty() { + String result = provisioner.getSanitizedJsonStringForLog(""); + assertEquals("", result); + } + + @Test + public void getSanitizedJsonStringForLogReturnsSameStringWhenNoPasswordField() { + String json = "{\"key\":\"value\"}"; + String result = provisioner.getSanitizedJsonStringForLog(json); + assertEquals(json, result); + } + + @Test + public void getSanitizedJsonStringForLogMasksPasswordField() { + String json = "{\"password\":\"secret\"}"; + String result = provisioner.getSanitizedJsonStringForLog(json); + assertEquals("{\"password\":\"****\"}", result); + } + + @Test + public void getSanitizedJsonStringForLogHandlesMultiplePasswordFields() { + String json = "{\"password\":\"secret\",\"nested\":{\"password\":\"anotherSecret\"}}"; + String result = provisioner.getSanitizedJsonStringForLog(json); + assertEquals("{\"password\":\"****\",\"nested\":{\"password\":\"****\"}}", result); + } + + @Test + public void getSanitizedJsonStringForLogHandlesMalformedJsonGracefully() { + String json = "{password:\"secret\""; + String result = provisioner.getSanitizedJsonStringForLog(json); + assertEquals("{password:\"secret\"", result); + } + + @Test + public void getExtensionConfigureErrorReturnsMessageWhenHostNameIsNotBlank() { + String result = provisioner.getExtensionConfigureError("test-extension", "test-host"); + assertEquals("Extension: test-extension not configured for host: test-host", result); + } + + @Test + public void getExtensionConfigureErrorReturnsMessageWhenHostNameIsBlank() { + String result = provisioner.getExtensionConfigureError("test-extension", ""); + assertEquals("Extension: test-extension not configured", result); + } + + @Test + public void getExtensionConfigureErrorReturnsMessageWhenHostNameIsNull() { + String result = provisioner.getExtensionConfigureError("test-extension", null); + assertEquals("Extension: test-extension not configured", result); + } } diff --git a/scripts/vm/hypervisor/external/provisioner/provisioner.sh b/scripts/vm/hypervisor/external/provisioner/provisioner.sh index 63d07653c0f..f067d892f1f 100755 --- a/scripts/vm/hypervisor/external/provisioner/provisioner.sh +++ b/scripts/vm/hypervisor/external/provisioner/provisioner.sh @@ -18,7 +18,7 @@ parse_json() { local json_string=$1 - echo "$json_string" | jq '.' > /dev/null || { echo '{"error":"Invalid JSON input"}'; exit 1; } + echo "$json_string" | jq '.' > /dev/null || { echo '{"status": "error", "error": "Invalid JSON input"}'; exit 1; } } generate_random_mac() { @@ -99,17 +99,24 @@ status() { echo '{"status": "success", "power_state": "poweron"}' } +get_console() { + parse_json "$1" || exit 1 + local response + jq -n '{status:"error", error: "Operation not supported"}' + exit 1 +} + action=$1 parameters_file="$2" wait_time="$3" if [[ -z "$action" || -z "$parameters_file" ]]; then - echo '{"error":"Missing required arguments"}' + echo '{"status": "error", "error": "Missing required arguments"}' exit 1 fi if [[ ! -r "$parameters_file" ]]; then - echo '{"error":"File not found or unreadable"}' + echo '{"status": "error", "error": "File not found or unreadable"}' exit 1 fi @@ -138,8 +145,11 @@ case $action in status) status "$parameters" ;; + getconsole) + get_console "$parameters" + ;; *) - echo '{"error":"Invalid action"}' + echo '{"status": "error", "error": "Invalid action"}' exit 1 ;; esac diff --git a/server/src/main/java/com/cloud/consoleproxy/AgentHookBase.java b/server/src/main/java/com/cloud/consoleproxy/AgentHookBase.java index ff79115b904..fd8c535a630 100644 --- a/server/src/main/java/com/cloud/consoleproxy/AgentHookBase.java +++ b/server/src/main/java/com/cloud/consoleproxy/AgentHookBase.java @@ -48,6 +48,7 @@ import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.host.Status; import com.cloud.host.dao.HostDao; +import com.cloud.hypervisor.Hypervisor; import com.cloud.servlet.ConsoleProxyPasswordBasedEncryptor; import com.cloud.servlet.ConsoleProxyServlet; import com.cloud.utils.Ternary; @@ -161,8 +162,12 @@ public abstract class AgentHookBase implements AgentHook { String sid = cmd.getSid(); if (sid == null || !sid.equals(vm.getVncPassword())) { - logger.warn("sid " + sid + " in url does not match stored sid."); - return new ConsoleAccessAuthenticationAnswer(cmd, false); + if (Hypervisor.HypervisorType.External.equals(vm.getHypervisorType())) { + logger.debug("{} is on External hypervisor, skip checking sid", vm.getHypervisorType()); + } else { + logger.warn("sid {} in url does not match stored sid.", sid); + return new ConsoleAccessAuthenticationAnswer(cmd, false); + } } if (cmd.isReauthenticating()) { diff --git a/server/src/main/java/com/cloud/server/ManagementServer.java b/server/src/main/java/com/cloud/server/ManagementServer.java index 611ba9b4200..3932006c292 100644 --- a/server/src/main/java/com/cloud/server/ManagementServer.java +++ b/server/src/main/java/com/cloud/server/ManagementServer.java @@ -16,7 +16,9 @@ // under the License. package com.cloud.server; +import com.cloud.agent.api.Answer; import com.cloud.host.DetailVO; +import com.cloud.host.Host; import com.cloud.host.HostVO; import com.cloud.storage.GuestOSHypervisorVO; import com.cloud.storage.GuestOSVO; @@ -74,4 +76,6 @@ public interface ManagementServer extends ManagementService, PluggableService { Pair updateSystemVM(VMInstanceVO systemVM, boolean forced); + Answer getExternalVmConsole(VirtualMachine vm, Host host); + } diff --git a/server/src/main/java/com/cloud/server/ManagementServerImpl.java b/server/src/main/java/com/cloud/server/ManagementServerImpl.java index 3245acfbbf2..43f6a8a5b87 100644 --- a/server/src/main/java/com/cloud/server/ManagementServerImpl.java +++ b/server/src/main/java/com/cloud/server/ManagementServerImpl.java @@ -44,18 +44,6 @@ import javax.crypto.spec.SecretKeySpec; import javax.inject.Inject; import javax.naming.ConfigurationException; -import com.cloud.cpu.CPU; -import com.cloud.dc.VlanDetailsVO; -import com.cloud.dc.dao.VlanDetailsDao; -import com.cloud.network.dao.NetrisProviderDao; -import com.cloud.network.dao.NsxProviderDao; - -import com.cloud.utils.security.CertificateHelper; -import com.cloud.api.query.dao.ManagementServerJoinDao; -import com.cloud.api.query.vo.ManagementServerJoinVO; -import com.cloud.gpu.VgpuProfileVO; -import com.cloud.gpu.dao.VgpuProfileDao; -import com.cloud.offering.ServiceOffering; import org.apache.cloudstack.acl.ControlledEntity; import org.apache.cloudstack.acl.SecurityChecker; import org.apache.cloudstack.affinity.AffinityGroupProcessor; @@ -695,7 +683,9 @@ import com.cloud.alert.AlertManager; import com.cloud.alert.AlertVO; import com.cloud.alert.dao.AlertDao; import com.cloud.api.ApiDBUtils; +import com.cloud.api.query.dao.ManagementServerJoinDao; import com.cloud.api.query.dao.StoragePoolJoinDao; +import com.cloud.api.query.vo.ManagementServerJoinVO; import com.cloud.api.query.vo.StoragePoolJoinVO; import com.cloud.capacity.Capacity; import com.cloud.capacity.CapacityVO; @@ -706,6 +696,7 @@ import com.cloud.configuration.Config; import com.cloud.configuration.ConfigurationManagerImpl; import com.cloud.consoleproxy.ConsoleProxyManagementState; import com.cloud.consoleproxy.ConsoleProxyManager; +import com.cloud.cpu.CPU; import com.cloud.dc.AccountVlanMapVO; import com.cloud.dc.ClusterVO; import com.cloud.dc.DataCenterVO; @@ -715,6 +706,7 @@ import com.cloud.dc.Pod; import com.cloud.dc.PodVlanMapVO; import com.cloud.dc.Vlan; import com.cloud.dc.Vlan.VlanType; +import com.cloud.dc.VlanDetailsVO; import com.cloud.dc.VlanVO; import com.cloud.dc.dao.AccountVlanMapDao; import com.cloud.dc.dao.ClusterDao; @@ -723,6 +715,7 @@ import com.cloud.dc.dao.DomainVlanMapDao; import com.cloud.dc.dao.HostPodDao; import com.cloud.dc.dao.PodVlanMapDao; import com.cloud.dc.dao.VlanDao; +import com.cloud.dc.dao.VlanDetailsDao; import com.cloud.deploy.DataCenterDeployment; import com.cloud.deploy.DeploymentPlanner; import com.cloud.deploy.DeploymentPlanner.ExcludeList; @@ -744,6 +737,8 @@ import com.cloud.exception.PermissionDeniedException; import com.cloud.exception.ResourceUnavailableException; import com.cloud.exception.VirtualMachineMigrationException; import com.cloud.gpu.GPU; +import com.cloud.gpu.VgpuProfileVO; +import com.cloud.gpu.dao.VgpuProfileDao; import com.cloud.ha.HighAvailabilityManager; import com.cloud.host.DetailVO; import com.cloud.host.Host; @@ -770,13 +765,16 @@ import com.cloud.network.dao.IPAddressDao; import com.cloud.network.dao.IPAddressVO; import com.cloud.network.dao.LoadBalancerDao; import com.cloud.network.dao.LoadBalancerVO; +import com.cloud.network.dao.NetrisProviderDao; import com.cloud.network.dao.NetworkAccountDao; import com.cloud.network.dao.NetworkAccountVO; import com.cloud.network.dao.NetworkDao; import com.cloud.network.dao.NetworkDomainDao; import com.cloud.network.dao.NetworkDomainVO; import com.cloud.network.dao.NetworkVO; +import com.cloud.network.dao.NsxProviderDao; import com.cloud.network.vpc.dao.VpcDao; +import com.cloud.offering.ServiceOffering; import com.cloud.org.Cluster; import com.cloud.org.Grouping.AllocationState; import com.cloud.projects.Project; @@ -850,6 +848,7 @@ import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.utils.fsm.StateMachine2; import com.cloud.utils.net.MacAddress; import com.cloud.utils.net.NetUtils; +import com.cloud.utils.security.CertificateHelper; import com.cloud.utils.ssh.SSHKeysHelper; import com.cloud.vm.ConsoleProxyVO; import com.cloud.vm.DiskProfile; @@ -5813,4 +5812,8 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe } + @Override + public Answer getExternalVmConsole(VirtualMachine vm, Host host) { + return extensionsManager.getInstanceConsole(vm, host); + } } diff --git a/server/src/main/java/com/cloud/servlet/ConsoleProxyClientParam.java b/server/src/main/java/com/cloud/servlet/ConsoleProxyClientParam.java index b416ab98288..b923d71bfd1 100644 --- a/server/src/main/java/com/cloud/servlet/ConsoleProxyClientParam.java +++ b/server/src/main/java/com/cloud/servlet/ConsoleProxyClientParam.java @@ -34,6 +34,8 @@ public class ConsoleProxyClientParam { private String username; private String password; + private boolean sessionRequiresNewViewer = false; + /** * IP that has generated the console endpoint */ @@ -218,4 +220,8 @@ public class ConsoleProxyClientParam { public void setClientIp(String clientIp) { this.clientIp = clientIp; } + + public void setSessionRequiresNewViewer(boolean sessionRequiresNewViewer) { + this.sessionRequiresNewViewer = sessionRequiresNewViewer; + } } diff --git a/server/src/main/java/com/cloud/servlet/ConsoleProxyServlet.java b/server/src/main/java/com/cloud/servlet/ConsoleProxyServlet.java index 2b786a8f1ef..265a975af24 100644 --- a/server/src/main/java/com/cloud/servlet/ConsoleProxyServlet.java +++ b/server/src/main/java/com/cloud/servlet/ConsoleProxyServlet.java @@ -43,6 +43,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.context.support.SpringBeanAutowiringSupport; +import com.cloud.hypervisor.Hypervisor; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -277,15 +278,19 @@ public class ConsoleProxyServlet extends HttpServlet { String sid = req.getParameter("sid"); if (sid == null || !sid.equals(vm.getVncPassword())) { - if(sid != null) { - sid = sid.replaceAll(SANITIZATION_REGEX, "_"); - LOGGER.warn(String.format("sid [%s] in url does not match stored sid.", sid)); + if (Hypervisor.HypervisorType.External.equals(vm.getHypervisorType())) { + LOGGER.debug("{} is on External hypervisor, skip checking sid", vm.getHypervisorType()); } else { - LOGGER.warn("Null sid in URL."); - } + if (sid != null) { + sid = sid.replaceAll(SANITIZATION_REGEX, "_"); + LOGGER.warn(String.format("sid [%s] in url does not match stored sid.", sid)); + } else { + LOGGER.warn("Null sid in URL."); + } - sendResponse(resp, "failed"); - return; + sendResponse(resp, "failed"); + return; + } } sendResponse(resp, "success"); diff --git a/server/src/main/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImpl.java b/server/src/main/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImpl.java index dc33b3434d4..fde937764e7 100644 --- a/server/src/main/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImpl.java +++ b/server/src/main/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImpl.java @@ -22,15 +22,15 @@ import java.util.Date; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import javax.inject.Inject; import javax.naming.ConfigurationException; -import com.cloud.domain.Domain; -import com.cloud.domain.dao.DomainDao; -import com.cloud.exception.InvalidParameterValueException; import org.apache.cloudstack.api.ResponseGenerator; import org.apache.cloudstack.api.ResponseObject; import org.apache.cloudstack.api.command.user.consoleproxy.ConsoleEndpoint; @@ -38,20 +38,29 @@ import org.apache.cloudstack.api.command.user.consoleproxy.ListConsoleSessionsCm import org.apache.cloudstack.api.response.ConsoleSessionResponse; import org.apache.cloudstack.api.response.ListResponse; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.security.keys.KeysManager; +import org.apache.cloudstack.managed.context.ManagedContextRunnable; import org.apache.commons.codec.binary.Base64; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.joda.time.DateTime; import com.cloud.agent.AgentManager; import com.cloud.agent.api.Answer; +import com.cloud.agent.api.GetExternalConsoleAnswer; import com.cloud.agent.api.GetVmVncTicketAnswer; import com.cloud.agent.api.GetVmVncTicketCommand; import com.cloud.consoleproxy.ConsoleProxyManager; import com.cloud.dc.DataCenter; import com.cloud.dc.dao.DataCenterDao; +import com.cloud.domain.Domain; +import com.cloud.domain.dao.DomainDao; import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.InvalidParameterValueException; import com.cloud.exception.OperationTimedoutException; import com.cloud.exception.PermissionDeniedException; import com.cloud.host.HostVO; @@ -72,7 +81,6 @@ import com.cloud.utils.db.EntityManager; import com.cloud.utils.db.GlobalLock; import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.vm.ConsoleSessionVO; -import com.cloud.vm.VMInstanceDetailVO; import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachineManager; import com.cloud.vm.VmDetailConstants; @@ -80,15 +88,6 @@ import com.cloud.vm.dao.ConsoleSessionDao; import com.cloud.vm.dao.VMInstanceDetailsDao; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.apache.cloudstack.framework.config.ConfigKey; -import org.apache.cloudstack.managed.context.ManagedContextRunnable; -import org.joda.time.DateTime; - -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; public class ConsoleAccessManagerImpl extends ManagerBase implements ConsoleAccessManager { @@ -128,6 +127,11 @@ public class ConsoleAccessManagerImpl extends ManagerBase implements ConsoleAcce VirtualMachine.State.Stopped, VirtualMachine.State.Restoring, VirtualMachine.State.Error, VirtualMachine.State.Destroyed ); + protected static final List MAINTENANCE_RESOURCE_STATES = new ArrayList<>(Arrays.asList( + ResourceState.ErrorInMaintenance, ResourceState.ErrorInPrepareForMaintenance + )); + protected static final String WEB_SOCKET_PATH= "websockify"; + @Override public boolean configure(String name, Map params) throws ConfigurationException { ConsoleAccessManagerImpl.secretKeysManager = keysManager; @@ -293,12 +297,6 @@ public class ConsoleAccessManagerImpl extends ManagerBase implements ConsoleAcce return new ConsoleEndpoint(false, null, "Cannot find VM with ID " + vmId); } - if (Hypervisor.HypervisorType.External.equals(vm.getHypervisorType())) { - logger.error("Console access for {} cannot be provided it is {} hypervisor instance", vm, - Hypervisor.HypervisorType.External); - return new ConsoleEndpoint(false, null, "Console access to this instance cannot be provided"); - } - if (!checkSessionPermission(vm, account)) { return new ConsoleEndpoint(false, null, "Permission denied"); } @@ -427,67 +425,119 @@ public class ConsoleAccessManagerImpl extends ManagerBase implements ConsoleAcce return consoleEndpoint; } - private ConsoleEndpoint composeConsoleAccessEndpoint(String rootUrl, VirtualMachine vm, HostVO hostVo, String addr, - String sessionUuid, String extraSecurityToken) { - String host = hostVo.getPrivateIpAddress(); - - Pair portInfo = null; - if (hostVo.getHypervisorType() == Hypervisor.HypervisorType.KVM && - (hostVo.getResourceState().equals(ResourceState.ErrorInMaintenance) || - hostVo.getResourceState().equals(ResourceState.ErrorInPrepareForMaintenance))) { - VMInstanceDetailVO detailAddress = vmInstanceDetailsDao.findDetail(vm.getId(), VmDetailConstants.KVM_VNC_ADDRESS); - VMInstanceDetailVO detailPort = vmInstanceDetailsDao.findDetail(vm.getId(), VmDetailConstants.KVM_VNC_PORT); - if (detailAddress != null && detailPort != null) { - portInfo = new Pair<>(detailAddress.getValue(), Integer.valueOf(detailPort.getValue())); - } else { - logger.warn("KVM Host in ErrorInMaintenance/ErrorInPrepareForMaintenance but " + - "no VNC Address/Port was available. Falling back to default one from MS."); - } + protected ConsoleConnectionDetails getConsoleConnectionDetailsForExternalVm(ConsoleConnectionDetails details, + VirtualMachine vm, HostVO host) { + Answer answer = managementServer.getExternalVmConsole(vm, host); + if (answer == null) { + logger.error("Unable to get console access details for external {} on {}: answer is null.", vm, host); + return null; } - - if (portInfo == null) { - portInfo = managementServer.getVncPort(vm); + if (!answer.getResult()) { + logger.error("Unable to get console access details for external {} on {}: answer result is false. Reason: {}", vm, host, answer.getDetails()); + return null; } - - if (logger.isDebugEnabled()) - logger.debug("Port info " + portInfo.first()); - - Ternary parsedHostInfo = parseHostInfo(portInfo.first()); - - int port = -1; - if (portInfo.second() == -9) { - //for hyperv - port = Integer.parseInt(managementServer.findDetail(hostVo.getId(), "rdp.server.port").getValue()); - } else { - port = portInfo.second(); + if (!(answer instanceof GetExternalConsoleAnswer)) { + logger.error("Unable to get console access details for external {} on {}: answer is not of type GetExternalConsoleAnswer.", vm, host); + return null; } + GetExternalConsoleAnswer getExternalConsoleAnswer = (GetExternalConsoleAnswer) answer; + details.setModeFromExternalProtocol(getExternalConsoleAnswer.getProtocol()); + details.setDirectUrl(getExternalConsoleAnswer.getUrl()); + details.setHost(getExternalConsoleAnswer.getHost()); + if (getExternalConsoleAnswer.getPort() != null) { + details.setPort(getExternalConsoleAnswer.getPort()); + } + if (StringUtils.isNotBlank(getExternalConsoleAnswer.getPassword())) { + details.setSid(getExternalConsoleAnswer.getPassword()); + details.setSessionRequiresNewViewer(getExternalConsoleAnswer.isPasswordOneTimeUseOnly()); + } + return details; + } - String sid = vm.getVncPassword(); - VMInstanceDetailVO details = vmInstanceDetailsDao.findDetail(vm.getId(), VmDetailConstants.KEYBOARD); + protected Pair getHostAndPortForKVMMaintenanceHostIfNeeded(HostVO host, + Map vmDetails) { + if (!Hypervisor.HypervisorType.KVM.equals(host.getHypervisorType())) { + return null; + } + if(!MAINTENANCE_RESOURCE_STATES.contains(host.getResourceState())) { + return null; + } + String address = vmDetails.get(VmDetailConstants.KVM_VNC_ADDRESS); + String port = vmDetails.get(VmDetailConstants.KVM_VNC_PORT); + if (ObjectUtils.allNotNull(address, port)) { + return new Pair<>(address, Integer.valueOf(port)); + } + logger.warn("KVM Host in ErrorInMaintenance/ErrorInPrepareForMaintenance but " + + "no VNC Address/Port was available. Falling back to default one from MS."); + return null; + } + protected ConsoleConnectionDetails getConsoleConnectionDetails(VirtualMachine vm, HostVO host) { + String locale = null; String tag = vm.getUuid(); String displayName = vm.getHostName(); if (vm instanceof UserVm) { displayName = ((UserVm) vm).getDisplayName(); } + Map vmDetails = vmInstanceDetailsDao.listDetailsKeyPairs(vm.getId(), + List.of(VmDetailConstants.KEYBOARD, VmDetailConstants.KVM_VNC_ADDRESS, VmDetailConstants.KVM_VNC_PORT)); + if (vmDetails.get(VmDetailConstants.KEYBOARD) != null) { + locale = vmDetails.get(VmDetailConstants.KEYBOARD); + } + ConsoleConnectionDetails details = new ConsoleConnectionDetails(vm.getVncPassword(), locale, tag, displayName); + if (Hypervisor.HypervisorType.External.equals(host.getHypervisorType())) { + return getConsoleConnectionDetailsForExternalVm(details, vm, host); + } + Pair hostPortInfo = getHostAndPortForKVMMaintenanceHostIfNeeded(host, vmDetails); + if (hostPortInfo == null) { + hostPortInfo = managementServer.getVncPort(vm); + } + logger.debug("Retrieved VNC host and port info :[{}, {}] for {} on {}", hostPortInfo.first(), + hostPortInfo.second(), vm, host); + Ternary parsedHostInfo = parseHostInfo(hostPortInfo.first()); + details.setHost(parsedHostInfo.first()); + details.setTunnelUrl(parsedHostInfo.second()); + details.setTunnelSession(parsedHostInfo.third()); + details.setPort(hostPortInfo.second()); + if (hostPortInfo.second() == -9) { + details.setUsingRDP(true); + details.setPort(Integer.parseInt(managementServer.findDetail(host.getId(), "rdp.server.port") + .getValue())); + logger.debug("HyperV RDP port for {} on {} is: {}", vm, host, details.getPort()); + } + return details; + } - String ticket = genAccessTicket(parsedHostInfo.first(), String.valueOf(port), sid, tag, sessionUuid); + protected ConsoleEndpoint composeConsoleAccessEndpoint(String rootUrl, VirtualMachine vm, HostVO hostVo, String addr, + String sessionUuid, String extraSecurityToken) { + ConsoleConnectionDetails result = getConsoleConnectionDetails(vm, hostVo); + if (result == null) { + return new ConsoleEndpoint(false, null, "Console access to this instance cannot be provided"); + } + + if (ConsoleConnectionDetails.Mode.Direct.equals(result.getMode())) { + persistConsoleSession(sessionUuid, vm.getId(), hostVo.getId(), addr); + return new ConsoleEndpoint(true, result.getDirectUrl()); + } + + String ticket = genAccessTicket(result.getHost(), String.valueOf(result.getPort()), result.getSid(), + result.getTag(), sessionUuid); ConsoleProxyPasswordBasedEncryptor encryptor = new ConsoleProxyPasswordBasedEncryptor(getEncryptorPassword()); - ConsoleProxyClientParam param = generateConsoleProxyClientParam(parsedHostInfo, port, sid, tag, ticket, - sessionUuid, addr, extraSecurityToken, vm, hostVo, details, portInfo, host, displayName); + ConsoleProxyClientParam param = generateConsoleProxyClientParam(result, ticket, sessionUuid, addr, + extraSecurityToken, vm, hostVo); String token = encryptor.encryptObject(ConsoleProxyClientParam.class, param); int vncPort = consoleProxyManager.getVncPort(vm.getDataCenterId()); - String url = generateConsoleAccessUrl(rootUrl, param, token, vncPort, vm, hostVo, details); + String url = generateConsoleAccessUrl(rootUrl, param, token, vncPort, vm, hostVo, result.getLocale()); - logger.debug("Adding allowed session: " + sessionUuid); + logger.debug("Adding allowed session: {}", sessionUuid); persistConsoleSession(sessionUuid, vm.getId(), hostVo.getId(), addr); managementServer.setConsoleAccessForVm(vm.getId(), sessionUuid); ConsoleEndpoint consoleEndpoint = new ConsoleEndpoint(true, url); consoleEndpoint.setWebsocketHost(managementServer.getConsoleAccessAddress(vm.getId())); consoleEndpoint.setWebsocketPort(String.valueOf(vncPort)); - consoleEndpoint.setWebsocketPath("websockify"); + consoleEndpoint.setWebsocketPath(WEB_SOCKET_PATH); consoleEndpoint.setWebsocketToken(token); if (StringUtils.isNotBlank(param.getExtraSecurityToken())) { consoleEndpoint.setWebsocketExtra(param.getExtraSecurityToken()); @@ -509,23 +559,23 @@ public class ConsoleAccessManagerImpl extends ManagerBase implements ConsoleAcce consoleSessionDao.persist(consoleSessionVo); } - private String generateConsoleAccessUrl(String rootUrl, ConsoleProxyClientParam param, String token, int vncPort, - VirtualMachine vm, HostVO hostVo, VMInstanceDetailVO details) { + protected String generateConsoleAccessUrl(String rootUrl, ConsoleProxyClientParam param, String token, int vncPort, + VirtualMachine vm, HostVO hostVo, String locale) { StringBuilder sb = new StringBuilder(rootUrl); if (param.getHypervHost() != null || !ConsoleProxyManager.NoVncConsoleDefault.value()) { - sb.append("/ajax?token=" + token); + sb.append("/ajax?token=").append(token); } else { sb.append("/resource/noVNC/vnc.html") .append("?autoconnect=true&show_dot=true") - .append("&port=" + vncPort) - .append("&token=" + token); - if (requiresVncOverWebSocketConnection(vm, hostVo) && details != null && details.getValue() != null) { - sb.append("&language=" + details.getValue()); + .append("&port=").append(vncPort) + .append("&token=").append(token); + if (requiresVncOverWebSocketConnection(vm, hostVo) && StringUtils.isNotBlank(locale)) { + sb.append("&language=").append(locale); } } if (StringUtils.isNotBlank(param.getExtraSecurityToken())) { - sb.append("&extra=" + param.getExtraSecurityToken()); + sb.append("&extra=").append(param.getExtraSecurityToken()); } // for console access, we need guest OS type to help implement keyboard @@ -535,50 +585,46 @@ public class ConsoleAccessManagerImpl extends ManagerBase implements ConsoleAcce sb.append("&guest=windows"); if (logger.isDebugEnabled()) { - logger.debug("Compose console url: " + sb); + logger.debug("Compose console url: {}", sb); } return sb.toString().startsWith("https") ? sb.toString() : "http:" + sb; } - private ConsoleProxyClientParam generateConsoleProxyClientParam(Ternary parsedHostInfo, - int port, String sid, String tag, String ticket, - String sessionUuid, String addr, - String extraSecurityToken, VirtualMachine vm, - HostVO hostVo, VMInstanceDetailVO details, - Pair portInfo, String host, - String displayName) { + protected ConsoleProxyClientParam generateConsoleProxyClientParam(ConsoleConnectionDetails details, String ticket, + String sessionUuid, String addr, String extraSecurityToken, VirtualMachine vm, HostVO host) { ConsoleProxyClientParam param = new ConsoleProxyClientParam(); - param.setClientHostAddress(parsedHostInfo.first()); - param.setClientHostPort(port); - param.setClientHostPassword(sid); - param.setClientTag(tag); - param.setClientDisplayName(displayName); + param.setClientHostAddress(details.getHost()); + param.setClientHostPort(details.getPort()); + param.setClientHostPassword(details.getSid()); + param.setClientTag(details.getTag()); + param.setClientDisplayName(details.getDisplayName()); param.setTicket(ticket); param.setSessionUuid(sessionUuid); param.setSourceIP(addr); + param.setSessionRequiresNewViewer(details.isSessionRequiresNewViewer()); if (StringUtils.isNotBlank(extraSecurityToken)) { param.setExtraSecurityToken(extraSecurityToken); logger.debug("Added security token for client validation"); } - if (requiresVncOverWebSocketConnection(vm, hostVo)) { + if (requiresVncOverWebSocketConnection(vm, host)) { setWebsocketUrl(vm, param); } - if (details != null) { - param.setLocale(details.getValue()); + if (details.getLocale() != null) { + param.setLocale(details.getLocale()); } - if (portInfo.second() == -9) { - //For Hyperv Clinet Host Address will send Instance id - param.setHypervHost(host); - param.setUsername(managementServer.findDetail(hostVo.getId(), "username").getValue()); - param.setPassword(managementServer.findDetail(hostVo.getId(), "password").getValue()); + if (details.isUsingRDP()) { + //For Hyperv Client Host Address will send Instance id + param.setHypervHost(host.getPrivateIpAddress()); + param.setUsername(managementServer.findDetail(host.getId(), "username").getValue()); + param.setPassword(managementServer.findDetail(host.getId(), "password").getValue()); } - if (parsedHostInfo.second() != null && parsedHostInfo.third() != null) { - param.setClientTunnelUrl(parsedHostInfo.second()); - param.setClientTunnelSession(parsedHostInfo.third()); + if (ObjectUtils.allNotNull(details.getTunnelUrl(), details.getTunnelSession())) { + param.setClientTunnelUrl(details.getTunnelUrl()); + param.setClientTunnelSession(details.getTunnelSession()); } return param; } @@ -651,7 +697,7 @@ public class ConsoleAccessManagerImpl extends ManagerBase implements ConsoleAcce return ""; } - private String getEncryptorPassword() { + protected String getEncryptorPassword() { String key = keysManager.getEncryptionKey(); String iv = keysManager.getEncryptionIV(); @@ -700,4 +746,120 @@ public class ConsoleAccessManagerImpl extends ManagerBase implements ConsoleAcce } } + protected static class ConsoleConnectionDetails { + public enum Mode { + ConsoleProxy, + Direct + } + + private Mode mode = Mode.ConsoleProxy; + private String host; + private int port = -1; + private String sid; + private String locale; + private String tag; + private String displayName; + private String tunnelUrl = null; + private String tunnelSession = null; + private boolean usingRDP; + private String directUrl; + private boolean sessionRequiresNewViewer = false; + + ConsoleConnectionDetails(String sid, String locale, String tag, String displayName) { + this.sid = sid; + this.locale = locale; + this.tag = tag; + this.displayName = displayName; + } + + public Mode getMode() { + return mode; + } + + public void setModeFromExternalProtocol(String protocol) { + this.mode = Mode.ConsoleProxy; + if (StringUtils.isBlank(protocol)) { + return; + } + if (Mode.Direct.name().toLowerCase().equalsIgnoreCase(protocol)) { + this.mode = Mode.Direct; + } + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public String getSid() { + return sid; + } + + public void setSid(String sid) { + this.sid = sid; + } + + public String getLocale() { + return locale; + } + + public String getTag() { + return tag; + } + + public String getDisplayName() { + return displayName; + } + + public String getTunnelUrl() { + return tunnelUrl; + } + + public void setTunnelUrl(String tunnelUrl) { + this.tunnelUrl = tunnelUrl; + } + + public String getTunnelSession() { + return tunnelSession; + } + + public void setTunnelSession(String tunnelSession) { + this.tunnelSession = tunnelSession; + } + + public boolean isUsingRDP() { + return usingRDP; + } + + public void setUsingRDP(boolean usingRDP) { + this.usingRDP = usingRDP; + } + + public String getDirectUrl() { + return directUrl; + } + + public void setDirectUrl(String directUrl) { + this.directUrl = directUrl; + } + + public boolean isSessionRequiresNewViewer() { + return sessionRequiresNewViewer; + } + + public void setSessionRequiresNewViewer(boolean sessionRequiresNewViewer) { + this.sessionRequiresNewViewer = sessionRequiresNewViewer; + } + } } diff --git a/server/src/test/java/com/cloud/server/ManagementServerImplTest.java b/server/src/test/java/com/cloud/server/ManagementServerImplTest.java index c31db2c6dd7..ebced92f8fe 100644 --- a/server/src/test/java/com/cloud/server/ManagementServerImplTest.java +++ b/server/src/test/java/com/cloud/server/ManagementServerImplTest.java @@ -49,6 +49,7 @@ import org.apache.cloudstack.framework.config.ConfigDepot; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.framework.config.impl.ConfigurationVO; +import org.apache.cloudstack.framework.extensions.manager.ExtensionsManager; import org.apache.cloudstack.userdata.UserDataManager; import org.junit.After; import org.junit.Assert; @@ -99,6 +100,7 @@ import com.cloud.utils.db.SearchCriteria; import com.cloud.utils.exception.CloudRuntimeException; import com.cloud.vm.UserVmVO; import com.cloud.vm.VMInstanceDetailVO; +import com.cloud.vm.VirtualMachine; import com.cloud.vm.dao.UserVmDao; import com.cloud.vm.dao.VMInstanceDetailsDao; @@ -165,6 +167,9 @@ public class ManagementServerImplTest { @Mock GuestOSDao _guestOSDao; + @Mock + ExtensionsManager extensionManager; + @Spy @InjectMocks ManagementServerImpl spy = new ManagementServerImpl(); @@ -1019,4 +1024,13 @@ public class ManagementServerImplTest { Mockito.verify(_guestOSCategoryDao, Mockito.times(1)).searchAndCount(Mockito.eq(searchCriteria), Mockito.any()); } + + @Test + public void testGetExternalVmConsole() { + VirtualMachine virtualMachine = Mockito.mock(VirtualMachine.class); + Host host = Mockito.mock(Host.class); + Mockito.when(extensionManager.getInstanceConsole(virtualMachine, host)).thenReturn(Mockito.mock(com.cloud.agent.api.Answer.class)); + Assert.assertNotNull(spy.getExternalVmConsole(virtualMachine, host)); + Mockito.verify(extensionManager).getInstanceConsole(virtualMachine, host); + } } diff --git a/server/src/test/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImplTest.java b/server/src/test/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImplTest.java index ec7ef20d441..97e6295da1a 100644 --- a/server/src/test/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImplTest.java +++ b/server/src/test/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImplTest.java @@ -16,25 +16,18 @@ // under the License. package org.apache.cloudstack.consoleproxy; -import com.cloud.agent.AgentManager; -import com.cloud.domain.DomainVO; -import com.cloud.domain.dao.DomainDao; -import com.cloud.exception.PermissionDeniedException; -import com.cloud.server.ManagementServer; -import com.cloud.user.Account; -import com.cloud.user.AccountManager; -import com.cloud.utils.Pair; -import com.cloud.utils.db.EntityManager; -import com.cloud.vm.ConsoleSessionVO; -import com.cloud.vm.VirtualMachine; -import com.cloud.vm.VirtualMachineManager; -import com.cloud.vm.dao.ConsoleSessionDao; -import com.cloud.vm.dao.VMInstanceDetailsDao; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; + import org.apache.cloudstack.acl.SecurityChecker; import org.apache.cloudstack.api.ResponseGenerator; import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.command.user.consoleproxy.ConsoleEndpoint; import org.apache.cloudstack.api.command.user.consoleproxy.ListConsoleSessionsCmd; import org.apache.cloudstack.api.response.ConsoleSessionResponse; +import org.apache.cloudstack.consoleproxy.ConsoleAccessManagerImpl.ConsoleConnectionDetails; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.framework.security.keys.KeysManager; import org.junit.Assert; @@ -47,8 +40,32 @@ import org.mockito.Mockito; import org.mockito.Spy; import org.mockito.junit.MockitoJUnitRunner; -import java.util.Arrays; -import java.util.List; +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.GetExternalConsoleAnswer; +import com.cloud.consoleproxy.ConsoleProxyManager; +import com.cloud.domain.DomainVO; +import com.cloud.domain.dao.DomainDao; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.host.DetailVO; +import com.cloud.host.HostVO; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.resource.ResourceState; +import com.cloud.serializer.GsonHelper; +import com.cloud.server.ManagementServer; +import com.cloud.servlet.ConsoleProxyClientParam; +import com.cloud.servlet.ConsoleProxyPasswordBasedEncryptor; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.utils.Pair; +import com.cloud.utils.Ternary; +import com.cloud.utils.db.EntityManager; +import com.cloud.vm.ConsoleSessionVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.dao.ConsoleSessionDao; +import com.cloud.vm.dao.VMInstanceDetailsDao; @RunWith(MockitoJUnitRunner.class) public class ConsoleAccessManagerImplTest { @@ -67,6 +84,8 @@ public class ConsoleAccessManagerImplTest { private KeysManager keysManager; @Mock private AgentManager agentManager; + @Mock + ConsoleProxyManager consoleProxyManager; @Spy @InjectMocks @@ -311,4 +330,461 @@ public class ConsoleAccessManagerImplTest { consoleAccessManager.listConsoleSessionById(1L); Mockito.verify(consoleSessionDaoMock).findByIdIncludingRemoved(1L); } + + @Test + public void getConsoleConnectionDetailsForExternalVmReturnsNullWhenAnswerIsNull() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + ConsoleConnectionDetails details = new ConsoleConnectionDetails("sid", "en", "tag", "displayName"); + + Mockito.when(managementServer.getExternalVmConsole(vm, host)).thenReturn(null); + + ConsoleConnectionDetails result = consoleAccessManager.getConsoleConnectionDetailsForExternalVm(details, vm, host); + + Assert.assertNull(result); + } + + @Test + public void getConsoleConnectionDetailsForExternalVmReturnsNullWhenAnswerResultIsFalse() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + ConsoleConnectionDetails details = new ConsoleConnectionDetails("sid", "en", "tag", "displayName"); + Answer answer = Mockito.mock(Answer.class); + + Mockito.when(answer.getResult()).thenReturn(false); + Mockito.when(answer.getDetails()).thenReturn("Error details"); + Mockito.when(managementServer.getExternalVmConsole(vm, host)).thenReturn(answer); + + ConsoleConnectionDetails result = consoleAccessManager.getConsoleConnectionDetailsForExternalVm(details, vm, host); + + Assert.assertNull(result); + } + + @Test + public void getConsoleConnectionDetailsForExternalVmReturnsNullWhenAnswerIsNotOfTypeGetExternalConsoleAnswer() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + ConsoleConnectionDetails details = new ConsoleConnectionDetails("sid", "en", "tag", "displayName"); + Answer answer = Mockito.mock(Answer.class); + + Mockito.when(answer.getResult()).thenReturn(true); + Mockito.when(managementServer.getExternalVmConsole(vm, host)).thenReturn(answer); + + ConsoleConnectionDetails result = consoleAccessManager.getConsoleConnectionDetailsForExternalVm(details, vm, host); + + Assert.assertNull(result); + } + + @Test + public void getConsoleConnectionDetailsForExternalVmSetsDetailsWhenAnswerIsValid() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + ConsoleConnectionDetails details = new ConsoleConnectionDetails("sid", "en", "tag", "displayName"); + GetExternalConsoleAnswer answer = Mockito.mock(GetExternalConsoleAnswer.class); + + String expectedHost = "10.0.0.1"; + int expectedPort = 5900; + String expectedPassword = "password"; + + Mockito.when(answer.getResult()).thenReturn(true); + Mockito.when(answer.getHost()).thenReturn(expectedHost); + Mockito.when(answer.getPort()).thenReturn(expectedPort); + Mockito.when(answer.getPassword()).thenReturn(expectedPassword); + Mockito.when(managementServer.getExternalVmConsole(vm, host)).thenReturn(answer); + + ConsoleConnectionDetails result = consoleAccessManager.getConsoleConnectionDetailsForExternalVm(details, vm, host); + + Assert.assertNotNull(result); + Assert.assertEquals(ConsoleConnectionDetails.Mode.ConsoleProxy, result.getMode()); + Assert.assertEquals(expectedHost, result.getHost()); + Assert.assertEquals(expectedPort, result.getPort()); + Assert.assertEquals(expectedPassword, result.getSid()); + Assert.assertNull(result.getDirectUrl()); + } + + @Test + public void getConsoleConnectionDetailsForExternalVmSetsDetailsWhenAnswerIsValidDirect() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + ConsoleConnectionDetails details = new ConsoleConnectionDetails("sid", "en", "tag", "displayName"); + GetExternalConsoleAnswer answer = Mockito.mock(GetExternalConsoleAnswer.class); + + String url = "url"; + + Mockito.when(answer.getResult()).thenReturn(true); + Mockito.when(answer.getUrl()).thenReturn(url); + Mockito.when(answer.getProtocol()).thenReturn(ConsoleConnectionDetails.Mode.Direct.name()); + Mockito.when(managementServer.getExternalVmConsole(vm, host)).thenReturn(answer); + + ConsoleConnectionDetails result = consoleAccessManager.getConsoleConnectionDetailsForExternalVm(details, vm, host); + + Assert.assertNotNull(result); + Assert.assertEquals(ConsoleConnectionDetails.Mode.Direct, result.getMode()); + Assert.assertEquals(url, result.getDirectUrl()); + } + + @Test + public void getConsoleConnectionDetailsForExternalVmDoesNotSetSidWhenPasswordIsBlank() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + ConsoleConnectionDetails details = new ConsoleConnectionDetails("sid", "en", "tag", "displayName"); + GetExternalConsoleAnswer answer = Mockito.mock(GetExternalConsoleAnswer.class); + + Mockito.when(answer.getResult()).thenReturn(true); + Mockito.when(answer.getHost()).thenReturn("10.0.0.1"); + Mockito.when(answer.getPort()).thenReturn(5900); + Mockito.when(answer.getPassword()).thenReturn(""); + Mockito.when(managementServer.getExternalVmConsole(vm, host)).thenReturn(answer); + + ConsoleConnectionDetails result = consoleAccessManager.getConsoleConnectionDetailsForExternalVm(details, vm, host); + + Assert.assertNotNull(result); + Assert.assertEquals("10.0.0.1", result.getHost()); + Assert.assertEquals(5900, result.getPort()); + Assert.assertEquals("sid", result.getSid()); + } + + @Test + public void getHostAndPortForKVMMaintenanceHostIfNeededReturnsNullForNonKVMHypervisor() { + HostVO host = Mockito.mock(HostVO.class); + Mockito.when(host.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.XenServer); + + Pair result = consoleAccessManager.getHostAndPortForKVMMaintenanceHostIfNeeded(host, Map.of()); + + Assert.assertNull(result); + } + + @Test + public void getHostAndPortForKVMMaintenanceHostIfNeededReturnsNullForNonMaintenanceResourceState() { + HostVO host = Mockito.mock(HostVO.class); + Mockito.when(host.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + Mockito.when(host.getResourceState()).thenReturn(ResourceState.Enabled); + + Pair result = consoleAccessManager.getHostAndPortForKVMMaintenanceHostIfNeeded(host, Map.of()); + + Assert.assertNull(result); + } + + @Test + public void getHostAndPortForKVMMaintenanceHostIfNeededReturnsHostAndPortForValidKVMInMaintenance() { + HostVO host = Mockito.mock(HostVO.class); + Mockito.when(host.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + Mockito.when(host.getResourceState()).thenReturn(ResourceState.ErrorInMaintenance); + + String address = "192.168.1.100"; + int port = 5901; + Map vmDetails = Map.of( + VmDetailConstants.KVM_VNC_ADDRESS, address, + VmDetailConstants.KVM_VNC_PORT, String.valueOf(port) + ); + + Pair result = consoleAccessManager.getHostAndPortForKVMMaintenanceHostIfNeeded(host, vmDetails); + + Assert.assertNotNull(result); + Assert.assertEquals(address, result.first()); + Assert.assertEquals(port, (int) result.second()); + } + + @Test + public void getHostAndPortForKVMMaintenanceHostIfNeededReturnsNullWhenVncAddressOrPortIsMissing() { + HostVO host = Mockito.mock(HostVO.class); + Mockito.when(host.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + Mockito.when(host.getResourceState()).thenReturn(ResourceState.ErrorInMaintenance); + + Map vmDetails = Map.of(VmDetailConstants.KVM_VNC_ADDRESS, "192.168.1.100"); + + Pair result = consoleAccessManager.getHostAndPortForKVMMaintenanceHostIfNeeded(host, vmDetails); + + Assert.assertNull(result); + } + + @Test + public void getConsoleConnectionDetailsReturnsDetailsForExternalHypervisor() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + ConsoleConnectionDetails details = Mockito.mock(ConsoleConnectionDetails.class); + + Mockito.when(vm.getUuid()).thenReturn("vm-uuid"); + Mockito.when(vm.getHostName()).thenReturn("vm-hostname"); + Mockito.when(vm.getVncPassword()).thenReturn("vnc-password"); + Mockito.when(host.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.External); + Mockito.when(vmInstanceDetailsDao.listDetailsKeyPairs(Mockito.anyLong(), Mockito.anyList())).thenReturn(Map.of()); + + Mockito.doReturn(details).when(consoleAccessManager).getConsoleConnectionDetailsForExternalVm(Mockito.any(), Mockito.eq(vm), Mockito.eq(host)); + + ConsoleConnectionDetails result = consoleAccessManager.getConsoleConnectionDetails(vm, host); + + Assert.assertNotNull(result); + Mockito.verify(consoleAccessManager).getConsoleConnectionDetailsForExternalVm(Mockito.any(), Mockito.eq(vm), Mockito.eq(host)); + } + + @Test + public void getConsoleConnectionDetailsReturnsDetailsForKVMHypervisor() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + String hostAddress = "192.168.1.100"; + int port = 5900; + String vmUuid = "vm-uuid"; + String vmHostName = "vm-hostname"; + String vncPassword = "vnc-password"; + + Pair hostPortInfo = new Pair<>(hostAddress, port); + + Mockito.when(vm.getUuid()).thenReturn(vmUuid); + Mockito.when(vm.getHostName()).thenReturn(vmHostName); + Mockito.when(vm.getVncPassword()).thenReturn(vncPassword); + Mockito.when(host.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + Mockito.when(vmInstanceDetailsDao.listDetailsKeyPairs(Mockito.anyLong(), Mockito.anyList())).thenReturn(Map.of()); + Mockito.when(managementServer.getVncPort(vm)).thenReturn(hostPortInfo); + Mockito.doReturn(new Ternary<>(hostAddress, null, null)).when(consoleAccessManager).parseHostInfo(Mockito.anyString()); + + ConsoleConnectionDetails result = consoleAccessManager.getConsoleConnectionDetails(vm, host); + + Assert.assertNotNull(result); + Assert.assertEquals(hostAddress, result.getHost()); + Assert.assertEquals(port, result.getPort()); + } + + @Test + public void getConsoleConnectionDetailsReturnsDetailsWithRDPForHyperV() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + String hostAddress = "192.168.1.100"; + Pair hostPortInfo = new Pair<>(hostAddress, -9); + + Mockito.when(vm.getUuid()).thenReturn("vm-uuid"); + Mockito.when(vm.getHostName()).thenReturn("vm-hostname"); + Mockito.when(vm.getVncPassword()).thenReturn("vnc-password"); + Mockito.when(host.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.Hyperv); + Mockito.when(vmInstanceDetailsDao.listDetailsKeyPairs(Mockito.anyLong(), Mockito.anyList())).thenReturn(Map.of()); + Mockito.when(managementServer.getVncPort(vm)).thenReturn(hostPortInfo); + int port = 3389; + DetailVO detailVO = Mockito.mock(DetailVO.class); + Mockito.when(detailVO.getValue()).thenReturn(String.valueOf(port)); + Mockito.when(managementServer.findDetail(Mockito.anyLong(), Mockito.eq("rdp.server.port"))).thenReturn(detailVO); + Mockito.doReturn(new Ternary<>(hostAddress, null, null)).when(consoleAccessManager).parseHostInfo(Mockito.anyString()); + + ConsoleConnectionDetails result = consoleAccessManager.getConsoleConnectionDetails(vm, host); + + Assert.assertNotNull(result); + Assert.assertTrue(result.isUsingRDP()); + Assert.assertEquals(port, result.getPort()); + } + + @Test + public void getConsoleConnectionDetailsReturnsNullHostInvalidPortWhenVncPortInfoIsMissing() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + + Mockito.when(vm.getUuid()).thenReturn("vm-uuid"); + Mockito.when(vm.getHostName()).thenReturn("vm-hostname"); + Mockito.when(vm.getVncPassword()).thenReturn("vnc-password"); + Mockito.when(host.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + Mockito.when(vmInstanceDetailsDao.listDetailsKeyPairs(Mockito.anyLong(), Mockito.anyList())).thenReturn(Map.of()); + Mockito.when(managementServer.getVncPort(vm)).thenReturn(new Pair<>(null, -1)); + + ConsoleConnectionDetails result = consoleAccessManager.getConsoleConnectionDetails(vm, host); + + Assert.assertNull(result.getHost()); + Assert.assertEquals(-1, result.getPort()); + } + + @Test + public void getConsoleConnectionDetailsSetsLocaleWhenKeyboardDetailIsPresent() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + String hostAddress = "192.168.1.100"; + Pair hostPortInfo = new Pair<>(hostAddress, 5900); + + Mockito.when(vm.getUuid()).thenReturn("vm-uuid"); + Mockito.when(vm.getHostName()).thenReturn("vm-hostname"); + Mockito.when(vm.getVncPassword()).thenReturn("vnc-password"); + Mockito.when(host.getHypervisorType()).thenReturn(Hypervisor.HypervisorType.KVM); + Mockito.when(vmInstanceDetailsDao.listDetailsKeyPairs(Mockito.anyLong(), Mockito.anyList())).thenReturn(Map.of(VmDetailConstants.KEYBOARD, "en-us")); + Mockito.when(managementServer.getVncPort(vm)).thenReturn(hostPortInfo); + Mockito.doReturn(new Ternary<>(hostAddress, null, null)).when(consoleAccessManager).parseHostInfo(Mockito.anyString()); + + ConsoleConnectionDetails result = consoleAccessManager.getConsoleConnectionDetails(vm, host); + + Assert.assertNotNull(result); + Assert.assertEquals("en-us", result.getLocale()); + } + + @Test + public void generateConsoleProxyClientParamSetsBasicDetailsCorrectly() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + String hostAddress = "192.168.1.100"; + int port = 5902; + String sid = "sid"; + String tag = "tag"; + String displayName = "displayName"; + String ticket = "ticket"; + String sessionUuid = "sessionUuid"; + String sourceIp = "127.0.0.1"; + ConsoleConnectionDetails details = new ConsoleConnectionDetails(sid, null, tag, displayName); + details.setHost(hostAddress); + details.setPort(port); + + ConsoleProxyClientParam param = consoleAccessManager.generateConsoleProxyClientParam(details, ticket, sessionUuid, sourceIp, null, vm, host); + + Assert.assertEquals(hostAddress, param.getClientHostAddress()); + Assert.assertEquals(port, param.getClientHostPort()); + Assert.assertEquals(sid, param.getClientHostPassword()); + Assert.assertEquals(tag, param.getClientTag()); + Assert.assertEquals(displayName, param.getClientDisplayName()); + Assert.assertEquals(ticket, param.getTicket()); + Assert.assertEquals(sessionUuid, param.getSessionUuid()); + Assert.assertEquals(sourceIp, param.getSourceIP()); + Assert.assertNull(param.getLocale()); + Assert.assertNull(param.getExtraSecurityToken()); + } + + @Test + public void generateConsoleProxyClientParamSetsExtraSecurityTokenWhenProvided() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + ConsoleConnectionDetails details = new ConsoleConnectionDetails("password", null, null, null); + + ConsoleProxyClientParam param = consoleAccessManager.generateConsoleProxyClientParam(details, "ticket", "sessionUuid", "127.0.0.1", "extraToken", vm, host); + + Assert.assertEquals("extraToken", param.getExtraSecurityToken()); + } + + @Test + public void generateConsoleProxyClientParamSetsLocaleWhenProvided() { + HostVO host = Mockito.mock(HostVO.class); + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + ConsoleConnectionDetails details = new ConsoleConnectionDetails(null, "fr-fr", null, null); + + ConsoleProxyClientParam param = consoleAccessManager.generateConsoleProxyClientParam(details, "ticket", "sessionUuid", "127.0.0.1", null, vm, host); + + Assert.assertEquals("fr-fr", param.getLocale()); + } + + @Test + public void generateConsoleProxyClientParamSetsRdpDetailsForHyperV() { + long hostId = 1L; + String username = "admin"; + String password = "adminPass"; + HostVO host = Mockito.mock(HostVO.class); + Mockito.when(host.getId()).thenReturn(hostId); + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + ConsoleConnectionDetails details = new ConsoleConnectionDetails(null, null, null, null); + details.setUsingRDP(true); + String ip = "10.0.0.1"; + Mockito.when(host.getPrivateIpAddress()).thenReturn(ip); + Mockito.when(managementServer.findDetail(host.getId(), "username")).thenReturn(new DetailVO(hostId, "username", username)); + Mockito.when(managementServer.findDetail(host.getId(), "password")).thenReturn(new DetailVO(hostId, "password", password)); + + ConsoleProxyClientParam param = consoleAccessManager.generateConsoleProxyClientParam(details, "ticket", "sessionUuid", "127.0.0.1", null, vm, host); + + Assert.assertEquals(ip, param.getHypervHost()); + Assert.assertEquals(username, param.getUsername()); + Assert.assertEquals(password, param.getPassword()); + } + + @Test + public void generateConsoleProxyClientParamSetsTunnelDetailsWhenProvided() { + HostVO host = Mockito.mock(HostVO.class); + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + ConsoleConnectionDetails details = new ConsoleConnectionDetails(null, null, null, null); + details.setTunnelUrl("tunnelUrl"); + details.setTunnelSession("tunnelSession"); + + ConsoleProxyClientParam param = consoleAccessManager.generateConsoleProxyClientParam(details, "ticket", "sessionUuid", "127.0.0.1", null, vm, host); + + Assert.assertEquals("tunnelUrl", param.getClientTunnelUrl()); + Assert.assertEquals("tunnelSession", param.getClientTunnelSession()); + } + + @Test + public void returnsNullWhenConsoleConnectionDetailsAreNull() { + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + HostVO host = Mockito.mock(HostVO.class); + Mockito.doReturn(null).when(consoleAccessManager).getConsoleConnectionDetails(vm, host); + + ConsoleEndpoint result = consoleAccessManager.composeConsoleAccessEndpoint("rootUrl", vm, host, "addr", "sessionUuid", "extraToken"); + + Assert.assertNotNull(result); + Assert.assertFalse(result.isResult()); + Assert.assertNull(result.getUrl()); + Assert.assertEquals("Console access to this instance cannot be provided", result.getDetails()); + } + + @Test + public void composeConsoleAccessEndpointReturnsConsoleEndpointWhenConsoleConnectionDetailsAreValid() { + String locale = "en"; + String hostStr = "192.168.1.100"; + int port = 5900; + String sid = "SID"; + String sessionUuid = UUID.randomUUID().toString(); + String ticket = UUID.randomUUID().toString(); + String addr = "addr"; + String extraToken = "extraToken"; + String rootUrl = "rootUrl"; + int vncPort = 443; + long vmId = 100L; + long hostId = 1L; + String url = "url"; + String consoleAddress = "127.0.0.1"; + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + Mockito.when(vm.getId()).thenReturn(vmId); + HostVO host = Mockito.mock(HostVO.class); + Mockito.when(host.getId()).thenReturn(hostId); + String tag = UUID.randomUUID().toString(); + ConsoleConnectionDetails details = new ConsoleConnectionDetails("password", locale, tag, null); + details.setHost(hostStr); + details.setPort(port); + details.setSid(sid); + Mockito.doReturn(details).when(consoleAccessManager).getConsoleConnectionDetails(vm, host); + Mockito.when(consoleProxyManager.getVncPort(Mockito.anyLong())).thenReturn(vncPort); + ConsoleProxyPasswordBasedEncryptor.KeyIVPair keyIvPair = new ConsoleProxyPasswordBasedEncryptor.KeyIVPair("key", "iv"); + Mockito.doReturn(GsonHelper.getGson().toJson(keyIvPair)).when(consoleAccessManager).getEncryptorPassword(); + Mockito.doReturn(ticket).when(consoleAccessManager).genAccessTicket(hostStr, String.valueOf(port), sid, tag, sessionUuid); + ConsoleProxyClientParam param = Mockito.mock(ConsoleProxyClientParam.class); + Mockito.when(param.getExtraSecurityToken()).thenReturn(extraToken); + Mockito.doReturn(param).when(consoleAccessManager).generateConsoleProxyClientParam(details, ticket, sessionUuid, addr, extraToken, vm, host); + Mockito.doReturn(url).when(consoleAccessManager).generateConsoleAccessUrl(Mockito.eq(rootUrl), + Mockito.eq(param), Mockito.anyString(), Mockito.eq(vncPort), Mockito.eq(vm), Mockito.eq(host), + Mockito.eq(locale)); + Mockito.doNothing().when(consoleAccessManager).persistConsoleSession(sessionUuid, vmId, hostId, addr); + Mockito.when(managementServer.getConsoleAccessAddress(vmId)).thenReturn(consoleAddress); + + ConsoleEndpoint endpoint = consoleAccessManager.composeConsoleAccessEndpoint(rootUrl, vm, host, addr, sessionUuid, extraToken); + + Mockito.verify(consoleAccessManager).persistConsoleSession(sessionUuid, vmId, hostId, addr); + Mockito.verify(managementServer).setConsoleAccessForVm(vmId, sessionUuid); + Assert.assertEquals(url, endpoint.getUrl()); + Assert.assertEquals(ConsoleAccessManagerImpl.WEB_SOCKET_PATH, endpoint.getWebsocketPath()); + Assert.assertEquals(extraToken, endpoint.getWebsocketExtra()); + Assert.assertEquals(consoleAddress, endpoint.getWebsocketHost()); + } + + @Test + public void composeConsoleAccessEndpointReturnsWithoutPersistWhenConsoleConnectionDetailsAreValidDirect() { + String url = "url"; + long vmId = 100L; + long hostId = 1L; + String sessionUuid = UUID.randomUUID().toString(); + String addr = "addr"; + ConsoleConnectionDetails details = new ConsoleConnectionDetails("password", "en", "tag", null); + details.setDirectUrl(url); + details.setModeFromExternalProtocol("direct"); + VirtualMachine vm = Mockito.mock(VirtualMachine.class); + Mockito.when(vm.getId()).thenReturn(vmId); + HostVO host = Mockito.mock(HostVO.class); + Mockito.when(host.getId()).thenReturn(hostId); + Mockito.doReturn(details).when(consoleAccessManager).getConsoleConnectionDetails(vm, host); + Mockito.doNothing().when(consoleAccessManager).persistConsoleSession(sessionUuid, vmId, hostId, addr); + + ConsoleEndpoint endpoint = consoleAccessManager.composeConsoleAccessEndpoint("rootUrl", vm, host, addr, sessionUuid, ""); + + Mockito.verify(consoleAccessManager).persistConsoleSession(sessionUuid, vmId, hostId, addr); + Mockito.verify(managementServer, Mockito.never()).setConsoleAccessForVm(Mockito.anyLong(), Mockito.anyString()); + Assert.assertEquals(url, endpoint.getUrl()); + Assert.assertNull(endpoint.getWebsocketPath()); + Assert.assertNull(endpoint.getWebsocketExtra()); + Assert.assertNull(endpoint.getWebsocketHost()); + } } diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxy.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxy.java index 0c46de2a4ac..a25abac981b 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxy.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxy.java @@ -36,6 +36,8 @@ import java.util.concurrent.Executor; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.core.config.Configurator; import org.eclipse.jetty.websocket.api.Session; @@ -43,9 +45,6 @@ import com.cloud.utils.PropertiesUtil; import com.google.gson.Gson; import com.sun.net.httpserver.HttpServer; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; - /** * * ConsoleProxy, singleton class that manages overall activities in console proxy process. To make legacy code work, we still @@ -598,6 +597,8 @@ public class ConsoleProxy { Session session) throws AuthenticationException { boolean reportLoadChange = false; String clientKey = param.getClientMapKey(); + LOGGER.debug("Getting NoVNC viewer for {}. Session requires new viewer: {}, client tag: {}. session UUID: {}", + clientKey, param.isSessionRequiresNewViewer(), param.getClientTag(), param.getSessionUuid()); synchronized (connectionMap) { ConsoleProxyClient viewer = connectionMap.get(clientKey); if (viewer == null || viewer.getClass() != ConsoleProxyNoVncClient.class) { diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientParam.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientParam.java index 01c4fa6480e..79c6b8c2a95 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientParam.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientParam.java @@ -16,6 +16,8 @@ // under the License. package com.cloud.consoleproxy; +import org.apache.commons.lang3.StringUtils; + /** * * Data object to store parameter info needed by client to connect to its host @@ -39,6 +41,8 @@ public class ConsoleProxyClientParam { private String password; private String websocketUrl; + private boolean sessionRequiresNewViewer; + /** * IP that has generated the console endpoint */ @@ -143,8 +147,12 @@ public class ConsoleProxyClientParam { } public String getClientMapKey() { - if (clientTag != null && !clientTag.isEmpty()) + if (sessionRequiresNewViewer && StringUtils.isNotBlank(sessionUuid)) { + return sessionUuid; + } + if (StringUtils.isNotBlank(clientTag)) { return clientTag; + } return clientHostAddress + ":" + clientHostPort; } @@ -220,4 +228,12 @@ public class ConsoleProxyClientParam { public void setClientIp(String clientIp) { this.clientIp = clientIp; } + + public boolean isSessionRequiresNewViewer() { + return sessionRequiresNewViewer; + } + + public void setSessionRequiresNewViewer(boolean sessionRequiresNewViewer) { + this.sessionRequiresNewViewer = sessionRequiresNewViewer; + } } diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyHttpHandlerHelper.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyHttpHandlerHelper.java index 48ac5f44ff2..483b90db05e 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyHttpHandlerHelper.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyHttpHandlerHelper.java @@ -110,6 +110,9 @@ public class ConsoleProxyHttpHandlerHelper { if (param.getExtraSecurityToken() != null) { map.put("extraSecurityToken", param.getExtraSecurityToken()); } + if (param.isSessionRequiresNewViewer()) { + map.put("sessionRequiresNewViewer", Boolean.TRUE.toString()); + } } else { LOGGER.error("Unable to decode token"); } diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCHandler.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCHandler.java index a9639d0b32e..a148b988e40 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCHandler.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCHandler.java @@ -93,6 +93,7 @@ public class ConsoleProxyNoVNCHandler extends WebSocketHandler { String websocketUrl = queryMap.get("websocketUrl"); String sessionUuid = queryMap.get("sessionUuid"); String clientIp = session.getRemoteAddress().getAddress().getHostAddress(); + boolean sessionRequiresNewViewer = Boolean.parseBoolean(queryMap.get("sessionRequiresNewViewer")); if (tag == null) tag = ""; @@ -141,6 +142,7 @@ public class ConsoleProxyNoVNCHandler extends WebSocketHandler { param.setSessionUuid(sessionUuid); param.setSourceIP(sourceIP); param.setClientIp(clientIp); + param.setSessionRequiresNewViewer(sessionRequiresNewViewer); if (queryMap.containsKey("extraSecurityToken")) { param.setExtraSecurityToken(queryMap.get("extraSecurityToken")); diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVncClient.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVncClient.java index 85a2e5c541f..36dce8b8554 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVncClient.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVncClient.java @@ -109,7 +109,12 @@ public class ConsoleProxyNoVncClient implements ConsoleProxyClient { String tunnelSession = param.getClientTunnelSession(); String websocketUrl = param.getWebsocketUrl(); - connectClientToVNCServer(tunnelUrl, tunnelSession, websocketUrl); + if (!connectClientToVNCServer(tunnelUrl, tunnelSession, websocketUrl)) { + logger.error("Failed to connect to VNC server, will close connection with client [{}] [IP: {}].", clientId, clientSourceIp); + connectionAlive = false; + session.close(); + return; + } authenticateToVNCServer(clientSourceIp); // Track consecutive iterations with no data and sleep accordingly. Only used for NIO socket connections. @@ -313,7 +318,7 @@ public class ConsoleProxyNoVncClient implements ConsoleProxyClient { * - When websocketUrl is not empty -> connect to websocket * - Otherwise -> connect to TCP port on host directly */ - private void connectClientToVNCServer(String tunnelUrl, String tunnelSession, String websocketUrl) { + private boolean connectClientToVNCServer(String tunnelUrl, String tunnelSession, String websocketUrl) { try { if (StringUtils.isNotBlank(websocketUrl)) { logger.info(String.format("Connect to VNC over websocket URL: %s", websocketUrl)); @@ -337,7 +342,9 @@ public class ConsoleProxyNoVncClient implements ConsoleProxyClient { logger.info("Connection to VNC server has been established successfully."); } catch (Throwable e) { logger.error("Unexpected exception while connecting to VNC server.", e); + return false; } + return true; } private void setClientParam(ConsoleProxyClientParam param) { diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/vnc/NoVncClient.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/vnc/NoVncClient.java index 493c2287931..ca7577d2bfc 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/vnc/NoVncClient.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/vnc/NoVncClient.java @@ -88,15 +88,16 @@ public class NoVncClient { setTunnelSocketStreams(); } - public void connectTo(String host, int port) { + public void connectTo(String host, int port) throws IOException { // Connect to server logger.info("Connecting to VNC server {}:{} ...", host, port); try { NioSocket nioSocket = new NioSocket(host, port); this.nioSocketConnection = new NioSocketHandlerImpl(nioSocket); - } catch (Exception e) { + } catch (IOException e) { logger.error(String.format("Cannot create socket to host: %s and port %s: %s", host, port, e.getMessage()), e); + throw e; } } diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/vnc/network/NioSocket.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/vnc/network/NioSocket.java index 9bd2a10e6f0..4ab88ea9fc7 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/vnc/network/NioSocket.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/vnc/network/NioSocket.java @@ -36,7 +36,7 @@ public class NioSocket { private static final int CONNECTION_TIMEOUT_MILLIS = 3000; protected Logger logger = LogManager.getLogger(getClass()); - private void initializeSocket() { + private void initializeSocket() throws IOException { try { socketChannel = SocketChannel.open(); socketChannel.configureBlocking(false); @@ -49,30 +49,27 @@ public class NioSocket { socketChannel.register(readSelector, SelectionKey.OP_READ); } catch (IOException e) { logger.error("Could not initialize NioSocket: " + e.getMessage(), e); + throw e; } } - private void waitForSocketSelectorConnected(Selector selector) { - try { - while (selector.select(CONNECTION_TIMEOUT_MILLIS) <= 0) { - logger.debug("Waiting for ready operations to connect to the socket"); - } - Set keys = selector.selectedKeys(); - for (SelectionKey selectionKey: keys) { - if (selectionKey.isConnectable()) { - if (socketChannel.isConnectionPending()) { - socketChannel.finishConnect(); - } - logger.debug("Connected to the socket"); - break; + private void waitForSocketSelectorConnected(Selector selector) throws IOException { + while (selector.select(CONNECTION_TIMEOUT_MILLIS) <= 0) { + logger.debug("Waiting for ready operations to connect to the socket"); + } + Set keys = selector.selectedKeys(); + for (SelectionKey selectionKey: keys) { + if (selectionKey.isConnectable()) { + if (socketChannel.isConnectionPending()) { + socketChannel.finishConnect(); } + logger.debug("Connected to the socket"); + break; } - } catch (IOException e) { - logger.error(String.format("Error waiting for socket selector ready: %s", e.getMessage()), e); } } - private void connectSocket(String host, int port) { + private void connectSocket(String host, int port) throws IOException { try { socketChannel.connect(new InetSocketAddress(host, port)); Selector selector = Selector.open(); @@ -80,11 +77,12 @@ public class NioSocket { waitForSocketSelectorConnected(selector); } catch (IOException e) { - logger.error(String.format("Error creating NioSocket to %s:%s: %s", host, port, e.getMessage()), e); + logger.error("Error connecting NioSocket to {}:{}: {}", host, port, e.getMessage(), e); + throw e; } } - public NioSocket(String host, int port) { + public NioSocket(String host, int port) throws IOException { initializeSocket(); connectSocket(host, port); } diff --git a/ui/src/components/view/ActionButton.vue b/ui/src/components/view/ActionButton.vue index 7733efdf66e..14128a5ce55 100644 --- a/ui/src/components/view/ActionButton.vue +++ b/ui/src/components/view/ActionButton.vue @@ -17,7 +17,7 @@