diff --git a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java index 8fca652518f2..0d77584ef2aa 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java +++ b/api/src/main/java/org/apache/cloudstack/api/ApiConstants.java @@ -98,8 +98,8 @@ public class ApiConstants { public static final String CONVERT_INSTANCE_HOST_ID = "convertinstancehostid"; public static final String CONVERT_INSTANCE_STORAGE_POOL_ID = "convertinstancepoolid"; public static final String ENABLED_REVOCATION_CHECK = "enabledrevocationcheck"; - public static final String CLIENT_ADDRESS = "clientaddress"; public static final String COMBINED_CAPACITY_ORDERING = "COMBINED"; + public static final String CONTEXT_ID = "contextid"; public static final String CONTROLLER = "controller"; public static final String CONTROLLER_UNIT = "controllerunit"; public static final String CONSOLE_ENDPOINT_CREATOR_ADDRESS = "consoleendpointcreatoraddress"; @@ -124,6 +124,7 @@ public class ApiConstants { public static final String DEST_CIDR_LIST = "destcidrlist"; public static final String CLEANUP = "cleanup"; public static final String MAKEREDUNDANT = "makeredundant"; + public static final String CLIENT_ADDRESS = "clientaddress"; public static final String CLUSTER_ID = "clusterid"; public static final String CLUSTER_IDS = "clusterids"; public static final String CLUSTER_NAME = "clustername"; @@ -137,12 +138,14 @@ public class ApiConstants { public static final String CNI_CONFIG_NAME = "cniconfigname"; public static final String CSI_ENABLED = "csienabled"; public static final String COMPONENT = "component"; + public static final String CONNECTED = "connected"; public static final String CPU = "CPU"; public static final String CPU_CORE_PER_SOCKET = "cpucorepersocket"; public static final String CPU_NUMBER = "cpunumber"; public static final String CPU_SPEED = "cpuspeed"; public static final String CPU_LOAD_AVERAGE = "cpuloadaverage"; public static final String CREATED = "created"; + public static final String CREATOR_ADDRESS = "creatoraddress"; public static final String CROSS_ZONE_INSTANCE_CREATION = "crosszoneinstancecreation"; public static final String CTX_ACCOUNT_ID = "ctxaccountid"; public static final String CTX_DETAILS = "ctxDetails"; @@ -161,6 +164,7 @@ public class ApiConstants { public static final String ENCRYPT_ROOT = "encryptroot"; public static final String ENCRYPTION_SUPPORTED = "encryptionsupported"; public static final String ETCD_IPS = "etcdips"; + public static final String FILTERS = "filters"; public static final String MIN_IOPS = "miniops"; public static final String MAX_IOPS = "maxiops"; public static final String HYPERVISOR_SNAPSHOT_RESERVE = "hypervisorsnapshotreserve"; @@ -368,6 +372,8 @@ public class ApiConstants { public static final String LIMIT_CPU_USE = "limitcpuuse"; public static final String LIST_HOSTS = "listhosts"; public static final String LOCATION_TYPE = "locationtype"; + public static final String LOG_IDS = "logids"; + public static final String LOGS_WEB_SERVER_ENABLED = "logswebserverenabled"; public static final String LOCK = "lock"; public static final String LUN = "lun"; public static final String LBID = "lbruleid"; @@ -1312,6 +1318,8 @@ public class ApiConstants { public static final String WEBHOOK_ID = "webhookid"; public static final String WEBHOOK_NAME = "webhookname"; + public static final String WEBSOCKET = "websocket"; + public static final String NFS_MOUNT_OPTIONS = "nfsmountopts"; public static final String MOUNT_OPTIONS = "mountopts"; diff --git a/api/src/main/java/org/apache/cloudstack/api/BaseResponse.java b/api/src/main/java/org/apache/cloudstack/api/BaseResponse.java index 45016c1a2a26..f37d22092366 100644 --- a/api/src/main/java/org/apache/cloudstack/api/BaseResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/BaseResponse.java @@ -16,6 +16,10 @@ // under the License. package org.apache.cloudstack.api; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + import com.google.gson.annotations.SerializedName; import com.cloud.serializer.Param; @@ -32,6 +36,10 @@ public abstract class BaseResponse implements ResponseObject { @Param(description = "the current status of the latest async job acting on this object") private Integer jobStatus; + @SerializedName(ApiConstants.LOG_IDS) + @Param(description = "the IDs of the logs for the request") + private List logsIds; + public BaseResponse() { } @@ -83,4 +91,17 @@ public Integer getJobStatus() { public void setJobStatus(Integer jobStatus) { this.jobStatus = jobStatus; } + + @Override + public List getLogIds() { + return logsIds; + } + + @Override + public void addLogIds(String... logId) { + if (this.logsIds == null) { + logsIds = new ArrayList<>(); + } + this.logsIds.addAll(Arrays.asList(logId)); + } } diff --git a/api/src/main/java/org/apache/cloudstack/api/ResponseObject.java b/api/src/main/java/org/apache/cloudstack/api/ResponseObject.java index ff2e172b70b3..80c487a49711 100644 --- a/api/src/main/java/org/apache/cloudstack/api/ResponseObject.java +++ b/api/src/main/java/org/apache/cloudstack/api/ResponseObject.java @@ -16,6 +16,8 @@ // under the License. package org.apache.cloudstack.api; +import java.util.List; + public interface ResponseObject { /** * Get the name of the API response @@ -76,6 +78,9 @@ public interface ResponseObject { */ void setJobStatus(Integer jobStatus); + List getLogIds(); + void addLogIds(String... contextId); + public enum ResponseView { Full, Restricted diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ListCfgsByCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ListCfgsByCmd.java index e365d8bc2dc7..74f04e57926c 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ListCfgsByCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ListCfgsByCmd.java @@ -19,23 +19,23 @@ import java.util.ArrayList; import java.util.List; -import org.apache.cloudstack.api.ApiErrorCode; -import org.apache.cloudstack.api.ServerApiException; -import org.apache.cloudstack.api.response.DomainResponse; -import org.apache.commons.lang3.StringUtils; - import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; import org.apache.cloudstack.api.BaseListCmd; import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; import org.apache.cloudstack.api.response.AccountResponse; import org.apache.cloudstack.api.response.ClusterResponse; import org.apache.cloudstack.api.response.ConfigurationResponse; +import org.apache.cloudstack.api.response.DomainResponse; import org.apache.cloudstack.api.response.ImageStoreResponse; import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.ManagementServerResponse; import org.apache.cloudstack.api.response.StoragePoolResponse; import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.config.Configuration; +import org.apache.commons.lang3.StringUtils; import com.cloud.exception.InvalidParameterValueException; import com.cloud.utils.Pair; @@ -94,6 +94,13 @@ public class ListCfgsByCmd extends BaseListCmd { description = "the ID of the Image Store to update the parameter value for corresponding image store") private Long imageStoreId; + @Parameter(name = ApiConstants.MANAGEMENT_SERVER_ID, + type = CommandType.UUID, + entityType = ManagementServerResponse.class, + description = "the ID of the Management Server to update the parameter value for corresponding management server", + since = "4.21.0") + private Long managementServerId; + @Parameter(name = ApiConstants.GROUP, type = CommandType.STRING, description = "lists configuration by group name (primarily used for UI)", since = "4.18.0") private String groupName; @@ -139,6 +146,10 @@ public Long getImageStoreId() { return imageStoreId; } + public Long getManagementServerId() { + return managementServerId; + } + public String getGroupName() { return groupName; } @@ -200,6 +211,9 @@ private void setScope(ConfigurationResponse cfgResponse) { if (getImageStoreId() != null){ cfgResponse.setScope("imagestore"); } + if (getManagementServerId() != null){ + cfgResponse.setScope("managementserver"); + } } @Override diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ResetCfgCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ResetCfgCmd.java index f114b263b634..2c8a39113ea7 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ResetCfgCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/config/ResetCfgCmd.java @@ -23,16 +23,16 @@ import org.apache.cloudstack.api.BaseCmd; import org.apache.cloudstack.api.Parameter; import org.apache.cloudstack.api.ServerApiException; -import org.apache.cloudstack.api.response.ImageStoreResponse; -import org.apache.cloudstack.framework.config.ConfigKey; - import org.apache.cloudstack.api.response.AccountResponse; import org.apache.cloudstack.api.response.ClusterResponse; import org.apache.cloudstack.api.response.ConfigurationResponse; import org.apache.cloudstack.api.response.DomainResponse; +import org.apache.cloudstack.api.response.ImageStoreResponse; +import org.apache.cloudstack.api.response.ManagementServerResponse; import org.apache.cloudstack.api.response.StoragePoolResponse; import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.config.Configuration; +import org.apache.cloudstack.framework.config.ConfigKey; import com.cloud.user.Account; import com.cloud.utils.Pair; @@ -84,6 +84,13 @@ public class ResetCfgCmd extends BaseCmd { description = "the ID of the Image Store to reset the parameter value for corresponding image store") private Long imageStoreId; + @Parameter(name = ApiConstants.MANAGEMENT_SERVER_ID, + type = CommandType.UUID, + entityType = ManagementServerResponse.class, + description = "the ID of the Management Server to update the parameter value for corresponding management server", + since = "4.21.0") + private Long managementServerId; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -116,6 +123,10 @@ public Long getImageStoreId() { return imageStoreId; } + public Long getManagementServerId() { + return managementServerId; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// @@ -149,6 +160,9 @@ public void execute() { if (getImageStoreId() != null) { response.setScope(ConfigKey.Scope.ImageStore.name()); } + if (getManagementServerId() != null) { + response.setScope(ConfigKey.Scope.ManagementServer.name()); + } response.setValue(cfg.second()); this.setResponseObject(response); } else { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/config/UpdateCfgCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/config/UpdateCfgCmd.java index dbf478df7012..24aa37603dc9 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/config/UpdateCfgCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/config/UpdateCfgCmd.java @@ -30,6 +30,7 @@ import org.apache.cloudstack.api.response.ClusterResponse; import org.apache.cloudstack.api.response.ConfigurationResponse; import org.apache.cloudstack.api.response.ImageStoreResponse; +import org.apache.cloudstack.api.response.ManagementServerResponse; import org.apache.cloudstack.api.response.StoragePoolResponse; import org.apache.cloudstack.api.response.ZoneResponse; import org.apache.cloudstack.config.Configuration; @@ -88,6 +89,14 @@ public class UpdateCfgCmd extends BaseCmd { validations = ApiArgValidator.PositiveNumber) private Long imageStoreId; + @Parameter(name = ApiConstants.MANAGEMENT_SERVER_ID, + type = CommandType.UUID, + entityType = ManagementServerResponse.class, + description = "the ID of the Management Server to update the parameter value for corresponding management server", + validations = ApiArgValidator.PositiveNumber, + since = "4.21.0") + private Long managementServerId; + ///////////////////////////////////////////////////// /////////////////// Accessors /////////////////////// ///////////////////////////////////////////////////// @@ -112,7 +121,7 @@ public Long getClusterId() { return clusterId; } - public Long getStoragepoolId() { + public Long getStoragePoolId() { return storagePoolId; } @@ -128,6 +137,10 @@ public Long getImageStoreId() { return imageStoreId; } + public Long getManagementServerId() { + return managementServerId; + } + ///////////////////////////////////////////////////// /////////////// API Implementation/////////////////// ///////////////////////////////////////////////////// @@ -184,7 +197,7 @@ public ConfigurationResponse setResponseScopes(ConfigurationResponse response) { if (getClusterId() != null) { response.setScope("cluster"); } - if (getStoragepoolId() != null) { + if (getStoragePoolId() != null) { response.setScope("storagepool"); } if (getAccountId() != null) { diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/config/ListCapabilitiesCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/config/ListCapabilitiesCmd.java index ed1bd7b063b2..599ec4d8411a 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/config/ListCapabilitiesCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/config/ListCapabilitiesCmd.java @@ -76,6 +76,7 @@ public void execute() { response.setExtensionsPath((String)capabilities.get(ApiConstants.EXTENSIONS_PATH)); response.setDynamicScalingEnabled((Boolean) capabilities.get(ApiConstants.DYNAMIC_SCALING_ENABLED)); response.setAdditionalConfigEnabled((Boolean) capabilities.get(ApiConstants.ADDITONAL_CONFIG_ENABLED)); + response.setLogsWebServerEnabled((Boolean)capabilities.get(ApiConstants.LOGS_WEB_SERVER_ENABLED)); response.setObjectName("capability"); response.setResponseName(getCommandName()); this.setResponseObject(response); diff --git a/api/src/main/java/org/apache/cloudstack/api/response/CapabilitiesResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/CapabilitiesResponse.java index d2c71b5f3525..0a1830882230 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/CapabilitiesResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/CapabilitiesResponse.java @@ -153,6 +153,10 @@ public class CapabilitiesResponse extends BaseResponse { @Param(description = "true if additional configurations or extraconfig can be passed to Instances", since = "4.20.2") private Boolean additionalConfigEnabled; + @SerializedName(ApiConstants.LOGS_WEB_SERVER_ENABLED) + @Param(description = "true if Logs Web Server plugin is enabled, false otherwise", since = "4.23.0") + private boolean logsWebServerEnabled; + public void setSecurityGroupsEnabled(boolean securityGroupsEnabled) { this.securityGroupsEnabled = securityGroupsEnabled; } @@ -279,5 +283,10 @@ public void setDynamicScalingEnabled(Boolean dynamicScalingEnabled) { public void setAdditionalConfigEnabled(Boolean additionalConfigEnabled) { this.additionalConfigEnabled = additionalConfigEnabled; + + } + + public void setLogsWebServerEnabled(boolean logsWebServerEnabled) { + this.logsWebServerEnabled = logsWebServerEnabled; } } diff --git a/api/src/main/java/org/apache/cloudstack/cluster/ClusterCommandProcessor.java b/api/src/main/java/org/apache/cloudstack/cluster/ClusterCommandProcessor.java new file mode 100644 index 000000000000..f3e5ce9bc314 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/cluster/ClusterCommandProcessor.java @@ -0,0 +1,25 @@ +// 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.cluster; + +import com.cloud.agent.api.Command; + +public interface ClusterCommandProcessor { + boolean supportsCommand(Class clazz); + String processCommand(Command cmd); +} diff --git a/client/conf/server.properties.in b/client/conf/server.properties.in index 5958486b4dff..015532652fc3 100644 --- a/client/conf/server.properties.in +++ b/client/conf/server.properties.in @@ -62,3 +62,10 @@ extensions.deployment.mode=@EXTENSIONSDEPLOYMENTMODE@ # Thread pool configuration #threads.min=10 #threads.max=500 + +# WebSocket server enable/disable flag used by the management server +websocket.enable=true + +# WebSocket server port used by the management server +# If not set, defaults to 8822. It can be set to same value as http.port or https.port if needed. +websocket.port=8822 diff --git a/client/pom.xml b/client/pom.xml index d8fa433d5be3..80acceca7f9a 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -52,6 +52,14 @@ org.eclipse.jetty jetty-util + + org.eclipse.jetty.websocket + websocket-server + + + javax.websocket + javax.websocket-api + com.mysql mysql-connector-j @@ -116,6 +124,11 @@ cloud-framework-spring-lifecycle ${project.version} + + org.apache.cloudstack + cloud-framework-websocket-server + ${project.version} + org.apache.cloudstack cloud-plugin-storage-volume-adaptive @@ -246,6 +259,11 @@ cloud-plugin-user-two-factor-authenticator-staticpin ${project.version} + + org.apache.cloudstack + cloud-plugin-logs-web-server + ${project.version} + org.apache.cloudstack cloud-plugin-metrics diff --git a/client/src/main/java/org/apache/cloudstack/ServerDaemon.java b/client/src/main/java/org/apache/cloudstack/ServerDaemon.java index 196695e1fc6b..d54e511ff4e2 100644 --- a/client/src/main/java/org/apache/cloudstack/ServerDaemon.java +++ b/client/src/main/java/org/apache/cloudstack/ServerDaemon.java @@ -27,10 +27,14 @@ import java.util.Arrays; import java.util.Properties; -import com.cloud.api.ApiServer; +import org.apache.cloudstack.framework.websocket.server.common.WebSocketRouter; +import org.apache.cloudstack.utils.server.ServerPropertiesUtil; +import org.apache.cloudstack.websocket.JettyWebSocketServlet; import org.apache.commons.daemon.Daemon; import org.apache.commons.daemon.DaemonContext; import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.eclipse.jetty.jmx.MBeanContainer; import org.eclipse.jetty.server.ForwardedRequestCustomizer; import org.eclipse.jetty.server.HttpConfiguration; @@ -46,14 +50,14 @@ import org.eclipse.jetty.server.handler.RequestLogHandler; import org.eclipse.jetty.server.handler.gzip.GzipHandler; import org.eclipse.jetty.server.session.SessionHandler; +import org.eclipse.jetty.servlet.ServletHolder; import org.eclipse.jetty.util.ssl.KeyStoreScanner; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.eclipse.jetty.util.thread.ScheduledExecutorScheduler; import org.eclipse.jetty.webapp.WebAppContext; -import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.LogManager; +import com.cloud.api.ApiServer; import com.cloud.utils.Pair; import com.cloud.utils.PropertiesUtil; import com.cloud.utils.server.ServerProperties; @@ -74,12 +78,6 @@ public class ServerDaemon implements Daemon { private static final String BIND_INTERFACE = "bind.interface"; private static final String CONTEXT_PATH = "context.path"; private static final String SESSION_TIMEOUT = "session.timeout"; - private static final String HTTP_ENABLE = "http.enable"; - private static final String HTTP_PORT = "http.port"; - private static final String HTTPS_ENABLE = "https.enable"; - private static final String HTTPS_PORT = "https.port"; - private static final String KEYSTORE_FILE = "https.keystore"; - private static final String KEYSTORE_PASSWORD = "https.keystore.password"; private static final String WEBAPP_DIR = "webapp.dir"; private static final String ACCESS_LOG = "access.log"; private static final String REQUEST_CONTENT_SIZE_KEY = "request.content.size"; @@ -96,8 +94,8 @@ public class ServerDaemon implements Daemon { private Server server; private boolean httpEnable = true; - private int httpPort = 8080; - private int httpsPort = 8443; + private int httpPort = ServerPropertiesUtil.HTTP_PORT; + private int httpsPort = ServerPropertiesUtil.HTTPS_PORT; private int sessionTimeout = 30; private int maxFormContentSize = DEFAULT_REQUEST_CONTENT_SIZE; private int maxFormKeys = DEFAULT_REQUEST_MAX_FORM_KEYS; @@ -140,12 +138,12 @@ public void init(final DaemonContext context) { } setBindInterface(properties.getProperty(BIND_INTERFACE, null)); setContextPath(properties.getProperty(CONTEXT_PATH, "/client")); - setHttpEnable(Boolean.valueOf(properties.getProperty(HTTP_ENABLE, "true"))); - setHttpPort(Integer.valueOf(properties.getProperty(HTTP_PORT, "8080"))); - setHttpsEnable(Boolean.valueOf(properties.getProperty(HTTPS_ENABLE, "false"))); - setHttpsPort(Integer.valueOf(properties.getProperty(HTTPS_PORT, "8443"))); - setKeystoreFile(properties.getProperty(KEYSTORE_FILE)); - setKeystorePassword(properties.getProperty(KEYSTORE_PASSWORD)); + setHttpEnable(Boolean.valueOf(properties.getProperty(ServerPropertiesUtil.KEY_HTTP_ENABLE, "true"))); + setHttpPort(Integer.valueOf(properties.getProperty(ServerPropertiesUtil.KEY_HTTP_PORT, "8080"))); + setHttpsEnable(Boolean.valueOf(properties.getProperty(ServerPropertiesUtil.KEY_HTTPS_ENABLE, "false"))); + setHttpsPort(Integer.valueOf(properties.getProperty(ServerPropertiesUtil.KEY_HTTPS_PORT, "8443"))); + setKeystoreFile(properties.getProperty(ServerPropertiesUtil.KEY_KEYSTORE_FILE)); + setKeystorePassword(properties.getProperty(ServerPropertiesUtil.KEY_KEYSTORE_PASSWORD)); setWebAppLocation(properties.getProperty(WEBAPP_DIR)); setAccessLogFile(properties.getProperty(ACCESS_LOG, "access.log")); setSessionTimeout(Integer.valueOf(properties.getProperty(SESSION_TIMEOUT, "30"))); @@ -199,7 +197,7 @@ public void start() throws Exception { createHttpConnector(httpConfig); // Setup handlers - Pair pair = createHandlers(); + Pair pair = createHandlers(); server.setHandler(pair.second()); // Extra config options @@ -287,14 +285,45 @@ private void createHttpsConnector(final HttpConfiguration httpConfig) { } } } + /** + * Adds a Jetty-native WebSocket servlet to the provided WebAppContext when the + * server is operating in same-port mode. The method checks the + * `websocket.server.port` server property; if that property is set and differs + * from this server's HTTP port, registration is skipped because a standalone + * WebSocket server is assumed. + * + * @param webApp the WebAppContext to which the WebSocket servlet will be added + */ + protected void addWebSocketHandler(final WebAppContext webApp) { + try { + if (!JettyWebSocketServlet.isWebSocketServletEnabled()) { + logger.info("WebSocket Server is not enabled, embedded WebSocket Server will not be running"); + return; + } + Integer port = JettyWebSocketServlet.getWebSocketServletPort(); + if (port == null) { + logger.info("WebSocket Server port is configured, embedded WebSocket Server will not be running"); + return; + } + final ServletHolder ws = new ServletHolder(new JettyWebSocketServlet()); + webApp.addServlet(ws, WebSocketRouter.WEBSOCKET_PATH_PREFIX + "/*"); + logger.info("Embedded WebSocket Server initialized at {}/* with port: {}", + WebSocketRouter.WEBSOCKET_PATH_PREFIX, port); + } catch (Exception e) { + logger.warn("Failed to initialize embedded WebSocket server", e); + } + } - private Pair createHandlers() { + private Pair createHandlers() { final WebAppContext webApp = new WebAppContext(); webApp.setContextPath(contextPath); webApp.setInitParameter("org.eclipse.jetty.servlet.Default.dirAllowed", "false"); webApp.setMaxFormContentSize(maxFormContentSize); webApp.setMaxFormKeys(maxFormKeys); + // Enable WebSockets + addWebSocketHandler(webApp); + // GZIP handler final GzipHandler gzipHandler = new GzipHandler(); gzipHandler.addIncludedMimeTypes("text/html", "text/xml", "text/css", "text/plain", "text/javascript", "application/javascript", "application/json", "application/xml"); diff --git a/client/src/main/java/org/apache/cloudstack/websocket/JettyWebSocketServlet.java b/client/src/main/java/org/apache/cloudstack/websocket/JettyWebSocketServlet.java new file mode 100644 index 000000000000..f6824dff582f --- /dev/null +++ b/client/src/main/java/org/apache/cloudstack/websocket/JettyWebSocketServlet.java @@ -0,0 +1,210 @@ +// 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.websocket; + +import java.io.IOException; +import java.util.Map; + +import javax.servlet.ServletException; + +import org.apache.cloudstack.framework.websocket.server.common.WebSocketHandler; +import org.apache.cloudstack.framework.websocket.server.common.WebSocketRouter; +import org.apache.cloudstack.framework.websocket.server.common.WebSocketSession; +import org.apache.cloudstack.utils.server.ServerPropertiesUtil; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.UpgradeRequest; +import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; +import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse; +import org.eclipse.jetty.websocket.servlet.WebSocketCreator; +import org.eclipse.jetty.websocket.servlet.WebSocketServlet; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; + +import com.cloud.utils.Pair; +import com.cloud.utils.component.ComponentContext; + +public class JettyWebSocketServlet extends WebSocketServlet { + protected static final Logger LOGGER = LogManager.getLogger(JettyWebSocketServlet.class); + private boolean enabled; + + public static boolean isWebSocketServletEnabled() { + return Boolean.parseBoolean(ServerPropertiesUtil.getProperty(ServerPropertiesUtil.KEY_WEBSOCKET_ENABLE, + "false")); + } + + public static Integer getWebSocketServletPort() { + final String webSocketServerPort = ServerPropertiesUtil.getProperty(ServerPropertiesUtil.KEY_WEBSOCKET_PORT); + final Pair mainServerModeAndPort = ServerPropertiesUtil.getServerModeAndPort(); + final int mainServerPort = mainServerModeAndPort.second(); + return (StringUtils.isBlank(webSocketServerPort) || + webSocketServerPort.equals(String.valueOf(mainServerPort))) ? + mainServerPort : null; + } + + @Override + public void init() throws ServletException { + LOGGER.info("Initializing JettyWebSocketServlet"); + if (!isWebSocketServletEnabled()) { + enabled = false; + LOGGER.info("WebSocket Server is not enabled, embedded WebSocket Server will not be running"); + } + Integer port = getWebSocketServletPort(); + if (port == null) { + enabled = false; + LOGGER.info("WebSocket Server port is configured, embedded WebSocket Server will not be running"); + return; + } + enabled = true; + LOGGER.info("Embedded WebSocket Server initialized at {}/* with port: {}", + WebSocketRouter.WEBSOCKET_PATH_PREFIX, port); + super.init(); + } + + + @Override + public void configure(WebSocketServletFactory factory) { + if (!enabled) { + return; + } + LOGGER.info("Configuring JettyWebSocketServlet (idle=120s, maxText=131072, maxBin=131072)"); + factory.getPolicy().setIdleTimeout(120_000); + factory.getPolicy().setMaxTextMessageSize(131_072); + factory.getPolicy().setMaxBinaryMessageSize(131_072); + + WebSocketRouter webSocketRouter = ComponentContext.getDelegateComponentOfType(WebSocketRouter.class); + factory.setCreator(new Creator(webSocketRouter)); + LOGGER.info("JettyWebSocketServlet configured, Creator installed"); + } + + static final class Creator implements WebSocketCreator { + private final WebSocketRouter router; + + Creator(WebSocketRouter router) { + this.router = router; + } + + @Override + public Object createWebSocket(ServletUpgradeRequest req, ServletUpgradeResponse resp) { + String ctx = req.getHttpServletRequest().getContextPath(); + String full = req.getRequestPath(); + String path = (ctx != null && !ctx.isEmpty() && full.startsWith(ctx)) ? full.substring(ctx.length()) : full; + LOGGER.debug("WebSocket connection for path: {}, query: {}", path, req.getQueryString()); + + path = WebSocketRouter.stripWebSocketPathPrefix(path); + + WebSocketRouter.ResolvedRoute rr = router.resolve(path.startsWith("/") ? path : ("/" + path)); + if (rr == null || rr.getHandler() == null) { + try { + resp.sendForbidden("No route for " + path); + } catch (IOException ignore) { + } + return null; + } + + WebSocketHandler handler = rr.getHandler(); + return new WebSocketAdapter(handler, path, req.getQueryString()); + } + } + + /** + * Adapts Jetty-native events to your WebSocketHandler + */ + static final class WebSocketAdapter extends org.eclipse.jetty.websocket.api.WebSocketAdapter { + private final WebSocketHandler handler; + private final String routePath; + private final String rawQuery; + private WebSocketSession session; + + private Map parse(String q) { + if (q == null || q.isEmpty()) return java.util.Collections.emptyMap(); + java.util.Map m = new java.util.HashMap<>(); + for (String kv : q.split("&")) { + int i = kv.indexOf('='); + String k = i >= 0 ? kv.substring(0, i) : kv; + String v = i >= 0 ? kv.substring(i + 1) : ""; + m.put(java.net.URLDecoder.decode(k, java.nio.charset.StandardCharsets.UTF_8), + java.net.URLDecoder.decode(v, java.nio.charset.StandardCharsets.UTF_8)); + } + return m; + } + + WebSocketAdapter(WebSocketHandler handler, String routePath, String rawQuery) { + this.handler = handler; + this.routePath = routePath; + this.rawQuery = rawQuery; + } + + @Override + public void onWebSocketConnect(Session jettySession) { + super.onWebSocketConnect(jettySession); + this.session = JettyWebSocketSession.adapt(jettySession, routePath, parse(rawQuery)); + UpgradeRequest request = jettySession.getUpgradeRequest(); + String remoteAddr = request.getHeader("X-Forwarded-For"); + if (remoteAddr == null) { + remoteAddr = jettySession.getRemoteAddress().getAddress().getHostAddress(); + } + this.session.setAttr(WebSocketSession.ATTR_REMOTE_ADDR, remoteAddr); + try { + + handler.onOpen(session); + } catch (Throwable t) { + try { + handler.onError(session, t); + } finally { + jettySession.close(); + } + } + } + + @Override + public void onWebSocketText(String message) { + try { + handler.onTextMessage(session, message); + } catch (Throwable t) { + handler.onError(session, t); + } + } + + @Override + public void onWebSocketBinary(byte[] payload, int offset, int len) { + try { + handler.onBinaryMessage(session, java.nio.ByteBuffer.wrap(payload, offset, len)); + } catch (Throwable t) { + handler.onError(session, t); + } + } + + @Override + public void onWebSocketClose(int statusCode, String reason) { + try { + handler.onClose(session, statusCode, reason); + } finally { + super.onWebSocketClose(statusCode, reason); + } + } + + @Override + public void onWebSocketError(Throwable cause) { + try { + handler.onError(session, cause); + } finally { /* no-op */ } + } + } +} diff --git a/client/src/main/java/org/apache/cloudstack/websocket/JettyWebSocketSession.java b/client/src/main/java/org/apache/cloudstack/websocket/JettyWebSocketSession.java new file mode 100644 index 000000000000..c62d64ffdb1d --- /dev/null +++ b/client/src/main/java/org/apache/cloudstack/websocket/JettyWebSocketSession.java @@ -0,0 +1,97 @@ +// 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.websocket; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.cloudstack.framework.websocket.server.common.WebSocketSession; +import org.eclipse.jetty.websocket.api.Session; + +public final class JettyWebSocketSession implements WebSocketSession { + private final Session jetty; + private final String path; + private final Map query; + private final String id; + private final ConcurrentHashMap attrs = new ConcurrentHashMap<>(); + + private JettyWebSocketSession(Session jetty, String path, Map query) { + this.jetty = jetty; + this.path = path; + this.query = query; + // Make an opaque stable id; Jetty's Session doesn't expose a UUID + this.id = Long.toHexString(System.identityHashCode(jetty)) + "-" + System.nanoTime(); + } + + public static JettyWebSocketSession adapt(Session s, String path, Map query) { + return new JettyWebSocketSession(s, path, query); + } + + @Override + public String id() { + return id; + } + + @Override + public String path() { + return path; + } + + @Override + public Map query() { + return query; + } + + @Override + public void sendText(String text) { + try { + // blocking send; if you prefer async: jetty.getRemote().sendStringByFuture(text).get(); + jetty.getRemote().sendString(text); + } catch (IOException e) { + throw new RuntimeException("sendText failed", e); + } + } + + @Override + public void sendBinary(ByteBuffer buf) { + try { + jetty.getRemote().sendBytes(buf); + } catch (IOException e) { + throw new RuntimeException("sendBinary failed", e); + } + } + + @Override + public void close(int code, String reason) { + jetty.close(code, reason == null ? "" : reason); + } + + @Override + @SuppressWarnings("unchecked") + public T getAttr(String key) { + return (T) attrs.get(key); + } + + @Override + public void setAttr(String key, T val) { + if (val == null) attrs.remove(key); + else attrs.put(key, val); + } +} diff --git a/client/src/main/webapp/WEB-INF/web.xml b/client/src/main/webapp/WEB-INF/web.xml index 43bee7e59d88..5105f6d0ed68 100644 --- a/client/src/main/webapp/WEB-INF/web.xml +++ b/client/src/main/webapp/WEB-INF/web.xml @@ -16,9 +16,9 @@ limitations under the License. --> + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd" + version="2.5"> log4jConfiguration @@ -54,14 +54,25 @@ 6 + + wsServlet + org.apache.cloudstack.websocket.JettyWebSocketServlet + 7 + + + + apiServlet + /api/* + + - apiServlet - /api/* + consoleServlet + /console - consoleServlet - /console + wsServlet + /ws/* diff --git a/core/src/main/resources/META-INF/cloudstack/cluster/spring-core-lifecycle-cluster-context-inheritable.xml b/core/src/main/resources/META-INF/cloudstack/cluster/spring-core-lifecycle-cluster-context-inheritable.xml index 6278c0fc8167..16e7e3df72d9 100644 --- a/core/src/main/resources/META-INF/cloudstack/cluster/spring-core-lifecycle-cluster-context-inheritable.xml +++ b/core/src/main/resources/META-INF/cloudstack/cluster/spring-core-lifecycle-cluster-context-inheritable.xml @@ -28,4 +28,9 @@ + + + + + diff --git a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml index 01c568d78916..7acf935c4eb3 100644 --- a/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml +++ b/core/src/main/resources/META-INF/cloudstack/core/spring-core-registry-core-context.xml @@ -351,6 +351,10 @@ class="org.apache.cloudstack.spring.lifecycle.registry.ExtensionRegistry"> + + + diff --git a/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java b/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java index c64489828033..57374b11e50c 100644 --- a/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java +++ b/engine/orchestration/src/main/java/com/cloud/agent/manager/ClusteredAgentManagerImpl.java @@ -42,7 +42,6 @@ import javax.net.ssl.SSLContext; import javax.net.ssl.SSLEngine; -import com.cloud.resource.ResourceState; import org.apache.cloudstack.ca.CAManager; import org.apache.cloudstack.framework.config.ConfigDepot; import org.apache.cloudstack.framework.config.ConfigKey; @@ -59,6 +58,7 @@ import org.apache.cloudstack.maintenance.command.TriggerShutdownManagementServerHostCommand; import org.apache.cloudstack.managed.context.ManagedContextRunnable; import org.apache.cloudstack.managed.context.ManagedContextTimerTask; +import org.apache.cloudstack.cluster.ClusterCommandProcessor; import org.apache.cloudstack.management.ManagementServerHost; import org.apache.cloudstack.outofbandmanagement.dao.OutOfBandManagementDao; import org.apache.cloudstack.utils.identity.ManagementServerNode; @@ -96,6 +96,7 @@ import com.cloud.host.HostVO; import com.cloud.host.Status; import com.cloud.host.Status.Event; +import com.cloud.resource.ResourceState; import com.cloud.resource.ServerResource; import com.cloud.serializer.GsonHelper; import com.cloud.utils.DateUtil; @@ -162,6 +163,17 @@ protected ClusteredAgentManagerImpl() { protected final ConfigKey ScanInterval = new ConfigKey<>(Integer.class, "direct.agent.scan.interval", "Advanced", "90", "Interval between scans to load direct agents", false, ConfigKey.Scope.Global, 1000); + private List commandProcessors; + + + public List getCommandProcessors() { + return commandProcessors; + } + + public void setCommandProcessors(final List commandProcessors) { + this.commandProcessors = commandProcessors; + } + @Override public boolean configure(final String name, final Map xmlParams) throws ConfigurationException { _peers = new HashMap<>(7); @@ -1325,6 +1337,14 @@ public String dispatch(final ClusterServicePdu pdu) { return handleShutdownManagementServerHostCommand(cmd); } else if (cmds.length == 1 && cmds[0] instanceof ExtensionServerActionBaseCommand) { return extensionsManager.handleExtensionServerCommands((ExtensionServerActionBaseCommand)cmds[0]); + } else if (cmds.length == 1) { + Command command = cmds[0]; + logger.debug("Attempting to find a command processor for command class: {}", command.getClass().getName()); + for (ClusterCommandProcessor commandProcessor : commandProcessors) { + if (commandProcessor.supportsCommand(command.getClass())) { + return commandProcessor.processCommand(command); + } + } } try { diff --git a/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml b/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml index 17c5002c718b..042bc4ae704c 100644 --- a/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml +++ b/engine/orchestration/src/main/resources/META-INF/cloudstack/core/spring-engine-orchestration-core-context.xml @@ -63,7 +63,9 @@ - + + + , String> { + + private static final ObjectMapper mapper = new ObjectMapper(); + + public static String getDatabaseColumnTypeValue(List attribute) { + try { + return attribute == null ? null : mapper.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Error converting list to JSON", e); + } + } + + public static boolean isValidAttribute(List attribute, int length) { + try { + String json = getDatabaseColumnTypeValue(attribute); + return json != null && json.length() <= length; + } catch (IllegalArgumentException e) { + return false; + } + } + + @Override + public String convertToDatabaseColumn(List attribute) { + return getDatabaseColumnTypeValue(attribute); + } + + @Override + public List convertToEntityAttribute(String dbData) { + try { + return dbData == null ? null : mapper.readValue(dbData, List.class); + } catch (IOException e) { + throw new IllegalArgumentException("Error converting JSON to list", e); + } + } +} diff --git a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml index 0656d5e3c440..6565e77b7fb6 100644 --- a/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml +++ b/engine/schema/src/main/resources/META-INF/cloudstack/core/spring-engine-schema-core-daos-context.xml @@ -116,6 +116,7 @@ + diff --git a/engine/schema/src/main/resources/META-INF/db/schema-42200to42300.sql b/engine/schema/src/main/resources/META-INF/db/schema-42200to42300.sql index c1f1bb2c094d..040e2d9ec36e 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-42200to42300.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-42200to42300.sql @@ -18,3 +18,32 @@ --; -- Schema upgrade from 4.22.0.0 to 4.23.0.0 --; + +-- Add management_server_details table to allow ManagementServer scope configs +CREATE TABLE IF NOT EXISTS `cloud`.`management_server_details` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'id', + `management_server_id` bigint unsigned NOT NULL COMMENT 'management server the detail is related to', + `name` varchar(255) NOT NULL COMMENT 'name of the detail', + `value` varchar(255) NOT NULL, + `display` tinyint(1) NOT NULL DEFAULT '1' COMMENT 'True if the detail can be displayed to the end user', + PRIMARY KEY (`id`), + CONSTRAINT `fk_management_server_details__management_server_id` FOREIGN KEY `fk_management_server_details__management_server_id`(`management_server_id`) REFERENCES `mshost`(`id`) ON DELETE CASCADE, + KEY `i_management_server_details__name__value` (`name`(128),`value`(128)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +-- Create table for Logs Web Session +CREATE TABLE IF NOT EXISTS `cloud`.`logs_web_session` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id of the session', + `uuid` varchar(40) NOT NULL COMMENT 'UUID generated for the session', + `filters` varchar(128) DEFAULT NULL COMMENT 'Filter keywords for the session', + `created` datetime NOT NULL COMMENT 'When the session was created', + `domain_id` bigint(20) unsigned NOT NULL COMMENT 'Domain of the account who generated the session', + `account_id` bigint(20) unsigned NOT NULL COMMENT 'Account who generated the session', + `creator_address` VARCHAR(45) DEFAULT NULL COMMENT 'Address of the creator of the session', + `connections` int unsigned NOT NULL DEFAULT 0 COMMENT 'Number of connections for the session', + `connected_time` datetime DEFAULT NULL COMMENT 'When the session was connected', + `client_address` VARCHAR(45) DEFAULT NULL COMMENT 'Address of the client that connected to the session', + `removed` datetime COMMENT 'When the session was removed/used', + PRIMARY KEY(`id`), + CONSTRAINT `uc_logs_web_session__uuid` UNIQUE (`uuid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8; diff --git a/framework/cluster/pom.xml b/framework/cluster/pom.xml index 2dd28e8e628f..d27fc911ffe7 100644 --- a/framework/cluster/pom.xml +++ b/framework/cluster/pom.xml @@ -48,6 +48,11 @@ cloud-api ${project.version} + + org.apache.cloudstack + cloud-engine-schema + ${project.version} + diff --git a/framework/cluster/src/main/java/com/cloud/cluster/ManagementServerHostDetailVO.java b/framework/cluster/src/main/java/com/cloud/cluster/ManagementServerHostDetailVO.java new file mode 100644 index 000000000000..fcaa2a22e341 --- /dev/null +++ b/framework/cluster/src/main/java/com/cloud/cluster/ManagementServerHostDetailVO.java @@ -0,0 +1,87 @@ +// 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.cluster; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.apache.cloudstack.api.ResourceDetail; + +@Entity +@Table(name = "management_server_details") +public class ManagementServerHostDetailVO implements ResourceDetail { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + long id; + + @Column(name = "management_server_id") + long resourceId; + + @Column(name = "name") + String name; + + @Column(name = "value") + String value; + + @Column(name = "display") + private boolean display = true; + + public ManagementServerHostDetailVO(long poolId, String name, String value, boolean display) { + this.resourceId = poolId; + this.name = name; + this.value = value; + this.display = display; + } + + public ManagementServerHostDetailVO() { + } + + @Override + public long getId() { + return id; + } + + @Override + public long getResourceId() { + return resourceId; + } + + @Override + public String getName() { + return name; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public String getValue() { + return value; + } + + @Override + public boolean isDisplay() { + return display; + } +} diff --git a/framework/cluster/src/main/java/com/cloud/cluster/dao/ManagementServerHostDetailsDao.java b/framework/cluster/src/main/java/com/cloud/cluster/dao/ManagementServerHostDetailsDao.java new file mode 100644 index 000000000000..24fd60d21b3c --- /dev/null +++ b/framework/cluster/src/main/java/com/cloud/cluster/dao/ManagementServerHostDetailsDao.java @@ -0,0 +1,26 @@ +// 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.cluster.dao; + +import org.apache.cloudstack.resourcedetail.ResourceDetailsDao; + +import com.cloud.cluster.ManagementServerHostDetailVO; +import com.cloud.utils.db.GenericDao; + +public interface ManagementServerHostDetailsDao extends GenericDao, ResourceDetailsDao { +} diff --git a/framework/cluster/src/main/java/com/cloud/cluster/dao/ManagementServerHostDetailsDaoImpl.java b/framework/cluster/src/main/java/com/cloud/cluster/dao/ManagementServerHostDetailsDaoImpl.java new file mode 100644 index 000000000000..5865bee0926b --- /dev/null +++ b/framework/cluster/src/main/java/com/cloud/cluster/dao/ManagementServerHostDetailsDaoImpl.java @@ -0,0 +1,46 @@ +// 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.cluster.dao; + +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.ScopedConfigStorage; +import org.apache.cloudstack.resourcedetail.ResourceDetailsDaoBase; + +import com.cloud.cluster.ManagementServerHostDetailVO; + +public class ManagementServerHostDetailsDaoImpl extends ResourceDetailsDaoBase implements ManagementServerHostDetailsDao, ScopedConfigStorage { + + public ManagementServerHostDetailsDaoImpl() { + } + + @Override + public ConfigKey.Scope getScope() { + return ConfigKey.Scope.ManagementServer; + } + + @Override + public String getConfigValue(long id, String key) { + ManagementServerHostDetailVO vo = findDetail(id, key); + return vo == null ? null : vo.getValue(); + } + + @Override + public void addDetail(long resourceId, String key, String value, boolean display) { + super.addDetail(new ManagementServerHostDetailVO(resourceId, key, value, display)); + } +} diff --git a/framework/websocket-server/pom.xml b/framework/websocket-server/pom.xml new file mode 100644 index 000000000000..0e06a0806b0f --- /dev/null +++ b/framework/websocket-server/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + cloud-framework-websocket-server + Apache CloudStack Framework - WebSocket Server + + org.apache.cloudstack + cloudstack-framework + 4.23.0.0-SNAPSHOT + ../pom.xml + + + + org.apache.cloudstack + cloud-framework-config + ${project.version} + + + io.netty + netty-transport + ${cs.netty.version} + + + io.netty + netty-codec-http + ${cs.netty.version} + + + diff --git a/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/NettyWebSocketSession.java b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/NettyWebSocketSession.java new file mode 100644 index 000000000000..b2e6ca58567c --- /dev/null +++ b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/NettyWebSocketSession.java @@ -0,0 +1,92 @@ +// 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.framework.websocket.server; + +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; + +import org.apache.cloudstack.framework.websocket.server.common.WebSocketSession; + +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelFutureListener; +import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; +import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.util.AttributeKey; + +final class NettyWebSocketSession implements WebSocketSession { + private final Channel ch; + private final String path; + private final Map query; + + NettyWebSocketSession(Channel ch, String path, Map query) { + this.ch = Objects.requireNonNull(ch, "channel"); + this.path = path == null ? "" : path; + this.query = (query == null) ? Collections.emptyMap() : Collections.unmodifiableMap(query); + } + + @Override + public String id() { + return ch.id().asShortText(); + } + + @Override + public String path() { + return path; + } + + @Override + public Map query() { + return query; + } + + @Override + public void sendText(String text) { + ch.writeAndFlush(new TextWebSocketFrame(text)); + } + + @Override + public void sendBinary(ByteBuffer buf) { + io.netty.buffer.ByteBuf bb = Unpooled.wrappedBuffer(buf); + ch.writeAndFlush(new BinaryWebSocketFrame(bb)); + } + + @Override + public void close(int code, String reason) { + ch.writeAndFlush(new CloseWebSocketFrame(code, reason)) + .addListener(ChannelFutureListener.CLOSE); + } + + @Override + public void setAttr(String key, T val) { + ch.attr(AttributeKey.valueOf(key)).set(val); + } + + @SuppressWarnings("unchecked") + @Override + public T getAttr(String key) { + return (T) ch.attr(AttributeKey.valueOf(key)).get(); + } + + Channel unwrap() { + return ch; + } +} diff --git a/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/WebSocketServer.java b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/WebSocketServer.java new file mode 100644 index 000000000000..5a1f87c6a89a --- /dev/null +++ b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/WebSocketServer.java @@ -0,0 +1,207 @@ +// 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.framework.websocket.server; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.KeyManagerFactory; + +import org.apache.cloudstack.framework.websocket.server.common.WebSocketRouter; +import org.apache.cloudstack.utils.server.ServerPropertiesUtil; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import io.netty.bootstrap.ServerBootstrap; +import io.netty.channel.Channel; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.HttpServerCodec; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolConfig; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.handler.ssl.SslProvider; + +/** + * Netty WebSocket server that delegates routing to WebSocketRouter. + * Replaces the previous helper-based WebSocketServer. + */ +public final class WebSocketServer { + private static final Logger LOG = LogManager.getLogger(WebSocketServer.class); + + private final String host; + private final int port; + private final WebSocketRouter router; + private final String websocketBasePath; + private final boolean sslEnabled; + + private EventLoopGroup bossGroup; + private EventLoopGroup workerGroup; + private Channel serverChannel; + private volatile boolean running; + + public WebSocketServer(int port, WebSocketRouter router, boolean sslEnabled) { + this(null, port, router, null, sslEnabled); + } + + public WebSocketServer(String host, int port, WebSocketRouter router, String websocketBasePath, boolean sslEnabled) { + this.host = StringUtils.isBlank(host) ? "0.0.0.0" : host; + this.port = port; + this.router = router; + this.websocketBasePath = StringUtils.isBlank(websocketBasePath) ? + WebSocketRouter.WEBSOCKET_PATH_PREFIX : websocketBasePath; + this.sslEnabled = sslEnabled; + } + + protected KeyManagerFactory buildKeyManagerFactory(Path storePath, char[] password) throws + KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException { + KeyStore ks = KeyStore.getInstance(detectType(storePath)); + try (InputStream in = Files.newInputStream(storePath)) { + ks.load(in, password); + } + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, password); + return kmf; + } + + private static String detectType(Path p) { + String name = p.getFileName().toString().toLowerCase(); + return (name.endsWith(".p12") || name.endsWith(".pfx")) ? "PKCS12" : "JKS"; + } + + /** + * Creates a Netty SslContext: + * uses only a keystore containing the server's private key and certificate chain. + * + * @param keystoreFile Path to the keystore file (JKS or PKCS12) + * @param keystorePassword Password for both the keystore and key entry + * @return configured Netty SslContext + */ + protected SslContext createServerSslContext(String keystoreFile, String keystorePassword) throws + UnrecoverableKeyException, CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException { + KeyManagerFactory kmf = buildKeyManagerFactory(Path.of(keystoreFile), keystorePassword.toCharArray()); + + // Build Netty SSL context (server mode) – same as Jetty's SSLContextFactory.Server + return SslContextBuilder.forServer(kmf) + .sslProvider(SslProvider.JDK) // Use JDK provider (same as Jetty) + .protocols("TLSv1.3", "TLSv1.2") // Match Jetty default supported protocols + .build(); + } + + protected SslContext createServerSslContextIfNeeded() throws IllegalArgumentException { + if (!sslEnabled) { + return null; + } + String keystoreFile = ServerPropertiesUtil.getProperty(ServerPropertiesUtil.KEY_KEYSTORE_FILE); + String keystorePassword = ServerPropertiesUtil.getProperty(ServerPropertiesUtil.KEY_KEYSTORE_PASSWORD); + if (StringUtils.isBlank(keystoreFile) || StringUtils.isBlank(keystorePassword)) { + throw new IllegalArgumentException("SSL is enabled but keystore file or password is not configured"); + } + if (!Files.exists(Path.of(keystoreFile))) { + throw new IllegalArgumentException(String.format("SSL is enabled but keystore file does not exist: %s", + keystoreFile)); + } + try { + return createServerSslContext(keystoreFile, keystorePassword); + } catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException | + UnrecoverableKeyException e) { + throw new IllegalArgumentException(String.format( + "SSL is enabled but unable to create SSL context with configured keystore: %s", keystoreFile), + e); + } + } + + public void start() throws InterruptedException { + bossGroup = new NioEventLoopGroup(1); + workerGroup = new NioEventLoopGroup(); + + final SslContext nettySslCtx = createServerSslContextIfNeeded(); + + final WebSocketServerProtocolConfig wsCfg = WebSocketServerProtocolConfig.newBuilder() + .websocketPath(websocketBasePath) + .checkStartsWith(true) + .allowExtensions(false).handshakeTimeoutMillis(10_000).build(); + + ServerBootstrap b = new ServerBootstrap().group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childOption(ChannelOption.TCP_NODELAY, true) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(SocketChannel ch) { + ChannelPipeline p = ch.pipeline(); + if (nettySslCtx != null) { + p.addLast("ssl", nettySslCtx.newHandler(ch.alloc())); + } + p.addLast(new HttpServerCodec()); + p.addLast(new HttpObjectAggregator(65536)); + p.addLast(new WebSocketServerProtocolHandler(wsCfg)); + p.addLast(new WebSocketServerRoutingHandler(router)); + } + }); + + serverChannel = b.bind(host, port).sync().channel(); + running = true; + LOG.info("WebSocketServer listening on {}:{} (base path: {}, router={})", host, port, + websocketBasePath, router); + } + + public void stop(long maxWaitSeconds) { + try { + if (serverChannel != null) { + serverChannel.close().sync(); + } + if (bossGroup != null) { + bossGroup.shutdownGracefully(0, maxWaitSeconds, TimeUnit.SECONDS).sync(); + } + if (workerGroup != null) { + workerGroup.shutdownGracefully(0, maxWaitSeconds, TimeUnit.SECONDS).sync(); + } + } catch (InterruptedException e) { + LOG.warn("Graceful stop interrupted; forcing shutdown", e); + if (bossGroup != null) bossGroup.shutdownGracefully(0, 0, TimeUnit.SECONDS); + if (workerGroup != null) workerGroup.shutdownGracefully(0, 0, TimeUnit.SECONDS); + } finally { + running = false; + LOG.info("WebSocketServer stopped"); + } + } + + public boolean isRunning() { + return running; + } + + public int getPort() { + return port; + } +} diff --git a/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/WebSocketServerRoutingHandler.java b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/WebSocketServerRoutingHandler.java new file mode 100644 index 000000000000..8aec3070fbec --- /dev/null +++ b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/WebSocketServerRoutingHandler.java @@ -0,0 +1,174 @@ +// 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.framework.websocket.server; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.apache.cloudstack.framework.websocket.server.common.WebSocketHandler; +import org.apache.cloudstack.framework.websocket.server.common.WebSocketRouter; +import org.apache.cloudstack.framework.websocket.server.common.WebSocketSession; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; +import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; +import io.netty.handler.codec.http.websocketx.PingWebSocketFrame; +import io.netty.handler.codec.http.websocketx.PongWebSocketFrame; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import io.netty.handler.timeout.IdleStateHandler; + +public class WebSocketServerRoutingHandler extends SimpleChannelInboundHandler { + protected static Logger LOGGER = LogManager.getLogger(WebSocketServerRoutingHandler.class); + + + private final WebSocketRouter router; + private WebSocketHandler handler; + private WebSocketSession session; + + public WebSocketServerRoutingHandler(WebSocketRouter router) { + this.router = router; + } + + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { + if (evt instanceof WebSocketServerProtocolHandler.HandshakeComplete) { + WebSocketServerProtocolHandler.HandshakeComplete hc = + (WebSocketServerProtocolHandler.HandshakeComplete) evt; + + final String requestUri = hc.requestUri(); + final URI uri = URI.create(requestUri); + final String rawQuery = uri.getQuery(); + String path = uri.getPath(); + LOGGER.trace("WebSocket connection for path: {}, query: {}", path, rawQuery); + + path = WebSocketRouter.stripWebSocketPathPrefix(uri.getPath()); + + WebSocketRouter.ResolvedRoute rr = router.resolve(path); + if (rr == null || rr.getHandler() == null) { + ctx.close(); + return; + } + handler = rr.getHandler(); + + long idleMs = (rr.getConfig() != null) ? rr.getConfig().getIdleTimeoutMillis() : 0L; + if (idleMs > 0) { + ctx.pipeline().addBefore(ctx.name(), "ws-idle", + new IdleStateHandler(0, 0, (int) TimeUnit.MILLISECONDS.toSeconds(idleMs))); + } + + session = new NettyWebSocketSession(ctx.channel(), path, QueryUtils.parse(rawQuery)); + session.setAttr(WebSocketSession.ATTR_REMOTE_ADDR, String.valueOf(ctx.channel().remoteAddress())); + + try { + handler.onOpen(session); + } catch (Throwable t) { + try { + session.close(1011, "Open failed"); + } catch (Throwable ignore) { + } + ctx.close(); + } + } + + super.userEventTriggered(ctx, evt); + } + + @Override + protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) { + if (handler == null || session == null) { + frame.release(); + return; + } + try { + if (frame instanceof TextWebSocketFrame) { + handler.onTextMessage(session, ((TextWebSocketFrame) frame).text()); + } else if (frame instanceof BinaryWebSocketFrame) { + ByteBuffer buf = frame.content().nioBuffer(); + handler.onBinaryMessage(session, buf); + } else if (frame instanceof CloseWebSocketFrame) { + CloseWebSocketFrame c = (CloseWebSocketFrame) frame.retain(); + handler.onClose(session, c.statusCode(), c.reasonText()); + ctx.close(); + } else if (frame instanceof PingWebSocketFrame) { + ctx.writeAndFlush(new PongWebSocketFrame(frame.content().retain())); + } + } catch (Throwable t) { + try { + handler.onError(session, t); + } catch (Throwable ignore) { + } + ctx.close(); + } finally { + frame.release(); + } + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + LOGGER.debug("Channel inactive, closing session"); + if (handler != null && session != null) { + try { + handler.onClose(session, 1006, "Channel inactive"); + } catch (Throwable ignore) { + } + } + super.channelInactive(ctx); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + if (handler != null && session != null) { + try { + handler.onError(session, cause); + } catch (Throwable ignore) { + } + } + ctx.close(); + } + + // tiny query parser + static final class QueryUtils { + static Map parse(String q) { + if (q == null || q.isEmpty()) return java.util.Collections.emptyMap(); + java.util.Map m = new java.util.HashMap<>(); + for (String kv : q.split("&")) { + int i = kv.indexOf('='); + String k = i >= 0 ? kv.substring(0, i) : kv; + String v = i >= 0 ? kv.substring(i + 1) : ""; + m.put(urlDecode(k), urlDecode(v)); + } + return m; + } + + static String urlDecode(String s) { + try { + return java.net.URLDecoder.decode(s, StandardCharsets.UTF_8); + } catch (Exception e) { + return s; + } + } + } +} diff --git a/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/common/WebSocketHandler.java b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/common/WebSocketHandler.java new file mode 100644 index 000000000000..1ecde8841090 --- /dev/null +++ b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/common/WebSocketHandler.java @@ -0,0 +1,32 @@ +// 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.framework.websocket.server.common; + +import java.nio.ByteBuffer; + +public interface WebSocketHandler { + void onOpen(WebSocketSession s); + + void onTextMessage(WebSocketSession s, String text); + + void onBinaryMessage(WebSocketSession s, ByteBuffer bin); + + void onClose(WebSocketSession s, int code, String reason); + + void onError(WebSocketSession s, Throwable t); +} diff --git a/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/common/WebSocketRouter.java b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/common/WebSocketRouter.java new file mode 100644 index 000000000000..6ab4f7fa61ff --- /dev/null +++ b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/common/WebSocketRouter.java @@ -0,0 +1,296 @@ +/* + * 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.framework.websocket.server.common; + +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.apache.commons.lang3.StringUtils; + +/** + * Dynamic WebSocket router supporting exact, prefix, and regex routes. + * + *

Design goals: + *

    + *
  • Fast, lock-free route resolution on the hot path
  • + *
  • Safe concurrent updates (register/unregister at runtime)
  • + *
  • Per-route configuration (e.g., idle timeout)
  • + *
  • Transport-agnostic (works with Jetty JSR-356 and Netty)
  • + *
+ * + * Typical usage: + *
+ *   WebSocketRouter router = new WebSocketRouter();
+ *   router.registerExact("/echo", echoHandler(), RouteConfig.ofSeconds(120));
+ *   router.registerPrefix("/logger/", logsHandler(), RouteConfig.ofSeconds(120));
+ *   router.registerRegex("^/chat/[^/]+$", chatHandler(), RouteConfig.ofSeconds(300));
+ *
+ *   ResolvedRoute rr = router.resolve("/logger/token");
+ *   if (rr != null) {
+ *     rr.handler().onOpen(...); // in your server binding (Jetty/Netty)
+ *   }
+ * 
+ */ +public final class WebSocketRouter { + public static final String WEBSOCKET_PATH_PREFIX = "/ws"; + + public static final class RouteConfig { + private final long idleTimeoutMillis; + + private RouteConfig(long idleTimeoutMillis) { + this.idleTimeoutMillis = idleTimeoutMillis; + } + + public long getIdleTimeoutMillis() { + return idleTimeoutMillis; + } + + public static RouteConfig ofMillis(long millis) { + return new RouteConfig(millis); + } + + public static RouteConfig ofSeconds(long seconds) { + return new RouteConfig(TimeUnit.SECONDS.toMillis(seconds)); + } + + @Override public String toString() { + return "RouteConfig{idileTimeoutMillis=" + idleTimeoutMillis + "}"; + } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof RouteConfig)) return false; + RouteConfig that = (RouteConfig) o; + return idleTimeoutMillis == that.idleTimeoutMillis; + } + + @Override public int hashCode() { + return Long.hashCode(idleTimeoutMillis); + } + } + + /** Result of resolving a path. */ + public static final class ResolvedRoute { + private final WebSocketHandler handler; + private final RouteConfig config; + private final String matchedKind; + private final String matchedKey; + + public ResolvedRoute(WebSocketHandler handler, RouteConfig config, String matchedKind, String matchedKey) { + this.handler = Objects.requireNonNull(handler, "handler"); + this.config = config; // may be null + this.matchedKind = matchedKind; + this.matchedKey = matchedKey; + } + + public WebSocketHandler getHandler() { return handler; } + public RouteConfig getConfig() { return config; } + public String getMatchedKind() { return matchedKind; } + public String getMatchedKey() { return matchedKey; } + } + + // ---- Internal state ----------------------------------------------------- + + private static final class Entry { + final String kind; // "exact" | "prefix" | "regex" + final String key; // for exact/prefix + final Pattern pattern; // for regex + final WebSocketHandler handler; + final RouteConfig config; + + Entry(String kind, String key, WebSocketHandler h, RouteConfig c) { + this.kind = kind; + this.key = key; + this.pattern = null; + this.handler = Objects.requireNonNull(h, "handler"); + this.config = (c == null) ? RouteConfig.ofSeconds(120) : c; + } + + Entry(Pattern p, WebSocketHandler h, RouteConfig c) { + this.kind = "regex"; + this.key = null; + this.pattern = Objects.requireNonNull(p, "pattern"); + this.handler = Objects.requireNonNull(h, "handler"); + this.config = (c == null) ? RouteConfig.ofSeconds(120) : c; + } + } + + /** Exact path routes: O(1) lookup. */ + private final ConcurrentHashMap exact = new ConcurrentHashMap<>(); + + /** Prefix routes: checked longest-first for specificity. */ + private final CopyOnWriteArrayList prefixes = new CopyOnWriteArrayList<>(); + + /** Regex routes: checked in registration order (keep few for perf). */ + private final CopyOnWriteArrayList regexes = new CopyOnWriteArrayList<>(); + + // --- Registration API ---------------------------------------------------- + + /** Register an exact route, e.g. "/echo". */ + public void registerExact(String path, WebSocketHandler handler, RouteConfig config) { + String norm = normalizeExact(path); + exact.put(norm, new Entry("exact", norm, handler, config)); + } + + /** Overload with seconds. */ + public void registerExact(String path, WebSocketHandler handler, long idleTimeoutSeconds) { + registerExact(path, handler, RouteConfig.ofSeconds(idleTimeoutSeconds)); + } + + /** + * Register a prefix route, e.g. "/logger/" which matches "/logger/**". + * The router keeps prefixes sorted by descending length so the most specific wins. + */ + public void registerPrefix(String basePath, WebSocketHandler handler, RouteConfig config) { + String norm = normalizePrefix(basePath); + prefixes.add(new Entry("prefix", norm, handler, config)); + // Keep most specific first (longest path first). COW list sorting is safe for concurrent readers. + prefixes.sort((a, b) -> Integer.compare(b.key.length(), a.key.length())); + } + + /** Overload with seconds. */ + public void registerPrefix(String basePath, WebSocketHandler handler, long idleTimeoutSeconds) { + registerPrefix(basePath, handler, RouteConfig.ofSeconds(idleTimeoutSeconds)); + } + + /** Register a regex route; use sparingly for performance. */ + public void registerRegex(String regex, WebSocketHandler handler, RouteConfig config) { + Pattern p = Pattern.compile(Objects.requireNonNull(regex, "regex")); + regexes.add(new Entry(p, handler, config)); + } + + /** Overload with seconds. */ + public void registerRegex(String regex, WebSocketHandler handler, long idleTimeoutSeconds) { + registerRegex(regex, handler, RouteConfig.ofSeconds(idleTimeoutSeconds)); + } + + /** + * Unregister a route by its key: + *
    + *
  • Exact: pass the exact path you used (e.g., "/echo")
  • + *
  • Prefix: pass the normalized base (e.g., "/logger/")
  • + *
  • Regex: pass the original pattern string
  • + *
+ */ + public void unregister(String keyOrPattern) { + if (keyOrPattern == null) return; + String exactKey = normalizeExactOrNull(keyOrPattern); + if (exactKey != null) exact.remove(exactKey); + + String prefixKey = normalizePrefixOrNull(keyOrPattern); + if (prefixKey != null) prefixes.removeIf(e -> prefixKey.equals(e.key)); + + regexes.removeIf(e -> e.pattern != null && keyOrPattern.equals(e.pattern.pattern())); + } + + /** Clear all routes (useful in tests or module resets). */ + public void clear() { + exact.clear(); + prefixes.clear(); + regexes.clear(); + } + + // --- Resolution ---------------------------------------------------------- + + /** + * Resolve a request path to a handler. + * Order: exact → longest prefix → first regex match. + * Returns {@code null} if no match. + */ + public ResolvedRoute resolve(String requestPath) { + if (requestPath == null || requestPath.isEmpty()) return null; + String p = ensureLeadingSlash(requestPath); + // Exact + Entry e = exact.get(p); + if (e != null) return new ResolvedRoute(e.handler, e.config, e.kind, e.key); + + // Prefix (already sorted longest-first) + for (Entry pe : prefixes) { + if (p.startsWith(pe.key)) { + return new ResolvedRoute(pe.handler, pe.config, pe.kind, pe.key); + } + } + + // Regex (keep few for perf) + for (Entry re : regexes) { + if (re.pattern.matcher(p).matches()) { + return new ResolvedRoute(re.handler, re.config, re.kind, re.pattern.pattern()); + } + } + + return null; + } + + // --- Introspection (optional helpers) ----------------------------------- + + /** Returns a snapshot of currently registered exact routes. */ + public List listExactRoutes() { return List.copyOf(exact.keySet()); } + + /** Returns a snapshot of currently registered prefix routes (normalized). */ + public List listPrefixRoutes() { + return prefixes.stream().map(e -> e.key).collect(Collectors.toList()); + } + + /** Returns a snapshot of currently registered regex patterns. */ + public List listRegexRoutes() { + return regexes.stream().map(e -> Objects.requireNonNull(e.pattern).pattern()).collect(Collectors.toList()); + } + + // --- Normalization helpers ---------------------------------------------- + + private static String normalizeExact(String path) { + String p = ensureLeadingSlash(Objects.requireNonNull(path, "path")); + // never allow trailing slash normalization for exact; treat "/a" and "/a/" as different on purpose + return p; + } + + private static String normalizePrefix(String base) { + String p = ensureLeadingSlash(Objects.requireNonNull(base, "basePath")); + // guarantee trailing slash for prefix semantics + return p.endsWith("/") ? p : p + "/"; + } + + private static String normalizeExactOrNull(String maybe) { + if (maybe == null || maybe.isEmpty()) return null; + if (maybe.endsWith("/*")) return null; // looks like a path spec, not exact + return ensureLeadingSlash(maybe); + } + + private static String normalizePrefixOrNull(String maybe) { + if (maybe == null || maybe.isEmpty()) return null; + String p = ensureLeadingSlash(maybe); + // treat strings ending with "/" as prefix keys + return p.endsWith("/") ? p : null; + } + + public static String ensureLeadingSlash(String p) { + return (p.charAt(0) == '/') ? p : ("/" + p); + } + + public static String stripWebSocketPathPrefix(String path) { + if (StringUtils.isNotBlank(path) && path.startsWith(WebSocketRouter.WEBSOCKET_PATH_PREFIX)) { + return path.replaceFirst(WebSocketRouter.WEBSOCKET_PATH_PREFIX, ""); + } + return path; + } +} diff --git a/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/common/WebSocketSession.java b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/common/WebSocketSession.java new file mode 100644 index 000000000000..c42f8e7dd9ef --- /dev/null +++ b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/common/WebSocketSession.java @@ -0,0 +1,41 @@ +// 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.framework.websocket.server.common; + +import java.nio.ByteBuffer; +import java.util.Map; + +public interface WebSocketSession { + String ATTR_REMOTE_ADDR = "remoteAddress"; + + String id(); + + String path(); + + Map query(); + + void sendText(String text); + + void sendBinary(ByteBuffer buf); + + void close(int code, String reason); + + void setAttr(String key, T val); + + T getAttr(String key); +} diff --git a/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/manager/WebSocketServerManager.java b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/manager/WebSocketServerManager.java new file mode 100644 index 000000000000..40535e3c2452 --- /dev/null +++ b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/manager/WebSocketServerManager.java @@ -0,0 +1,43 @@ +// 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.framework.websocket.server.manager; + +import org.apache.cloudstack.framework.websocket.server.common.WebSocketHandler; + +public interface WebSocketServerManager { + boolean isServerEnabled(); + + int getServerPort(); + + String getWebSocketBasePath(); + + boolean isServerSslEnabled(); + + /** + * Register a route: + * - If pathSpec ends with "/", it's treated as a PREFIX (e.g., "/logger/..." matches). + * - If pathSpec looks like a regex (e.g., starts with '^' or contains ".*", "[", "(", "|"), it's REGEX. + * - Otherwise it's an EXACT path (e.g., "/echo"). + */ + void registerRoute(String pathSpec, WebSocketHandler handler, long idleTimeoutSeconds); + + /** + * Unregister the same key you used to register (exact path, normalized prefix with '/', or regex string). + */ + void unregisterRoute(String pathSpec); +} diff --git a/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/manager/WebSocketServerManagerImpl.java b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/manager/WebSocketServerManagerImpl.java new file mode 100644 index 000000000000..708ec0b42900 --- /dev/null +++ b/framework/websocket-server/src/main/java/org/apache/cloudstack/framework/websocket/server/manager/WebSocketServerManagerImpl.java @@ -0,0 +1,190 @@ +// 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.framework.websocket.server.manager; + +import javax.inject.Inject; + +import org.apache.cloudstack.framework.websocket.server.WebSocketServer; +import org.apache.cloudstack.framework.websocket.server.common.WebSocketHandler; +import org.apache.cloudstack.framework.websocket.server.common.WebSocketRouter; +import org.apache.cloudstack.utils.server.ServerPropertiesUtil; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.utils.Pair; +import com.cloud.utils.component.ManagerBase; + +public class WebSocketServerManagerImpl extends ManagerBase implements WebSocketServerManager { + + @Inject + WebSocketRouter webSocketRouter; + + protected enum ServerMode { + STANDALONE, + EMBEDDED + } + + private boolean serverEnabled = false; + private boolean serverSslEnabled = false; + private int serverPort; + private ServerMode serverMode = ServerMode.STANDALONE; + private WebSocketServer webSocketServer; + + protected boolean isServerRunning() { + return webSocketServer != null && webSocketServer.isRunning(); + } + + protected void startWebSocketServer() { + if (!serverEnabled) { + return; + } + if (isServerRunning()) { + logger.info("WebSocket Server is already running on port {}!", webSocketServer.getPort()); + return; + } + if (!ServerMode.STANDALONE.equals(serverMode)) { + logger.info("Standalone WebSocket Server not started as it is not configured!"); + return; + } + webSocketServer = new WebSocketServer(serverPort, webSocketRouter, serverSslEnabled); + try { + webSocketServer.start(); + } catch (IllegalArgumentException | InterruptedException e) { + logger.error("Failed to start WebSocket Server", e); + webSocketServer = null; + } + } + + protected void stopWebSocketServer(Integer maxWaitSeconds) { + if (webSocketServer == null || !webSocketServer.isRunning()) { + logger.info("WebSocket Server is already stopped!"); + return; + } + webSocketServer.stop(maxWaitSeconds == null ? 5 : maxWaitSeconds); + webSocketServer = null; + } + + protected void initializeServerModeAndPort() { + if (!serverEnabled) { + return; + } + final String webSocketServerPort = ServerPropertiesUtil.getProperty(ServerPropertiesUtil.KEY_WEBSOCKET_PORT); + final Pair mainServerModeAndPort = ServerPropertiesUtil.getServerModeAndPort(); + serverSslEnabled = mainServerModeAndPort.first(); + final int mainServerPort = mainServerModeAndPort.second(); + if (StringUtils.isBlank(webSocketServerPort)) { + logger.info("WebSocket Server port is not configured, WebSocket Server will not be started!"); + serverPort = mainServerPort; + serverMode = ServerMode.EMBEDDED; + return; + } + try { + serverPort = Integer.parseInt(webSocketServerPort); + if (serverPort == mainServerPort) { + logger.info("WebSocket Server port {} is same as main server port {}, " + + "standalone WebSocket Server will not be started!", serverPort, mainServerPort); + serverMode = ServerMode.EMBEDDED; + } + } catch (NumberFormatException nfe) { + logger.error( + "WebSocket Server port is not a valid number: {}, WebSocket Server will not be started!", + webSocketServerPort, nfe); + serverEnabled = false; + } + } + + protected static boolean looksRegex(String s) { + // starts with ^ or ends with $ is a strong hint + if (s.startsWith("^") || s.endsWith("$")) return true; + // common meta characters + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '.' || c == '*' || c == '[' || c == ']' || c == '(' || c == ')' || c == '|' || c == '\\') { + return true; + } + } + return false; + } + + @Override + public boolean isServerEnabled() { + return serverEnabled; + } + + @Override + public int getServerPort() { + return serverPort; + } + + @Override + public String getWebSocketBasePath() { + StringBuilder sb = new StringBuilder(WebSocketRouter.WEBSOCKET_PATH_PREFIX); + if (!ServerMode.STANDALONE.equals(serverMode)) { + String contextPath = ServerPropertiesUtil.getProperty("context.path", "/client").trim(); + sb.insert(0, contextPath); + } + return sb.toString(); + } + + @Override + public boolean isServerSslEnabled() { + return serverSslEnabled; + } + + @Override + public void registerRoute(String pathSpec, WebSocketHandler handler, long idleTimeoutSeconds) { + if (pathSpec == null || pathSpec.isEmpty()) { + throw new IllegalArgumentException("pathSpec must not be empty"); + } + final String norm = WebSocketRouter.ensureLeadingSlash(pathSpec); + + if (looksRegex(norm)) { + webSocketRouter.registerRegex(norm, handler, idleTimeoutSeconds); + logger.info("Registered REGEX route: {} (idle={}s)", norm, idleTimeoutSeconds); + } else if (norm.endsWith("/")) { + webSocketRouter.registerPrefix(norm, handler, idleTimeoutSeconds); + logger.info("Registered PREFIX route: {} (idle={}s)", norm, idleTimeoutSeconds); + } else { + webSocketRouter.registerExact(norm, handler, idleTimeoutSeconds); + logger.info("Registered EXACT route: {} (idle={}s)", norm, idleTimeoutSeconds); + } + } + + @Override + public void unregisterRoute(String pathSpec) { + if (pathSpec == null || pathSpec.isEmpty()) return; + final String key = WebSocketRouter.ensureLeadingSlash(pathSpec); + webSocketRouter.unregister(key); + logger.info("Unregistered route: {}", key); + } + + @Override + public boolean start() { + super.start(); + serverEnabled = Boolean.parseBoolean(ServerPropertiesUtil.getProperty( + ServerPropertiesUtil.KEY_WEBSOCKET_ENABLE, "false")); + initializeServerModeAndPort(); + startWebSocketServer(); + return true; + } + + @Override + public boolean stop() { + stopWebSocketServer(1); + return true; + } +} diff --git a/framework/websocket-server/src/main/resources/META-INF/cloudstack/core/spring-framework-websocket-server-core-context.xml b/framework/websocket-server/src/main/resources/META-INF/cloudstack/core/spring-framework-websocket-server-core-context.xml new file mode 100644 index 000000000000..e6f1bd71bb48 --- /dev/null +++ b/framework/websocket-server/src/main/resources/META-INF/cloudstack/core/spring-framework-websocket-server-core-context.xml @@ -0,0 +1,29 @@ + + + + + + diff --git a/plugins/logs-web-server/pom.xml b/plugins/logs-web-server/pom.xml new file mode 100644 index 000000000000..c29e864a43a3 --- /dev/null +++ b/plugins/logs-web-server/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + cloud-plugin-logs-web-server + Apache CloudStack Plugin - Logs Web Server + + org.apache.cloudstack + cloudstack-plugins + 4.23.0.0-SNAPSHOT + ../pom.xml + + + + org.apache.cloudstack + cloud-api + ${project.version} + + + io.netty + netty-all + ${cs.netty.version} + + + org.apache.cloudstack + cloud-framework-websocket-server + ${project.version} + + + diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSession.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSession.java new file mode 100644 index 000000000000..48754023b77a --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSession.java @@ -0,0 +1,39 @@ +// 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.logsws; + +import java.util.Date; +import java.util.List; + +import org.apache.cloudstack.acl.ControlledEntity; +import org.apache.cloudstack.api.Identity; +import org.apache.cloudstack.api.InternalIdentity; + +public interface LogsWebSession extends ControlledEntity, Identity, InternalIdentity { + int MAX_FILTERS_LENGTH = 128; + + long getId(); + List getFilters(); + long getDomainId(); + long getAccountId(); + int getConnections(); + Date getConnectedTime(); + String getCreatorAddress(); + String getClientAddress(); + Date getCreated(); +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionApiService.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionApiService.java new file mode 100644 index 000000000000..ecdc6f116190 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionApiService.java @@ -0,0 +1,34 @@ +// 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.logsws; + +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.logsws.api.command.admin.CreateLogsWebSessionCmd; +import org.apache.cloudstack.logsws.api.command.admin.DeleteLogsWebSession; +import org.apache.cloudstack.logsws.api.command.admin.ListLogsWebSessionsCmd; +import org.apache.cloudstack.logsws.api.response.LogsWebSessionResponse; + +import com.cloud.utils.component.PluggableService; +import com.cloud.utils.exception.CloudRuntimeException; + +public interface LogsWebSessionApiService extends PluggableService { + + ListResponse listLogsWebSessions(ListLogsWebSessionsCmd cmd); + LogsWebSessionResponse createLogsWebSession(CreateLogsWebSessionCmd cmd) throws CloudRuntimeException; + boolean deleteLogsWebSession(DeleteLogsWebSession cmd) throws CloudRuntimeException; +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionApiServiceImpl.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionApiServiceImpl.java new file mode 100644 index 000000000000..64bddfc54159 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionApiServiceImpl.java @@ -0,0 +1,239 @@ +// 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.logsws; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import javax.inject.Inject; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.logsws.api.command.admin.CreateLogsWebSessionCmd; +import org.apache.cloudstack.logsws.api.command.admin.DeleteLogsWebSession; +import org.apache.cloudstack.logsws.api.command.admin.ListLogsWebSessionsCmd; +import org.apache.cloudstack.logsws.api.response.LogsWebSessionResponse; +import org.apache.cloudstack.logsws.api.response.LogsWebSessionWebSocketResponse; +import org.apache.cloudstack.logsws.dao.LogsWebSessionDao; +import org.apache.cloudstack.logsws.vo.LogsWebSessionVO; +import org.apache.cloudstack.util.StringListJsonConverter; +import org.apache.cloudstack.utils.identity.ManagementServerNode; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.api.ApiServlet; +import com.cloud.domain.Domain; +import com.cloud.exception.InternalErrorException; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.PermissionDeniedException; +import com.cloud.user.Account; +import com.cloud.user.AccountService; +import com.cloud.user.DomainService; +import com.cloud.utils.Pair; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.db.Filter; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.utils.db.Transaction; +import com.cloud.utils.db.TransactionCallbackWithException; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.net.NetUtils; + +public class LogsWebSessionApiServiceImpl extends ManagerBase implements LogsWebSessionApiService { + + @Inject + LogsWebSessionManager logsWSManager; + @Inject + LogsWebSessionDao logsWebSessionDao; + @Inject + AccountService accountService; + @Inject + DomainService domainService; + + @Override + public ListResponse listLogsWebSessions(ListLogsWebSessionsCmd cmd) { + final Long id = cmd.getId(); + if (!accountService.isRootAdmin(CallContext.current().getCallingAccountId())) { + throw new PermissionDeniedException("Invalid request"); + } + List responsesList = new ArrayList<>(); + SearchBuilder sb = logsWebSessionDao.createSearchBuilder(); + sb.and("id", sb.entity().getId(), SearchCriteria.Op.EQ); + SearchCriteria sc = sb.create(); + if (id != null) { + sc.setParameters("id", id); + } + + Filter searchFilter = new Filter(LogsWebSessionVO.class, "id", true, cmd.getStartIndex(), + cmd.getPageSizeVal()); + Pair, Integer> logsWebSessionsAndCount = logsWebSessionDao.searchAndCount(sc, searchFilter); + for (LogsWebSessionVO session : logsWebSessionsAndCount.first()) { + try { + LogsWebSessionResponse response = createLogsWebSessionResponse(session); + responsesList.add(response); + } catch (InternalErrorException exception) { + logger.error("Failed to create response for {}", session, exception); + } + } + ListResponse response = new ListResponse<>(); + response.setResponses(responsesList, logsWebSessionsAndCount.second()); + return response; + } + + @Override + public LogsWebSessionResponse createLogsWebSession(CreateLogsWebSessionCmd cmd) throws CloudRuntimeException { + final Account caller = CallContext.current().getCallingAccount(); + final List filters = cmd.getFilters(); + final Map params = cmd.getFullUrlParams(); + String clientAddress; + if (MapUtils.isNotEmpty(params)) { + clientAddress = params.get(ApiServlet.CLIENT_INET_ADDRESS_KEY); + } else { + clientAddress = null; + } + if (!accountService.isRootAdmin(caller.getAccountId())) { + throw new PermissionDeniedException("Invalid request"); + } + if (CollectionUtils.isNotEmpty(filters)) { + for (String filter : filters) { + if (StringUtils.isBlank(filter)) { + throw new InvalidParameterValueException(String.format("Invalid value for parameter - %s", + ApiConstants.FILTERS)); + } + } + if (!StringListJsonConverter.isValidAttribute(filters, LogsWebSession.MAX_FILTERS_LENGTH)) { + throw new InvalidParameterValueException("Combined filters length too long"); + } + } + + if (!logsWSManager.canCreateNewLogsWebSession()) { + throw new CloudRuntimeException("Failed to create Logs Web Session as max session limit reached"); + } + try { + return Transaction.execute((TransactionCallbackWithException) status -> { + LogsWebSessionVO logsWebSessionVO = new LogsWebSessionVO(filters, caller.getDomainId(), caller.getAccountId(), + clientAddress); + logsWebSessionVO = logsWebSessionDao.persist(logsWebSessionVO); + return createLogsWebSessionResponse(logsWebSessionVO); + }); + } catch (InternalErrorException e) { + throw new CloudRuntimeException("Failed to create Logs Web Session as unable to prepare response", e); + } + } + + @Override + public boolean deleteLogsWebSession(DeleteLogsWebSession cmd) throws CloudRuntimeException { + final long id = cmd.getId(); + if (!accountService.isRootAdmin(CallContext.current().getCallingAccountId())) { + throw new PermissionDeniedException("Invalid request"); + } + return logsWebSessionDao.remove(id); + } + + protected String getRealIp4Address() { + try { + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface iface = interfaces.nextElement(); + if (iface.isLoopback() || iface.isPointToPoint() || !iface.isUp()) continue; + + for (InterfaceAddress addr : iface.getInterfaceAddresses()) { + InetAddress inetAddr = addr.getAddress(); + if (inetAddr instanceof Inet4Address && !inetAddr.isLoopbackAddress()) { + return inetAddr.getHostAddress(); + } + } + } + } catch (SocketException ignored) {} + return null; + } + + protected Set getLogsWebSessionWebSocketResponses( + final LogsWebSessionVO logsWebSessionVO) throws InternalErrorException { + Set responses = new HashSet<>(); + List webSockets = logsWSManager.getLogsWebSessionWebSockets(logsWebSessionVO); + if (CollectionUtils.isEmpty(webSockets)) { + throw new CloudRuntimeException(String.format("Failed to get websocket endpoints for Logs Web Session %s", + logsWebSessionVO.getUuid())); + } + for (LogsWebSessionWebSocket socket : webSockets) { + LogsWebSessionWebSocketResponse webSocketResponse = new LogsWebSessionWebSocketResponse(); + webSocketResponse.setManagementServerId(socket.getManagementServerHost().getUuid()); + webSocketResponse.setManagementServerName(socket.getManagementServerHost().getName()); + if (LogsWebSessionManager.LogsWebServerDirectConnect.value()) { + String serviceIp = socket.getManagementServerHost().getServiceIP(); + if (ManagementServerNode.getManagementServerId() == socket.getManagementServerHost().getMsid() && + NetUtils.isLocalAddress(serviceIp)) { + String realIp = getRealIp4Address(); + if (realIp != null) { + serviceIp = realIp; + } + } + webSocketResponse.setHost(serviceIp); + } + webSocketResponse.setPort(socket.getPort()); + webSocketResponse.setPath(socket.getPath()); + webSocketResponse.setSsl(socket.isSsl()); + responses.add(webSocketResponse); + } + return responses; + } + + protected LogsWebSessionResponse createLogsWebSessionResponse(final LogsWebSessionVO logsWebSessionVO) throws InternalErrorException { + LogsWebSessionResponse response = new LogsWebSessionResponse(); + response.setObjectName("logswebsession"); + response.setId(logsWebSessionVO.getUuid()); + response.setFilters(logsWebSessionVO.getFilters()); + Account account = accountService.getAccount(logsWebSessionVO.getAccountId()); + response.setAccountName(account.getAccountName()); + Domain domain = domainService.getDomain(logsWebSessionVO.getDomainId()); + response.setDomainId(domain.getUuid()); + response.setDomainName(domain.getName()); + response.setDomainPath(domain.getName()); + response.setCreatorAddress(logsWebSessionVO.getCreatorAddress()); + response.setConnected(logsWebSessionVO.getConnections()); + response.setClientAddress(logsWebSessionVO.getClientAddress()); + response.setCreated(logsWebSessionVO.getCreated()); + response.setWebsocketResponse(getLogsWebSessionWebSocketResponses(logsWebSessionVO)); + return response; + } + + @Override + public List> getCommands() { + if (!LogsWebSessionManager.LogsWebServerEnabled.value()) { + return Collections.emptyList(); + } + return List.of( + CreateLogsWebSessionCmd.class, + ListLogsWebSessionsCmd.class, + DeleteLogsWebSession.class + ); + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionManager.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionManager.java new file mode 100644 index 000000000000..3cc7519ce0e2 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionManager.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.logsws; + +import java.util.List; + +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.config.Configurable; + +import com.cloud.exception.InternalErrorException; +import com.cloud.utils.component.PluggableService; + +public interface LogsWebSessionManager extends PluggableService, Configurable { + String WS_PATH = "/logger"; + + ConfigKey LogsWebServerEnabled = new ConfigKey<>("Advanced", Boolean.class, + "logs.web.server.enabled", "false", + "Indicates whether Logs Web Server plugin is enabled or not", + false); + + ConfigKey LogsWebServerDirectConnect = new ConfigKey<>("Advanced", Boolean.class, + "logs.web.server.direct.connect", "true", + "Whether clients directly connect to the management server hosts for Logs Web Server sessions", + true); + + ConfigKey LogsWebServerPath = new ConfigKey<>("Advanced", String.class, + "logs.web.server.path", WS_PATH, + "The path prefix to be used for Logs Web Server", + false, + ConfigKey.Scope.ManagementServer); + + ConfigKey LogsWebServerSessionIdleTimeout = new ConfigKey<>("Advanced", Integer.class, + "logs.web.server.session.idle.timeout", "60", + "Time(in seconds) after which a Logs Web Server session will be automatically disconnected if in idle state", + false); + + ConfigKey LogsWebServerConcurrentSessions = new ConfigKey<>("Advanced", Integer.class, + "logs.web.server.concurrent.sessions", "1", + "Number of concurrent sessions that can be created at a time. To allow unlimited a value of zero can used", + true); + + ConfigKey LogsWebServerSessionStaleCleanupInterval = new ConfigKey<>("Advanced", Integer.class, + "logs.web.server.session.stale.cleanup.interval", "600", + "Time(in seconds) after which a stale (not connected or disconnected) Logs Web Server session will be automatically deleted", + false); + + ConfigKey LogsWebServerLogFile = new ConfigKey<>("Advanced", String.class, + "logs.web.server.log.file", "/var/logs/cloudstack/management/management-server.log", + "Log file to be used by Logs Web Server", + true, + ConfigKey.Scope.ManagementServer); + + ConfigKey LogsWebServerSessionTailExistingLines = new ConfigKey<>("Advanced", Integer.class, + "logs.web.server.session.tail.existing.lines", "512", + "Number of existing lines to be read from the logs file on session connect", + true, + ConfigKey.Scope.ManagementServer); + + List getLogsWebSessionWebSockets(final LogsWebSession logsWebSession) throws InternalErrorException; + boolean canCreateNewLogsWebSession(); +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionManagerImpl.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionManagerImpl.java new file mode 100644 index 000000000000..f9d6c7c9637d --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionManagerImpl.java @@ -0,0 +1,392 @@ +// 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.logsws; + +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.framework.config.ConfigKey; +import org.apache.cloudstack.framework.websocket.server.manager.WebSocketServerManager; +import org.apache.cloudstack.logsws.command.GetLogsSessionWebSocketAnswer; +import org.apache.cloudstack.logsws.command.GetLogsSessionWebSocketCommand; +import org.apache.cloudstack.logsws.dao.LogsWebSessionDao; +import org.apache.cloudstack.logsws.server.LogsWebSocketRouteManager; +import org.apache.cloudstack.logsws.server.LogsWebSocketRoutingHandler; +import org.apache.cloudstack.logsws.server.LogsWebSocketServerHelper; +import org.apache.cloudstack.logsws.vo.LogsWebSessionVO; +import org.apache.cloudstack.managed.context.ManagedContextRunnable; +import org.apache.cloudstack.cluster.ClusterCommandProcessor; +import org.apache.cloudstack.management.ManagementServerHost; +import org.apache.cloudstack.utils.identity.ManagementServerNode; +import org.apache.commons.lang3.StringUtils; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.api.Command; +import com.cloud.cluster.ClusterManager; +import com.cloud.cluster.ManagementServerHostVO; +import com.cloud.cluster.dao.ManagementServerHostDao; +import com.cloud.exception.InternalErrorException; +import com.cloud.serializer.GsonHelper; +import com.cloud.utils.DateUtil; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.concurrency.NamedThreadFactory; +import com.cloud.utils.db.GlobalLock; + +public class LogsWebSessionManagerImpl extends ManagerBase implements LogsWebSessionManager, LogsWebSocketServerHelper, + ClusterCommandProcessor { + + @Inject + WebSocketServerManager webSocketServerManager; + @Inject + LogsWebSessionDao logsWebSessionDao; + @Inject + ManagementServerHostDao managementServerHostDao; + @Inject + ClusterManager clusterManager; + + private String serverPath; + private int idleTimeoutSeconds; + private ScheduledExecutorService staleLogsWebSessionCleanupExecutor; + private ManagementServerHostVO managementServer = null; + private LogsWebSocketRouteManager logsWebSocketRouteManager; + + private final static List> SUPPORTED_COMMANDS = List.of( + GetLogsSessionWebSocketCommand.class + ); + + protected ManagementServerHostVO getCurrentManagementServer() { + if (managementServer == null) { + managementServer = + managementServerHostDao.findByMsid(ManagementServerNode.getManagementServerId()); + } + return managementServer; + } + + protected Long getManagementServerId() { + return getCurrentManagementServer().getId(); + } + + protected Long getManagementServerRunId() { + return getCurrentManagementServer().getRunid(); + } + + protected void registerLogsWebSocketServerRoute() { + logsWebSocketRouteManager = new LogsWebSocketRouteManager(); + logger.info("Registering Logs WebSocket server at path: {}", serverPath); + webSocketServerManager.registerRoute(serverPath + "/", new LogsWebSocketRoutingHandler( + logsWebSocketRouteManager, this), idleTimeoutSeconds); + } + + protected LogsWebSessionWebSocket getLogsWebSessionWebSocket(LogsWebSessionTokenPayload payload) throws + InternalErrorException { + if (!webSocketServerManager.isServerEnabled()) { + logger.error("WebSocket server not running on this management server, websocket can not be " + + "returned for LogsWebSession ID: {}", payload.getSessionUuid()); + return null; + } + ManagementServerHostVO managementServerHostVO = getCurrentManagementServer(); + return new LogsWebSessionWebSocket(managementServerHostVO, + webSocketServerManager.getServerPort(), + getLogsWebSessionWebSocketPathForManagementServer(managementServerHostVO, payload), + webSocketServerManager.isServerSslEnabled()); + } + + protected GetLogsSessionWebSocketAnswer processGetLogsSessionWebSocketCommand( + GetLogsSessionWebSocketCommand cmd) { + LogsWebSessionTokenPayload payload = cmd.getTokenPayload(); + LogsWebSessionWebSocket webSocket; + try { + webSocket = getLogsWebSessionWebSocket(payload); + } catch (InternalErrorException e) { + logger.error("Failed to process GetLogsSessionWebSocketCommand command for ID: {}", + cmd.getSessionId(), e); + return new GetLogsSessionWebSocketAnswer(cmd, e.getMessage()); + } + if (webSocket == null) { + return new GetLogsSessionWebSocketAnswer(cmd, "WebSocket server not running"); + } + return new GetLogsSessionWebSocketAnswer(cmd, webSocket.getPort(), webSocket.getPath(), webSocket.isSsl()); + } + + @Override + public String getConfigComponentName() { + return LogsWebSessionManager.class.getSimpleName(); + } + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + try { + staleLogsWebSessionCleanupExecutor = Executors.newScheduledThreadPool(1, + new NamedThreadFactory("Logs-Web-Sessions-Stale-Cleanup-Worker")); + } catch (final Exception e) { + throw new ConfigurationException("Unable to to configure " + + LogsWebSessionManagerImpl.class.getSimpleName()); + } + return true; + } + + @Override + public boolean start() { + if (!LogsWebServerEnabled.value()) { + return true; + } + serverPath = LogsWebServerPath.valueIn(getManagementServerId()); + idleTimeoutSeconds = LogsWebServerSessionIdleTimeout.value(); + long staleLogsWebSessionCleanupInterval = LogsWebServerSessionStaleCleanupInterval.value(); + staleLogsWebSessionCleanupExecutor.scheduleWithFixedDelay(new StaleLogsWebSessionCleanupWorker(), + staleLogsWebSessionCleanupInterval, staleLogsWebSessionCleanupInterval, TimeUnit.SECONDS); + registerLogsWebSocketServerRoute(); + return true; + } + + @Override + public boolean stop() { + logsWebSessionDao.markAllActiveAsDisconnected(); + webSocketServerManager.unregisterRoute(serverPath + "/"); + return true; + } + + protected LogsWebSessionTokenPayload getLogsWebSessionWebSocketTokenPayloadUsingVO(LogsWebSession session) { + LogsWebSessionVO sessionVO; + if (session instanceof LogsWebSessionVO) { + sessionVO = (LogsWebSessionVO) session; + } else { + sessionVO = logsWebSessionDao.findById(session.getId()); + } + return new LogsWebSessionTokenPayload(sessionVO.getUuid(), sessionVO.getCreatorAddress()); + } + + protected String getLogsWebSessionWebSocketPathForManagementServer(ManagementServerHostVO managementServerHostVO, + LogsWebSessionTokenPayload payload) throws InternalErrorException { + String path = serverPath; + if (!Objects.equals(managementServerHostVO.getId(), getManagementServerId())) { + path = LogsWebServerPath.valueIn(managementServerHostVO.getId()); + } + try { + return String.format("%s%s/%s", + webSocketServerManager.getWebSocketBasePath(), + path, + LogsWebSessionTokenCryptoUtil.encrypt(payload, String.valueOf(managementServerHostVO.getRunid()))); + } catch (GeneralSecurityException e) { + logger.error("Failed to encrypt token payload: {}", payload, e); + throw new InternalErrorException("Failed to encrypt token payload: " + payload, e); + } + } + + @Override + public List> getCommands() { + return List.of(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[]{ + LogsWebServerEnabled, + LogsWebServerPath, + LogsWebServerSessionIdleTimeout, + LogsWebServerConcurrentSessions, + LogsWebServerLogFile, + LogsWebServerSessionTailExistingLines, + LogsWebServerSessionStaleCleanupInterval + }; + } + + @Override + public String getServerPath() { + return serverPath; + } + + @Override + public String getLogFile() { + return LogsWebServerLogFile.valueIn(getManagementServerId()); + } + + @Override + public int getMaxReadExistingLines() { + return LogsWebServerSessionTailExistingLines.valueIn(getManagementServerId()); + } + + @Override + public LogsWebSessionTokenPayload parseToken(String token) { + try { + return LogsWebSessionTokenCryptoUtil.decrypt(token, String.valueOf(getManagementServerRunId())); + } catch (GeneralSecurityException e) { + logger.error("Failed to decrypt route token: {}", token, e); + } + return null; + } + + @Override + public LogsWebSession getSession(String route) { + if (StringUtils.isBlank(route)) { + return null; + } + return logsWebSessionDao.findByUuid(route); + } + + @Override + public void updateSessionConnection(long sessionId, String clientAddress) { + LogsWebSessionVO logsWebSessionVO = logsWebSessionDao.findById(sessionId); + if (logsWebSessionVO == null) { + return; + } + if (StringUtils.isNotBlank(clientAddress)) { + logsWebSessionVO.setConnections(logsWebSessionVO.getConnections() + 1); + logsWebSessionVO.setConnectedTime(new Date()); + logsWebSessionVO.setClientAddress(clientAddress); + } else { + logsWebSocketRouteManager.removeRoute(logsWebSessionVO.getUuid()); + if (logsWebSessionVO.getConnections() == 0) { + return; + } + logsWebSessionVO.setConnections(Math.max(0, logsWebSessionVO.getConnections() - 1)); + } + logger.trace("Updating session: {}, is connected: {}, connections: {}", + logsWebSessionVO.getUuid(), + StringUtils.isBlank(clientAddress), + logsWebSessionVO.getConnections()); + logsWebSessionDao.update(sessionId, logsWebSessionVO); + } + + protected LogsWebSessionWebSocket getWebSocketResultFromAnswersString(String answersStr, + LogsWebSession logsWebSession, ManagementServerHostVO msHost) { + Answer[] answers; + try { + answers = GsonHelper.getGson().fromJson(answersStr, Answer[].class); + } catch (Exception e) { + logger.error("Failed to parse answer JSON during get websocket for {} on {}: {}", + logsWebSession, msHost, e.getMessage(), e); + return null; + } + Answer answer = answers != null && answers.length > 0 ? answers[0] : null; + String details = "Unknown error"; + if (answer instanceof GetLogsSessionWebSocketAnswer && answer.getResult()) { + GetLogsSessionWebSocketAnswer wsAnswer = (GetLogsSessionWebSocketAnswer) answer; + return new LogsWebSessionWebSocket(msHost, wsAnswer.getPort(), wsAnswer.getPath(), wsAnswer.isSsl()); + } + if (answer != null) { + details = answer.getDetails(); + } + logger.error("Failed to get websocket for {} on {} due to {}", logsWebSession, msHost, details); + return null; + } + + @Override + public List getLogsWebSessionWebSockets(final LogsWebSession logsWebSession) throws + InternalErrorException { + List webSockets = new ArrayList<>(); + final List activeMsList = + managementServerHostDao.listBy(ManagementServerHost.State.Up); + LogsWebSessionTokenPayload payload = getLogsWebSessionWebSocketTokenPayloadUsingVO(logsWebSession); + LogsWebSessionWebSocket localWebSocket = getLogsWebSessionWebSocket(payload); + if (localWebSocket != null) { + webSockets.add(localWebSocket); + } + for (ManagementServerHostVO msHost : activeMsList) { + if (Objects.equals(msHost.getId(), getManagementServerId())) { + continue; + } + final String msPeer = Long.toString(msHost.getMsid()); + logger.debug("Sending get websocket command for {} to MS: {}", logsWebSession, msPeer); + final Command[] commands = new Command[1]; + commands[0] = new GetLogsSessionWebSocketCommand(logsWebSession.getId(), payload); + String answersStr = clusterManager.execute(msPeer, 0L, GsonHelper.getGson().toJson(commands), true); + LogsWebSessionWebSocket webSocket = getWebSocketResultFromAnswersString(answersStr, logsWebSession, msHost); + if (webSocket != null) { + webSockets.add(webSocket); + } + } + return webSockets; + } + + @Override + public boolean canCreateNewLogsWebSession() { + int maxSessions = LogsWebServerConcurrentSessions.valueIn(getManagementServerId()); + if (maxSessions <= 0) { + return true; + } + return maxSessions > logsWebSessionDao.countConnected(); + } + + @Override + public boolean supportsCommand(Class clazz) { + return clazz != null && SUPPORTED_COMMANDS.contains(clazz); + } + + @Override + public String processCommand(Command cmd) { + logger.debug("Processing command: {}", cmd); + String commandClass = cmd.getClass().getName(); + if (cmd instanceof GetLogsSessionWebSocketCommand) { + GetLogsSessionWebSocketCommand getCmd = (GetLogsSessionWebSocketCommand) cmd; + GetLogsSessionWebSocketAnswer answer = processGetLogsSessionWebSocketCommand(getCmd); + return GsonHelper.getGson().toJson(answer); + } + return GsonHelper.getGson().toJson(new Answer(cmd, false, + "Unsupported command: " + commandClass)); + } + + public class StaleLogsWebSessionCleanupWorker extends ManagedContextRunnable { + + protected void runCleanupForStaleLogsWebSessions() { + try { + ManagementServerHostVO msHost = managementServerHostDao.findOneByLongestRuntime(); + if (msHost == null || (msHost.getMsid() != ManagementServerNode.getManagementServerId())) { + logger.debug("Skipping the stale Logs Web Sessions cleanup task on this management server"); + return; + } + long cutOffSeconds = LogsWebServerSessionStaleCleanupInterval.value(); + Date cutOffDate = new Date(System.currentTimeMillis() - (cutOffSeconds * 1000)); + String cutOffDateString = DateUtil.getOutputString(cutOffDate); + logger.debug("Clearing stale stale Logs Web Sessions older than {} using management server {}", + cutOffDateString, msHost); + long processed = logsWebSessionDao.removeStaleForCutOff(cutOffDate); + logger.debug("Cleared {} stale stale Logs Web Sessions older than {}", processed, + cutOffDateString); + } catch (Exception e) { + logger.warn("Cleanup task failed to stale Logs Web Sessions", e); + } + } + + @Override + protected void runInContext() { + GlobalLock gcLock = GlobalLock.getInternLock("LogsWebSessionsCleanup"); + try { + if (gcLock.lock(3)) { + try { + runCleanupForStaleLogsWebSessions(); + } finally { + gcLock.unlock(); + } + } + } finally { + gcLock.releaseRef(); + } + } + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionTokenCryptoUtil.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionTokenCryptoUtil.java new file mode 100644 index 000000000000..516032517aca --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionTokenCryptoUtil.java @@ -0,0 +1,106 @@ +// 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.logsws; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import com.cloud.serializer.GsonHelper; + +public class LogsWebSessionTokenCryptoUtil { + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + + private static final int IV_LENGTH_BYTES = 12; + private static final int GCM_TAG_LENGTH_BITS = 128; + private static final byte TOKEN_VERSION = 1; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private static SecretKey deriveKey(String keyMaterial) throws GeneralSecurityException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(keyMaterial.getBytes(StandardCharsets.UTF_8)); + return new SecretKeySpec(hash, 0, 32, ALGORITHM); + } + + public static String encrypt(LogsWebSessionTokenPayload payload, String keyMaterial) + throws GeneralSecurityException { + + String json = GsonHelper.getGson().toJson(payload); + byte[] plaintext = json.getBytes(StandardCharsets.UTF_8); + + SecretKey key = deriveKey(keyMaterial); + + byte[] iv = new byte[IV_LENGTH_BYTES]; + SECURE_RANDOM.nextBytes(iv); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv); + cipher.init(Cipher.ENCRYPT_MODE, key, spec); + + byte[] ciphertextWithTag = cipher.doFinal(plaintext); + ByteBuffer buffer = ByteBuffer.allocate(1 + IV_LENGTH_BYTES + ciphertextWithTag.length); + buffer.put(TOKEN_VERSION); + buffer.put(iv); + buffer.put(ciphertextWithTag); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(buffer.array()); + } + + public static LogsWebSessionTokenPayload decrypt(String token, String keyMaterial) + throws GeneralSecurityException { + + byte[] allBytes = Base64.getUrlDecoder().decode(token); + ByteBuffer buffer = ByteBuffer.wrap(allBytes); + + if (buffer.remaining() < 1 + IV_LENGTH_BYTES + 1) { + throw new GeneralSecurityException("Invalid token format"); + } + + byte version = buffer.get(); + if (version != TOKEN_VERSION) { + throw new GeneralSecurityException("Unsupported token version: " + version); + } + + byte[] iv = new byte[IV_LENGTH_BYTES]; + buffer.get(iv); + + byte[] ciphertextWithTag = new byte[buffer.remaining()]; + buffer.get(ciphertextWithTag); + + SecretKey key = deriveKey(keyMaterial); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv); + cipher.init(Cipher.DECRYPT_MODE, key, spec); + + byte[] plaintext = cipher.doFinal(ciphertextWithTag); + String json = new String(plaintext, StandardCharsets.UTF_8); + + return GsonHelper.getGson().fromJson(json, LogsWebSessionTokenPayload.class); + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionTokenPayload.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionTokenPayload.java new file mode 100644 index 000000000000..8321233df7d4 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionTokenPayload.java @@ -0,0 +1,36 @@ +// 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.logsws; + +public class LogsWebSessionTokenPayload { + private final String sessionUuid; + private final String creatorAddress; + + public LogsWebSessionTokenPayload(String sessionUuid, String creatorAddress) { + this.sessionUuid = sessionUuid; + this.creatorAddress = creatorAddress; + } + + public String getSessionUuid() { + return sessionUuid; + } + + public String getCreatorAddress() { + return creatorAddress; + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionWebSocket.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionWebSocket.java new file mode 100644 index 000000000000..5dcbd4617d4c --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/LogsWebSessionWebSocket.java @@ -0,0 +1,52 @@ +// 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.logsws; + +import com.cloud.cluster.ManagementServerHostVO; + +public class LogsWebSessionWebSocket { + + private ManagementServerHostVO managementServerHost; + private int port; + private String path; + private boolean ssl; + + public LogsWebSessionWebSocket(final ManagementServerHostVO managementServerHost, final int port, + final String path, final boolean ssl) { + this.managementServerHost = managementServerHost; + this.port = port; + this.path = path; + this.ssl = ssl; + } + + public ManagementServerHostVO getManagementServerHost() { + return managementServerHost; + } + + public int getPort() { + return port; + } + + public String getPath() { + return path; + } + + public boolean isSsl() { + return ssl; + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/command/admin/CreateLogsWebSessionCmd.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/command/admin/CreateLogsWebSessionCmd.java new file mode 100644 index 000000000000..25befc992236 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/command/admin/CreateLogsWebSessionCmd.java @@ -0,0 +1,91 @@ +// 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.logsws.api.command.admin; + + +import java.util.List; + +import javax.inject.Inject; + +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.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.logsws.LogsWebSession; +import org.apache.cloudstack.logsws.LogsWebSessionApiService; +import org.apache.cloudstack.logsws.api.response.LogsWebSessionResponse; + +import com.cloud.utils.exception.CloudRuntimeException; + +@APICommand(name = "createLogsWebSession", + description = "Creates a Logs Web Session", + responseObject = LogsWebSessionResponse.class, + responseView = ResponseObject.ResponseView.Restricted, + entityType = {LogsWebSession.class}, + requestHasSensitiveInfo = false, + authorized = {RoleType.Admin}, + since = "4.23.0") +public class CreateLogsWebSessionCmd extends BaseCmd { + + @Inject + LogsWebSessionApiService logsWebSessionApiService; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.FILTERS, type = CommandType.LIST, collectionType = CommandType.STRING, + description = "Comma separated list of keywords to filter the logs") + private List filters; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public List getFilters() { + return filters; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccountId(); + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException { + try { + LogsWebSessionResponse response = logsWebSessionApiService.createLogsWebSession(this); + if (response == null) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, "Failed to create Logs Web Session"); + } + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (CloudRuntimeException ex) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, ex.getMessage()); + } + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/command/admin/DeleteLogsWebSession.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/command/admin/DeleteLogsWebSession.java new file mode 100644 index 000000000000..ab8dbd89b93a --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/command/admin/DeleteLogsWebSession.java @@ -0,0 +1,85 @@ +// 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.logsws.api.command.admin; + +import javax.inject.Inject; + +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.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.logsws.LogsWebSessionApiService; +import org.apache.cloudstack.logsws.LogsWebSession; +import org.apache.cloudstack.logsws.api.response.LogsWebSessionResponse; + +import com.cloud.utils.exception.CloudRuntimeException; + +@APICommand(name = "deleteLogsWebSession", + description = "Deletes a Logs Web Session", + responseObject = SuccessResponse.class, + entityType = {LogsWebSession.class}, + authorized = {RoleType.Admin}, + since = "4.23.0") +public class DeleteLogsWebSession extends BaseCmd { + + @Inject + LogsWebSessionApiService logsWebSessionApiService; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + @Parameter(name = ApiConstants.ID, type = BaseCmd.CommandType.UUID, + entityType = LogsWebSessionResponse.class, + required = true, + description = "The ID of the Logs Web Session") + private Long id; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + public Long getId() { + return id; + } + + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccountId(); + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + @Override + public void execute() throws ServerApiException { + try { + if (!logsWebSessionApiService.deleteLogsWebSession(this)) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, + String.format("Failed to delete log web session ID: %d", getId())); + } + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } catch (CloudRuntimeException ex) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, ex.getMessage()); + } + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/command/admin/ListLogsWebSessionsCmd.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/command/admin/ListLogsWebSessionsCmd.java new file mode 100644 index 000000000000..0636de2f8bd2 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/command/admin/ListLogsWebSessionsCmd.java @@ -0,0 +1,70 @@ +// 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.logsws.api.command.admin; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListAccountResourcesCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.logsws.LogsWebSessionApiService; +import org.apache.cloudstack.logsws.LogsWebSession; +import org.apache.cloudstack.logsws.api.response.LogsWebSessionResponse; + +@APICommand(name = "listLogsWebSessions", + description = "Lists Logs Web Sessions", + responseObject = LogsWebSessionResponse.class, + responseView = ResponseObject.ResponseView.Restricted, + entityType = {LogsWebSession.class}, + authorized = {RoleType.Admin}, + since = "4.23.0") +public class ListLogsWebSessionsCmd extends BaseListAccountResourcesCmd { + + @Inject + LogsWebSessionApiService logsWebSessionApiService; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + @Parameter(name = ApiConstants.ID, type = CommandType.UUID, + entityType = LogsWebSessionResponse.class, + description = "The ID of the Logs Web Session") + private Long id; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + public Long getId() { + return id; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + @Override + public void execute() throws ServerApiException { + ListResponse response = logsWebSessionApiService.listLogsWebSessions(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/response/LogsWebSessionResponse.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/response/LogsWebSessionResponse.java new file mode 100644 index 000000000000..918f3ad640ff --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/response/LogsWebSessionResponse.java @@ -0,0 +1,135 @@ +// 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.logsws.api.response; + + +import java.util.Date; +import java.util.List; +import java.util.Set; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import org.apache.cloudstack.api.EntityReference; +import org.apache.cloudstack.api.response.ControlledViewEntityResponse; +import org.apache.cloudstack.logsws.LogsWebSession; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@EntityReference(value = {LogsWebSession.class}) +public class LogsWebSessionResponse extends BaseResponse implements ControlledViewEntityResponse { + @SerializedName(ApiConstants.ID) + @Param(description = "The ID of the Logs Web Session") + private String id; + + @SerializedName(ApiConstants.FILTERS) + @Param(description = "The filters for the Logs Web Session") + private List filters; + + @SerializedName(ApiConstants.DOMAIN_ID) + @Param(description = "The ID of the domain of the Logs Web Session creator") + private String domainId; + + @SerializedName(ApiConstants.DOMAIN) + @Param(description = "The name of the domain of the Logs Web Session creator") + private String domainName; + + @SerializedName(ApiConstants.DOMAIN_PATH) + @Param(description = "The path of the domain of the Logs Web Session creator") + private String domainPath; + + @SerializedName(ApiConstants.ACCOUNT) + @Param(description = "The account which created the Logs Web Session") + private String accountName; + + @SerializedName(ApiConstants.CREATOR_ADDRESS) + @Param(description = "The address of creator for this Logs Web Session") + private String creatorAddress; + + @SerializedName(ApiConstants.CONNECTED) + @Param(description = "The number of clients connected for this Logs Web Session") + private Integer connected; + + @SerializedName(ApiConstants.CLIENT_ADDRESS) + @Param(description = "The address of the last connected client for this Logs Web Session") + private String clientAddress; + + @SerializedName(ApiConstants.CREATED) + @Param(description = "The date when this Logs Web Session was created") + private Date created; + + @SerializedName(ApiConstants.WEBSOCKET) + @Param(description = "The Logs Web Session websocket options") + private Set websocketResponses; + + public void setId(String id) { + this.id = id; + } + + public void setFilters(List filters) { + this.filters = filters; + } + + @Override + public void setDomainId(String domainId) { + this.domainId = domainId; + } + + @Override + public void setDomainName(String domainName) { + this.domainName = domainName; + } + + @Override + public void setDomainPath(String domainPath) { + this.domainPath = domainPath; + } + + @Override + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + @Override + public void setProjectId(String projectId) { + } + + @Override + public void setProjectName(String projectName) { + } + + public void setCreatorAddress(String creatorAddress) { + this.creatorAddress = creatorAddress; + } + + public void setConnected(Integer connected) { + this.connected = connected; + } + + public void setClientAddress(String clientAddress) { + this.clientAddress = clientAddress; + } + + public void setCreated(Date created) { + this.created = created; + } + + public void setWebsocketResponse(Set websocketResponse) { + this.websocketResponses = websocketResponse; + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/response/LogsWebSessionWebSocketResponse.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/response/LogsWebSessionWebSocketResponse.java new file mode 100644 index 000000000000..563f64a34a21 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/api/response/LogsWebSessionWebSocketResponse.java @@ -0,0 +1,98 @@ +// 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.logsws.api.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class LogsWebSessionWebSocketResponse extends BaseResponse { + @SerializedName(ApiConstants.MANAGEMENT_SERVER_ID) + @Param(description = "The ID of the management for this websocket") + private String managementServerId; + + @SerializedName(ApiConstants.MANAGEMENT_SERVER_NAME) + @Param(description = "The name of the management for this websocket") + private String managementServerName; + + @SerializedName(ApiConstants.HOST) + @Param(description = "the websocket host") + private String host; + + @SerializedName(ApiConstants.PORT) + @Param(description = "the websocket port") + private Integer port; + + @SerializedName(ApiConstants.PATH) + @Param(description = "the websocket path") + private String path; + + @SerializedName(ApiConstants.USE_SSL) + @Param(description = "the websocket uses SSL or not") + private Boolean ssl; + + public String getManagementServerId() { + return managementServerId; + } + + public void setManagementServerId(String managementServerId) { + this.managementServerId = managementServerId; + } + + public String getManagementServerName() { + return managementServerName; + } + + public void setManagementServerName(String managementServerName) { + this.managementServerName = managementServerName; + } + + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + } + + public Integer getPort() { + return port; + } + + public void setPort(Integer port) { + this.port = port; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public Boolean getSsl() { + return ssl; + } + + public void setSsl(Boolean ssl) { + this.ssl = ssl; + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/command/GetLogsSessionWebSocketAnswer.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/command/GetLogsSessionWebSocketAnswer.java new file mode 100644 index 000000000000..4f3d17dda3d4 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/command/GetLogsSessionWebSocketAnswer.java @@ -0,0 +1,49 @@ +// 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.logsws.command; + +import com.cloud.agent.api.Answer; + +public class GetLogsSessionWebSocketAnswer extends Answer { + int port; + String path; + boolean ssl; + + public GetLogsSessionWebSocketAnswer(GetLogsSessionWebSocketCommand cmd, int port, String path, boolean useSsl) { + super(cmd, true, "success"); + this.port = port; + this.path = path; + this.ssl = useSsl; + } + + public GetLogsSessionWebSocketAnswer(GetLogsSessionWebSocketCommand cmd, String error) { + super(null, false, error); + } + + public int getPort() { + return port; + } + + public String getPath() { + return path; + } + + public boolean isSsl() { + return ssl; + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/command/GetLogsSessionWebSocketCommand.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/command/GetLogsSessionWebSocketCommand.java new file mode 100644 index 000000000000..a90db279d269 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/command/GetLogsSessionWebSocketCommand.java @@ -0,0 +1,45 @@ +// 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.logsws.command; + +import org.apache.cloudstack.logsws.LogsWebSessionTokenPayload; + +import com.cloud.agent.api.Command; + +public class GetLogsSessionWebSocketCommand extends Command { + private final long sessionId; + private final LogsWebSessionTokenPayload tokenPayload; + + public GetLogsSessionWebSocketCommand(long sessionId, LogsWebSessionTokenPayload tokenPayload) { + this.sessionId = sessionId; + this.tokenPayload = tokenPayload; + } + + public long getSessionId() { + return sessionId; + } + + public LogsWebSessionTokenPayload getTokenPayload() { + return tokenPayload; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/dao/LogsWebSessionDao.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/dao/LogsWebSessionDao.java new file mode 100644 index 000000000000..2d9784c847ec --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/dao/LogsWebSessionDao.java @@ -0,0 +1,33 @@ +// 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.logsws.dao; + +import java.util.Date; +import java.util.List; + +import org.apache.cloudstack.logsws.vo.LogsWebSessionVO; + +import com.cloud.utils.db.GenericDao; + +public interface LogsWebSessionDao extends GenericDao { + List listByAccount(long accountId); + void deleteByAccount(long accountId); + void markAllActiveAsDisconnected(); + int removeStaleForCutOff(Date cutOff); + int countConnected(); +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/dao/LogsWebSessionDaoImpl.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/dao/LogsWebSessionDaoImpl.java new file mode 100644 index 000000000000..95415f27a78d --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/dao/LogsWebSessionDaoImpl.java @@ -0,0 +1,101 @@ +// 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.logsws.dao; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.logsws.vo.LogsWebSessionVO; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.GenericSearchBuilder; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; +import com.cloud.utils.db.UpdateBuilder; + +public class LogsWebSessionDaoImpl extends GenericDaoBase implements LogsWebSessionDao { + SearchBuilder accountIdSearch; + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + super.configure(name, params); + + accountIdSearch = createSearchBuilder(); + accountIdSearch.and("accountId", accountIdSearch.entity().getAccountId(), SearchCriteria.Op.EQ); + + return true; + } + + @Override + public void deleteByAccount(long accountId) { + SearchCriteria sc = accountIdSearch.create(); + sc.setParameters("accountId", accountId); + remove(sc); + } + + @Override + public List listByAccount(long accountId) { + SearchCriteria sc = accountIdSearch.create(); + sc.setParameters("accountId", accountId); + return listBy(sc); + } + + @Override + public void markAllActiveAsDisconnected() { + SearchBuilder sb = createSearchBuilder(); + sb.and("connections", sb.entity().getConnections(), SearchCriteria.Op.GT); + sb.done(); + SearchCriteria sc = sb.create(); + sc.setParameters("connections", 0); + LogsWebSessionVO logsWebSessionVO = createForUpdate(); + logsWebSessionVO.setConnections(0); + UpdateBuilder updateBuilder = getUpdateBuilder(logsWebSessionVO); + update(updateBuilder, sc, null); + } + + @Override + public int removeStaleForCutOff(Date cutOff) { + SearchBuilder sb = createSearchBuilder(); + sb.and("connections", sb.entity().getConnections(), SearchCriteria.Op.EQ); + sb.and().op("connected_time", sb.entity().getConnectedTime(), SearchCriteria.Op.LT); + sb.or().op("null_connected_time", sb.entity().getConnectedTime(), SearchCriteria.Op.NULL); + sb.and("created", sb.entity().getCreated(), SearchCriteria.Op.LT); + sb.cp(); + sb.cp(); + sb.done(); + SearchCriteria sc = sb.create(); + sc.setParameters("connections", 0); + sc.setParameters("connected_time", cutOff); + sc.setParameters("created", cutOff); + return remove(sc); + } + + @Override + public int countConnected() { + GenericSearchBuilder sb = createSearchBuilder(Integer.class); + sb.and("connections", sb.entity().getConnections(), SearchCriteria.Op.GT); + sb.select(null, SearchCriteria.Func.COUNT, sb.entity().getId()); + sb.done(); + SearchCriteria sc = sb.create(); + sc.setParameters("connections", 0); + return customSearch(sc, null).get(0); + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/logreader/FilteredLogTailerListener.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/logreader/FilteredLogTailerListener.java new file mode 100644 index 000000000000..bee84d94ea5d --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/logreader/FilteredLogTailerListener.java @@ -0,0 +1,91 @@ +// 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.logsws.logreader; + +import java.time.LocalDate; +import java.util.List; + +import org.apache.cloudstack.framework.websocket.server.common.WebSocketSession; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.io.input.TailerListenerAdapter; +import org.apache.commons.lang3.StringUtils; + +public class FilteredLogTailerListener extends TailerListenerAdapter { + private final WebSocketSession session; + private final List filters; + private final boolean isFilterEmpty; + private boolean isLastLineValid; + + public static boolean isValidLine(String line, boolean isFilterEmpty, + boolean isLastLineValid, List filters) { + if (StringUtils.isBlank(line)) { + return false; + } + if (isFilterEmpty) { + return true; + } + + // ToDo: Improve. If the last line was valid and the current line does not start with YEAR-MONTH- + // consider it valid + String logLinePrefix = String.format("%d-%02d-", LocalDate.now().getYear(), + LocalDate.now().getMonthValue()); + if (isLastLineValid && !line.startsWith(logLinePrefix)) { + return true; + } + + for (String filter : filters) { + if (line.contains(filter)) { + return true; + } + } + return false; + } + + public FilteredLogTailerListener(WebSocketSession session, List filters) { + this.session = session; + this.filters = filters; + isFilterEmpty = CollectionUtils.isEmpty(filters); + isLastLineValid = false; + } + + @Override + public void handle(String line) { + // Check if the line contains the filter string + if (isValidLine(line, isFilterEmpty, isLastLineValid, filters)) { + session.sendText(line); + isLastLineValid = true; + } else { + isLastLineValid = false; + } + } + + @Override + public void fileNotFound() { + session.sendText("Log file not found."); + } + + @Override + public void fileRotated() { + session.sendText("Log file rotated."); + } + + @Override + public void handle(Exception ex) { + session.sendText("Tailer error: " + ex.getMessage()); + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsStreamer.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsStreamer.java new file mode 100644 index 000000000000..2bf4219db758 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsStreamer.java @@ -0,0 +1,218 @@ +// 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.logsws.server; + +import java.io.File; +import java.io.IOException; +import java.net.InetAddress; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.apache.cloudstack.framework.websocket.server.common.WebSocketSession; +import org.apache.cloudstack.logsws.LogsWebSession; +import org.apache.cloudstack.logsws.logreader.FilteredLogTailerListener; +import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.io.input.ReversedLinesFileReader; +import org.apache.commons.io.input.Tailer; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; + +public class LogsStreamer implements AutoCloseable { + protected static Logger LOGGER = LogManager.getLogger(LogsStreamer.class); + private Tailer tailer; + private ExecutorService tailerExecutor; + private final LogsWebSession logsWebSession; + private final LogsWebSocketServerHelper serverHelper; + private ScheduledExecutorService testBroadcasterExecutor; + + public LogsStreamer(final LogsWebSession logsWebSession, final LogsWebSocketServerHelper serverHelper) { + this.logsWebSession = logsWebSession; + this.serverHelper = serverHelper; + testBroadcasterExecutor = Executors.newSingleThreadScheduledExecutor(); + } + + @Nullable + protected File getValidatedLogFile() { + File logFile = new File(serverHelper.getLogFile()); + if (!logFile.exists()) { + LOGGER.error("Log file {} does not exist", logFile.getAbsolutePath()); + return null; + } + if (!logFile.canRead()) { + LOGGER.error("Log file {} is not readable", logFile.getAbsolutePath()); + return null; + } + return logFile; + } + + /** + * For testing purpose - Start broadcasting messages to the given session at fixed intervals. + * + * @param session WebSocket session to send test messages to + */ + protected void startTestBroadcasting(final WebSocketSession session) { + testBroadcasterExecutor.scheduleAtFixedRate(() -> { + String msg = String.format("Hello from Logger broadcaster! Route: %s", session.path()); + session.sendText(msg); + LOGGER.debug("Broadcasting message: '{}' for context: {}", msg, session.path()); + }, 0, 2, TimeUnit.SECONDS); + } + + /** + * Process existing lines from the log file and send matching lines to the session. + * + * @param session WebSocket session to send matching lines to + * @param logFile log file to read existing lines from + * @param filters filter strings for matching; may be null or empty + */ + protected void processExistingLines(final WebSocketSession session, File logFile, final List filters) { + try (ReversedLinesFileReader reader = new ReversedLinesFileReader(logFile, StandardCharsets.UTF_8)) { + List lastLines = new ArrayList<>(); + String line; + int count = 0; + while ((line = reader.readLine()) != null && count < serverHelper.getMaxReadExistingLines()) { + lastLines.add(line); + count++; + } + Collections.reverse(lastLines); + boolean isFilterEmpty = CollectionUtils.isEmpty(filters); + boolean isLastLineValid = false; + for (String l : lastLines) { + if (FilteredLogTailerListener.isValidLine(l, isFilterEmpty, isLastLineValid, filters)) { + session.sendText(l); + isLastLineValid = true; + } else { + isLastLineValid = false; + } + } + } catch (IOException e) { + session.sendText(String.format("Error reading existing log lines: %s", e.getMessage())); + } + } + + /** + * Tail the given log file and stream new matching lines to the session. + * + * @param session WebSocket session to send matching lines to + * @param filters filter strings for the tailer listener; may be null or empty + * @param logFile log file to tail (must be readable) + */ + protected void startLogTailing(WebSocketSession session, List filters, File logFile) { + FilteredLogTailerListener listener = new FilteredLogTailerListener(session, filters); + tailer = new Tailer(logFile, listener, 50, true); + tailerExecutor = Executors.newSingleThreadExecutor(); + tailerExecutor.submit(tailer); + } + + /** + * Asynchronously extracts and normalizes the remote address from the provided + * WebSocketSession and updates the connection info on the server helper. + * + *

The method reads the session attribute {@code "remoteAddress"}, strips a + * leading slash if present, handles bracketed IPv6 addresses, and attempts to + * resolve the host to an IP address. Any errors during parsing or resolution + * are caught and logged at debug level so the caller is not impacted. + * + * @param session the web socket session containing the {@code "remoteAddress"} attribute + */ + protected void updateSessionWithRemoteAddressAsync(WebSocketSession session) { + CompletableFuture.runAsync(() -> { + try { + String remoteStr = session.getAttr(WebSocketSession.ATTR_REMOTE_ADDR); + if (StringUtils.isEmpty(remoteStr)) { + return; + } + String s = remoteStr.startsWith("/") ? remoteStr.substring(1) : remoteStr; + String host; + if (s.startsWith("[")) { + int end = s.indexOf(']'); + host = end > 0 ? s.substring(1, end) : s; + } else { + int lastColon = s.lastIndexOf(':'); + if (lastColon > 0 && s.indexOf(':') == lastColon) { + host = s.substring(0, lastColon); + } else { + host = s; + } + } + try { + host = InetAddress.getByName(host).getHostAddress(); + } catch (Exception ignore) {} + serverHelper.updateSessionConnection(logsWebSession.getId(), host); + } catch (Throwable t) { + LOGGER.debug("Failed to update remote address asynchronously", t); + } + }); + } + + /** + * Starts log streaming for the given WebSocket session and route. + * First, it sends the existing log lines that match the session's filters, + * then it starts tailing the log file to stream new matching lines. + * + * @param session the WebSocket session to stream logs to + * @param route the route associated with the log streaming session + */ + public void start(WebSocketSession session, String route) { + LOGGER.debug("Starting log streaming for route: {}", route); + File logFile = getValidatedLogFile(); + if (logFile == null) { + session.sendText("Log file not available or cannot be read."); + return; + } + updateSessionWithRemoteAddressAsync(session); + + processExistingLines(session, logFile, logsWebSession.getFilters()); + + startLogTailing(session, logsWebSession.getFilters(), logFile); + } + + @Override + public void close() { + try { + if (tailer != null) { + tailer.stop(); + tailer = null; + } + } catch (Throwable ignore) { + } + if (tailerExecutor != null && !tailerExecutor.isShutdown()) { + tailerExecutor.shutdownNow(); + tailerExecutor = null; + } + try { + if (logsWebSession != null) { + serverHelper.updateSessionConnection(logsWebSession.getId(), null); + } + } catch (Throwable ignore) { + } + if (testBroadcasterExecutor != null && !testBroadcasterExecutor.isShutdown()) { + testBroadcasterExecutor.shutdownNow(); + testBroadcasterExecutor = null; + } + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsWebSocketRouteManager.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsWebSocketRouteManager.java new file mode 100644 index 000000000000..be47b9457044 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsWebSocketRouteManager.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 org.apache.cloudstack.logsws.server; + +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import io.netty.channel.group.ChannelGroup; +import io.netty.channel.group.DefaultChannelGroup; +import io.netty.util.concurrent.GlobalEventExecutor; + +public class LogsWebSocketRouteManager { + protected static Logger LOGGER = LogManager.getLogger(LogsWebSocketRouteManager.class); + private final ConcurrentHashMap routeMap = new ConcurrentHashMap<>(); + + public void addRoute(String route) { + routeMap.putIfAbsent(route, new DefaultChannelGroup(GlobalEventExecutor.INSTANCE)); + LOGGER.debug("Added route: {}", route); + } + + public void removeRoute(String route) { + ChannelGroup group = routeMap.remove(route); + if (group == null) { + LOGGER.debug("Route: {} doesn't exist", route); + return; + } + group.close(); + LOGGER.debug("Removed route: {}", route); + } + + public ChannelGroup getRouteGroup(String route) { + return routeMap.get(route); + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsWebSocketRoutingHandler.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsWebSocketRoutingHandler.java new file mode 100644 index 000000000000..4a9b22a8db9e --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsWebSocketRoutingHandler.java @@ -0,0 +1,244 @@ +// 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.logsws.server; + +import java.nio.ByteBuffer; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.apache.cloudstack.framework.websocket.server.common.WebSocketHandler; +import org.apache.cloudstack.framework.websocket.server.common.WebSocketSession; +import org.apache.cloudstack.logsws.LogsWebSession; +import org.apache.cloudstack.logsws.LogsWebSessionTokenPayload; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Transport-agnostic handler for loggerroutes. + *

+ * Responsibilities: + * - Extract and validate the dynamic route from the request path (after serverPath/) + * - Validate access token from the route + * - Enforce single active connection per route (close older one) + * - Register/unregister route with LogsWebSocketRouteManager + *

+ * Notes: + * - If the transport can provide the remote address, set it in session attrs under "remoteAddress" + * before calling onOpen(..). (Netty initializer can do this easily; Jetty can omit and skip that check.) + */ +public final class LogsWebSocketRoutingHandler implements WebSocketHandler { + private static final Logger LOGGER = LogManager.getLogger(LogsWebSocketRoutingHandler.class); + + /** + * Session attr keys + */ + public static final String ATTR_ROUTE = "loggerRoute"; + private static final String ATTR_LOGS_STREAM = "logsStreamer"; + public static final String ATTR_LOGS_SESSION = "logsSession"; + + private final LogsWebSocketRouteManager routeManager; + private final LogsWebSocketServerHelper serverHelper; + + /** + * Keep at most one active connection per route + */ + private final ConcurrentMap activeByRoute = new ConcurrentHashMap<>(); + + /** + * Base WS path for this module, e.g. "/logger" (no trailing slash). + */ + private final String serverPath; + + public LogsWebSocketRoutingHandler(LogsWebSocketRouteManager routeManager, + LogsWebSocketServerHelper serverHelper) { + this.routeManager = Objects.requireNonNull(routeManager, "routeManager"); + this.serverHelper = Objects.requireNonNull(serverHelper, "serverHelper"); + String p = Objects.requireNonNull(serverHelper.getServerPath(), "serverPath"); + this.serverPath = p.endsWith("/") ? p.substring(0, p.length() - 1) : p; + } + + + private LogsWebSession getValidSession(final String route, final WebSocketSession session) { + final LogsWebSessionTokenPayload tokenPayload = serverHelper.parseToken(route); + if (tokenPayload == null) { + LOGGER.error("Decrypted token payload is null for route: {}", route); + return null; + } + + final String sessionUuid = tokenPayload.getSessionUuid(); + if (StringUtils.isBlank(sessionUuid)) { + LOGGER.error("Session UUID is blank in token payload for route: {}", route); + return null; + } + + final String creatorAddress = tokenPayload.getCreatorAddress(); + if (StringUtils.isNotBlank(creatorAddress)) { + final String remote = session.getAttr(WebSocketSession.ATTR_REMOTE_ADDR); + if (remote != null && !remote.contains(creatorAddress)) { + LOGGER.error("Remote address '{}' does not match creator address '{}' for session {}", + remote, creatorAddress, sessionUuid); + return null; + } + } else { + LOGGER.warn("Creator address is blank in token payload (skipping remote verification)."); + } + + final LogsWebSession logsSession = serverHelper.getSession(sessionUuid); + if (logsSession == null) { + LOGGER.error("No server-side LogsWebSession for uuid {} (route {})", sessionUuid, route); + return null; + } + return logsSession; + } + + private static void safeClose(WebSocketSession s, int code, String reason) { + LOGGER.debug("Closing session {} with code {}, reason: {}", s.id(), code, reason); + try { + s.close(code, reason); + } catch (Throwable ignore) { + } + } + + private static void closeSessionStreamer(WebSocketSession session) { + final Object streamObj = session.getAttr(ATTR_LOGS_STREAM); + if (streamObj instanceof LogsStreamer) { + final LogsStreamer streamer = (LogsStreamer) streamObj; + try { + streamer.close(); + } catch (Throwable t) { + LOGGER.debug("Error closing LogsStreamer for session with route {}", + session.getAttr(ATTR_ROUTE), t); + } + session.setAttr(ATTR_LOGS_STREAM, null); + } + } + + @Override + public void onOpen(WebSocketSession session) { + final String path = session.path(); + if (path == null || !path.startsWith(serverPath + "/")) { + LOGGER.warn("Invalid request path '{}', expected prefix '{}/*'", path, serverPath); + safeClose(session, 4000, "Invalid request path"); + return; + } + + final String route = path.substring((serverPath + "/").length()); + if (route.isEmpty()) { + LOGGER.warn("Empty route in request path '{}'", path); + safeClose(session, 4001, "Empty route"); + return; + } + + final LogsWebSession logsSession = getValidSession(route, session); + if (logsSession == null) { + LOGGER.warn("Unauthorized connection attempt for route: {}", route); + safeClose(session, 4003, "Unauthorized"); + return; + } + + // Enforce single connection per route + WebSocketSession prev = activeByRoute.put(route, session); + if (prev != null && prev != session) { + LOGGER.debug("Closing existing connection for route: {}", route); + try { + prev.close(4008, "Superseded by a new connection"); + } catch (Throwable ignored) { + } + } + + // Register with route manager (idempotent add) + try { + routeManager.addRoute(route); + } catch (Throwable t) { + LOGGER.error("Failed to add route '{}' to routeManager", route, t); + activeByRoute.remove(route, session); + safeClose(session, 1011, "Route registration failed"); + return; + } + + LOGGER.trace("Starting LogsStreamer for route: {}", route); + LogsStreamer streamer = new LogsStreamer(logsSession, serverHelper); + try { + streamer.start(session, route); + } catch (Throwable t) { + LOGGER.error("Failed to start logs streamer for route {}", route, t); + try { + streamer.close(); + } catch (Throwable ignore) { + } + activeByRoute.remove(route, session); + try { + routeManager.removeRoute(route); + } catch (Throwable ignore) { + } + safeClose(session, 1011, "Stream start failed"); + return; + } + + // Stash per-connection state on session + session.setAttr(ATTR_LOGS_STREAM, streamer); + session.setAttr(ATTR_ROUTE, route); + session.setAttr(ATTR_LOGS_SESSION, logsSession); + + LOGGER.debug("Logs WS connected. route={}, sessionId={}", route, session.id()); + } + + @Override + public void onTextMessage(WebSocketSession session, String text) { + if (text == null) { + return; + } + if ("ping".equalsIgnoreCase(text.trim())) { + session.sendText("pong"); + } else { + LOGGER.debug("Ignoring client text message on logs route: {} bytes", text.length()); + } + } + + @Override + public void onBinaryMessage(WebSocketSession session, ByteBuffer bin) { + // Usually unused for logs; ignore. + } + + @Override + public void onClose(WebSocketSession session, int code, String reason) { + closeSessionStreamer(session); + final String route = session.getAttr(ATTR_ROUTE); + if (route == null) { + return; + } + // Remove only if this exact session is the current owner + activeByRoute.compute(route, (r, current) -> (current == session) ? null : current); + try { + routeManager.removeRoute(route); + } catch (Throwable t) { + LOGGER.debug("Error while removing route '{}' on close (ignored)", route, t); + } + LOGGER.debug("Logs Web Session closed. route={}, code={}, reason={}", route, code, reason); + + } + + @Override + public void onError(WebSocketSession session, Throwable t) { + closeSessionStreamer(session); + LOGGER.error("Exception in LogsWebSocketRoutingHandler (route={})", session.getAttr(ATTR_ROUTE), t); + safeClose(session, 1011, "Internal error"); + } +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsWebSocketServerHelper.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsWebSocketServerHelper.java new file mode 100644 index 000000000000..572d3ed56b7b --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/server/LogsWebSocketServerHelper.java @@ -0,0 +1,30 @@ +// 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.logsws.server; + +import org.apache.cloudstack.logsws.LogsWebSession; +import org.apache.cloudstack.logsws.LogsWebSessionTokenPayload; + +public interface LogsWebSocketServerHelper { + String getServerPath(); + String getLogFile(); + int getMaxReadExistingLines(); + LogsWebSessionTokenPayload parseToken(String token); + LogsWebSession getSession(String route); + void updateSessionConnection(long sessionId, String clientAddress); +} diff --git a/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/vo/LogsWebSessionVO.java b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/vo/LogsWebSessionVO.java new file mode 100644 index 000000000000..e9c294bd1610 --- /dev/null +++ b/plugins/logs-web-server/src/main/java/org/apache/cloudstack/logsws/vo/LogsWebSessionVO.java @@ -0,0 +1,192 @@ +// 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.logsws.vo; + + +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import javax.persistence.Column; +import javax.persistence.Convert; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; + +import org.apache.cloudstack.logsws.LogsWebSession; +import org.apache.cloudstack.util.StringListJsonConverter; + +import com.cloud.utils.db.GenericDao; + +@Entity +@Table(name = "logs_web_session") +public class LogsWebSessionVO implements LogsWebSession { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private long id; + + @Column(name = "uuid") + private String uuid; + + @Column(name = "filters", columnDefinition = "json") + @Convert(converter = StringListJsonConverter.class) + private List filters; + + @Column(name = "domain_id") + private long domainId; + + @Column(name = "account_id") + private long accountId; + + @Column(name = "creator_address") + private String creatorAddress; + + @Column(name = "connections") + private int connections; + + @Column(name = "connected_time") + @Temporal(value = TemporalType.TIMESTAMP) + private Date connectedTime; + + @Column(name = "client_address") + private String clientAddress; + + @Column(name = GenericDao.CREATED_COLUMN) + private Date created; + + @Column(name = GenericDao.REMOVED_COLUMN) + private Date removed; + + @Override + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + @Override + public String getUuid() { + return uuid; + } + + public void setUuid(String uuid) { + this.uuid = uuid; + } + + @Override + public List getFilters() { + return filters; + } + + @Override + public long getDomainId() { + return domainId; + } + + public void setDomainId(long domainId) { + this.domainId = domainId; + } + + @Override + public long getAccountId() { + return accountId; + } + + public void setAccountId(long accountId) { + this.accountId = accountId; + } + + @Override + public int getConnections() { + return connections; + } + + public void setConnections(int connections) { + this.connections = connections; + } + + @Override + public Date getConnectedTime() { + return connectedTime; + } + + public void setConnectedTime(Date connectedTime) { + this.connectedTime = connectedTime; + } + + @Override + public String getCreatorAddress() { + return creatorAddress; + } + + public void setCreatorAddress(String creatorAddress) { + this.creatorAddress = creatorAddress; + } + + @Override + public String getClientAddress() { + return clientAddress; + } + + public void setClientAddress(String clientAddress) { + this.clientAddress = clientAddress; + } + + @Override + public Date getCreated() { + return created; + } + + public Date getRemoved() { + return removed; + } + + public void setRemoved(Date removed) { + this.removed = removed; + } + + public LogsWebSessionVO() { + this.uuid = UUID.randomUUID().toString(); + } + + public LogsWebSessionVO(List filters, long domainId, long accountId, String creatorAddress) { + this.filters = filters; + this.uuid = UUID.randomUUID().toString(); + this.domainId = domainId; + this.accountId = accountId; + this.creatorAddress = creatorAddress; + } + + @Override + public Class getEntityType() { + return LogsWebSession.class; + } + + @Override + public String getName() { + return uuid; + } +} diff --git a/plugins/logs-web-server/src/main/resources/META-INF/cloudstack/logs-web-server/module.properties b/plugins/logs-web-server/src/main/resources/META-INF/cloudstack/logs-web-server/module.properties new file mode 100644 index 000000000000..1839e8819b2f --- /dev/null +++ b/plugins/logs-web-server/src/main/resources/META-INF/cloudstack/logs-web-server/module.properties @@ -0,0 +1,18 @@ +# 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. +name=logs-web-server +parent=backend diff --git a/plugins/logs-web-server/src/main/resources/META-INF/cloudstack/logs-web-server/spring-logs-web-server-context.xml b/plugins/logs-web-server/src/main/resources/META-INF/cloudstack/logs-web-server/spring-logs-web-server-context.xml new file mode 100644 index 000000000000..dc42491d9570 --- /dev/null +++ b/plugins/logs-web-server/src/main/resources/META-INF/cloudstack/logs-web-server/spring-logs-web-server-context.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/plugins/logs-web-server/src/test/java/org/apache/cloudstack/logsws/LogsWebSessionApiServiceImplTest.java b/plugins/logs-web-server/src/test/java/org/apache/cloudstack/logsws/LogsWebSessionApiServiceImplTest.java new file mode 100644 index 000000000000..ac3186d2023f --- /dev/null +++ b/plugins/logs-web-server/src/test/java/org/apache/cloudstack/logsws/LogsWebSessionApiServiceImplTest.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 org.apache.cloudstack.logsws; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Enumeration; + +import org.junit.Test; + +public class LogsWebSessionApiServiceImplTest { + + @Test + public void getRelaIp() { + try { + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces.hasMoreElements()) { + NetworkInterface iface = interfaces.nextElement(); + if (iface.isLoopback() || iface.isPointToPoint() || !iface.isUp()) continue; + + for (InterfaceAddress addr : iface.getInterfaceAddresses()) { + InetAddress inetAddr = addr.getAddress(); + if (inetAddr instanceof Inet4Address && !inetAddr.isLoopbackAddress()) { + System.out.println("IP address: " + inetAddr.getHostAddress()); + } + } + } + } catch (SocketException e) { + System.err.println("Error retrieving IP address: " + e.getMessage()); + } + } + +} diff --git a/plugins/logs-web-server/src/test/java/org/apache/cloudstack/logsws/LogsWebSessionManagerImplTest.java b/plugins/logs-web-server/src/test/java/org/apache/cloudstack/logsws/LogsWebSessionManagerImplTest.java new file mode 100644 index 000000000000..df4062f03c19 --- /dev/null +++ b/plugins/logs-web-server/src/test/java/org/apache/cloudstack/logsws/LogsWebSessionManagerImplTest.java @@ -0,0 +1,52 @@ +// 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.logsws; + +import java.util.UUID; + +import org.apache.cloudstack.logsws.dao.LogsWebSessionDao; +import org.apache.cloudstack.logsws.vo.LogsWebSessionVO; +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.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class LogsWebSessionManagerImplTest { + + @Mock + LogsWebSessionDao logsWebSessionDao; + + @InjectMocks + LogsWebSessionManagerImpl logsWSManager = new LogsWebSessionManagerImpl(); + + @Test + public void test_getSession_nullRoute() { + Assert.assertNull(logsWSManager.getSession(null)); + Assert.assertNull(logsWSManager.getSession("abc")); + } + + @Test + public void test_getSession_validRoute() { + String uuid = UUID.randomUUID().toString(); + Mockito.when(logsWebSessionDao.findByUuid(uuid)).thenReturn(Mockito.mock(LogsWebSessionVO.class)); + Assert.assertNotNull(logsWSManager.getSession(uuid)); + } +} diff --git a/plugins/pom.xml b/plugins/pom.xml index e7d13871285e..48eddeaae438 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -97,6 +97,8 @@ integrations/prometheus integrations/kubernetes-service + logs-web-server + metrics network-elements/bigswitch diff --git a/pom.xml b/pom.xml index 2a36c1cc4efa..459ac05ff33f 100644 --- a/pom.xml +++ b/pom.xml @@ -188,6 +188,7 @@ 5.3.26 0.5.4 3.1.7 + 4.1.95.Final @@ -273,6 +274,7 @@ client services quickcloud + framework/websocket-server @@ -634,6 +636,17 @@ jetty-webapp ${cs.jetty.version} + + org.eclipse.jetty.websocket + websocket-server + ${cs.jetty.version} + + + javax.websocket + javax.websocket-api + 1.1 + provided + org.eclipse.persistence javax.persistence @@ -735,6 +748,11 @@ java-linstor ${cs.java-linstor.version} + + io.netty + netty-all + ${cs.netty.version} + diff --git a/server/src/main/java/com/cloud/api/ApiServer.java b/server/src/main/java/com/cloud/api/ApiServer.java index c78ac05102f8..ec02b653ff2a 100644 --- a/server/src/main/java/com/cloud/api/ApiServer.java +++ b/server/src/main/java/com/cloud/api/ApiServer.java @@ -16,6 +16,9 @@ // under the License. package com.cloud.api; +import static com.cloud.user.AccountManagerImpl.apiKeyAccess; +import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled; + import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InterruptedIOException; @@ -57,15 +60,6 @@ import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; -import com.cloud.cluster.ManagementServerHostVO; -import com.cloud.cluster.dao.ManagementServerHostDao; -import com.cloud.user.Account; -import com.cloud.user.AccountManager; -import com.cloud.user.AccountManagerImpl; -import com.cloud.user.DomainManager; -import com.cloud.user.User; -import com.cloud.user.UserAccount; -import com.cloud.user.UserVO; import org.apache.cloudstack.acl.APIChecker; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiConstants; @@ -105,6 +99,7 @@ import org.apache.cloudstack.api.response.LoginCmdResponse; import org.apache.cloudstack.config.ApiServiceConfiguration; import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.context.LogContext; import org.apache.cloudstack.framework.config.ConfigKey; import org.apache.cloudstack.framework.config.Configurable; import org.apache.cloudstack.framework.events.EventDistributor; @@ -154,6 +149,8 @@ import com.cloud.api.dispatch.DispatchChainFactory; import com.cloud.api.dispatch.DispatchTask; import com.cloud.api.response.ApiResponseSerializer; +import com.cloud.cluster.ManagementServerHostVO; +import com.cloud.cluster.dao.ManagementServerHostDao; import com.cloud.domain.Domain; import com.cloud.domain.DomainVO; import com.cloud.domain.dao.DomainDao; @@ -172,14 +169,22 @@ import com.cloud.exception.UnavailableCommandException; import com.cloud.projects.dao.ProjectDao; import com.cloud.storage.VolumeApiService; +import com.cloud.user.Account; +import com.cloud.user.AccountManager; +import com.cloud.user.AccountManagerImpl; +import com.cloud.user.DomainManager; +import com.cloud.user.User; +import com.cloud.user.UserAccount; +import com.cloud.user.UserVO; import com.cloud.utils.ConstantTimeComparator; import com.cloud.utils.DateUtil; import com.cloud.utils.HttpUtils; -import com.cloud.utils.HttpUtils.ApiSessionKeySameSite; import com.cloud.utils.HttpUtils.ApiSessionKeyCheckOption; +import com.cloud.utils.HttpUtils.ApiSessionKeySameSite; import com.cloud.utils.Pair; import com.cloud.utils.ReflectUtil; import com.cloud.utils.StringUtils; +import com.cloud.utils.UuidUtils; import com.cloud.utils.component.ComponentContext; import com.cloud.utils.component.ManagerBase; import com.cloud.utils.component.PluggableService; @@ -192,9 +197,6 @@ import com.cloud.utils.net.NetUtils; import com.google.gson.reflect.TypeToken; -import static com.cloud.user.AccountManagerImpl.apiKeyAccess; -import static org.apache.cloudstack.user.UserPasswordResetManager.UserPasswordResetEnabled; - @Component public class ApiServer extends ManagerBase implements HttpRequestHandler, ApiServerService, Configurable { private static final Logger ACCESSLOGGER = LogManager.getLogger("apiserver." + ApiServer.class.getName()); @@ -735,12 +737,17 @@ public boolean isPostRequestsAndTimestampsEnforced() { return isPostRequestsAndTimestampsEnforced; } + private String getCurrentLogContextId() { + return UuidUtils.first(LogContext.current().getLogContextId()); + } + private String getBaseAsyncResponse(final long jobId, final BaseAsyncCmd cmd) { final AsyncJobResponse response = new AsyncJobResponse(); final AsyncJob job = entityMgr.findByIdIncludingRemoved(AsyncJob.class, jobId); response.setJobId(job.getUuid()); response.setResponseName(cmd.getCommandName()); + response.addLogIds(UuidUtils.first(job.getUuid()), getCurrentLogContextId()); return ApiResponseSerializer.toSerializedString(response, cmd.getResponseType()); } @@ -750,6 +757,7 @@ private String getBaseAsyncCreateResponse(final long jobId, final BaseAsyncCreat response.setJobId(job.getUuid()); response.setId(objectUuid); response.setResponseName(cmd.getCommandName()); + response.addLogIds(UuidUtils.first(job.getUuid()), getCurrentLogContextId()); return ApiResponseSerializer.toSerializedString(response, cmd.getResponseType()); } @@ -867,7 +875,9 @@ private String queueCommand(final BaseCmd cmdObj, final Map para } SerializationContext.current().setUuidTranslation(true); - return ApiResponseSerializer.toSerializedStringWithSecureLogs((ResponseObject)cmdObj.getResponseObject(), cmdObj.getResponseType(), log); + ResponseObject responseObject = (ResponseObject)cmdObj.getResponseObject(); + responseObject.addLogIds(getCurrentLogContextId()); + return ApiResponseSerializer.toSerializedStringWithSecureLogs(responseObject, cmdObj.getResponseType(), log); } } @@ -902,6 +912,7 @@ private void buildAsyncListResponse(final BaseListCmd command, final Account acc final AsyncJob job = objectJobMap.get(response.getObjectId()); response.setJobId(job.getUuid()); response.setJobStatus(job.getStatus().ordinal()); + response.addLogIds(getCurrentLogContextId()); } } } diff --git a/server/src/main/java/com/cloud/api/ApiServlet.java b/server/src/main/java/com/cloud/api/ApiServlet.java index 21d093758127..7a37b8f86f53 100644 --- a/server/src/main/java/com/cloud/api/ApiServlet.java +++ b/server/src/main/java/com/cloud/api/ApiServlet.java @@ -25,8 +25,8 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.regex.Pattern; import java.util.Set; +import java.util.regex.Pattern; import javax.inject.Inject; import javax.servlet.ServletConfig; @@ -52,10 +52,9 @@ import org.apache.cloudstack.managed.context.ManagedContext; import org.apache.cloudstack.utils.consoleproxy.ConsoleAccessUtils; import org.apache.commons.collections.MapUtils; - -import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.LogManager; import org.apache.commons.lang3.EnumUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Nullable; import org.springframework.stereotype.Component; import org.springframework.web.context.support.SpringBeanAutowiringSupport; @@ -70,10 +69,9 @@ import com.cloud.user.AccountService; import com.cloud.user.User; import com.cloud.user.UserAccount; - import com.cloud.utils.HttpUtils; -import com.cloud.utils.HttpUtils.ApiSessionKeySameSite; import com.cloud.utils.HttpUtils.ApiSessionKeyCheckOption; +import com.cloud.utils.HttpUtils.ApiSessionKeySameSite; import com.cloud.utils.StringUtils; import com.cloud.utils.db.EntityManager; import com.cloud.utils.net.NetUtils; @@ -117,6 +115,7 @@ public class ApiServlet extends HttpServlet { "listandswitchsamlaccount", "uploadresourceicon" )); + public static final String CLIENT_INET_ADDRESS_KEY = "client-inet-address"; @Inject ApiServerService apiServer; diff --git a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java index 81970b0f8a79..be61f190ec33 100644 --- a/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java +++ b/server/src/main/java/com/cloud/configuration/ConfigurationManagerImpl.java @@ -153,6 +153,9 @@ import com.cloud.api.query.vo.NetworkOfferingJoinVO; import com.cloud.capacity.CapacityManager; import com.cloud.capacity.dao.CapacityDao; +import com.cloud.cluster.ManagementServerHostVO; +import com.cloud.cluster.dao.ManagementServerHostDao; +import com.cloud.cluster.dao.ManagementServerHostDetailsDao; import com.cloud.configuration.Resource.ResourceType; import com.cloud.dc.AccountVlanMapVO; import com.cloud.dc.ClusterDetailsDao; @@ -456,6 +459,10 @@ public class ConfigurationManagerImpl extends ManagerBase implements Configurati @Inject ImageStoreDetailsDao _imageStoreDetailsDao; @Inject + ManagementServerHostDao managementServerHostDao; + @Inject + ManagementServerHostDetailsDao managementServerHostDetailsDao; + @Inject MessageBus messageBus; @Inject AgentManager _agentManager; @@ -864,6 +871,13 @@ public String updateConfiguration(final long userId, final String name, final St } break; + case ManagementServer: + final ManagementServerHostVO managementServer = managementServerHostDao.findById(resourceId); + Preconditions.checkState(managementServer != null); + resourceType = ApiCommandResourceType.ManagementServer; + managementServerHostDetailsDao.addDetail(resourceId, name, value, true); + break; + default: throw new InvalidParameterValueException("Scope provided is invalid"); } @@ -998,8 +1012,9 @@ public Configuration updateConfiguration(final UpdateCfgCmd cmd) throws InvalidP String value = cmd.getValue(); final Long zoneId = cmd.getZoneId(); final Long clusterId = cmd.getClusterId(); - final Long storagepoolId = cmd.getStoragepoolId(); + final Long storagepoolId = cmd.getStoragePoolId(); final Long imageStoreId = cmd.getImageStoreId(); + final Long managementServerId = cmd.getManagementServerId(); Long accountId = cmd.getAccountId(); Long domainId = cmd.getDomainId(); // check if config value exists @@ -1079,6 +1094,11 @@ public Configuration updateConfiguration(final UpdateCfgCmd cmd) throws InvalidP id = imageStoreId; paramCountCheck++; } + if (managementServerId != null) { + scope = ConfigKey.Scope.ManagementServer; + id = managementServerId; + paramCountCheck++; + } if (paramCountCheck > 1) { throw new InvalidParameterValueException("cannot handle multiple IDs, provide only one ID corresponding to the scope"); @@ -1138,6 +1158,7 @@ public Pair resetConfiguration(final ResetCfgCmd cmd) thr final Long accountId = cmd.getAccountId(); final Long domainId = cmd.getDomainId(); final Long imageStoreId = cmd.getImageStoreId(); + final Long managementServerId = cmd.getManagementServerId(); ConfigKey configKey = null; Optional optionalValue; String defaultValue; @@ -1171,6 +1192,7 @@ public Pair resetConfiguration(final ResetCfgCmd cmd) thr scopeMap.put(ConfigKey.Scope.Account.toString(), accountId); scopeMap.put(ConfigKey.Scope.StoragePool.toString(), storagepoolId); scopeMap.put(ConfigKey.Scope.ImageStore.toString(), imageStoreId); + scopeMap.put(ConfigKey.Scope.ManagementServer.toString(), managementServerId); ParamCountPair paramCountPair = getParamCount(scopeMap); id = paramCountPair.getId(); @@ -1267,6 +1289,16 @@ public Pair resetConfiguration(final ResetCfgCmd cmd) thr newValue = optionalValue.isPresent() ? optionalValue.get().toString() : defaultValue; break; + case ManagementServer: + final ManagementServerHostVO managementServer = managementServerHostDao.findById(id); + if (managementServer == null) { + throw new InvalidParameterValueException("unable to find management server by id " + id); + } + managementServerHostDetailsDao.removeDetail(id, name); + optionalValue = Optional.ofNullable(configKey != null ? configKey.valueIn(id) : config.getValue()); + newValue = optionalValue.isPresent() ? optionalValue.get().toString() : defaultValue; + break; + default: if (!_configDao.update(name, category, defaultValue)) { logger.error("Failed to reset configuration option, name: {}, defaultValue: {}", name, defaultValue); diff --git a/server/src/main/java/com/cloud/server/ManagementServerImpl.java b/server/src/main/java/com/cloud/server/ManagementServerImpl.java index 3f811c152f00..6d5eec41bf4f 100644 --- a/server/src/main/java/com/cloud/server/ManagementServerImpl.java +++ b/server/src/main/java/com/cloud/server/ManagementServerImpl.java @@ -789,7 +789,6 @@ import com.cloud.storage.GuestOSVO; import com.cloud.storage.GuestOsCategory; import com.cloud.storage.ScopeType; -import com.cloud.storage.snapshot.SnapshotManager; import com.cloud.storage.Storage; import com.cloud.storage.StorageManager; import com.cloud.storage.StoragePool; @@ -806,6 +805,7 @@ import com.cloud.storage.dao.VMTemplateDao; import com.cloud.storage.dao.VolumeDao; import com.cloud.storage.secondary.SecondaryStorageVmManager; +import com.cloud.storage.snapshot.SnapshotManager; import com.cloud.tags.ResourceTagVO; import com.cloud.tags.dao.ResourceTagDao; import com.cloud.template.TemplateManager; @@ -2261,6 +2261,7 @@ public Pair, Integer> searchForConfigurations(fina final Long clusterId = cmd.getClusterId(); final Long storagepoolId = cmd.getStoragepoolId(); final Long imageStoreId = cmd.getImageStoreId(); + final Long managementServerId = cmd.getManagementServerId(); Long accountId = cmd.getAccountId(); Long domainId = cmd.getDomainId(); final String groupName = cmd.getGroupName(); @@ -2314,6 +2315,11 @@ public Pair, Integer> searchForConfigurations(fina id = imageStoreId; paramCountCheck++; } + if (managementServerId != null) { + scope = ConfigKey.Scope.ManagementServer; + id = managementServerId; + paramCountCheck++; + } if (paramCountCheck > 1) { throw new InvalidParameterValueException("cannot handle multiple IDs, provide only one ID corresponding to the scope"); @@ -4710,6 +4716,7 @@ public Map listCapabilities(final ListCapabilitiesCmd cmd) { final boolean kubernetesServiceEnabled = Boolean.parseBoolean(_configDao.getValue("cloud.kubernetes.service.enabled")); final boolean kubernetesClusterExperimentalFeaturesEnabled = Boolean.parseBoolean(_configDao.getValue("cloud.kubernetes.cluster.experimental.features.enabled")); + final boolean logsWebServerEnabled = Boolean.parseBoolean(_configDao.getValue("logs.web.server.enabled")); // check if region-wide secondary storage is used boolean regionSecondaryEnabled = false; @@ -4756,6 +4763,7 @@ public Map listCapabilities(final ListCapabilitiesCmd cmd) { capabilities.put(ApiConstants.SHAREDFSVM_MIN_RAM_SIZE, fsVmMinRam); if (isCallerRootAdmin) { capabilities.put(ApiConstants.EXTENSIONS_PATH, extensionsManager.getExtensionsPath()); + capabilities.put(ApiConstants.LOGS_WEB_SERVER_ENABLED, logsWebServerEnabled); } capabilities.put(ApiConstants.ADDITONAL_CONFIG_ENABLED, UserVmManager.EnableAdditionalVmConfig.valueIn(caller.getId())); diff --git a/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java b/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java index a62f4d113afc..eb0446a1c7c6 100644 --- a/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java +++ b/server/src/test/java/com/cloud/configuration/ConfigurationManagerImplTest.java @@ -853,14 +853,9 @@ public void validateValueRangeTestValidatesIntValueWhenConfigHasNumericRange() { @Test public void testResetConfigurations() { Long poolId = 1L; - ResetCfgCmd cmd = Mockito.mock(ResetCfgCmd.class); - Mockito.when(cmd.getCfgName()).thenReturn("pool.storage.capacity.disablethreshold"); - Mockito.when(cmd.getStoragepoolId()).thenReturn(poolId); - Mockito.when(cmd.getZoneId()).thenReturn(null); - Mockito.when(cmd.getClusterId()).thenReturn(null); - Mockito.when(cmd.getAccountId()).thenReturn(null); - Mockito.when(cmd.getDomainId()).thenReturn(null); - Mockito.when(cmd.getImageStoreId()).thenReturn(null); + ResetCfgCmd cmd = new ResetCfgCmd(); + ReflectionTestUtils.setField(cmd, "storagePoolId", poolId); + ReflectionTestUtils.setField(cmd, "cfgName", "pool.storage.capacity.disablethreshold"); ConfigurationVO cfg = new ConfigurationVO("Advanced", "DEFAULT", "test", "pool.storage.capacity.disablethreshold", null, "description"); cfg.setScope(10); diff --git a/server/src/test/java/com/cloud/server/ManagementServerImplTest.java b/server/src/test/java/com/cloud/server/ManagementServerImplTest.java index ebced92f8fe9..7c10d7124284 100644 --- a/server/src/test/java/com/cloud/server/ManagementServerImplTest.java +++ b/server/src/test/java/com/cloud/server/ManagementServerImplTest.java @@ -732,14 +732,9 @@ public void testSearchForConfigurationsMultipleIds() { @Test public void testSearchForConfigurations() { Long poolId = 1L; - ListCfgsByCmd cmd = Mockito.mock(ListCfgsByCmd.class); - Mockito.when(cmd.getConfigName()).thenReturn("pool.storage.capacity.disablethreshold"); - Mockito.when(cmd.getStoragepoolId()).thenReturn(poolId); - Mockito.when(cmd.getZoneId()).thenReturn(null); - Mockito.when(cmd.getClusterId()).thenReturn(null); - Mockito.when(cmd.getAccountId()).thenReturn(null); - Mockito.when(cmd.getDomainId()).thenReturn(null); - Mockito.when(cmd.getImageStoreId()).thenReturn(null); + ListCfgsByCmd cmd = new ListCfgsByCmd(); + ReflectionTestUtils.setField(cmd, "storagePoolId", poolId); + ReflectionTestUtils.setField(cmd, "configName", "pool.storage.capacity.disablethreshold"); SearchCriteria sc = Mockito.mock(SearchCriteria.class); Mockito.when(configDao.createSearchCriteria()).thenReturn(sc); diff --git a/tools/apidoc/gen_toc.py b/tools/apidoc/gen_toc.py index e41a04ff2e1b..f5877a1dd0c9 100644 --- a/tools/apidoc/gen_toc.py +++ b/tools/apidoc/gen_toc.py @@ -273,7 +273,9 @@ 'Extensions' : 'Extension', 'CustomAction' : 'Extension', 'CustomActions' : 'Extension', - 'ImportVmTask': 'Import VM Task' + 'ImportVmTask': 'Import VM Task', + 'LogsWebSession': 'Logs Web Session', + 'LogsWebSessions': 'Logs Web Session' } diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 805ea1adae94..9917ddcc0ef4 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -557,6 +557,7 @@ "label.clear.list": "Clear list", "label.clear.notification": "Clear notification", "label.clientid": "Provider Client ID", +"label.clientaddress": "Client Address", "label.close": "Close", "label.cloud.managed": "CloudManaged", "label.cloudian.admin.password": "Admin Service Password", @@ -624,6 +625,7 @@ "label.confirmdeclineinvitation": "Are you sure you want to decline this project invitation?", "label.confirmpassword": "Confirm password", "label.confirmpassword.description": "Please type the same password again.", +"label.connected": "Connected Clients", "label.connected.agents": "Connected Agents", "label.connect": "Connect", "label.connectiontimeout": "Connection timeout", @@ -776,6 +778,7 @@ "label.delete.internal.lb": "Delete internal LB", "label.delete.ipv4.subnet": "Delete IPv4 subnet", "label.delete.ip.v6.prefix": "Delete IPv6 prefix", +"label.delete.logs.web.sessions": "Delete Logs Web Session", "label.delete.netscaler": "Delete NetScaler", "label.delete.niciranvp": "Remove Nvp controller", "label.delete.opendaylight.device": "Delete OpenDaylight controller", @@ -1075,6 +1078,7 @@ "label.shared.filesystems": "Shared FileSystems", "label.filesystem": "Filesystem", "label.filter": "Filter", +"label.filters": "Filters", "label.filter.annotations.all": "All comments", "label.filter.annotations.self": "Created by me", "label.filterby": "Filter by", @@ -1487,6 +1491,7 @@ "label.login.portal": "Portal login", "label.login.single.signon": "Single sign-on", "label.logout": "Logout", +"label.logs.web.sessions": "Logs Web Sessions", "label.lun": "LUN", "label.lun.number": "LUN #", "label.lxc": "LXC", @@ -2305,8 +2310,9 @@ "label.snapshottype": "Snapshot Type", "label.sockettimeout": "Socket timeout", "label.softwareversion": "Software version", -"label.source": "Select Import-Export Source Hypervisor", +"label.source": "Source", "label.source.based": "SourceBased", +"label.source.hypervisor": "Select Import-Export Source Hypervisor", "label.sourcecidr": "Source CIDR", "label.sourcecidrlist": "Source CIDR list", "label.sourcehost": "Source host", @@ -2693,6 +2699,7 @@ "label.view": "View", "label.view.all": "View all", "label.view.console": "View console", +"label.view.logs": "View logs", "label.viewing": "Viewing", "label.virtualmachine": "Instance", "label.virtualmachinecount": "Instances Count", @@ -3264,6 +3271,7 @@ "message.delete.failed": "Delete fail", "message.delete.gateway": "Please confirm you want to delete the gateway.", "message.delete.ip.v6.prefix.processing": "Deleting IPv6 prefix...", +"message.delete.logs.web.session": "Deleting logs web session...", "message.delete.port.forward.processing": "Deleting port forwarding rule...", "message.delete.project": "Are you sure you want to delete this project?", "message.delete.rule.processing": "Deleting rule...", @@ -3771,6 +3779,7 @@ "message.setup.physical.network.during.zone.creation.basic": "When adding a basic Zone, you can set up one physical Network, which corresponds to a NIC on the hypervisor. The Network carries several types of traffic.

You may also add other traffic types onto the physical Network.", "message.shared.network.offering.warning": "Domain admins and regular Users can only create shared Networks from Network offering with the setting specifyvlan=false. Please contact an administrator to create a Network offering if this list is empty.", "message.shared.network.unsupported.for.nsx": "Shared networks aren't supported for NSX enabled Zones", +"message.showing.logs": "Showing logs for '%x'", "message.shutdown.triggered": "A shutdown has been triggered. CloudStack will not accept new jobs", "message.snapshot.additional.zones": "Snapshots will always be created in its native Zone - %x, here you can select additional zone(s) where it will be copied to at creation time", "message.snapshot.desc": "Snapshot to create a ROOT disk from", diff --git a/ui/public/locales/te.json b/ui/public/locales/te.json index 5f89bbf7ed74..f41bc456d54a 100644 --- a/ui/public/locales/te.json +++ b/ui/public/locales/te.json @@ -2088,8 +2088,8 @@ "label.snapshottype": "స్నాప్‌షాట్ రకం", "label.sockettimeout": "సాకెట్ గడువు ముగిసింది", "label.softwareversion": "సాఫ్ట్‌వేర్ వెర్షన్", - "label.source": "దిగుమతి-ఎగుమతి మూలం హైపర్‌వైజర్‌ని ఎంచుకోండి", "label.source.based": "మూలాధారం", + "label.source.hypervisor": "దిగుమతి-ఎగుమతి మూలం హైపర్‌వైజర్‌ని ఎంచుకోండి", "label.sourcecidr": "మూలం CIDR", "label.sourcehost": "మూల హోస్ట్", "label.sourceipaddress": "మూల IP చిరునామా", diff --git a/ui/src/components/header/UserMenu.vue b/ui/src/components/header/UserMenu.vue index a67fe06096ac..c4ca0b5afbfb 100644 --- a/ui/src/components/header/UserMenu.vue +++ b/ui/src/components/header/UserMenu.vue @@ -59,6 +59,10 @@ {{ $t('label.help') }} + + + {{ $t('label.logs') }} + @@ -177,6 +181,9 @@ export default { case 'logout': this.handleLogout() break + case 'logs': + eventBus.emit('view-logs', ['websocket', 'account']) + break } }, handleLogout () { diff --git a/ui/src/components/page/GlobalLayout.vue b/ui/src/components/page/GlobalLayout.vue index 5c37c6f83d66..e5adfbfbe60d 100644 --- a/ui/src/components/page/GlobalLayout.vue +++ b/ui/src/components/page/GlobalLayout.vue @@ -70,6 +70,11 @@ + + + + + + + diff --git a/ui/src/components/view/SettingsTab.vue b/ui/src/components/view/SettingsTab.vue index be55d03c4b94..fb8ef5ebad92 100644 --- a/ui/src/components/view/SettingsTab.vue +++ b/ui/src/components/view/SettingsTab.vue @@ -104,6 +104,9 @@ export default { case 'imagestore': this.scopeKey = 'imagestoreuuid' break + case 'managementserver': + this.scopeKey = 'managementserverid' + break default: this.scopeKey = '' } diff --git a/ui/src/config/section/infra/managementServers.js b/ui/src/config/section/infra/managementServers.js index d2d11d5b25d0..4bf54943fd85 100644 --- a/ui/src/config/section/infra/managementServers.js +++ b/ui/src/config/section/infra/managementServers.js @@ -39,6 +39,10 @@ export default { name: 'details', component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) }, + { + name: 'settings', + component: shallowRef(defineAsyncComponent(() => import('@/components/view/SettingsTab.vue'))) + }, { name: 'management.server.peers', component: shallowRef(defineAsyncComponent(() => import('@/views/infra/ManagementServerPeerTab.vue'))) diff --git a/ui/src/config/section/tools.js b/ui/src/config/section/tools.js index a07228ca87b4..2edccb60e6e2 100644 --- a/ui/src/config/section/tools.js +++ b/ui/src/config/section/tools.js @@ -234,6 +234,32 @@ export default { groupMap: (selection) => { return selection.map(x => { return { id: x } }) } } ] + }, + { + name: 'listLogsWebSessions', + title: 'label.logs.web.sessions', + icon: 'gateway-outlined', + permission: ['listLogsWebSessions'], + columns: () => { + const cols = ['filters', 'account', 'clientaddress', 'connected'] + return cols + }, + details: () => { + const fields = ['id', 'account', 'filters', 'clientaddress', 'connected'] + return fields + }, + actions: [ + { + api: 'deleteLogsWebSessions', + icon: 'delete-outlined', + label: 'label.delete.logs.web.session', + message: 'message.delete.logs.web.session', + dataView: true, + groupAction: true, + popup: true, + groupMap: (selection) => { return selection.map(x => { return { id: x } }) } + } + ] } ] } diff --git a/ui/src/utils/plugins.js b/ui/src/utils/plugins.js index 648bc3ae0811..2adace08a7b0 100644 --- a/ui/src/utils/plugins.js +++ b/ui/src/utils/plugins.js @@ -18,7 +18,8 @@ import _ from 'lodash' import { i18n } from '@/locales' import { getAPI } from '@/api' -import { message, notification, Modal } from 'ant-design-vue' +import { message, notification, Modal, Button } from 'ant-design-vue' +import { h } from 'vue' import eventBus from '@/config/eventBus' import store from '@/store' import { sourceToken } from '@/utils/request' @@ -26,6 +27,27 @@ import { toLocalDate, toLocaleDate } from '@/utils/date' export const pollJobPlugin = { install (app) { + function canViewLogs (logIds) { + console.log('canViewLogs', store.getters.features.logswebserverenabled, 'createLogsWebSession' in store.getters.apis, logIds, logIds && logIds.length > 0) + return store.getters.features.logswebserverenabled && + 'createLogsWebSession' in store.getters.apis && + logIds && logIds.length > 0 + } + + function handleViewLogs (logIds) { + eventBus.emit('view-logs', logIds) + } + + function getMessageContent (message, logIds) { + if (canViewLogs(logIds)) { + return h('span', [ + message + ' ', + h(Button, { type: 'link', onClick: () => { handleViewLogs(logIds) } }, i18n.global.t('label.view.logs')) + ]) + } + return message + } + app.config.globalProperties.$pollJob = function (options) { /** * @param {String} jobId @@ -44,6 +66,7 @@ export const pollJobPlugin = { * @param {Object} [action=null] * @param {Object} [bulkAction=false] * @param {String} resourceId + * @param {String} [logIds=() => []] */ const { jobId, @@ -61,7 +84,8 @@ export const pollJobPlugin = { catchMethod = () => {}, action = null, bulkAction = false, - resourceId = null + resourceId = null, + logIds = [] } = options store.dispatch('AddHeaderNotice', { @@ -89,10 +113,19 @@ export const pollJobPlugin = { this.$store.commit('SET_HEADER_NOTICES', jobs) }) + const allLogIds = [] + if (logIds) { + allLogIds.push(...logIds) + } + options.originalPage = options.originalPage || this.$router.currentRoute.value.path getAPI('queryAsyncJobResult', { jobId }).then(json => { const result = json.queryasyncjobresultresponse eventBus.emit('update-job-details', { jobId, resourceId }) + if (result.logids) { + allLogIds.push(...result.logids) + } + console.log('pollJobPlugin', result.logids, allLogIds) if (result.jobstatus === 1) { if (showSuccessMessage) { var content = successMessage @@ -103,7 +136,7 @@ export const pollJobPlugin = { content = content + ' - ' + name } message.success({ - content, + content: getMessageContent(content, allLogIds), key: jobId, duration: 2 }) @@ -129,7 +162,7 @@ export const pollJobPlugin = { } else if (result.jobstatus === 2) { if (!bulkAction) { message.error({ - content: errorMessage, + content: getMessageContent(errorMessage, allLogIds), key: jobId, duration: 1 }) @@ -153,14 +186,26 @@ export const pollJobPlugin = { store.commit('SET_COUNT_NOTIFY', countNotify) } } - notification.error({ + const errorConfig = { top: '65px', message: errMessage, description: desc, key: jobId, duration: 0, onClose: onClose - }) + } + if (canViewLogs(allLogIds)) { + errorConfig.btn = h( + Button, + { + type: 'secondary', + size: 'small', + onClick: () => handleViewLogs(allLogIds) + }, + i18n.global.t('label.view.logs') + ) + } + notification.error(errorConfig) store.dispatch('AddHeaderNotice', { key: jobId, title, @@ -180,7 +225,7 @@ export const pollJobPlugin = { } else if (result.jobstatus === 0) { if (showLoading) { message.loading({ - content: loadingMessage, + content: getMessageContent(loadingMessage, allLogIds), key: jobId, duration: 0 }) diff --git a/ui/src/views/AutogenView.vue b/ui/src/views/AutogenView.vue index 8c46ca64225c..91db89c7aaf6 100644 --- a/ui/src/views/AutogenView.vue +++ b/ui/src/views/AutogenView.vue @@ -1482,7 +1482,7 @@ export default { param.loading = false }) }, - pollActionCompletion (jobId, action, resourceName, resource, showLoading = true) { + pollActionCompletion (jobId, action, resourceName, resource, showLoading = true, logIds = null) { if (this.shouldNavigateBack(action)) { action.isFetchData = false } @@ -1542,7 +1542,8 @@ export default { catchMessage: this.$t('error.fetching.async.job.result'), action, bulkAction: `${this.selectedItems.length > 0}` && this.showGroupActionModal, - resourceId: resource + resourceId: resource, + logIds: logIds }) }) }, @@ -1655,6 +1656,7 @@ export default { handleResponse (response, resourceName, resource, action, showLoading = true) { return new Promise(resolve => { let jobId = null + let logIds = null for (const obj in response) { if (obj.includes('response')) { if (response[obj].jobid) { @@ -1690,6 +1692,9 @@ export default { } break } + if (response[obj].logids) { + logIds = response[obj].logids + } } } if (['addLdapConfiguration', 'deleteLdapConfiguration'].includes(action.api)) { @@ -1697,7 +1702,7 @@ export default { } if (jobId) { eventBus.emit('update-resource-state', { selectedItems: this.selectedItems, resource, state: 'InProgress', jobid: jobId }) - resolve(this.pollActionCompletion(jobId, action, resourceName, resource, showLoading)) + resolve(this.pollActionCompletion(jobId, action, resourceName, resource, showLoading, logIds)) } resolve(false) }) diff --git a/ui/src/views/compute/DeployVM.vue b/ui/src/views/compute/DeployVM.vue index e13c9bc17b25..094da04e8472 100644 --- a/ui/src/views/compute/DeployVM.vue +++ b/ui/src/views/compute/DeployVM.vue @@ -2565,7 +2565,8 @@ export default { catchMessage: this.$t('error.fetching.async.job.result'), action: { isFetchData: false - } + }, + logIds: response.deployvirtualmachineresponse.logids }) } // Sending a refresh in case it hasn't picked up the new VM diff --git a/ui/src/views/tools/ManageInstances.vue b/ui/src/views/tools/ManageInstances.vue index a6260bfb316d..3d35db66680a 100644 --- a/ui/src/views/tools/ManageInstances.vue +++ b/ui/src/views/tools/ManageInstances.vue @@ -72,7 +72,7 @@ layout="vertical" > - + propertiesRef = new AtomicReference<>(); + + public static String getProperty(String name) { + Properties props = propertiesRef.get(); + if (props != null) { + return props.getProperty(name); + } + File propsFile = PropertiesUtil.findConfigFile(PROPERTIES_FILE); + if (propsFile == null) { + logger.error("{} file not found", PROPERTIES_FILE); + return null; + } + Properties tempProps = new Properties(); + try (FileInputStream is = new FileInputStream(propsFile)) { + tempProps.load(is); + } catch (IOException e) { + logger.error("Error loading {}: {}", PROPERTIES_FILE, e.getMessage(), e); + return null; + } + if (!propertiesRef.compareAndSet(null, tempProps)) { + tempProps = propertiesRef.get(); + } + return tempProps.getProperty(name); + } + + public static String getProperty(String name, String defaultValue) { + String value = getProperty(name); + if (value == null) { + value = defaultValue; + } + return value; + } + + public static Pair getServerModeAndPort() { + boolean httpsEnabled = Boolean.parseBoolean(getProperty(KEY_HTTPS_ENABLE, "false")); + if (!httpsEnabled) { + return new Pair<>(false, getIntegerProperty(KEY_HTTP_PORT, HTTP_PORT)); + } + return new Pair<>(true, getIntegerProperty(KEY_HTTPS_PORT, HTTPS_PORT)); + } + + protected static int getIntegerProperty(String key, int defaultValue) { + String portStr = getProperty(key); + if (portStr == null) { + return defaultValue; + } + try { + return Integer.parseInt(portStr); + } catch (NumberFormatException e) { + logger.warn("Invalid value for {}: {}, using default {}", key, portStr, defaultValue); + return defaultValue; + } + } +} diff --git a/utils/src/test/java/org/apache/cloudstack/utils/server/ServerPropertiesUtilTest.java b/utils/src/test/java/org/apache/cloudstack/utils/server/ServerPropertiesUtilTest.java new file mode 100644 index 000000000000..2eece43e47b1 --- /dev/null +++ b/utils/src/test/java/org/apache/cloudstack/utils/server/ServerPropertiesUtilTest.java @@ -0,0 +1,95 @@ +// 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.utils.server; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Properties; + +import org.junit.After; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.utils.PropertiesUtil; + +@RunWith(MockitoJUnitRunner.class) +public class ServerPropertiesUtilTest { + + @After + public void clearCache() { + ServerPropertiesUtil.propertiesRef.set(null); + } + + @Test + public void returnsPropertyValueWhenPropertiesAreLoaded() { + Properties mockProperties = mock(Properties.class); + when(mockProperties.getProperty("key")).thenReturn("value"); + ServerPropertiesUtil.propertiesRef.set(mockProperties); + String result = ServerPropertiesUtil.getProperty("key"); + assertEquals("value", result); + } + + @Test + public void returnsNullWhenPropertyDoesNotExist() { + Properties mockProperties = mock(Properties.class); + ServerPropertiesUtil.propertiesRef.set(mockProperties); + assertNull(ServerPropertiesUtil.getProperty("nonexistentKey")); + } + + @Test + public void loadsPropertiesFromFileWhenNotCached() throws Exception { + File tempFile = Files.createTempFile("server", ".properties").toFile(); + tempFile.deleteOnExit(); + Files.writeString(tempFile.toPath(), "key=value\n"); + try (MockedStatic mocked = mockStatic(PropertiesUtil.class)) { + mocked.when(() -> PropertiesUtil.findConfigFile(ServerPropertiesUtil.PROPERTIES_FILE)) + .thenReturn(tempFile); + assertEquals("value", ServerPropertiesUtil.getProperty("key")); + } + } + + @Test + public void returnsNullWhenPropertiesFileNotFound() { + try (MockedStatic mocked = mockStatic(PropertiesUtil.class)) { + mocked.when(() -> PropertiesUtil.findConfigFile(ServerPropertiesUtil.PROPERTIES_FILE)) + .thenReturn(null); + assertNull(ServerPropertiesUtil.getProperty("key")); + } + } + + @Test + public void returnsNullWhenIOExceptionOccurs() throws IOException { + File tempFile = Files.createTempFile("bad", ".properties").toFile(); + tempFile.deleteOnExit(); + Files.writeString(tempFile.toPath(), "\u0000\u0000\u0000"); + try (MockedStatic mocked = mockStatic(PropertiesUtil.class)) { + mocked.when(() -> PropertiesUtil.findConfigFile(ServerPropertiesUtil.PROPERTIES_FILE)) + .thenReturn(tempFile); + assertNull(ServerPropertiesUtil.getProperty("key")); + } + } +}