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 257a1fc984a1..5b04f5751e3d 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 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 Answer executeRequest(final Command cmd) { } 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 @@ protected void runInContext() { } } - 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 000000000000..84922dc3272f --- /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 000000000000..37b9871ceae8 --- /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.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 000000000000..d98b52d08636 --- /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 000000000000..c60917bbe7a2 --- /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 000000000000..ac503c9ef6d7 --- /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 dd533d8774da..683d4afd5b2f 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 ConsoleAccessAuthenticationCommand() { _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 boolean isReauthenticating() { 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 000000000000..782dc3ab9350 --- /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 48819f494750..0cc9d011ca19 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 f92b93a39796..1e5ad11b49dd 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 boolean isSslEnabled() { 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 3d3dda9a508a..e9ef26a63b9e 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 @@ public class ConsoleProxyStatus { private ConsoleProxyConnectionInfo[] connections; + private String[] removedSessions; public ConsoleProxyStatus() { } @@ -28,4 +29,8 @@ public 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 e9a37ae7f462..1128897b2973 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 @@ -41,14 +41,11 @@ public interface CapacityManager { static final String StorageOverprovisioningFactorCK = "storage.overprovisioning.factor"; static final String StorageAllocatedCapacityDisableThresholdCK = "pool.storage.allocated.capacity.disablethreshold"; - static final String CATEGORY_ADVANCED = "Advanced"; - static final String CATEGORY_ALERT = "Alert"; - static final ConfigKey CpuOverprovisioningFactor = new ConfigKey<>( Float.class, CpuOverprovisioningFactorCK, - CATEGORY_ADVANCED, + ConfigKey.CATEGORY_ADVANCED, "1.0", "Used for CPU overprovisioning calculation; available CPU will be (actualCpuCapacity * cpu.overprovisioning.factor)", true, @@ -58,7 +55,7 @@ public interface CapacityManager { new ConfigKey<>( Float.class, MemOverprovisioningFactorCK, - CATEGORY_ADVANCED, + ConfigKey.CATEGORY_ADVANCED, "1.0", "Used for memory overprovisioning calculation", true, @@ -66,7 +63,7 @@ public interface CapacityManager { null); static final ConfigKey StorageCapacityDisableThreshold = new ConfigKey<>( - CATEGORY_ALERT, + ConfigKey.CATEGORY_ALERT, Double.class, StorageCapacityDisableThresholdCK, "0.85", @@ -84,7 +81,7 @@ public interface CapacityManager { ConfigKey.Scope.StoragePool); static final ConfigKey StorageAllocatedCapacityDisableThreshold = new ConfigKey<>( - CATEGORY_ALERT, + ConfigKey.CATEGORY_ALERT, Double.class, StorageAllocatedCapacityDisableThresholdCK, "0.85", @@ -95,7 +92,7 @@ public interface CapacityManager { new ConfigKey<>( Boolean.class, "cluster.storage.operations.exclude", - CATEGORY_ADVANCED, + ConfigKey.CATEGORY_ADVANCED, "false", "Exclude cluster from storage operations", true, @@ -105,7 +102,7 @@ public interface CapacityManager { new ConfigKey<>( String.class, "secstorage.nfs.version", - CATEGORY_ADVANCED, + ConfigKey.CATEGORY_ADVANCED, null, "Enforces specific NFS version when mounting Secondary Storage. If NULL default selection is performed", true, @@ -114,7 +111,7 @@ public interface CapacityManager { static final ConfigKey SecondaryStorageCapacityThreshold = new ConfigKey<>( - CATEGORY_ADVANCED, + ConfigKey.CATEGORY_ADVANCED, Float.class, "secondary.storage.capacity.threshold", "0.90", 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 d22fe1f1cd85..926e65cbd180 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 @@ */ 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/server/src/main/java/com/cloud/api/ApiServlet.java b/server/src/main/java/com/cloud/api/ApiServlet.java index 4bdf31defaf4..1ab12d326af0 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.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.jetbrains.annotations.Nullable; import org.springframework.stereotype.Component; @@ -324,6 +326,7 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp // 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 { @@ -356,6 +359,15 @@ void processRequestInContext(final HttpServletRequest req, final HttpServletResp } } + 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}); + } + } + @Nullable private String saveLogString(String stringToLog) { return stringToLog == null ? null : stringToLog.replace(LOG_REPLACEMENTS, REPLACEMENT); diff --git a/server/src/main/java/com/cloud/consoleproxy/AgentBasedConsoleProxyManager.java b/server/src/main/java/com/cloud/consoleproxy/AgentBasedConsoleProxyManager.java index 487ec45a4249..a71c692aab11 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 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 boolean configure(String name, Map params) throws Configu _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 boolean destroyProxy(long proxyVmId) { 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 2bc092e056ba..619825ecf430 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.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 AgentControlAnswer onConsoleAccessAuthentication(ConsoleAccessAuthenticat 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 AgentControlAnswer onConsoleAccessAuthentication(ConsoleAccessAuthenticat } 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 f7f88b0da66e..6280495fb1a9 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 @@ 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); + + ConsoleProxyManagementState getManagementState(); - public ConsoleProxyManagementState getManagementState(); + void resumeLastManagementState(); - public void resumeLastManagementState(); + ConsoleProxyVO startProxy(long proxyVmId, boolean ignoreRestartSetting); - public ConsoleProxyVO startProxy(long proxyVmId, boolean ignoreRestartSetting); + boolean stopProxy(long proxyVmId); - public boolean stopProxy(long proxyVmId); + boolean rebootProxy(long proxyVmId); - public boolean rebootProxy(long proxyVmId); + boolean destroyProxy(long proxyVmId); - public 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 f984dab21b75..e0c48956a63a 100644 --- a/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java +++ b/server/src/main/java/com/cloud/consoleproxy/ConsoleProxyManagerImpl.java @@ -32,6 +32,7 @@ import com.cloud.utils.PasswordGenerator; import org.apache.cloudstack.agent.lb.IndirectAgentLB; import org.apache.cloudstack.ca.CAManager; +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.ca.Certificate; @@ -263,11 +264,14 @@ 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 @@ -340,11 +344,14 @@ public ConsoleProxyInfo assignProxy(final long dataCenterId, final long vmId) { 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) { @@ -982,7 +989,7 @@ public void doInTransactionWithoutResult(TransactionStatus status) { } }); } - } 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); } } @@ -1017,7 +1024,7 @@ public void resumeLastManagementState() { 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); } } @@ -1096,6 +1103,11 @@ public boolean destroyProxy(long vmId) { } } + @Override + public int getVncPort() { + return sslEnabled && _ksDao.findByName(ConsoleProxyManager.CERTIFICATE_NAME) != null ? 8443 : 8080; + } + private String getAllocProxyLockName() { return "consoleproxy.alloc"; } @@ -1156,7 +1168,8 @@ public boolean configure(String name, Map params) throws Configu 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); @@ -1288,6 +1301,9 @@ public boolean finalizeVirtualMachineProfile(VirtualMachineProfile profile, Depl if (dc.getDns2() != null) { buf.append(" dns2=").append(dc.getDns2()); } + if (VirtualMachine.Type.ConsoleProxy == profile.getVirtualMachine().getType()) { + buf.append(" vncport=").append(getVncPort()); + } buf.append(" keystore_password=").append(VirtualMachineGuru.getEncodedString(PasswordGenerator.generateRandomPassword(16))); String bootArgs = buf.toString(); if (s_logger.isDebugEnabled()) { @@ -1621,7 +1637,9 @@ protected void updateConsoleProxyStatus(String statusInfo, Long proxyVmId) { if (status.getConnections() != null) { count = status.getConnections().length; } - + if (status.getRemovedSessions() != null) { + consoleAccessManager.removeSessions(status.getRemovedSessions()); + } details = statusInfo.getBytes(Charset.forName("US-ASCII")); } else { s_logger.debug(String.format("Unable to retrieve load info from proxy {\"vmId\": %s}. Invalid load info [%s].", proxyVmId, statusInfo)); diff --git a/server/src/main/java/com/cloud/server/ManagementServer.java b/server/src/main/java/com/cloud/server/ManagementServer.java index 7ecb66503732..611ba9b4200f 100644 --- a/server/src/main/java/com/cloud/server/ManagementServer.java +++ b/server/src/main/java/com/cloud/server/ManagementServer.java @@ -52,8 +52,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 074fff86be5a..9f1b5fe78fa1 100644 --- a/server/src/main/java/com/cloud/server/ManagementServerImpl.java +++ b/server/src/main/java/com/cloud/server/ManagementServerImpl.java @@ -47,6 +47,7 @@ import com.cloud.agent.api.Command; import com.cloud.agent.api.PatchSystemVmAnswer; import com.cloud.agent.api.PatchSystemVmCommand; +import com.cloud.agent.api.proxy.AllowConsoleAccessCommand; import com.cloud.agent.api.routing.NetworkElementCommand; import com.cloud.agent.manager.Commands; import com.cloud.dc.DomainVlanMapVO; @@ -365,6 +366,7 @@ 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; @@ -2847,6 +2849,49 @@ public String getConsoleAccessUrlRoot(final long vmId) { 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); + } + boolean result = false; + String details = "null answer"; + + if (answer != null) { + result = answer.getResult(); + details = answer.getDetails(); + } + return new Pair<>(result, details); + } + + @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) { @@ -3625,6 +3670,7 @@ public List> getCommands() { 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 8f9363df5ba9..7f0fb0ba7f7f 100644 --- a/server/src/main/java/com/cloud/servlet/ConsoleProxyClientParam.java +++ b/server/src/main/java/com/cloud/servlet/ConsoleProxyClientParam.java @@ -36,6 +36,19 @@ 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 +172,28 @@ public String getWebsocketUrl() { 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 f0b295f4bb25..f7101e1a36e6 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,45 +35,29 @@ 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 org.apache.cloudstack.framework.security.keys.KeysManager; import org.apache.commons.codec.binary.Base64; -import org.apache.commons.lang3.StringUtils; import org.apache.log4j.Logger; 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 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) { 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 @@ private void handleThumbnailRequest(HttpServletRequest req, HttpServletResponse } } - 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 @@ private String composeThumbnailUrl(String rootUrl, VirtualMachine vm, HostVO hos 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 000000000000..f6dd2e061580 --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/consoleproxy/ConsoleAccessManagerImpl.java @@ -0,0 +1,494 @@ +// 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.ObjectUtils; +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 (ObjectUtils.anyNull(accountManager, virtualMachineManager, managementServer)) { + 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) { + String errorMsg = String.format("Unexepected exception in ConsoleAccessManager - vmId: %s, clientAddress: %s", + vmId, clientAddress); + s_logger.error(errorMsg, 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 for VM ID " + vm.getUuid() + ". 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.Type.READ_ONLY_ADMIN) && s_logger.isDebugEnabled()) { + s_logger.debug("VM access is denied for VM ID " + vm.getUuid() + ". 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) { + 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 = generateConsoleProxyClientParam(parsedHostInfo, port, sid, tag, ticket, + sessionUuid, addr, extraSecurityToken, vm, hostVo, details, portInfo, host); + String token = encryptor.encryptObject(ConsoleProxyClientParam.class, param); + int vncPort = consoleProxyManager.getVncPort(); + + String url = generateConsoleAccessUrl(rootUrl, param, token, vncPort, vm); + + s_logger.debug("Adding allowed session: " + sessionUuid); + allowedSessions.add(sessionUuid); + managementServer.setConsoleAccessForVm(vm.getId(), sessionUuid); + + ConsoleEndpoint consoleEndpoint = new ConsoleEndpoint(true, url); + consoleEndpoint.setWebsocketHost(managementServer.getConsoleAccessAddress(vm.getId())); + consoleEndpoint.setWebsocketPort(String.valueOf(vncPort)); + consoleEndpoint.setWebsocketPath("websockify"); + consoleEndpoint.setWebsocketToken(token); + if (StringUtils.isNotBlank(param.getExtraSecurityToken())) { + consoleEndpoint.setWebsocketExtra(param.getExtraSecurityToken()); + } + return consoleEndpoint; + } + + private String generateConsoleAccessUrl(String rootUrl, ConsoleProxyClientParam param, String token, int vncPort, + VirtualMachine vm) { + StringBuilder sb = new StringBuilder(rootUrl); + 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); + } + return sb.toString().startsWith("https") ? sb.toString() : "http:" + sb; + } + + private ConsoleProxyClientParam generateConsoleProxyClientParam(Ternary parsedHostInfo, + int port, String sid, String tag, String ticket, + String sessionUuid, String addr, + String extraSecurityToken, VirtualMachine vm, + HostVO hostVo, UserVmDetailVO details, + Pair portInfo, String host) { + 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()); + } + return param; + } + + 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 9c0e9a125f93..5e75388547ca 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 @@ -109,6 +109,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 fa582991e6b0..fa467e877f01 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.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 000000000000..df218f49bfd3 --- /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 908819abb8b4..c2b54e991909 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.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 org.apache.commons.lang3.ArrayUtils; @@ -77,6 +79,12 @@ public class ConsoleProxy { static String encryptorPassword = "Dummy"; static final String[] skipProperties = new String[]{"certificate", "cacertificate", "keystore_password", "privatekey"}; + 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"); @@ -178,6 +186,29 @@ public static ConsoleProxyAuthenticationResult authenticateConsoleAccess(Console 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; @@ -192,7 +223,7 @@ public static ConsoleProxyAuthenticationResult authenticateConsoleAccess(Console 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); @@ -266,7 +297,8 @@ public static void startWithContext(Properties conf, Object context, byte[] ksBi 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) { @@ -371,9 +403,10 @@ private static void startupHttpMain() { } 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() { 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 6429de4ad2fa..e47837d49e9e 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 1c0f28d0a213..9c24ef619b04 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 void setClientParam(ConsoleProxyClientParam clientParam) { 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 c071f551da7d..b27532edd371 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,19 @@ 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 +175,28 @@ public String getWebsocketUrl() { 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 5251b9386d87..f82bfdfebb58 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.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 @@ 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 @@ private void setConnections(Hashtable connMap) { conn.tag = client.getClientTag(); conn.createTime = client.getClientCreateTime(); conn.lastUsedTime = client.getClientLastFrontEndActivityTime(); + conn.setSessionUuid(client.getSessionUuid()); conns.add(conn); } } @@ -81,6 +89,15 @@ public static class ConsoleProxyConnection { 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 2ddb2c7a1d58..7965a2083504 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 void run() { 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 void run() { } synchronized (connMap) { + removedSessions.add(client.getSessionUuid()); connMap.remove(key); bReportLoad = true; } @@ -100,7 +106,9 @@ public void run() { 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 28d6ec1cab72..d51a46680e70 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 static Map getQueryMap(String query) { 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 static Map getQueryMap(String query) { 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 static Map getQueryMap(String query) { 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 91d8e192fd9b..72f019bd3d12 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 void handle(String target, Request baseRequest, HttpServletRequest reques @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 void onConnect(final Session session) throws IOException, InterruptedExce 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 void onConnect(final Session session) throws IOException, InterruptedExce 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 28d179ba6fcd..a8e300429ae5 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 @@ 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 VNC_CONF_FILE_LOCATION = "/root/vncport"; private Server server; + public static int getVNCPort() { + String portStr; + try { + portStr = Files.readString(Path.of(VNC_CONF_FILE_LOCATION)).trim(); + } catch (IOException e) { + s_logger.error("Cannot read the VNC port from the file " + VNC_CONF_FILE_LOCATION + " 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 ConsoleProxyNoVNCServer(byte[] ksBits, String ksPassword) { 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 ConsoleProxyNoVNCServer(byte[] ksBits, String ksPassword) { 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 596084955fad..f1c195913038 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 void initClient(ConsoleProxyClientParam param) { setClientParam(param); client = new NoVncClient(); connectionAlive = true; + this.sessionUuid = param.getSessionUuid(); updateFrontEndActivityTime(); Thread worker = new Thread(new Runnable() { @@ -192,6 +194,11 @@ public void closeClient() { 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 000000000000..690b0c19a9e9 --- /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 1c6a00799c3f..f503f55c6c9f 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 6f1b7998fe44..060dc736aa87 100644 --- a/systemvm/agent/noVNC/vnc.html +++ b/systemvm/agent/noVNC/vnc.html @@ -234,6 +234,10 @@

no
+
  • + + +

  • diff --git a/systemvm/debian/opt/cloud/bin/setup/common.sh b/systemvm/debian/opt/cloud/bin/setup/common.sh index e908519c459c..c8b333e50888 100755 --- a/systemvm/debian/opt/cloud/bin/setup/common.sh +++ b/systemvm/debian/opt/cloud/bin/setup/common.sh @@ -885,6 +885,9 @@ parse_cmd_line() { useHttpsToUpload) export USEHTTPS=$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 8006f6bb2445..aa414c4117d0 100755 --- a/systemvm/debian/opt/cloud/bin/setup/consoleproxy.sh +++ b/systemvm/debian/opt/cloud/bin/setup/consoleproxy.sh @@ -32,6 +32,11 @@ setup_console_proxy() { public_ip=`getPublicIp` echo "$public_ip $NAME" >> /etc/hosts + 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" + disable_rpfilter enable_fwding 0 enable_irqbalance 0 diff --git a/systemvm/patch-sysvms.sh b/systemvm/patch-sysvms.sh index c2083369be7d..554218c98781 100644 --- a/systemvm/patch-sysvms.sh +++ b/systemvm/patch-sysvms.sh @@ -90,7 +90,13 @@ restart_services() { fi done < "$svcfile" if [ "$TYPE" == "consoleproxy" ]; then - iptables -A INPUT -i eth2 -p tcp -m state --state NEW -m tcp --dport 8080 -j ACCEPT + vncport=8080 + if [ -f /root/vncport ] + then + vncport=`cat /root/vncport` + log_it "vncport read: ${vncport}" + fi + iptables -A INPUT -i eth2 -p tcp -m state --state NEW -m tcp --dport $vncport -j ACCEPT fi } diff --git a/test/integration/smoke/test_console_endpoint.py b/test/integration/smoke/test_console_endpoint.py new file mode 100644 index 000000000000..84b893e57012 --- /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 7d020a515a5b..441b21f1d920 100644 --- a/tools/apidoc/gen_toc.py +++ b/tools/apidoc/gen_toc.py @@ -201,7 +201,8 @@ '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.json b/ui/package.json index 42d33a402f8d..174502905af2 100644 --- a/ui/package.json +++ b/ui/package.json @@ -58,6 +58,7 @@ "vue-router": "^4.0.14", "vue-web-storage": "^6.1.0", "vue3-clipboard": "^1.0.0", + "vue-uuid": "^3.0.0", "vuedraggable": "^4.0.3", "vuex": "^4.0.0-0" }, diff --git a/ui/src/components/widgets/Console.vue b/ui/src/components/widgets/Console.vue index a42843946fe0..9c91072217a0 100644 --- a/ui/src/components/widgets/Console.vue +++ b/ui/src/components/widgets/Console.vue @@ -17,9 +17,8 @@