diff --git a/agent/src/main/java/com/cloud/agent/resource/consoleproxy/ConsoleProxyResource.java b/agent/src/main/java/com/cloud/agent/resource/consoleproxy/ConsoleProxyResource.java index 257a1fc984a..5b04f5751e3 100644 --- a/agent/src/main/java/com/cloud/agent/resource/consoleproxy/ConsoleProxyResource.java +++ b/agent/src/main/java/com/cloud/agent/resource/consoleproxy/ConsoleProxyResource.java @@ -32,6 +32,7 @@ import java.util.Properties; import javax.naming.ConfigurationException; +import com.cloud.agent.api.proxy.AllowConsoleAccessCommand; import org.apache.cloudstack.managed.context.ManagedContextRunnable; import org.apache.log4j.Logger; @@ -105,12 +106,28 @@ public class ConsoleProxyResource extends ServerResourceBase implements ServerRe } else if (cmd instanceof CheckHealthCommand) { return new CheckHealthAnswer((CheckHealthCommand)cmd, true); } else if (cmd instanceof StartConsoleProxyAgentHttpHandlerCommand) { - return execute((StartConsoleProxyAgentHttpHandlerCommand)cmd); + return execute((StartConsoleProxyAgentHttpHandlerCommand) cmd); + } else if (cmd instanceof AllowConsoleAccessCommand) { + return execute((AllowConsoleAccessCommand) cmd); } else { return Answer.createUnsupportedCommandAnswer(cmd); } } + private Answer execute(AllowConsoleAccessCommand cmd) { + String sessionUuid = cmd.getSessionUuid(); + try { + Class consoleProxyClazz = Class.forName("com.cloud.consoleproxy.ConsoleProxy"); + Method methodSetup = consoleProxyClazz.getMethod("addAllowedSession", String.class); + methodSetup.invoke(null, sessionUuid); + return new Answer(cmd); + } catch (SecurityException | NoSuchMethodException | ClassNotFoundException | InvocationTargetException | IllegalAccessException e) { + String errorMsg = "Unable to add allowed session due to: " + e.getMessage(); + s_logger.error(errorMsg, e); + return new Answer(cmd, false, errorMsg); + } + } + private Answer execute(StartConsoleProxyAgentHttpHandlerCommand cmd) { s_logger.info("Invoke launchConsoleProxy() in responding to StartConsoleProxyAgentHttpHandlerCommand"); launchConsoleProxy(cmd.getKeystoreBits(), cmd.getKeystorePassword(), cmd.getEncryptorPassword(), cmd.isSourceIpCheckEnabled()); @@ -382,9 +399,10 @@ public class ConsoleProxyResource extends ServerResourceBase implements ServerRe } } - public String authenticateConsoleAccess(String host, String port, String vmId, String sid, String ticket, Boolean isReauthentication) { + public String authenticateConsoleAccess(String host, String port, String vmId, String sid, String ticket, + Boolean isReauthentication, String sessionToken) { - ConsoleAccessAuthenticationCommand cmd = new ConsoleAccessAuthenticationCommand(host, port, vmId, sid, ticket); + ConsoleAccessAuthenticationCommand cmd = new ConsoleAccessAuthenticationCommand(host, port, vmId, sid, ticket, sessionToken); cmd.setReauthenticating(isReauthentication); ConsoleProxyAuthenticationResult result = new ConsoleProxyAuthenticationResult(); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/consoleproxy/ConsoleEndpoint.java b/api/src/main/java/org/apache/cloudstack/api/command/user/consoleproxy/ConsoleEndpoint.java new file mode 100644 index 00000000000..84922dc3272 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/consoleproxy/ConsoleEndpoint.java @@ -0,0 +1,103 @@ +// 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 org.apache.cloudstack.api.command.user.consoleproxy; + +public class ConsoleEndpoint { + + private boolean result; + private String details; + private String url; + private String websocketToken; + private String websocketPath; + private String websocketHost; + private String websocketPort; + private String websocketExtra; + + public ConsoleEndpoint(boolean result, String url) { + this.result = result; + this.url = url; + } + + public ConsoleEndpoint(boolean result, String url, String details) { + this(result, url); + this.details = details; + } + + public boolean isResult() { + return result; + } + + public void setResult(boolean result) { + this.result = result; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getDetails() { + return details; + } + + public void setDetails(String details) { + this.details = details; + } + + public String getWebsocketToken() { + return websocketToken; + } + + public void setWebsocketToken(String websocketToken) { + this.websocketToken = websocketToken; + } + + public String getWebsocketPath() { + return websocketPath; + } + + public void setWebsocketPath(String websocketPath) { + this.websocketPath = websocketPath; + } + + public String getWebsocketHost() { + return websocketHost; + } + + public void setWebsocketHost(String websocketHost) { + this.websocketHost = websocketHost; + } + + public String getWebsocketPort() { + return websocketPort; + } + + public void setWebsocketPort(String websocketPort) { + this.websocketPort = websocketPort; + } + + public String getWebsocketExtra() { + return websocketExtra; + } + + public void setWebsocketExtra(String websocketExtra) { + this.websocketExtra = websocketExtra; + } +} 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 new file mode 100644 index 00000000000..35e34268cfd --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/consoleproxy/CreateConsoleEndpointCmd.java @@ -0,0 +1,120 @@ +// 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 org.apache.cloudstack.api.command.user.consoleproxy; + +import com.cloud.exception.ConcurrentOperationException; +import com.cloud.exception.InsufficientCapacityException; +import com.cloud.exception.NetworkRuleConflictException; +import com.cloud.exception.ResourceAllocationException; +import com.cloud.exception.ResourceUnavailableException; +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ConsoleEndpointWebsocketResponse; +import org.apache.cloudstack.api.response.CreateConsoleEndpointResponse; +import org.apache.cloudstack.api.response.UserVmResponse; +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.log4j.Logger; + +import javax.inject.Inject; +import java.util.Map; + +@APICommand(name = CreateConsoleEndpointCmd.APINAME, description = "Create a console endpoint to connect to a VM console", + responseObject = CreateConsoleEndpointResponse.class, since = "4.18.0", + requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class CreateConsoleEndpointCmd extends BaseCmd { + + public static final String APINAME = "createConsoleEndpoint"; + public static final Logger s_logger = Logger.getLogger(CreateConsoleEndpointCmd.class.getName()); + + @Inject + private ConsoleAccessManager consoleManager; + + @Parameter(name = ApiConstants.VIRTUAL_MACHINE_ID, + type = CommandType.UUID, + entityType = UserVmResponse.class, + required = true, + description = "ID of the VM") + private Long vmId; + + @Parameter(name = ApiConstants.TOKEN, + type = CommandType.STRING, + required = false, + description = "(optional) extra security token, valid when the extra validation is enabled") + private String extraSecurityToken; + + @Override + public void execute() throws ResourceUnavailableException, InsufficientCapacityException, ServerApiException, ConcurrentOperationException, ResourceAllocationException, NetworkRuleConflictException { + String clientAddress = getClientAddress(); + ConsoleEndpoint endpoint = consoleManager.generateConsoleEndpoint(vmId, extraSecurityToken, clientAddress); + if (endpoint != null) { + CreateConsoleEndpointResponse response = createResponse(endpoint); + setResponseObject(response); + } else { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Unable to generate console endpoint for vm " + vmId); + } + } + + private CreateConsoleEndpointResponse createResponse(ConsoleEndpoint endpoint) { + CreateConsoleEndpointResponse response = new CreateConsoleEndpointResponse(); + response.setResult(endpoint.isResult()); + response.setDetails(endpoint.getDetails()); + response.setUrl(endpoint.getUrl()); + response.setWebsocketResponse(createWebsocketResponse(endpoint)); + response.setResponseName(getCommandName()); + response.setObjectName("consoleendpoint"); + return response; + } + + private ConsoleEndpointWebsocketResponse createWebsocketResponse(ConsoleEndpoint endpoint) { + ConsoleEndpointWebsocketResponse wsResponse = new ConsoleEndpointWebsocketResponse(); + wsResponse.setHost(endpoint.getWebsocketHost()); + wsResponse.setPort(endpoint.getWebsocketPort()); + wsResponse.setPath(endpoint.getWebsocketPath()); + wsResponse.setToken(endpoint.getWebsocketToken()); + wsResponse.setExtra(endpoint.getWebsocketExtra()); + wsResponse.setObjectName("websocket"); + return wsResponse; + } + + private String getParameterBase(String paramKey) { + Map params = getFullUrlParams(); + return MapUtils.isNotEmpty(params) && params.containsKey(paramKey) ? params.get(paramKey) : null; + } + + private String getClientAddress() { + return getParameterBase(ConsoleAccessUtils.CLIENT_INET_ADDRESS_KEY); + } + + @Override + public String getCommandName() { + return APINAME.toLowerCase() + BaseCmd.RESPONSE_SUFFIX; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccount().getId(); + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/ConsoleEndpointWebsocketResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/ConsoleEndpointWebsocketResponse.java new file mode 100644 index 00000000000..d98b52d0863 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/ConsoleEndpointWebsocketResponse.java @@ -0,0 +1,88 @@ +// 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 org.apache.cloudstack.api.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +public class ConsoleEndpointWebsocketResponse extends BaseResponse { + + public ConsoleEndpointWebsocketResponse() { + } + + @SerializedName(ApiConstants.TOKEN) + @Param(description = "the console websocket token") + private String token; + + @SerializedName("host") + @Param(description = "the console websocket host") + private String host; + + @SerializedName(ApiConstants.PORT) + @Param(description = "the console websocket port") + private String port; + + @SerializedName(ApiConstants.PATH) + @Param(description = "the console websocket path") + private String path; + + @SerializedName("extra") + @Param(description = "the console websocket extra field for validation (if enabled)") + private String extra; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public String getPort() { + return port; + } + + public void setPort(String port) { + this.port = port; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public String getExtra() { + return extra; + } + + public void setExtra(String extra) { + this.extra = extra; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/CreateConsoleEndpointResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/CreateConsoleEndpointResponse.java new file mode 100644 index 00000000000..c60917bbe7a --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/CreateConsoleEndpointResponse.java @@ -0,0 +1,76 @@ +// 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 org.apache.cloudstack.api.response; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +public class CreateConsoleEndpointResponse extends BaseResponse { + + public CreateConsoleEndpointResponse() { + } + + @SerializedName(ApiConstants.RESULT) + @Param(description = "true if the console endpoint is generated properly") + private Boolean result; + + @SerializedName(ApiConstants.DETAILS) + @Param(description = "details in case of an error") + private String details; + + @SerializedName(ApiConstants.URL) + @Param(description = "the console url") + private String url; + + @SerializedName("websocket") + @Param(description = "the console websocket options") + private ConsoleEndpointWebsocketResponse websocketResponse; + + public Boolean getResult() { + return result; + } + + public void setResult(Boolean result) { + this.result = result; + } + + public String getDetails() { + return details; + } + + public void setDetails(String details) { + this.details = details; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public ConsoleEndpointWebsocketResponse getWebsocketResponse() { + return websocketResponse; + } + + public void setWebsocketResponse(ConsoleEndpointWebsocketResponse websocketResponse) { + this.websocketResponse = websocketResponse; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManager.java b/api/src/main/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManager.java new file mode 100644 index 00000000000..80c6b30f5e2 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManager.java @@ -0,0 +1,35 @@ +// 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 org.apache.cloudstack.consoleproxy; + +import com.cloud.utils.component.Manager; +import org.apache.cloudstack.api.command.user.consoleproxy.ConsoleEndpoint; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; + +public interface ConsoleAccessManager extends Manager, Configurable { + + ConfigKey ConsoleProxyExtraSecurityValidationEnabled = new ConfigKey<>(ConfigKey.CATEGORY_ADVANCED, Boolean.class, + "consoleproxy.extra.security.validation.enabled", "false", + "Enable/disable extra security validation for console proxy using an extra token", true); + + ConsoleEndpoint generateConsoleEndpoint(Long vmId, String extraSecurityToken, String clientAddress); + + boolean isSessionAllowed(String sessionUuid); + + void removeSessions(String[] sessionUuids); +} diff --git a/core/src/main/java/com/cloud/agent/api/ConsoleAccessAuthenticationCommand.java b/core/src/main/java/com/cloud/agent/api/ConsoleAccessAuthenticationCommand.java index dd533d8774d..683d4afd5b2 100644 --- a/core/src/main/java/com/cloud/agent/api/ConsoleAccessAuthenticationCommand.java +++ b/core/src/main/java/com/cloud/agent/api/ConsoleAccessAuthenticationCommand.java @@ -26,6 +26,7 @@ public class ConsoleAccessAuthenticationCommand extends AgentControlCommand { private String _vmId; private String _sid; private String _ticket; + private String sessionUuid; private boolean _isReauthenticating; @@ -33,12 +34,14 @@ public class ConsoleAccessAuthenticationCommand extends AgentControlCommand { _isReauthenticating = false; } - public ConsoleAccessAuthenticationCommand(String host, String port, String vmId, String sid, String ticket) { + public ConsoleAccessAuthenticationCommand(String host, String port, String vmId, String sid, String ticket, + String sessiontkn) { _host = host; _port = port; _vmId = vmId; _sid = sid; _ticket = ticket; + sessionUuid = sessiontkn; } public String getHost() { @@ -68,4 +71,12 @@ public class ConsoleAccessAuthenticationCommand extends AgentControlCommand { public void setReauthenticating(boolean value) { _isReauthenticating = value; } + + public String getSessionUuid() { + return sessionUuid; + } + + public void setSessionUuid(String sessionUuid) { + this.sessionUuid = sessionUuid; + } } diff --git a/core/src/main/java/com/cloud/agent/api/proxy/AllowConsoleAccessCommand.java b/core/src/main/java/com/cloud/agent/api/proxy/AllowConsoleAccessCommand.java new file mode 100644 index 00000000000..782dc3ab935 --- /dev/null +++ b/core/src/main/java/com/cloud/agent/api/proxy/AllowConsoleAccessCommand.java @@ -0,0 +1,44 @@ +// +// 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.proxy; + +public class AllowConsoleAccessCommand extends ProxyCommand { + + private String sessionUuid; + + public AllowConsoleAccessCommand() { + } + + public AllowConsoleAccessCommand(String sessionUuid) { + this.sessionUuid = sessionUuid; + } + + @Override + public boolean executeInSequence() { + return false; + } + + public String getSessionUuid() { + return sessionUuid; + } + + public void setSessionUuid(String sessionUuid) { + this.sessionUuid = sessionUuid; + } +} diff --git a/core/src/main/java/com/cloud/info/ConsoleProxyConnectionInfo.java b/core/src/main/java/com/cloud/info/ConsoleProxyConnectionInfo.java index 48819f49475..0cc9d011ca1 100644 --- a/core/src/main/java/com/cloud/info/ConsoleProxyConnectionInfo.java +++ b/core/src/main/java/com/cloud/info/ConsoleProxyConnectionInfo.java @@ -26,7 +26,16 @@ public class ConsoleProxyConnectionInfo { public String tag; public long createTime; public long lastUsedTime; + protected String sessionUuid; public ConsoleProxyConnectionInfo() { } + + public String getSessionUuid() { + return sessionUuid; + } + + public void setSessionUuid(String sessionUuid) { + this.sessionUuid = sessionUuid; + } } diff --git a/core/src/main/java/com/cloud/info/ConsoleProxyInfo.java b/core/src/main/java/com/cloud/info/ConsoleProxyInfo.java index f92b93a3979..1e5ad11b49d 100644 --- a/core/src/main/java/com/cloud/info/ConsoleProxyInfo.java +++ b/core/src/main/java/com/cloud/info/ConsoleProxyInfo.java @@ -28,6 +28,7 @@ public class ConsoleProxyInfo { private int proxyPort; private String proxyImageUrl; private int proxyUrlPort = 8000; + private String proxyName; public ConsoleProxyInfo(int proxyUrlPort) { this.proxyUrlPort = proxyUrlPort; @@ -100,4 +101,12 @@ public class ConsoleProxyInfo { public void setSslEnabled(boolean sslEnabled) { this.sslEnabled = sslEnabled; } + + public String getProxyName() { + return proxyName; + } + + public void setProxyName(String proxyName) { + this.proxyName = proxyName; + } } diff --git a/core/src/main/java/com/cloud/info/ConsoleProxyStatus.java b/core/src/main/java/com/cloud/info/ConsoleProxyStatus.java index 3d3dda9a508..e9ef26a63b9 100644 --- a/core/src/main/java/com/cloud/info/ConsoleProxyStatus.java +++ b/core/src/main/java/com/cloud/info/ConsoleProxyStatus.java @@ -21,6 +21,7 @@ package com.cloud.info; public class ConsoleProxyStatus { private ConsoleProxyConnectionInfo[] connections; + private String[] removedSessions; public ConsoleProxyStatus() { } @@ -28,4 +29,8 @@ public class ConsoleProxyStatus { public ConsoleProxyConnectionInfo[] getConnections() { return connections; } + + public String[] getRemovedSessions() { + return removedSessions; + } } diff --git a/engine/components-api/src/main/java/com/cloud/capacity/CapacityManager.java b/engine/components-api/src/main/java/com/cloud/capacity/CapacityManager.java index 44de85edeef..713d70a936b 100644 --- a/engine/components-api/src/main/java/com/cloud/capacity/CapacityManager.java +++ b/engine/components-api/src/main/java/com/cloud/capacity/CapacityManager.java @@ -42,18 +42,18 @@ public interface CapacityManager { static final String StorageAllocatedCapacityDisableThresholdCK = "pool.storage.allocated.capacity.disablethreshold"; static final String VmwareCreateCloneFullCK = "vmware.create.full.clone"; - static final ConfigKey CpuOverprovisioningFactor = new ConfigKey(Float.class, CpuOverprovisioningFactorCK, "Advanced", "1.0", + static final ConfigKey CpuOverprovisioningFactor = new ConfigKey(Float.class, CpuOverprovisioningFactorCK, ConfigKey.CATEGORY_ADVANCED, "1.0", "Used for CPU overprovisioning calculation; available CPU will be (actualCpuCapacity * cpu.overprovisioning.factor)", true, ConfigKey.Scope.Cluster, null); - static final ConfigKey MemOverprovisioningFactor = new ConfigKey(Float.class, MemOverprovisioningFactorCK, "Advanced", "1.0", + static final ConfigKey MemOverprovisioningFactor = new ConfigKey(Float.class, MemOverprovisioningFactorCK, ConfigKey.CATEGORY_ADVANCED, "1.0", "Used for memory overprovisioning calculation", true, ConfigKey.Scope.Cluster, null); - static final ConfigKey StorageCapacityDisableThreshold = new ConfigKey("Alert", Double.class, StorageCapacityDisableThresholdCK, "0.85", + static final ConfigKey StorageCapacityDisableThreshold = new ConfigKey(ConfigKey.CATEGORY_ALERT, Double.class, StorageCapacityDisableThresholdCK, "0.85", "Percentage (as a value between 0 and 1) of storage utilization above which allocators will disable using the pool for low storage available.", true, ConfigKey.Scope.Zone); static final ConfigKey StorageOverprovisioningFactor = new ConfigKey("Storage", Double.class, StorageOverprovisioningFactorCK, "2", "Used for storage overprovisioning calculation; available storage will be (actualStorageSize * storage.overprovisioning.factor)", true, ConfigKey.Scope.StoragePool); static final ConfigKey StorageAllocatedCapacityDisableThreshold = new ConfigKey( - "Alert", + ConfigKey.CATEGORY_ALERT, Double.class, StorageAllocatedCapacityDisableThresholdCK, "0.85", @@ -63,7 +63,7 @@ public interface CapacityManager { new ConfigKey( Boolean.class, "cluster.storage.operations.exclude", - "Advanced", + ConfigKey.CATEGORY_ADVANCED, "false", "Exclude cluster from storage operations", true, @@ -82,14 +82,14 @@ public interface CapacityManager { new ConfigKey( String.class, "secstorage.nfs.version", - "Advanced", + ConfigKey.CATEGORY_ADVANCED, null, "Enforces specific NFS version when mounting Secondary Storage. If NULL default selection is performed", true, ConfigKey.Scope.ImageStore, null); - static final ConfigKey SecondaryStorageCapacityThreshold = new ConfigKey("Advanced", Float.class, "secondary.storage.capacity.threshold", "0.90", + static final ConfigKey SecondaryStorageCapacityThreshold = new ConfigKey(ConfigKey.CATEGORY_ADVANCED, Float.class, "secondary.storage.capacity.threshold", "0.90", "Percentage (as a value between 0 and 1) of secondary storage capacity threshold.", true); public boolean releaseVmCapacity(VirtualMachine vm, boolean moveFromReserved, boolean moveToReservered, Long hostId); diff --git a/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java b/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java index d22fe1f1cd8..926e65cbd18 100644 --- a/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java +++ b/framework/config/src/main/java/org/apache/cloudstack/framework/config/ConfigKey.java @@ -30,6 +30,9 @@ import com.cloud.utils.exception.CloudRuntimeException; */ public class ConfigKey { + public static final String CATEGORY_ADVANCED = "Advanced"; + public static final String CATEGORY_ALERT = "Alert"; + public static enum Scope { Global, Zone, Cluster, StoragePool, Account, ManagementServer, ImageStore, Domain } diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStartCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStartCommandWrapper.java index f151255d5cd..77d1b1424d6 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStartCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtStartCommandWrapper.java @@ -44,7 +44,7 @@ import com.cloud.vm.UserVmManager; import com.cloud.vm.VirtualMachine; @ResourceWrapper(handles = StartCommand.class) -public final class LibvirtStartCommandWrapper extends CommandWrapper { +public class LibvirtStartCommandWrapper extends CommandWrapper { private static final Logger s_logger = Logger.getLogger(LibvirtStartCommandWrapper.class); diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java index 5afe84548a8..df1831c149b 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/LibvirtComputingResourceTest.java @@ -27,6 +27,7 @@ import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Matchers.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -5335,6 +5336,9 @@ public class LibvirtComputingResourceTest { when(libvirtComputingResource.getLibvirtUtilitiesHelper()).thenReturn(libvirtUtilitiesHelper); try { when(libvirtUtilitiesHelper.getConnectionByType(vmDef.getHvsType())).thenReturn(conn); + when(libvirtUtilitiesHelper.retrieveSshPrvKeyPath()).thenReturn(LibvirtComputingResource.SSHPRVKEYPATH); + File pemFile = mock(File.class); + PowerMockito.whenNew(File.class).withAnyArguments().thenReturn(pemFile); when(conn.listDomains()).thenReturn(vms); doNothing().when(libvirtComputingResource).createVbd(conn, vmSpec, vmName, vmDef); } catch (final LibvirtException e) { @@ -5343,6 +5347,8 @@ public class LibvirtComputingResourceTest { fail(e.getMessage()); } catch (final URISyntaxException e) { fail(e.getMessage()); + } catch (Exception e) { + fail(e.getMessage()); } when(storagePoolMgr.connectPhysicalDisksViaVmSpec(vmSpec)).thenReturn(true); @@ -5409,6 +5415,9 @@ public class LibvirtComputingResourceTest { when(libvirtComputingResource.getLibvirtUtilitiesHelper()).thenReturn(libvirtUtilitiesHelper); try { when(libvirtUtilitiesHelper.getConnectionByType(vmDef.getHvsType())).thenReturn(conn); + when(libvirtUtilitiesHelper.retrieveSshPrvKeyPath()).thenReturn(LibvirtComputingResource.SSHPRVKEYPATH); + File pemFile = mock(File.class); + PowerMockito.whenNew(File.class).withAnyArguments().thenReturn(pemFile); when(conn.listDomains()).thenReturn(vms); doNothing().when(libvirtComputingResource).createVbd(conn, vmSpec, vmName, vmDef); } catch (final LibvirtException e) { @@ -5417,6 +5426,8 @@ public class LibvirtComputingResourceTest { fail(e.getMessage()); } catch (final URISyntaxException e) { fail(e.getMessage()); + } catch (Exception e) { + fail(e.getMessage()); } when(storagePoolMgr.connectPhysicalDisksViaVmSpec(vmSpec)).thenReturn(true); diff --git a/server/src/main/java/com/cloud/api/ApiServlet.java b/server/src/main/java/com/cloud/api/ApiServlet.java index 3d4167eaf4c..926c1553e5a 100644 --- a/server/src/main/java/com/cloud/api/ApiServlet.java +++ b/server/src/main/java/com/cloud/api/ApiServlet.java @@ -41,8 +41,10 @@ import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.auth.APIAuthenticationManager; import org.apache.cloudstack.api.auth.APIAuthenticationType; import org.apache.cloudstack.api.auth.APIAuthenticator; +import org.apache.cloudstack.api.command.user.consoleproxy.CreateConsoleEndpointCmd; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.managed.context.ManagedContext; +import org.apache.cloudstack.utils.consoleproxy.ConsoleAccessUtils; import org.apache.log4j.Logger; import org.springframework.stereotype.Component; import org.springframework.web.context.support.SpringBeanAutowiringSupport; @@ -187,8 +189,8 @@ public class ApiServlet extends HttpServlet { } final Object[] commandObj = params.get(ApiConstants.COMMAND); + final String command = commandObj == null ? null : (String) commandObj[0]; if (commandObj != null) { - final String command = (String) commandObj[0]; APIAuthenticator apiAuthenticator = authManager.getAPIAuthenticator(command); if (apiAuthenticator != null) { @@ -283,7 +285,6 @@ public class ApiServlet extends HttpServlet { // Do a sanity check here to make sure the user hasn't already been deleted if ((userId != null) && (account != null) && (accountObj != null) && apiServer.verifyUser(userId)) { - final String[] command = (String[])params.get(ApiConstants.COMMAND); if (command == null) { s_logger.info("missing command, ignoring request..."); auditTrailSb.append(" " + HttpServletResponse.SC_BAD_REQUEST + " " + "no command specified"); @@ -318,6 +319,7 @@ public class ApiServlet extends HttpServlet { // Add the HTTP method (GET/POST/PUT/DELETE) as well into the params map. params.put("httpmethod", new String[]{req.getMethod()}); setProjectContext(params); + setClientAddressForConsoleEndpointAccess(command, params, req); final String response = apiServer.handleRequest(params, responseType, auditTrailSb); HttpUtils.writeHttpResponse(resp, response != null ? response : "", HttpServletResponse.SC_OK, responseType, ApiServer.JSONcontentType.value()); } else { @@ -353,6 +355,15 @@ public class ApiServlet extends HttpServlet { } } + protected void setClientAddressForConsoleEndpointAccess(String command, Map params, HttpServletRequest req) throws UnknownHostException { + if (org.apache.commons.lang3.StringUtils.isNotBlank(command) && + command.equalsIgnoreCase(CreateConsoleEndpointCmd.APINAME)) { + InetAddress addr = getClientAddress(req); + String clientAddress = addr != null ? addr.getHostAddress() : null; + params.put(ConsoleAccessUtils.CLIENT_INET_ADDRESS_KEY, new String[]{clientAddress}); + } + } + private void setProjectContext(Map requestParameters) { final String[] command = (String[])requestParameters.get(ApiConstants.COMMAND); if (command == null) { diff --git a/server/src/main/java/com/cloud/consoleproxy/AgentBasedConsoleProxyManager.java b/server/src/main/java/com/cloud/consoleproxy/AgentBasedConsoleProxyManager.java index 487ec45a424..a71c692aab1 100644 --- a/server/src/main/java/com/cloud/consoleproxy/AgentBasedConsoleProxyManager.java +++ b/server/src/main/java/com/cloud/consoleproxy/AgentBasedConsoleProxyManager.java @@ -21,6 +21,7 @@ import java.util.Map; import javax.inject.Inject; import javax.naming.ConfigurationException; +import org.apache.cloudstack.consoleproxy.ConsoleAccessManager; import org.apache.log4j.Logger; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; @@ -67,6 +68,8 @@ public class AgentBasedConsoleProxyManager extends ManagerBase implements Consol protected ConsoleProxyDao _cpDao; @Inject protected KeystoreManager _ksMgr; + @Inject + protected ConsoleAccessManager consoleAccessManager; @Inject ConfigurationDao _configDao; @@ -77,8 +80,9 @@ public class AgentBasedConsoleProxyManager extends ManagerBase implements Consol public class AgentBasedAgentHook extends AgentHookBase { - public AgentBasedAgentHook(VMInstanceDao instanceDao, HostDao hostDao, ConfigurationDao cfgDao, KeystoreManager ksMgr, AgentManager agentMgr, KeysManager keysMgr) { - super(instanceDao, hostDao, cfgDao, ksMgr, agentMgr, keysMgr); + public AgentBasedAgentHook(VMInstanceDao instanceDao, HostDao hostDao, ConfigurationDao cfgDao, KeystoreManager ksMgr, + AgentManager agentMgr, KeysManager keysMgr, ConsoleAccessManager consoleAccessManager) { + super(instanceDao, hostDao, cfgDao, ksMgr, agentMgr, keysMgr, consoleAccessManager); } @Override @@ -121,7 +125,8 @@ public class AgentBasedConsoleProxyManager extends ManagerBase implements Consol _consoleProxyUrlDomain = configs.get("consoleproxy.url.domain"); - _listener = new ConsoleProxyListener(new AgentBasedAgentHook(_instanceDao, _hostDao, _configDao, _ksMgr, _agentMgr, _keysMgr)); + _listener = new ConsoleProxyListener(new AgentBasedAgentHook(_instanceDao, _hostDao, _configDao, _ksMgr, + _agentMgr, _keysMgr, consoleAccessManager)); _agentMgr.registerForHostEvents(_listener, true, true, false); if (s_logger.isInfoEnabled()) { @@ -182,6 +187,11 @@ public class AgentBasedConsoleProxyManager extends ManagerBase implements Consol return false; } + @Override + public int getVncPort() { + return _consoleProxyPort; + } + @Override public boolean rebootProxy(long proxyVmId) { return false; diff --git a/server/src/main/java/com/cloud/consoleproxy/AgentHookBase.java b/server/src/main/java/com/cloud/consoleproxy/AgentHookBase.java index 2bc092e056b..619825ecf43 100644 --- a/server/src/main/java/com/cloud/consoleproxy/AgentHookBase.java +++ b/server/src/main/java/com/cloud/consoleproxy/AgentHookBase.java @@ -21,6 +21,8 @@ import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Date; +import org.apache.cloudstack.consoleproxy.ConsoleAccessManager; +import org.apache.cloudstack.consoleproxy.ConsoleAccessManagerImpl; import org.apache.cloudstack.framework.config.dao.ConfigurationDao; import org.apache.cloudstack.framework.security.keys.KeysManager; import org.apache.cloudstack.framework.security.keystore.KeystoreManager; @@ -68,14 +70,17 @@ public abstract class AgentHookBase implements AgentHook { AgentManager _agentMgr; KeystoreManager _ksMgr; KeysManager _keysMgr; + ConsoleAccessManager consoleAccessManager; - public AgentHookBase(VMInstanceDao instanceDao, HostDao hostDao, ConfigurationDao cfgDao, KeystoreManager ksMgr, AgentManager agentMgr, KeysManager keysMgr) { + protected AgentHookBase(VMInstanceDao instanceDao, HostDao hostDao, ConfigurationDao cfgDao, KeystoreManager ksMgr, + AgentManager agentMgr, KeysManager keysMgr, ConsoleAccessManager consoleAccessMgr) { _instanceDao = instanceDao; _hostDao = hostDao; _agentMgr = agentMgr; _configDao = cfgDao; _ksMgr = ksMgr; _keysMgr = keysMgr; + consoleAccessManager = consoleAccessMgr; } @Override @@ -83,6 +88,8 @@ public abstract class AgentHookBase implements AgentHook { Long vmId = null; String ticketInUrl = cmd.getTicket(); + String sessionUuid = cmd.getSessionUuid(); + if (ticketInUrl == null) { s_logger.error("Access ticket could not be found, you could be running an old version of console proxy. vmId: " + cmd.getVmId()); return new ConsoleAccessAuthenticationAnswer(cmd, false); @@ -93,16 +100,20 @@ public abstract class AgentHookBase implements AgentHook { } if (!cmd.isReauthenticating()) { - String ticket = ConsoleProxyServlet.genAccessTicket(cmd.getHost(), cmd.getPort(), cmd.getSid(), cmd.getVmId()); + String ticket = ConsoleAccessManagerImpl.genAccessTicket(cmd.getHost(), cmd.getPort(), cmd.getSid(), cmd.getVmId(), sessionUuid); if (s_logger.isDebugEnabled()) { s_logger.debug("Console authentication. Ticket in 1 minute boundary for " + cmd.getHost() + ":" + cmd.getPort() + "-" + cmd.getVmId() + " is " + ticket); } + if (!consoleAccessManager.isSessionAllowed(sessionUuid)) { + s_logger.error("Invalid session, only one session allowed per token"); + return new ConsoleAccessAuthenticationAnswer(cmd, false); + } + if (!ticket.equals(ticketInUrl)) { Date now = new Date(); // considering of minute round-up - String minuteEarlyTicket = - ConsoleProxyServlet.genAccessTicket(cmd.getHost(), cmd.getPort(), cmd.getSid(), cmd.getVmId(), new Date(now.getTime() - 60 * 1000)); + String minuteEarlyTicket = ConsoleAccessManagerImpl.genAccessTicket(cmd.getHost(), cmd.getPort(), cmd.getSid(), cmd.getVmId(), new Date(now.getTime() - 60 * 1000), sessionUuid); if (s_logger.isDebugEnabled()) { s_logger.debug("Console authentication. Ticket in 2-minute boundary for " + cmd.getHost() + ":" + cmd.getPort() + "-" + cmd.getVmId() + " is " + diff --git a/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManager.java b/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManager.java index f7f88b0da66..6280495fb1a 100644 --- a/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManager.java +++ b/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManager.java @@ -23,39 +23,39 @@ import org.apache.cloudstack.framework.config.ConfigKey; public interface ConsoleProxyManager extends Manager, ConsoleProxyService { - public static final int DEFAULT_PROXY_CAPACITY = 50; - public static final int DEFAULT_STANDBY_CAPACITY = 10; - public static final int DEFAULT_PROXY_VM_RAMSIZE = 1024; // 1G - public static final int DEFAULT_PROXY_VM_CPUMHZ = 500; // 500 MHz + int DEFAULT_PROXY_CAPACITY = 50; + int DEFAULT_STANDBY_CAPACITY = 10; + int DEFAULT_PROXY_VM_RAMSIZE = 1024; // 1G + int DEFAULT_PROXY_VM_CPUMHZ = 500; // 500 MHz - public static final int DEFAULT_PROXY_CMD_PORT = 8001; - public static final int DEFAULT_PROXY_VNC_PORT = 0; - public static final int DEFAULT_PROXY_URL_PORT = 80; - public static final int DEFAULT_PROXY_SESSION_TIMEOUT = 300000; // 5 minutes + int DEFAULT_PROXY_CMD_PORT = 8001; + int DEFAULT_PROXY_VNC_PORT = 0; + int DEFAULT_PROXY_URL_PORT = 80; + int DEFAULT_PROXY_SESSION_TIMEOUT = 300000; // 5 minutes - public static final int DEFAULT_NOVNC_PORT = 8080; + String ALERT_SUBJECT = "proxy-alert"; + String CERTIFICATE_NAME = "CPVMCertificate"; - public static final String ALERT_SUBJECT = "proxy-alert"; - public static final String CERTIFICATE_NAME = "CPVMCertificate"; - - public static final ConfigKey NoVncConsoleDefault = new ConfigKey("Advanced", Boolean.class, "novnc.console.default", "true", + ConfigKey NoVncConsoleDefault = new ConfigKey<>(ConfigKey.CATEGORY_ADVANCED, Boolean.class, "novnc.console.default", "true", "If true, noVNC console will be default console for virtual machines", true); - public static final ConfigKey NoVncConsoleSourceIpCheckEnabled = new ConfigKey("Advanced", Boolean.class, "novnc.console.sourceip.check.enabled", "false", + ConfigKey NoVncConsoleSourceIpCheckEnabled = new ConfigKey<>(ConfigKey.CATEGORY_ADVANCED, Boolean.class, "novnc.console.sourceip.check.enabled", "false", "If true, The source IP to access novnc console must be same as the IP in request to management server for console URL. Needs to reconnect CPVM to management server when this changes (via restart CPVM, or management server, or cloud service in CPVM)", false); - public void setManagementState(ConsoleProxyManagementState state); + void setManagementState(ConsoleProxyManagementState state); - public ConsoleProxyManagementState getManagementState(); + ConsoleProxyManagementState getManagementState(); - public void resumeLastManagementState(); + void resumeLastManagementState(); - public ConsoleProxyVO startProxy(long proxyVmId, boolean ignoreRestartSetting); + ConsoleProxyVO startProxy(long proxyVmId, boolean ignoreRestartSetting); - public boolean stopProxy(long proxyVmId); + boolean stopProxy(long proxyVmId); - public boolean rebootProxy(long proxyVmId); + boolean rebootProxy(long proxyVmId); - public boolean destroyProxy(long proxyVmId); + boolean destroyProxy(long proxyVmId); + + int getVncPort(); } diff --git a/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java b/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java index a3177fa7705..8b0d8351e11 100644 --- a/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java +++ b/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java @@ -30,6 +30,7 @@ import javax.inject.Inject; import javax.naming.ConfigurationException; import org.apache.cloudstack.agent.lb.IndirectAgentLB; +import org.apache.cloudstack.consoleproxy.ConsoleAccessManager; import org.apache.cloudstack.context.CallContext; import org.apache.cloudstack.engine.orchestration.service.NetworkOrchestrationService; import org.apache.cloudstack.framework.config.ConfigKey; @@ -256,11 +257,13 @@ public class ConsoleProxyManagerImpl extends ManagerBase implements ConsoleProxy private KeystoreDao _ksDao; @Inject private KeystoreManager _ksMgr; + @Inject + private ConsoleAccessManager consoleAccessManager; public class VmBasedAgentHook extends AgentHookBase { - public VmBasedAgentHook(VMInstanceDao instanceDao, HostDao hostDao, ConfigurationDao cfgDao, KeystoreManager ksMgr, AgentManager agentMgr, KeysManager keysMgr) { - super(instanceDao, hostDao, cfgDao, ksMgr, agentMgr, keysMgr); + public VmBasedAgentHook(VMInstanceDao instanceDao, HostDao hostDao, ConfigurationDao cfgDao, KeystoreManager ksMgr, AgentManager agentMgr, KeysManager keysMgr, ConsoleAccessManager consoleAccessManager) { + super(instanceDao, hostDao, cfgDao, ksMgr, agentMgr, keysMgr, consoleAccessManager); } @Override @@ -333,11 +336,14 @@ public class ConsoleProxyManagerImpl extends ManagerBase implements ConsoleProxy s_logger.warn(String.format("SSL is enabled for console proxy [%s] but no server certificate found in database.", proxy.toString())); } + ConsoleProxyInfo info; if (staticPublicIp == null) { - return new ConsoleProxyInfo(proxy.isSslEnabled(), proxy.getPublicIpAddress(), consoleProxyPort, proxy.getPort(), consoleProxyUrlDomain); + info = new ConsoleProxyInfo(proxy.isSslEnabled(), proxy.getPublicIpAddress(), consoleProxyPort, proxy.getPort(), consoleProxyUrlDomain); } else { - return new ConsoleProxyInfo(proxy.isSslEnabled(), staticPublicIp, consoleProxyPort, staticPort, consoleProxyUrlDomain); + info = new ConsoleProxyInfo(proxy.isSslEnabled(), staticPublicIp, consoleProxyPort, staticPort, consoleProxyUrlDomain); } + info.setProxyName(proxy.getHostName()); + return info; } public ConsoleProxyVO doAssignProxy(long dataCenterId, long vmId) { @@ -974,7 +980,7 @@ public class ConsoleProxyManagerImpl extends ManagerBase implements ConsoleProxy } }); } - } catch (Throwable e) { + } catch (Exception e) { s_logger.error(String.format("Unable to set console proxy management state to [%s] due to [%s].", state, e.getMessage()), e); } } @@ -1009,7 +1015,7 @@ public class ConsoleProxyManagerImpl extends ManagerBase implements ConsoleProxy if (lastState != state) { configurationDao.update(Config.ConsoleProxyManagementState.key(), Config.ConsoleProxyManagementState.getCategory(), lastState.toString()); } - } catch (Throwable e) { + } catch (Exception e) { s_logger.error(String.format("Unable to resume last management state due to [%s].", e.getMessage()), e); } } @@ -1088,6 +1094,11 @@ public class ConsoleProxyManagerImpl extends ManagerBase implements ConsoleProxy } } + @Override + public int getVncPort() { + return sslEnabled && _ksDao.findByName(ConsoleProxyManager.CERTIFICATE_NAME) != null ? 8443 : 8080; + } + private String getAllocProxyLockName() { return "consoleproxy.alloc"; } @@ -1148,7 +1159,8 @@ public class ConsoleProxyManagerImpl extends ManagerBase implements ConsoleProxy value = agentMgrConfigs.get("port"); managementPort = NumbersUtil.parseInt(value, 8250); - consoleProxyListener = new ConsoleProxyListener(new VmBasedAgentHook(vmInstanceDao, hostDao, configurationDao, _ksMgr, agentManager, keysManager)); + consoleProxyListener = new ConsoleProxyListener(new VmBasedAgentHook(vmInstanceDao, hostDao, configurationDao, + _ksMgr, agentManager, keysManager, consoleAccessManager)); agentManager.registerForHostEvents(consoleProxyListener, true, true, false); virtualMachineManager.registerGuru(VirtualMachine.Type.ConsoleProxy, this); @@ -1273,6 +1285,9 @@ public class ConsoleProxyManagerImpl extends ManagerBase implements ConsoleProxy if (dc.getDns2() != null) { buf.append(" dns2=").append(dc.getDns2()); } + if (VirtualMachine.Type.ConsoleProxy == profile.getVirtualMachine().getType()) { + buf.append(" vncport=").append(getVncPort()); + } String bootArgs = buf.toString(); if (s_logger.isDebugEnabled()) { @@ -1606,6 +1621,9 @@ public class ConsoleProxyManagerImpl extends ManagerBase implements ConsoleProxy if (status.getConnections() != null) { count = status.getConnections().length; } + if (status.getRemovedSessions() != null) { + consoleAccessManager.removeSessions(status.getRemovedSessions()); + } details = statusInfo.getBytes(Charset.forName("US-ASCII")); } else { diff --git a/server/src/main/java/com/cloud/server/ManagementServer.java b/server/src/main/java/com/cloud/server/ManagementServer.java index 4e58a4f5576..6d2c42dbc2f 100644 --- a/server/src/main/java/com/cloud/server/ManagementServer.java +++ b/server/src/main/java/com/cloud/server/ManagementServer.java @@ -51,8 +51,12 @@ public interface ManagementServer extends ManagementService, PluggableService { DetailVO findDetail(long hostId, String name); + Pair setConsoleAccessForVm(long vmId, String sessionUuid); + String getConsoleAccessUrlRoot(long vmId); + String getConsoleAccessAddress(long vmId); + GuestOSVO getGuestOs(Long guestOsId); GuestOSHypervisorVO getGuestOsHypervisor(Long guestOsHypervisorId); diff --git a/server/src/main/java/com/cloud/server/ManagementServerImpl.java b/server/src/main/java/com/cloud/server/ManagementServerImpl.java index c31b583780c..c5aa4eac0d9 100644 --- a/server/src/main/java/com/cloud/server/ManagementServerImpl.java +++ b/server/src/main/java/com/cloud/server/ManagementServerImpl.java @@ -42,6 +42,9 @@ import javax.crypto.spec.SecretKeySpec; import javax.inject.Inject; import javax.naming.ConfigurationException; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.proxy.AllowConsoleAccessCommand; +import com.cloud.exception.AgentUnavailableException; import org.apache.cloudstack.acl.ControlledEntity; import org.apache.cloudstack.affinity.AffinityGroupProcessor; import org.apache.cloudstack.affinity.dao.AffinityGroupVMMapDao; @@ -343,6 +346,7 @@ import org.apache.cloudstack.api.command.user.autoscale.UpdateAutoScalePolicyCmd import org.apache.cloudstack.api.command.user.autoscale.UpdateAutoScaleVmGroupCmd; import org.apache.cloudstack.api.command.user.autoscale.UpdateAutoScaleVmProfileCmd; import org.apache.cloudstack.api.command.user.config.ListCapabilitiesCmd; +import org.apache.cloudstack.api.command.user.consoleproxy.CreateConsoleEndpointCmd; import org.apache.cloudstack.api.command.user.event.ArchiveEventsCmd; import org.apache.cloudstack.api.command.user.event.DeleteEventsCmd; import org.apache.cloudstack.api.command.user.event.ListEventTypesCmd; @@ -2735,6 +2739,42 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe return null; } + @Override + public Pair setConsoleAccessForVm(long vmId, String sessionUuid) { + final VMInstanceVO vm = _vmInstanceDao.findById(vmId); + if (vm == null) { + return new Pair<>(false, "Cannot find a VM with id = " + vmId); + } + final ConsoleProxyInfo proxy = getConsoleProxyForVm(vm.getDataCenterId(), vmId); + if (proxy == null) { + return new Pair<>(false, "Cannot find a console proxy for the VM " + vmId); + } + AllowConsoleAccessCommand cmd = new AllowConsoleAccessCommand(sessionUuid); + HostVO hostVO = _hostDao.findByTypeNameAndZoneId(vm.getDataCenterId(), proxy.getProxyName(), Type.ConsoleProxy); + if (hostVO == null) { + return new Pair<>(false, "Cannot find a console proxy agent for CPVM with name " + proxy.getProxyName()); + } + Answer answer; + try { + answer = _agentMgr.send(hostVO.getId(), cmd); + } catch (AgentUnavailableException | OperationTimedoutException e) { + String errorMsg = "Could not send allow session command to CPVM: " + e.getMessage(); + s_logger.error(errorMsg, e); + return new Pair<>(false, errorMsg); + } + return new Pair<>(answer != null && answer.getResult(), answer != null ? answer.getDetails() : "null answer"); + } + + @Override + public String getConsoleAccessAddress(long vmId) { + final VMInstanceVO vm = _vmInstanceDao.findById(vmId); + if (vm != null) { + final ConsoleProxyInfo proxy = getConsoleProxyForVm(vm.getDataCenterId(), vmId); + return proxy != null ? proxy.getProxyAddress() : null; + } + return null; + } + @Override public Pair getVncPort(final VirtualMachine vm) { if (vm.getHostId() == null) { @@ -3503,6 +3543,7 @@ public class ManagementServerImpl extends ManagerBase implements ManagementServe cmdList.add(IssueOutOfBandManagementPowerActionCmd.class); cmdList.add(ChangeOutOfBandManagementPasswordCmd.class); cmdList.add(GetUserKeysCmd.class); + cmdList.add(CreateConsoleEndpointCmd.class); return cmdList; } diff --git a/server/src/main/java/com/cloud/servlet/ConsoleProxyClientParam.java b/server/src/main/java/com/cloud/servlet/ConsoleProxyClientParam.java index 8f9363df5ba..fed6318a054 100644 --- a/server/src/main/java/com/cloud/servlet/ConsoleProxyClientParam.java +++ b/server/src/main/java/com/cloud/servlet/ConsoleProxyClientParam.java @@ -36,6 +36,15 @@ public class ConsoleProxyClientParam { private String sourceIP; private String websocketUrl; + private String sessionUuid; + + // The server-side generated value for extra console endpoint validation + private String extraSecurityToken; + + // The extra parameter received in the console URL, must be compared against the server-side generated value + // for extra validation (if has been enabled) + private String clientProvidedExtraSecurityToken; + public ConsoleProxyClientParam() { clientHostPort = 0; } @@ -159,4 +168,28 @@ public class ConsoleProxyClientParam { public void setWebsocketUrl(String websocketUrl) { this.websocketUrl = websocketUrl; } + + public String getSessionUuid() { + return sessionUuid; + } + + public String getExtraSecurityToken() { + return extraSecurityToken; + } + + public void setExtraSecurityToken(String extraSecurityToken) { + this.extraSecurityToken = extraSecurityToken; + } + + public String getClientProvidedExtraSecurityToken() { + return clientProvidedExtraSecurityToken; + } + + public void setClientProvidedExtraSecurityToken(String clientProvidedExtraSecurityToken) { + this.clientProvidedExtraSecurityToken = clientProvidedExtraSecurityToken; + } + + public void setSessionUuid(String sessionUuid) { + this.sessionUuid = sessionUuid; + } } diff --git a/server/src/main/java/com/cloud/servlet/ConsoleProxyServlet.java b/server/src/main/java/com/cloud/servlet/ConsoleProxyServlet.java index b755a84887d..ccef4007259 100644 --- a/server/src/main/java/com/cloud/servlet/ConsoleProxyServlet.java +++ b/server/src/main/java/com/cloud/servlet/ConsoleProxyServlet.java @@ -17,9 +17,7 @@ package com.cloud.servlet; import java.io.IOException; -import java.net.InetAddress; import java.net.URLEncoder; -import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Collections; import java.util.Date; @@ -37,13 +35,6 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; -import com.cloud.agent.AgentManager; -import com.cloud.agent.api.Answer; -import com.cloud.agent.api.GetVmVncTicketAnswer; -import com.cloud.agent.api.GetVmVncTicketCommand; -import com.cloud.exception.AgentUnavailableException; -import com.cloud.exception.OperationTimedoutException; -import com.cloud.utils.StringUtils; import org.apache.cloudstack.framework.security.keys.KeysManager; import org.apache.commons.codec.binary.Base64; import org.apache.log4j.Logger; @@ -51,31 +42,22 @@ import org.springframework.stereotype.Component; import org.springframework.web.context.support.SpringBeanAutowiringSupport; -import com.cloud.vm.VmDetailConstants; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.cloud.api.ApiServlet; -import com.cloud.consoleproxy.ConsoleProxyManager; import com.cloud.exception.PermissionDeniedException; import com.cloud.host.HostVO; -import com.cloud.hypervisor.Hypervisor; -import com.cloud.resource.ResourceState; import com.cloud.server.ManagementServer; -import com.cloud.storage.GuestOSVO; import com.cloud.user.Account; import com.cloud.user.AccountManager; import com.cloud.user.User; -import com.cloud.uservm.UserVm; import com.cloud.utils.ConstantTimeComparator; import com.cloud.utils.Pair; import com.cloud.utils.Ternary; import com.cloud.utils.db.EntityManager; import com.cloud.utils.db.TransactionLegacy; -import com.cloud.vm.UserVmDetailVO; import com.cloud.vm.VirtualMachine; import com.cloud.vm.VirtualMachineManager; -import com.cloud.vm.dao.UserVmDetailsDao; /** * Thumbnail access : /console?cmd=thumbnail&vm=xxx&w=xxx&h=xxx @@ -98,11 +80,7 @@ public class ConsoleProxyServlet extends HttpServlet { @Inject EntityManager _entityMgr; @Inject - UserVmDetailsDao _userVmDetailsDao; - @Inject KeysManager _keysMgr; - @Inject - AgentManager agentManager; static KeysManager s_keysMgr; @@ -198,12 +176,10 @@ public class ConsoleProxyServlet extends HttpServlet { if (cmd.equalsIgnoreCase("thumbnail")) { handleThumbnailRequest(req, resp, vmId); - } else if (cmd.equalsIgnoreCase("access")) { - handleAccessRequest(req, resp, vmId); } else { handleAuthRequest(req, resp, vmId); } - } catch (Throwable e) { + } catch (Exception e) { s_logger.error("Unexepected exception in ConsoleProxyServlet", e); sendResponse(resp, "Server Internal Error"); } @@ -260,61 +236,6 @@ public class ConsoleProxyServlet extends HttpServlet { } } - private void handleAccessRequest(HttpServletRequest req, HttpServletResponse resp, long vmId) { - VirtualMachine vm = _vmMgr.findById(vmId); - if (vm == null) { - s_logger.warn("VM " + vmId + " does not exist, sending blank response for console access request"); - sendResponse(resp, ""); - return; - } - - if (vm.getHostId() == null) { - s_logger.warn("VM " + vmId + " lost host info, sending blank response for console access request"); - sendResponse(resp, ""); - return; - } - - HostVO host = _ms.getHostBy(vm.getHostId()); - if (host == null) { - s_logger.warn("VM " + vmId + "'s host does not exist, sending blank response for console access request"); - sendResponse(resp, ""); - return; - } - - if (Hypervisor.HypervisorType.LXC.equals(vm.getHypervisorType())){ - sendResponse(resp, "

Console access is not supported for LXC

"); - return; - } - - String rootUrl = _ms.getConsoleAccessUrlRoot(vmId); - if (rootUrl == null) { - sendResponse(resp, "

Console access will be ready in a few minutes. Please try it again later.

"); - return; - } - - String vmName = vm.getHostName(); - if (vm.getType() == VirtualMachine.Type.User) { - UserVm userVm = _entityMgr.findById(UserVm.class, vmId); - String displayName = userVm.getDisplayName(); - if (displayName != null && !displayName.isEmpty() && !displayName.equals(vmName)) { - vmName += "(" + displayName + ")"; - } - } - - InetAddress remoteAddress = null; - try { - remoteAddress = ApiServlet.getClientAddress(req); - } catch (UnknownHostException e) { - s_logger.warn("UnknownHostException when trying to lookup remote IP-Address. This should never happen. Blocking request.", e); - } - - StringBuffer sb = new StringBuffer(); - sb.append("").append(escapeHTML(vmName)).append(""); - s_logger.debug("the console url is :: " + sb.toString()); - sendResponse(resp, sb.toString()); - } - private void handleAuthRequest(HttpServletRequest req, HttpServletResponse resp, long vmId) { // TODO authentication channel between console proxy VM and management server needs to be secured, @@ -436,145 +357,6 @@ public class ConsoleProxyServlet extends HttpServlet { return sb.toString(); } - /** - * Sets the URL to establish a VNC over websocket connection - */ - private void setWebsocketUrl(VirtualMachine vm, ConsoleProxyClientParam param) { - String ticket = acquireVncTicketForVmwareVm(vm); - if (StringUtils.isBlank(ticket)) { - s_logger.error("Could not obtain VNC ticket for VM " + vm.getInstanceName()); - return; - } - String wsUrl = composeWebsocketUrlForVmwareVm(ticket, param); - param.setWebsocketUrl(wsUrl); - } - - /** - * Format expected: wss://:443/ticket/ - */ - private String composeWebsocketUrlForVmwareVm(String ticket, ConsoleProxyClientParam param) { - param.setClientHostPort(443); - return String.format("wss://%s:%s/ticket/%s", param.getClientHostAddress(), param.getClientHostPort(), ticket); - } - - /** - * Acquires a ticket to be used for console proxy as described in 'Removal of VNC Server from ESXi' on: - * https://docs.vmware.com/en/VMware-vSphere/7.0/rn/vsphere-esxi-vcenter-server-70-release-notes.html - */ - private String acquireVncTicketForVmwareVm(VirtualMachine vm) { - try { - s_logger.info("Acquiring VNC ticket for VM = " + vm.getHostName()); - GetVmVncTicketCommand cmd = new GetVmVncTicketCommand(vm.getInstanceName()); - Answer answer = agentManager.send(vm.getHostId(), cmd); - GetVmVncTicketAnswer ans = (GetVmVncTicketAnswer) answer; - if (!ans.getResult()) { - s_logger.info("VNC ticket could not be acquired correctly: " + ans.getDetails()); - } - return ans.getTicket(); - } catch (AgentUnavailableException | OperationTimedoutException e) { - s_logger.error("Error acquiring ticket", e); - return null; - } - } - - private String composeConsoleAccessUrl(String rootUrl, VirtualMachine vm, HostVO hostVo, InetAddress addr) { - StringBuffer sb = new StringBuffer(rootUrl); - String host = hostVo.getPrivateIpAddress(); - - Pair portInfo = null; - if (hostVo.getHypervisorType() == Hypervisor.HypervisorType.KVM && - (hostVo.getResourceState().equals(ResourceState.ErrorInMaintenance) || - hostVo.getResourceState().equals(ResourceState.ErrorInPrepareForMaintenance))) { - UserVmDetailVO detailAddress = _userVmDetailsDao.findDetail(vm.getId(), VmDetailConstants.KVM_VNC_ADDRESS); - UserVmDetailVO detailPort = _userVmDetailsDao.findDetail(vm.getId(), VmDetailConstants.KVM_VNC_PORT); - if (detailAddress != null && detailPort != null) { - portInfo = new Pair<>(detailAddress.getValue(), Integer.valueOf(detailPort.getValue())); - } else { - s_logger.warn("KVM Host in ErrorInMaintenance/ErrorInPrepareForMaintenance but " + - "no VNC Address/Port was available. Falling back to default one from MS."); - } - } - - if (portInfo == null) { - portInfo = _ms.getVncPort(vm); - } - - if (s_logger.isDebugEnabled()) - s_logger.debug("Port info " + portInfo.first()); - - Ternary parsedHostInfo = parseHostInfo(portInfo.first()); - - int port = -1; - if (portInfo.second() == -9) { - //for hyperv - port = Integer.parseInt(_ms.findDetail(hostVo.getId(), "rdp.server.port").getValue()); - } else { - port = portInfo.second(); - } - - String sid = vm.getVncPassword(); - UserVmDetailVO details = _userVmDetailsDao.findDetail(vm.getId(), VmDetailConstants.KEYBOARD); - - String tag = vm.getUuid(); - - String ticket = genAccessTicket(parsedHostInfo.first(), String.valueOf(port), sid, tag); - ConsoleProxyPasswordBasedEncryptor encryptor = new ConsoleProxyPasswordBasedEncryptor(getEncryptorPassword()); - ConsoleProxyClientParam param = new ConsoleProxyClientParam(); - param.setClientHostAddress(parsedHostInfo.first()); - param.setClientHostPort(port); - param.setClientHostPassword(sid); - param.setClientTag(tag); - param.setTicket(ticket); - param.setSourceIP(addr != null ? addr.getHostAddress(): null); - - if (requiresVncOverWebSocketConnection(vm, hostVo)) { - setWebsocketUrl(vm, param); - } - - if (details != null) { - param.setLocale(details.getValue()); - } - - if (portInfo.second() == -9) { - //For Hyperv Clinet Host Address will send Instance id - param.setHypervHost(host); - param.setUsername(_ms.findDetail(hostVo.getId(), "username").getValue()); - param.setPassword(_ms.findDetail(hostVo.getId(), "password").getValue()); - } - if (parsedHostInfo.second() != null && parsedHostInfo.third() != null) { - param.setClientTunnelUrl(parsedHostInfo.second()); - param.setClientTunnelSession(parsedHostInfo.third()); - } - - if (param.getHypervHost() != null || !ConsoleProxyManager.NoVncConsoleDefault.value()) { - sb.append("/ajax?token=" + encryptor.encryptObject(ConsoleProxyClientParam.class, param)); - } else { - sb.append("/resource/noVNC/vnc.html") - .append("?autoconnect=true") - .append("&port=" + ConsoleProxyManager.DEFAULT_NOVNC_PORT) - .append("&token=" + encryptor.encryptObject(ConsoleProxyClientParam.class, param)); - } - - // for console access, we need guest OS type to help implement keyboard - long guestOs = vm.getGuestOSId(); - GuestOSVO guestOsVo = _ms.getGuestOs(guestOs); - if (guestOsVo.getCategoryId() == 6) - sb.append("&guest=windows"); - - if (s_logger.isDebugEnabled()) { - s_logger.debug("Compose console url: " + sb.toString()); - } - return sb.toString(); - } - - /** - * Since VMware 7.0 VNC servers are deprecated, it uses a ticket to create a VNC over websocket connection - * Check: https://docs.vmware.com/en/VMware-vSphere/7.0/rn/vsphere-esxi-vcenter-server-70-release-notes.html - */ - private boolean requiresVncOverWebSocketConnection(VirtualMachine vm, HostVO hostVo) { - return vm.getHypervisorType() == Hypervisor.HypervisorType.VMware && hostVo.getHypervisorVersion().compareTo("7.0") >= 0; - } - public static String genAccessTicket(String host, String port, String sid, String tag) { return genAccessTicket(host, port, sid, tag, new Date()); } diff --git a/server/src/main/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImpl.java b/server/src/main/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImpl.java new file mode 100644 index 00000000000..fb70d9dd26b --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImpl.java @@ -0,0 +1,472 @@ +// 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 org.apache.cloudstack.consoleproxy; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.GetVmVncTicketAnswer; +import com.cloud.agent.api.GetVmVncTicketCommand; +import com.cloud.consoleproxy.ConsoleProxyManager; +import com.cloud.exception.AgentUnavailableException; +import com.cloud.exception.OperationTimedoutException; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.host.HostVO; +import com.cloud.hypervisor.Hypervisor; +import com.cloud.resource.ResourceState; +import com.cloud.server.ManagementServer; +import com.cloud.servlet.ConsoleProxyClientParam; +import com.cloud.servlet.ConsoleProxyPasswordBasedEncryptor; +import com.cloud.storage.GuestOSVO; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.utils.Pair; +import com.cloud.utils.Ternary; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.db.EntityManager; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.UserVmDetailVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.VmDetailConstants; +import com.cloud.vm.dao.UserVmDetailsDao; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.apache.cloudstack.api.command.user.consoleproxy.ConsoleEndpoint; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.security.keys.KeysManager; +import org.apache.commons.codec.binary.Base64; +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.log4j.Logger; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import java.util.Date; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public class ConsoleAccessManagerImpl extends ManagerBase implements ConsoleAccessManager { + + @Inject + private AccountManager accountManager; + @Inject + private VirtualMachineManager virtualMachineManager; + @Inject + private ManagementServer managementServer; + @Inject + private EntityManager entityManager; + @Inject + private UserVmDetailsDao userVmDetailsDao; + @Inject + private KeysManager keysManager; + @Inject + private AgentManager agentManager; + @Inject + private ConsoleProxyManager consoleProxyManager; + + private static KeysManager secretKeysManager; + private final Gson gson = new GsonBuilder().create(); + + public static final Logger s_logger = Logger.getLogger(ConsoleAccessManagerImpl.class.getName()); + + private static Set allowedSessions; + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + ConsoleAccessManagerImpl.secretKeysManager = keysManager; + ConsoleAccessManagerImpl.allowedSessions = new HashSet<>(); + return super.configure(name, params); + } + + @Override + public ConsoleEndpoint generateConsoleEndpoint(Long vmId, String extraSecurityToken, String clientAddress) { + try { + if (accountManager == null || virtualMachineManager == null || managementServer == null) { + return new ConsoleEndpoint(false, null,"Console service is not ready"); + } + + if (keysManager.getHashKey() == null) { + String msg = "Console access denied. Ticket service is not ready yet"; + s_logger.debug(msg); + return new ConsoleEndpoint(false, null, msg); + } + + Account account = CallContext.current().getCallingAccount(); + + // Do a sanity check here to make sure the user hasn't already been deleted + if (account == null) { + s_logger.debug("Invalid user/account, reject console access"); + return new ConsoleEndpoint(false, null,"Access denied. Invalid or inconsistent account is found"); + } + + VirtualMachine vm = entityManager.findById(VirtualMachine.class, vmId); + if (vm == null) { + s_logger.info("Invalid console servlet command parameter: " + vmId); + return new ConsoleEndpoint(false, null, "Cannot find VM with ID " + vmId); + } + + if (!checkSessionPermission(vm, account)) { + return new ConsoleEndpoint(false, null, "Permission denied"); + } + + if (BooleanUtils.isTrue(ConsoleAccessManager.ConsoleProxyExtraSecurityValidationEnabled.value()) && + StringUtils.isBlank(extraSecurityToken)) { + String errorMsg = "Extra security validation is enabled but the extra token is missing"; + s_logger.error(errorMsg); + return new ConsoleEndpoint(false, errorMsg); + } + + String sessionUuid = UUID.randomUUID().toString(); + return generateAccessEndpoint(vmId, sessionUuid, extraSecurityToken, clientAddress); + } catch (Exception e) { + s_logger.error("Unexepected exception in ConsoleAccessManager", e); + return new ConsoleEndpoint(false, null, "Server Internal Error: " + e.getMessage()); + } + } + + @Override + public boolean isSessionAllowed(String sessionUuid) { + return allowedSessions.contains(sessionUuid); + } + + @Override + public void removeSessions(String[] sessionUuids) { + for (String r : sessionUuids) { + allowedSessions.remove(r); + } + } + + protected boolean checkSessionPermission(VirtualMachine vm, Account account) { + if (accountManager.isRootAdmin(account.getId())) { + return true; + } + + switch (vm.getType()) { + case User: + try { + accountManager.checkAccess(account, null, true, vm); + } catch (PermissionDeniedException ex) { + if (accountManager.isNormalUser(account.getId())) { + if (s_logger.isDebugEnabled()) { + s_logger.debug("VM access is denied. VM owner account " + vm.getAccountId() + " does not match the account id in session " + + account.getId() + " and caller is a normal user"); + } + } else if ((accountManager.isDomainAdmin(account.getId()) + || account.getType() == Account.ACCOUNT_TYPE_READ_ONLY_ADMIN) && s_logger.isDebugEnabled()) { + s_logger.debug("VM access is denied. VM owner account " + vm.getAccountId() + + " does not match the account id in session " + account.getId() + " and the domain-admin caller does not manage the target domain"); + } + return false; + } + break; + + case DomainRouter: + case ConsoleProxy: + case SecondaryStorageVm: + return false; + + default: + s_logger.warn("Unrecoginized virtual machine type, deny access by default. type: " + vm.getType()); + return false; + } + + return true; + } + + private ConsoleEndpoint generateAccessEndpoint(Long vmId, String sessionUuid, String extraSecurityToken, String clientAddress) { + VirtualMachine vm = virtualMachineManager.findById(vmId); + String msg; + if (vm == null) { + msg = "VM " + vmId + " does not exist, sending blank response for console access request"; + s_logger.warn(msg); + throw new CloudRuntimeException(msg); + } + + if (vm.getHostId() == null) { + msg = "VM " + vmId + " lost host info, sending blank response for console access request"; + s_logger.warn(msg); + throw new CloudRuntimeException(msg); + } + + HostVO host = managementServer.getHostBy(vm.getHostId()); + if (host == null) { + msg = "VM " + vmId + "'s host does not exist, sending blank response for console access request"; + s_logger.warn(msg); + throw new CloudRuntimeException(msg); + } + + if (Hypervisor.HypervisorType.LXC.equals(vm.getHypervisorType())) { + throw new CloudRuntimeException("Console access is not supported for LXC"); + } + + String rootUrl = managementServer.getConsoleAccessUrlRoot(vmId); + if (rootUrl == null) { + throw new CloudRuntimeException("Console access will be ready in a few minutes. Please try it again later."); + } + + ConsoleEndpoint consoleEndpoint = composeConsoleAccessEndpoint(rootUrl, vm, host, clientAddress, sessionUuid, extraSecurityToken); + s_logger.debug("The console URL is: " + consoleEndpoint.getUrl()); + return consoleEndpoint; + } + + private ConsoleEndpoint composeConsoleAccessEndpoint(String rootUrl, VirtualMachine vm, HostVO hostVo, String addr, + String sessionUuid, String extraSecurityToken) { + StringBuilder sb = new StringBuilder(rootUrl); + String host = hostVo.getPrivateIpAddress(); + + Pair portInfo = null; + if (hostVo.getHypervisorType() == Hypervisor.HypervisorType.KVM && + (hostVo.getResourceState().equals(ResourceState.ErrorInMaintenance) || + hostVo.getResourceState().equals(ResourceState.ErrorInPrepareForMaintenance))) { + UserVmDetailVO detailAddress = userVmDetailsDao.findDetail(vm.getId(), VmDetailConstants.KVM_VNC_ADDRESS); + UserVmDetailVO detailPort = userVmDetailsDao.findDetail(vm.getId(), VmDetailConstants.KVM_VNC_PORT); + if (detailAddress != null && detailPort != null) { + portInfo = new Pair<>(detailAddress.getValue(), Integer.valueOf(detailPort.getValue())); + } else { + s_logger.warn("KVM Host in ErrorInMaintenance/ErrorInPrepareForMaintenance but " + + "no VNC Address/Port was available. Falling back to default one from MS."); + } + } + + if (portInfo == null) { + portInfo = managementServer.getVncPort(vm); + } + + if (s_logger.isDebugEnabled()) + s_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(); + } + + String sid = vm.getVncPassword(); + UserVmDetailVO details = userVmDetailsDao.findDetail(vm.getId(), VmDetailConstants.KEYBOARD); + + String tag = vm.getUuid(); + + String ticket = genAccessTicket(parsedHostInfo.first(), String.valueOf(port), sid, tag, sessionUuid); + ConsoleProxyPasswordBasedEncryptor encryptor = new ConsoleProxyPasswordBasedEncryptor(getEncryptorPassword()); + ConsoleProxyClientParam param = new ConsoleProxyClientParam(); + param.setClientHostAddress(parsedHostInfo.first()); + param.setClientHostPort(port); + param.setClientHostPassword(sid); + param.setClientTag(tag); + param.setTicket(ticket); + param.setSessionUuid(sessionUuid); + param.setSourceIP(addr); + + if (StringUtils.isNotBlank(extraSecurityToken)) { + param.setExtraSecurityToken(extraSecurityToken); + s_logger.debug("Added security token for client validation"); + } + + if (requiresVncOverWebSocketConnection(vm, hostVo)) { + setWebsocketUrl(vm, param); + } + + if (details != null) { + param.setLocale(details.getValue()); + } + + 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 (parsedHostInfo.second() != null && parsedHostInfo.third() != null) { + param.setClientTunnelUrl(parsedHostInfo.second()); + param.setClientTunnelSession(parsedHostInfo.third()); + } + + String token = encryptor.encryptObject(ConsoleProxyClientParam.class, param); + int vncPort = consoleProxyManager.getVncPort(); + if (param.getHypervHost() != null || !ConsoleProxyManager.NoVncConsoleDefault.value()) { + sb.append("/ajax?token=" + token); + } else { + sb.append("/resource/noVNC/vnc.html") + .append("?autoconnect=true") + .append("&port=" + vncPort) + .append("&token=" + token); + } + + if (StringUtils.isNotBlank(param.getExtraSecurityToken())) { + sb.append("&extra=" + param.getExtraSecurityToken()); + } + + // for console access, we need guest OS type to help implement keyboard + long guestOs = vm.getGuestOSId(); + GuestOSVO guestOsVo = managementServer.getGuestOs(guestOs); + if (guestOsVo.getCategoryId() == 6) + sb.append("&guest=windows"); + + if (s_logger.isDebugEnabled()) { + s_logger.debug("Compose console url: " + sb); + } + s_logger.debug("Adding allowed session: " + sessionUuid); + allowedSessions.add(sessionUuid); + managementServer.setConsoleAccessForVm(vm.getId(), sessionUuid); + + String url = sb.toString().startsWith("https") ? sb.toString() : "http:" + sb; + ConsoleEndpoint consoleEndpoint = new ConsoleEndpoint(true, url); + consoleEndpoint.setWebsocketHost(managementServer.getConsoleAccessAddress(vm.getId())); + consoleEndpoint.setWebsocketPort(String.valueOf(vncPort)); + consoleEndpoint.setWebsocketPath("websockify"); + consoleEndpoint.setWebsocketToken(token); + if (StringUtils.isNotBlank(param.getExtraSecurityToken())) { + consoleEndpoint.setWebsocketExtra(param.getExtraSecurityToken()); + } + return consoleEndpoint; + } + + public static Ternary parseHostInfo(String hostInfo) { + String host = null; + String tunnelUrl = null; + String tunnelSession = null; + + s_logger.info("Parse host info returned from executing GetVNCPortCommand. host info: " + hostInfo); + + if (hostInfo != null) { + if (hostInfo.startsWith("consoleurl")) { + String[] tokens = hostInfo.split("&"); + + if (hostInfo.length() > 19 && hostInfo.indexOf('/', 19) > 19) { + host = hostInfo.substring(19, hostInfo.indexOf('/', 19)).trim(); + tunnelUrl = tokens[0].substring("consoleurl=".length()); + tunnelSession = tokens[1].split("=")[1]; + } else { + host = ""; + } + } else if (hostInfo.startsWith("instanceId")) { + host = hostInfo.substring(hostInfo.indexOf('=') + 1); + } else { + host = hostInfo; + } + } else { + host = hostInfo; + } + + return new Ternary<>(host, tunnelUrl, tunnelSession); + } + + /** + * Since VMware 7.0 VNC servers are deprecated, it uses a ticket to create a VNC over websocket connection + * Check: https://docs.vmware.com/en/VMware-vSphere/7.0/rn/vsphere-esxi-vcenter-server-70-release-notes.html + */ + private boolean requiresVncOverWebSocketConnection(VirtualMachine vm, HostVO hostVo) { + return vm.getHypervisorType() == Hypervisor.HypervisorType.VMware && hostVo.getHypervisorVersion().compareTo("7.0") >= 0; + } + + public static String genAccessTicket(String host, String port, String sid, String tag, String sessionUuid) { + return genAccessTicket(host, port, sid, tag, new Date(), sessionUuid); + } + + public static String genAccessTicket(String host, String port, String sid, String tag, Date normalizedHashTime, String sessionUuid) { + String params = "host=" + host + "&port=" + port + "&sid=" + sid + "&tag=" + tag + "&session=" + sessionUuid; + + try { + Mac mac = Mac.getInstance("HmacSHA512"); + + long ts = normalizedHashTime.getTime(); + ts = ts / 60000; // round up to 1 minute + String secretKey = secretKeysManager.getHashKey(); + + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(), mac.getAlgorithm()); + mac.init(keySpec); + mac.update(params.getBytes()); + mac.update(String.valueOf(ts).getBytes()); + + byte[] encryptedBytes = mac.doFinal(); + + return Base64.encodeBase64String(encryptedBytes); + } catch (Exception e) { + s_logger.error("Unexpected exception ", e); + } + return ""; + } + + private String getEncryptorPassword() { + String key = keysManager.getEncryptionKey(); + String iv = keysManager.getEncryptionIV(); + + ConsoleProxyPasswordBasedEncryptor.KeyIVPair keyIvPair = new ConsoleProxyPasswordBasedEncryptor.KeyIVPair(key, iv); + return gson.toJson(keyIvPair); + } + + /** + * Sets the URL to establish a VNC over websocket connection + */ + private void setWebsocketUrl(VirtualMachine vm, ConsoleProxyClientParam param) { + String ticket = acquireVncTicketForVmwareVm(vm); + if (StringUtils.isBlank(ticket)) { + s_logger.error("Could not obtain VNC ticket for VM " + vm.getInstanceName()); + return; + } + String wsUrl = composeWebsocketUrlForVmwareVm(ticket, param); + param.setWebsocketUrl(wsUrl); + } + + /** + * Format expected: wss://:443/ticket/ + */ + private String composeWebsocketUrlForVmwareVm(String ticket, ConsoleProxyClientParam param) { + param.setClientHostPort(443); + return String.format("wss://%s:%s/ticket/%s", param.getClientHostAddress(), param.getClientHostPort(), ticket); + } + + /** + * Acquires a ticket to be used for console proxy as described in 'Removal of VNC Server from ESXi' on: + * https://docs.vmware.com/en/VMware-vSphere/7.0/rn/vsphere-esxi-vcenter-server-70-release-notes.html + */ + private String acquireVncTicketForVmwareVm(VirtualMachine vm) { + try { + s_logger.info("Acquiring VNC ticket for VM = " + vm.getHostName()); + GetVmVncTicketCommand cmd = new GetVmVncTicketCommand(vm.getInstanceName()); + Answer answer = agentManager.send(vm.getHostId(), cmd); + GetVmVncTicketAnswer ans = (GetVmVncTicketAnswer) answer; + if (!ans.getResult()) { + s_logger.info("VNC ticket could not be acquired correctly: " + ans.getDetails()); + } + return ans.getTicket(); + } catch (AgentUnavailableException | OperationTimedoutException e) { + s_logger.error("Error acquiring ticket", e); + return null; + } + } + + @Override + public String getConfigComponentName() { + return ConsoleAccessManagerImpl.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[] { ConsoleProxyExtraSecurityValidationEnabled }; + } +} diff --git a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml index 30248d73a36..2604f8ed127 100644 --- a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml +++ b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml @@ -107,6 +107,8 @@ value="#{consoleProxyAllocatorsRegistry.registered}" /> + + diff --git a/server/src/test/java/com/cloud/api/ApiServletTest.java b/server/src/test/java/com/cloud/api/ApiServletTest.java index fa582991e6b..fa467e877f0 100644 --- a/server/src/test/java/com/cloud/api/ApiServletTest.java +++ b/server/src/test/java/com/cloud/api/ApiServletTest.java @@ -44,12 +44,12 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; import com.cloud.server.ManagementServer; import com.cloud.user.Account; import com.cloud.user.AccountService; import com.cloud.user.User; +import org.mockito.junit.MockitoJUnitRunner; @RunWith(MockitoJUnitRunner.class) public class ApiServletTest { diff --git a/server/src/test/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImplTest.java b/server/src/test/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImplTest.java new file mode 100644 index 00000000000..df218f49bfd --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImplTest.java @@ -0,0 +1,109 @@ +// 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 org.apache.cloudstack.consoleproxy; + +import com.cloud.agent.AgentManager; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.server.ManagementServer; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.utils.db.EntityManager; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachineManager; +import com.cloud.vm.dao.UserVmDetailsDao; +import org.apache.cloudstack.acl.SecurityChecker; +import org.apache.cloudstack.framework.security.keys.KeysManager; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.Spy; +import org.mockito.junit.MockitoJUnitRunner; + +import java.util.Arrays; +import java.util.List; + +@RunWith(MockitoJUnitRunner.class) +public class ConsoleAccessManagerImplTest { + + @Mock + private AccountManager accountManager; + @Mock + private VirtualMachineManager virtualMachineManager; + @Mock + private ManagementServer managementServer; + @Mock + private EntityManager entityManager; + @Mock + private UserVmDetailsDao userVmDetailsDao; + @Mock + private KeysManager keysManager; + @Mock + private AgentManager agentManager; + + @Spy + @InjectMocks + ConsoleAccessManagerImpl consoleAccessManager = new ConsoleAccessManagerImpl(); + + @Mock + VirtualMachine virtualMachine; + @Mock + Account account; + + @Test + public void testCheckSessionPermissionAdminAccount() { + Mockito.when(account.getId()).thenReturn(1L); + Mockito.when(accountManager.isRootAdmin(1L)).thenReturn(true); + Assert.assertTrue(consoleAccessManager.checkSessionPermission(virtualMachine, account)); + } + + @Test + public void testCheckSessionPermissionUserOwnedVm() { + Mockito.when(account.getId()).thenReturn(1L); + Mockito.when(accountManager.isRootAdmin(1L)).thenReturn(false); + Mockito.when(virtualMachine.getType()).thenReturn(VirtualMachine.Type.User); + Mockito.doNothing().when(accountManager).checkAccess( + Mockito.eq(account), Mockito.nullable(SecurityChecker.AccessType.class), + Mockito.eq(true), Mockito.eq(virtualMachine)); + Assert.assertTrue(consoleAccessManager.checkSessionPermission(virtualMachine, account)); + } + + @Test + public void testCheckSessionPermissionDifferentUserOwnedVm() { + Mockito.when(account.getId()).thenReturn(1L); + Mockito.when(accountManager.isRootAdmin(1L)).thenReturn(false); + Mockito.when(virtualMachine.getType()).thenReturn(VirtualMachine.Type.User); + Mockito.doThrow(PermissionDeniedException.class).when(accountManager).checkAccess( + Mockito.eq(account), Mockito.nullable(SecurityChecker.AccessType.class), + Mockito.eq(true), Mockito.eq(virtualMachine)); + Assert.assertFalse(consoleAccessManager.checkSessionPermission(virtualMachine, account)); + } + + @Test + public void testCheckSessionPermissionForUsersOnSystemVms() { + Mockito.when(account.getId()).thenReturn(1L); + Mockito.when(accountManager.isRootAdmin(1L)).thenReturn(false); + List systemVmTypes = Arrays.asList(VirtualMachine.Type.DomainRouter, + VirtualMachine.Type.ConsoleProxy, VirtualMachine.Type.SecondaryStorageVm); + for (VirtualMachine.Type type : systemVmTypes) { + Mockito.when(virtualMachine.getType()).thenReturn(type); + Assert.assertFalse(consoleAccessManager.checkSessionPermission(virtualMachine, account)); + } + } +} 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 702e9a855d1..9ad9e513fc5 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 @@ -26,9 +26,11 @@ import java.lang.reflect.Method; import java.net.InetSocketAddress; import java.net.URISyntaxException; import java.net.URL; +import java.util.HashSet; import java.util.Hashtable; import java.util.Map; import java.util.Properties; +import java.util.Set; import java.util.concurrent.Executor; import com.cloud.utils.StringUtils; @@ -75,6 +77,12 @@ public class ConsoleProxy { static String encryptorPassword = "Dummy"; + static Set allowedSessions = new HashSet<>(); + + public static void addAllowedSession(String sessionUuid) { + allowedSessions.add(sessionUuid); + } + private static void configLog4j() { final ClassLoader loader = Thread.currentThread().getContextClassLoader(); URL configUrl = loader.getResource("/conf/log4j-cloud.xml"); @@ -165,7 +173,8 @@ public class ConsoleProxy { } } - public static ConsoleProxyAuthenticationResult authenticateConsoleAccess(ConsoleProxyClientParam param, boolean reauthentication) { + public static ConsoleProxyAuthenticationResult authenticateConsoleAccess(ConsoleProxyClientParam param, + boolean reauthentication, Session session) { ConsoleProxyAuthenticationResult authResult = new ConsoleProxyAuthenticationResult(); authResult.setSuccess(true); @@ -173,6 +182,29 @@ public class ConsoleProxy { authResult.setHost(param.getClientHostAddress()); authResult.setPort(param.getClientHostPort()); + if (org.apache.commons.lang3.StringUtils.isNotBlank(param.getExtraSecurityToken())) { + String extraToken = param.getExtraSecurityToken(); + String clientProvidedToken = param.getClientProvidedExtraSecurityToken(); + s_logger.debug(String.format("Extra security validation for the console access, provided %s " + + "to validate against %s", clientProvidedToken, extraToken)); + + if (!extraToken.equals(clientProvidedToken)) { + s_logger.error("The provided extra token does not match the expected value for this console endpoint"); + authResult.setSuccess(false); + return authResult; + } + } + + String sessionUuid = param.getSessionUuid(); + if (allowedSessions.contains(sessionUuid)) { + s_logger.debug("Acquiring the session " + sessionUuid + " not available for future use"); + allowedSessions.remove(sessionUuid); + } else { + s_logger.info("Session " + sessionUuid + " has already been used, cannot connect"); + authResult.setSuccess(false); + return authResult; + } + String websocketUrl = param.getWebsocketUrl(); if (StringUtils.isNotBlank(websocketUrl)) { return authResult; @@ -187,7 +219,7 @@ public class ConsoleProxy { try { result = authMethod.invoke(ConsoleProxy.context, param.getClientHostAddress(), String.valueOf(param.getClientHostPort()), param.getClientTag(), - param.getClientHostPassword(), param.getTicket(), new Boolean(reauthentication)); + param.getClientHostPassword(), param.getTicket(), reauthentication, param.getSessionUuid()); } catch (IllegalAccessException e) { s_logger.error("Unable to invoke authenticateConsoleAccess due to IllegalAccessException" + " for vm: " + param.getClientTag(), e); authResult.setSuccess(false); @@ -259,7 +291,8 @@ public class ConsoleProxy { try { final ClassLoader loader = Thread.currentThread().getContextClassLoader(); Class contextClazz = loader.loadClass("com.cloud.agent.resource.consoleproxy.ConsoleProxyResource"); - authMethod = contextClazz.getDeclaredMethod("authenticateConsoleAccess", String.class, String.class, String.class, String.class, String.class, Boolean.class); + authMethod = contextClazz.getDeclaredMethod("authenticateConsoleAccess", String.class, String.class, + String.class, String.class, String.class, Boolean.class, String.class); reportMethod = contextClazz.getDeclaredMethod("reportLoadInfo", String.class); ensureRouteMethod = contextClazz.getDeclaredMethod("ensureRoute", String.class); } catch (SecurityException e) { @@ -364,9 +397,10 @@ public class ConsoleProxy { } private static ConsoleProxyNoVNCServer getNoVNCServer() { - if (httpListenPort == 443) - return new ConsoleProxyNoVNCServer(ksBits, ksPassword); - return new ConsoleProxyNoVNCServer(); + int vncPort = ConsoleProxyNoVNCServer.getVNCPort(); + return vncPort == ConsoleProxyNoVNCServer.WSS_PORT ? + new ConsoleProxyNoVNCServer(ksBits, ksPassword) : + new ConsoleProxyNoVNCServer(); } private static void startupHttpCmdPort() { @@ -449,7 +483,7 @@ public class ConsoleProxy { synchronized (connectionMap) { ConsoleProxyClient viewer = connectionMap.get(clientKey); if (viewer == null || viewer.getClass() == ConsoleProxyNoVncClient.class) { - authenticationExternally(param); + authenticationExternally(param, null); viewer = getClient(param); viewer.initClient(param); @@ -470,7 +504,7 @@ public class ConsoleProxy { if (!viewer.isFrontEndAlive()) { - authenticationExternally(param); + authenticationExternally(param, null); viewer.initClient(param); reportLoadChange = true; } @@ -512,8 +546,8 @@ public class ConsoleProxy { } } - public static void authenticationExternally(ConsoleProxyClientParam param) throws AuthenticationException { - ConsoleProxyAuthenticationResult authResult = authenticateConsoleAccess(param, false); + public static void authenticationExternally(ConsoleProxyClientParam param, Session session) throws AuthenticationException { + ConsoleProxyAuthenticationResult authResult = authenticateConsoleAccess(param, false, session); if (authResult == null || !authResult.isSuccess()) { s_logger.warn("External authenticator failed authencation request for vm " + param.getClientTag() + " with sid " + param.getClientHostPassword()); @@ -523,7 +557,7 @@ public class ConsoleProxy { } public static ConsoleProxyAuthenticationResult reAuthenticationExternally(ConsoleProxyClientParam param) { - return authenticateConsoleAccess(param, true); + return authenticateConsoleAccess(param, true, null); } public static String getEncryptorPassword() { @@ -552,7 +586,7 @@ public class ConsoleProxy { synchronized (connectionMap) { ConsoleProxyClient viewer = connectionMap.get(clientKey); if (viewer == null || viewer.getClass() != ConsoleProxyNoVncClient.class) { - authenticationExternally(param); + authenticationExternally(param, session); viewer = new ConsoleProxyNoVncClient(session); viewer.initClient(param); @@ -564,7 +598,7 @@ public class ConsoleProxy { throw new AuthenticationException("Cannot use the existing viewer " + viewer + ": bad sid"); try { - authenticationExternally(param); + authenticationExternally(param, session); } catch (Exception e) { s_logger.error("Authencation failed for param: " + param); return null; diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClient.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClient.java index 6429de4ad2f..e47837d49e9 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClient.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClient.java @@ -78,4 +78,6 @@ public interface ConsoleProxyClient { void initClient(ConsoleProxyClientParam param); void closeClient(); + + String getSessionUuid(); } diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientBase.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientBase.java index bc2230b7aa9..d789ca9d096 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientBase.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientBase.java @@ -55,6 +55,7 @@ public abstract class ConsoleProxyClientBase implements ConsoleProxyClient, Cons protected boolean framebufferResized = false; protected int resizedFramebufferWidth; protected int resizedFramebufferHeight; + protected String sessionUuid; public ConsoleProxyClientBase() { tracker = new TileTracker(); @@ -422,4 +423,9 @@ public abstract class ConsoleProxyClientBase implements ConsoleProxyClient, Cons ConsoleProxyPasswordBasedEncryptor encryptor = new ConsoleProxyPasswordBasedEncryptor(ConsoleProxy.getEncryptorPassword()); this.clientToken = encryptor.encryptObject(ConsoleProxyClientParam.class, clientParam); } + + @Override + public String getSessionUuid() { + return sessionUuid; + } } 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 c071f551da7..018ca385332 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 @@ -40,6 +40,15 @@ public class ConsoleProxyClientParam { private String sourceIP; + private String sessionUuid; + + // The server-side generated value for extra console endpoint validation + private String extraSecurityToken; + + // The extra parameter received in the console URL, must be compared against the server-side generated value + // for extra validation (if has been enabled) + private String clientProvidedExtraSecurityToken; + public ConsoleProxyClientParam() { clientHostPort = 0; } @@ -162,4 +171,28 @@ public class ConsoleProxyClientParam { public void setWebsocketUrl(String websocketUrl) { this.websocketUrl = websocketUrl; } + + public String getSessionUuid() { + return sessionUuid; + } + + public void setSessionUuid(String sessionUuid) { + this.sessionUuid = sessionUuid; + } + + public String getExtraSecurityToken() { + return extraSecurityToken; + } + + public void setExtraSecurityToken(String extraSecurityToken) { + this.extraSecurityToken = extraSecurityToken; + } + + public String getClientProvidedExtraSecurityToken() { + return clientProvidedExtraSecurityToken; + } + + public void setClientProvidedExtraSecurityToken(String clientProvidedExtraSecurityToken) { + this.clientProvidedExtraSecurityToken = clientProvidedExtraSecurityToken; + } } diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientStatsCollector.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientStatsCollector.java index 5251b9386d8..f82bfdfebb5 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientStatsCollector.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyClientStatsCollector.java @@ -20,6 +20,7 @@ import java.io.OutputStreamWriter; import java.util.ArrayList; import java.util.Enumeration; import java.util.Hashtable; +import java.util.List; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -31,10 +32,16 @@ import com.google.gson.GsonBuilder; public class ConsoleProxyClientStatsCollector { ArrayList connections; + ArrayList removedSessions; public ConsoleProxyClientStatsCollector() { } + public void setRemovedSessions(List removed) { + removedSessions = new ArrayList<>(); + removedSessions.addAll(removed); + } + public ConsoleProxyClientStatsCollector(Hashtable connMap) { setConnections(connMap); } @@ -67,6 +74,7 @@ public class ConsoleProxyClientStatsCollector { conn.tag = client.getClientTag(); conn.createTime = client.getClientCreateTime(); conn.lastUsedTime = client.getClientLastFrontEndActivityTime(); + conn.setSessionUuid(client.getSessionUuid()); conns.add(conn); } } @@ -81,6 +89,15 @@ public class ConsoleProxyClientStatsCollector { public String tag; public long createTime; public long lastUsedTime; + protected String sessionUuid; + + public String getSessionUuid() { + return sessionUuid; + } + + public void setSessionUuid(String sessionUuid) { + this.sessionUuid = sessionUuid; + } public ConsoleProxyConnection() { } diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyGCThread.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyGCThread.java index 2e987d707e5..de9577408c9 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyGCThread.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyGCThread.java @@ -17,8 +17,10 @@ package com.cloud.consoleproxy; import java.io.File; +import java.util.ArrayList; import java.util.Enumeration; import java.util.Hashtable; +import java.util.List; import org.apache.log4j.Logger; @@ -67,9 +69,12 @@ public class ConsoleProxyGCThread extends Thread { boolean bReportLoad = false; long lastReportTick = System.currentTimeMillis(); + List removedSessions = new ArrayList<>(); + while (true) { cleanupLogging(); bReportLoad = false; + removedSessions.clear(); if (s_logger.isDebugEnabled()) s_logger.debug("connMap=" + connMap); @@ -89,6 +94,7 @@ public class ConsoleProxyGCThread extends Thread { } synchronized (connMap) { + removedSessions.add(client.getSessionUuid()); connMap.remove(key); bReportLoad = true; } @@ -100,7 +106,9 @@ public class ConsoleProxyGCThread extends Thread { if (bReportLoad || System.currentTimeMillis() - lastReportTick > 5000) { // report load changes - String loadInfo = new ConsoleProxyClientStatsCollector(connMap).getStatsReport(); + ConsoleProxyClientStatsCollector collector = new ConsoleProxyClientStatsCollector(connMap); + collector.setRemovedSessions(removedSessions); + String loadInfo = collector.getStatsReport(); ConsoleProxy.reportLoadInfo(loadInfo); lastReportTick = System.currentTimeMillis(); 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 b7f969a1e57..2ee013a7f3b 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 @@ -51,7 +51,6 @@ public class ConsoleProxyHttpHandlerHelper { ConsoleProxyClientParam param = encryptor.decryptObject(ConsoleProxyClientParam.class, map.get("token")); - // make sure we get information from token only guardUserInput(map); if (param != null) { if (param.getClientHostAddress() != null) { @@ -96,6 +95,12 @@ public class ConsoleProxyHttpHandlerHelper { if (param.getWebsocketUrl() != null) { map.put("websocketUrl", param.getWebsocketUrl()); } + if (param.getSessionUuid() != null) { + map.put("sessionUuid", param.getSessionUuid()); + } + if (param.getExtraSecurityToken() != null) { + map.put("extraSecurityToken", param.getExtraSecurityToken()); + } } else { s_logger.error("Unable to decode token"); } @@ -104,6 +109,11 @@ public class ConsoleProxyHttpHandlerHelper { guardUserInput(map); } + if (map.containsKey("extra")) { + s_logger.debug(String.format("Found extra parameter: %s for client security validation check " + + "on the VNC server", map.get("extra"))); + } + return map; } 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 91d8e192fd9..72f019bd3d1 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 @@ -71,7 +71,6 @@ public class ConsoleProxyNoVNCHandler extends WebSocketHandler { @OnWebSocketConnect public void onConnect(final Session session) throws IOException, InterruptedException { - String queries = session.getUpgradeRequest().getQueryString(); Map queryMap = ConsoleProxyHttpHandlerHelper.getQueryMap(queries); @@ -89,6 +88,7 @@ public class ConsoleProxyNoVNCHandler extends WebSocketHandler { String password = queryMap.get("password"); String sourceIP = queryMap.get("sourceIP"); String websocketUrl = queryMap.get("websocketUrl"); + String sessionUuid = queryMap.get("sessionUuid"); if (tag == null) tag = ""; @@ -133,6 +133,13 @@ public class ConsoleProxyNoVNCHandler extends WebSocketHandler { param.setUsername(username); param.setPassword(password); param.setWebsocketUrl(websocketUrl); + param.setSessionUuid(sessionUuid); + if (queryMap.containsKey("extraSecurityToken")) { + param.setExtraSecurityToken(queryMap.get("extraSecurityToken")); + } + if (queryMap.containsKey("extra")) { + param.setClientProvidedExtraSecurityToken(queryMap.get("extra")); + } viewer = ConsoleProxy.getNoVncViewer(param, ajaxSessionIdStr, session); } catch (Exception e) { s_logger.warn("Failed to create viewer due to " + e.getMessage(), e); diff --git a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCServer.java b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCServer.java index 28d179ba6fc..025e4c98587 100644 --- a/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCServer.java +++ b/services/console-proxy/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyNoVNCServer.java @@ -17,6 +17,9 @@ package com.cloud.consoleproxy; import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.KeyStore; import com.cloud.consoleproxy.util.Logger; @@ -32,12 +35,25 @@ import org.eclipse.jetty.util.ssl.SslContextFactory; public class ConsoleProxyNoVNCServer { private static final Logger s_logger = Logger.getLogger(ConsoleProxyNoVNCServer.class); - private static final int wsPort = 8080; + public static final int WS_PORT = 8080; + public static final int WSS_PORT = 8443; + private static final String vncConfFileLocation = "/root/vncport"; private Server server; + public static int getVNCPort() { + String portStr; + try { + portStr = Files.readString(Path.of(vncConfFileLocation)).trim(); + } catch (IOException e) { + s_logger.error("Cannot read the VNC port from the file " + vncConfFileLocation + " setting it to 8080", e); + return WS_PORT; + } + return Integer.parseInt(portStr); + } + public ConsoleProxyNoVNCServer() { - this.server = new Server(wsPort); + this.server = new Server(WS_PORT); ConsoleProxyNoVNCHandler handler = new ConsoleProxyNoVNCHandler(); this.server.setHandler(handler); } @@ -50,7 +66,7 @@ public class ConsoleProxyNoVNCServer { try { final HttpConfiguration httpConfig = new HttpConfiguration(); httpConfig.setSecureScheme("https"); - httpConfig.setSecurePort(wsPort); + httpConfig.setSecurePort(WSS_PORT); final HttpConfiguration httpsConfig = new HttpConfiguration(httpConfig); httpsConfig.addCustomizer(new SecureRequestCustomizer()); @@ -66,7 +82,7 @@ public class ConsoleProxyNoVNCServer { final ServerConnector sslConnector = new ServerConnector(server, new SslConnectionFactory(sslContextFactory, "http/1.1"), new HttpConnectionFactory(httpsConfig)); - sslConnector.setPort(wsPort); + sslConnector.setPort(WSS_PORT); server.addConnector(sslConnector); } catch (Exception e) { s_logger.error("Unable to secure server due to exception ", e); 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 cf0a05de622..04a5c5df3a4 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 @@ -46,6 +46,7 @@ public class ConsoleProxyNoVncClient implements ConsoleProxyClient { private boolean connectionAlive; private ConsoleProxyClientParam clientParam; + private String sessionUuid; public ConsoleProxyNoVncClient(Session session) { this.session = session; @@ -89,6 +90,7 @@ public class ConsoleProxyNoVncClient implements ConsoleProxyClient { setClientParam(param); client = new NoVncClient(); connectionAlive = true; + this.sessionUuid = param.getSessionUuid(); updateFrontEndActivityTime(); Thread worker = new Thread(new Runnable() { @@ -192,6 +194,11 @@ public class ConsoleProxyNoVncClient implements ConsoleProxyClient { ConsoleProxy.removeViewer(this); } + @Override + public String getSessionUuid() { + return sessionUuid; + } + @Override public int getClientId() { return this.clientId; diff --git a/services/console-proxy/server/src/test/java/com/cloud/consoleproxy/ConsoleProxyHttpHandlerHelperTest.java b/services/console-proxy/server/src/test/java/com/cloud/consoleproxy/ConsoleProxyHttpHandlerHelperTest.java new file mode 100644 index 00000000000..690b0c19a9e --- /dev/null +++ b/services/console-proxy/server/src/test/java/com/cloud/consoleproxy/ConsoleProxyHttpHandlerHelperTest.java @@ -0,0 +1,51 @@ +// 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.consoleproxy; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import java.util.Map; + +@RunWith(PowerMockRunner.class) +public class ConsoleProxyHttpHandlerHelperTest { + + @Mock + ConsoleProxyPasswordBasedEncryptor encryptor; + + @Test + @PrepareForTest({ConsoleProxy.class, ConsoleProxyHttpHandlerHelper.class}) + public void testQueryMapExtraParameter() throws Exception { + PowerMockito.mockStatic(ConsoleProxy.class); + PowerMockito.when(ConsoleProxy.getEncryptorPassword()).thenReturn("password"); + PowerMockito.whenNew(ConsoleProxyPasswordBasedEncryptor.class).withArguments(Mockito.anyString()).thenReturn(encryptor); + Mockito.when(encryptor.decryptObject(Mockito.eq(ConsoleProxyClientParam.class), Mockito.anyString())).thenReturn(null); + + String extraValidationToken = "test-token"; + String query = String.format("token=SOME_TOKEN&extra=%s", extraValidationToken); + + Map queryMap = ConsoleProxyHttpHandlerHelper.getQueryMap(query); + Assert.assertTrue(queryMap.containsKey("extra")); + Assert.assertEquals(extraValidationToken, queryMap.get("extra")); + } +} diff --git a/systemvm/agent/noVNC/app/ui.js b/systemvm/agent/noVNC/app/ui.js index 1c6a00799c3..f503f55c6c9 100644 --- a/systemvm/agent/noVNC/app/ui.js +++ b/systemvm/agent/noVNC/app/ui.js @@ -157,6 +157,7 @@ const UI = { } } + UI.initSetting('extra', window.location.extra) /* Populate the controls if defaults are provided in the URL */ UI.initSetting('host', window.location.hostname); UI.initSetting('port', port); @@ -997,7 +998,8 @@ const UI = { const host = UI.getSetting('host'); const port = UI.getSetting('port'); const path = UI.getSetting('path'); - const token = UI.getSetting('token') + const token = UI.getSetting('token'); + const extra = UI.getSetting('extra'); if (typeof password === 'undefined') { password = WebUtil.getConfigVar('password'); @@ -1031,6 +1033,10 @@ const UI = { url += '/' + path; url += '?token=' + token; + if (extra) { + url += '&extra=' + extra + } + UI.rfb = new RFB(document.getElementById('noVNC_container'), url, { shared: UI.getSetting('shared'), repeaterID: UI.getSetting('repeaterID'), @@ -1116,14 +1122,13 @@ const UI = { UI.connected = false; UI.rfb = undefined; - if (!e.detail.clean) { UI.updateVisualState('disconnected'); if (wasConnected) { UI.showStatus(_("Something went wrong, connection is closed"), 'error'); } else { - UI.showStatus(_("Failed to connect to server"), 'error'); + UI.showStatus(_("Failed to connect to server / access token has expired"), 'error'); } } else if (UI.getSetting('reconnect', false) === true && !UI.inhibitReconnect) { UI.updateVisualState('reconnecting'); diff --git a/systemvm/agent/noVNC/vnc.html b/systemvm/agent/noVNC/vnc.html index 6f1b7998fe4..060dc736aa8 100644 --- a/systemvm/agent/noVNC/vnc.html +++ b/systemvm/agent/noVNC/vnc.html @@ -234,6 +234,10 @@ +
  • + + +

  • diff --git a/systemvm/debian/opt/cloud/bin/setup/common.sh b/systemvm/debian/opt/cloud/bin/setup/common.sh index 60b88754bee..fdeb98a281e 100755 --- a/systemvm/debian/opt/cloud/bin/setup/common.sh +++ b/systemvm/debian/opt/cloud/bin/setup/common.sh @@ -762,6 +762,9 @@ parse_cmd_line() { authorized_key) export AUTHORIZED_KEYS=$VALUE ;; + vncport) + export VNCPORT=$VALUE + ;; esac done echo -e "\n\t}\n}" >> ${CHEF_TMP_FILE} diff --git a/systemvm/debian/opt/cloud/bin/setup/consoleproxy.sh b/systemvm/debian/opt/cloud/bin/setup/consoleproxy.sh index 3f00f3da43a..18b2701f732 100755 --- a/systemvm/debian/opt/cloud/bin/setup/consoleproxy.sh +++ b/systemvm/debian/opt/cloud/bin/setup/consoleproxy.sh @@ -36,6 +36,11 @@ setup_console_proxy() { log_it "Applying iptables rules" cp /etc/iptables/iptables-consoleproxy /etc/iptables/rules.v4 + log_it "Applying iptables rule for VNC port ${VNCPORT}" + sed -i 's/8080/${VNCPORT}/' /etc/iptables/rules.v4 + echo "${VNCPORT}" > /root/vncport + log_it "Creating VNC port ${VNCPORT} file for VNC server configuration" + log_it "Configuring sshd" local hyp=$HYPERVISOR if [ "$hyp" == "vmware" ] || [ "$hyp" == "hyperv" ]; then diff --git a/test/integration/smoke/test_console_endpoint.py b/test/integration/smoke/test_console_endpoint.py new file mode 100644 index 00000000000..84b893e5701 --- /dev/null +++ b/test/integration/smoke/test_console_endpoint.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python +# 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. + +from marvin.cloudstackTestCase import * +from marvin.lib.utils import * +from marvin.lib.base import * +from marvin.lib.common import (get_domain, + get_zone, + get_template) +from nose.plugins.attrib import attr + +class TestConsoleEndpoint(cloudstackTestCase): + + @classmethod + def setUpClass(cls): + cls.testClient = super(TestConsoleEndpoint, cls).getClsTestClient() + cls.apiclient = cls.testClient.getApiClient() + cls.domain = get_domain(cls.apiclient) + cls.services = cls.testClient.getParsedTestDataConfig() + # Get Zone, Domain and templates + cls.zone = get_zone(cls.apiclient, cls.testClient.getZoneForTests()) + cls.hypervisor = cls.testClient.getHypervisorInfo() + + cls.template = get_template( + cls.apiclient, + cls.zone.id, + cls.hypervisor + ) + + if cls.template == FAILED: + assert False, "get_template() failed to return template" + + cls.services["virtual_machine"]["zoneid"] = cls.zone.id + + cls.services["template"] = cls.template.id + cls.services["zoneid"] = cls.zone.id + + cls.account = Account.create( + cls.apiclient, + cls.services["account"], + domainid=cls.domain.id + ) + cls.service_offering = ServiceOffering.create( + cls.apiclient, + cls.services["service_offerings"]["tiny"] + ) + cls.vm1 = VirtualMachine.create( + cls.apiclient, + cls.services["virtual_machine"], + templateid=cls.template.id, + accountid=cls.account.name, + domainid=cls.account.domainid, + serviceofferingid=cls.service_offering.id + ) + + cls._cleanup = [ + cls.service_offering, + cls.vm1, + cls.account + ] + return + + @classmethod + def tearDownClass(cls): + try: + cleanup_resources(cls.apiclient, cls._cleanup) + except Exception as e: + raise Exception("Warning: Exception during cleanup : %s" % e) + + def setUp(self): + self.apiclient = self.testClient.getApiClient() + self.dbclient = self.testClient.getDbConnection() + self.cleanup = [] + return + + def tearDown(self): + try: + # Clean up, terminate the created instance, volumes and snapshots + cleanup_resources(self.apiclient, self.cleanup) + except Exception as e: + raise Exception("Warning: Exception during cleanup : %s" % e) + return + + @attr(tags=["basic", "advanced"], required_hardware="false") + def test_console_endpoint_permissions(self): + cmd = createConsoleEndpoint.createConsoleEndpointCmd() + cmd.virtualmachineid=self.vm1.id + endpoint = self.apiclient.createConsoleEndpoint(cmd) + + if not endpoint: + self.fail("Failed to get generate VM console endpoint") + + self.assertTrue(endpoint.success) + self.assertNotEqual(len(endpoint.url), 0, "VM console endpoint url was empty") + + account2 = Account.create( + self.apiclient, + self.services["account2"], + domainid=self.domain.id + ) + self.cleanup.append(account2) + account2_user = account2.user[0] + account2ApiClient = self.testClient.getUserApiClient(account2_user.username, self.domain.name) + + endpoint = account2ApiClient.createConsoleEndpoint(cmd) + self.assertFalse(endpoint.success) + self.assertTrue(endpoint.url is None) + return diff --git a/tools/apidoc/gen_toc.py b/tools/apidoc/gen_toc.py index 18996028182..832514031cc 100644 --- a/tools/apidoc/gen_toc.py +++ b/tools/apidoc/gen_toc.py @@ -201,7 +201,8 @@ known_categories = { 'UnmanagedInstance': 'Virtual Machine', 'Rolling': 'Rolling Maintenance', 'importVsphereStoragePolicies' : 'vSphere storage policies', - 'listVsphereStoragePolicies' : 'vSphere storage policies' + 'listVsphereStoragePolicies' : 'vSphere storage policies', + 'ConsoleEndpoint': 'Console Endpoint' } diff --git a/ui/package-lock.json b/ui/package-lock.json index e603698dfe6..d8b0298f535 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2562,6 +2562,11 @@ "source-map": "^0.6.1" } }, + "@types/uuid": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", + "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==" + }, "@types/webpack": { "version": "4.41.21", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.21.tgz", @@ -22067,6 +22072,22 @@ "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==", "dev": true }, + "vue-uuid": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vue-uuid/-/vue-uuid-3.0.0.tgz", + "integrity": "sha512-+5DP857xVmTHYd00dMC1c1gVg/nxG6+K4Lepojv9ckHt8w0fDpGc5gQCCttS9D+AkSkTJgb0cekidKjTWu5OQQ==", + "requires": { + "@types/uuid": "^8.3.4", + "uuid": "^8.3.2" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } + } + }, "vuedraggable": { "version": "2.24.3", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-2.24.3.tgz", diff --git a/ui/package.json b/ui/package.json index 3879ee92a6c..22725578cda 100644 --- a/ui/package.json +++ b/ui/package.json @@ -58,6 +58,7 @@ "vue-ls": "^3.2.2", "vue-router": "^3.5.1", "vue-svg-component-runtime": "^1.0.1", + "vue-uuid": "^3.0.0", "vuedraggable": "^2.24.3", "vuex": "^3.6.2" }, diff --git a/ui/src/components/widgets/Console.vue b/ui/src/components/widgets/Console.vue index 0c65600ea45..772d86f1a94 100644 --- a/ui/src/components/widgets/Console.vue +++ b/ui/src/components/widgets/Console.vue @@ -17,9 +17,8 @@