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 @@