content: // Licensed to the Apache Software Foundation (ASF) under one
  // or more contributor license agreements.  See the NOTICE file
  // distributed with this work for additional information
  // regarding copyright ownership.  The ASF licenses this file
  // to you under the Apache License, Version 2.0 (the
  // "License"); you may not use this file except in compliance
  // with the License.  You may obtain a copy of the License at
  //
  //   http://www.apache.org/licenses/LICENSE-2.0
  //
  // Unless required by applicable law or agreed to in writing,
  // software distributed under the License is distributed on an
  // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
  // KIND, either express or implied.  See the License for the
  // specific language governing permissions and limitations
  // under the License.
  package org.apache.cloudstack.consoleproxy;
  
  import java.util.ArrayList;
  import java.util.Arrays;
  import java.util.Date;
  import java.util.List;
  import java.util.Map;
  import java.util.UUID;
  import java.util.concurrent.Executors;
  import java.util.concurrent.ScheduledExecutorService;
  import java.util.concurrent.TimeUnit;
  
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import javax.inject.Inject;
  import javax.naming.ConfigurationException;
  
  import org.apache.cloudstack.api.ResponseGenerator;
  import org.apache.cloudstack.api.ResponseObject;
  import org.apache.cloudstack.api.command.user.consoleproxy.ConsoleEndpoint;
  import org.apache.cloudstack.api.command.user.consoleproxy.ListConsoleSessionsCmd;
  import org.apache.cloudstack.api.response.ConsoleSessionResponse;
  import org.apache.cloudstack.api.response.ListResponse;
  import org.apache.cloudstack.context.CallContext;
  import org.apache.cloudstack.framework.config.ConfigKey;
  import org.apache.cloudstack.framework.security.keys.KeysManager;
  import org.apache.cloudstack.managed.context.ManagedContextRunnable;
  import org.apache.commons.codec.binary.Base64;
  import org.apache.commons.lang3.ArrayUtils;
  import org.apache.commons.lang3.ObjectUtils;
  import org.apache.commons.lang3.StringUtils;
  import org.apache.logging.log4j.LogManager;
  import org.apache.logging.log4j.Logger;
  import org.joda.time.DateTime;
  
  import com.cloud.agent.AgentManager;
  import com.cloud.agent.api.Answer;
  import com.cloud.agent.api.GetExternalConsoleAnswer;
  import com.cloud.agent.api.GetVmVncTicketAnswer;
  import com.cloud.agent.api.GetVmVncTicketCommand;
  import com.cloud.consoleproxy.ConsoleProxyManager;
  import com.cloud.dc.DataCenter;
  import com.cloud.dc.dao.DataCenterDao;
  import com.cloud.domain.Domain;
  import com.cloud.domain.dao.DomainDao;
  import com.cloud.exception.AgentUnavailableException;
  import com.cloud.exception.InvalidParameterValueException;
  import com.cloud.exception.OperationTimedoutException;
  import com.cloud.exception.PermissionDeniedException;
  import com.cloud.host.HostVO;
  import com.cloud.hypervisor.Hypervisor;
  import com.cloud.resource.ResourceState;
  import com.cloud.server.ManagementServer;
  import com.cloud.servlet.ConsoleProxyClientParam;
  import com.cloud.servlet.ConsoleProxyPasswordBasedEncryptor;
  import com.cloud.storage.GuestOSVO;
  import com.cloud.user.Account;
  import com.cloud.user.AccountManager;
  import com.cloud.uservm.UserVm;
  import com.cloud.utils.Pair;
  import com.cloud.utils.Ternary;
  import com.cloud.utils.component.ManagerBase;
  import com.cloud.utils.concurrency.NamedThreadFactory;
  import com.cloud.utils.db.EntityManager;
  import com.cloud.utils.db.GlobalLock;
  import com.cloud.utils.exception.CloudRuntimeException;
  import com.cloud.vm.ConsoleSessionVO;
  import com.cloud.vm.VirtualMachine;
  import com.cloud.vm.VirtualMachineManager;
  import com.cloud.vm.VmDetailConstants;
  import com.cloud.vm.dao.ConsoleSessionDao;
  import com.cloud.vm.dao.VMInstanceDetailsDao;
  import com.google.gson.Gson;
  import com.google.gson.GsonBuilder;
  
  import static com.cloud.consoleproxy.ConsoleProxyManager.NoVncConsoleShowDot;
  
  public class ConsoleAccessManagerImpl extends ManagerBase implements ConsoleAccessManager {
  
      @Inject
      private AccountManager accountManager;
      @Inject
      private DomainDao domainDao;
      @Inject
      private VirtualMachineManager virtualMachineManager;
      @Inject
      private ManagementServer managementServer;
      @Inject
      private EntityManager entityManager;
      @Inject
      private VMInstanceDetailsDao vmInstanceDetailsDao;
      @Inject
      private KeysManager keysManager;
      @Inject
      private AgentManager agentManager;
      @Inject
      private ConsoleProxyManager consoleProxyManager;
      @Inject
      DataCenterDao dataCenterDao;
      @Inject
      private ConsoleSessionDao consoleSessionDao;
      @Inject
      private ResponseGenerator responseGenerator;
  
      private ScheduledExecutorService executorService = null;
  
      private static KeysManager secretKeysManager;
      private final Gson gson = new GsonBuilder().create();
  
      protected Logger logger = LogManager.getLogger(ConsoleAccessManagerImpl.class);
  
      private static final List<VirtualMachine.State> unsupportedConsoleVMState = Arrays.asList(
              VirtualMachine.State.Stopped, VirtualMachine.State.Restoring, VirtualMachine.State.Error, VirtualMachine.State.Destroyed
      );
  
      protected static final List<ResourceState> MAINTENANCE_RESOURCE_STATES = new ArrayList<>(Arrays.asList(
              ResourceState.ErrorInMaintenance, ResourceState.ErrorInPrepareForMaintenance
      ));
      protected static final String WEB_SOCKET_PATH= "websockify";
  
      @Override
      public boolean configure(String name, Map<String, Object> params) throws ConfigurationException {
          ConsoleAccessManagerImpl.secretKeysManager = keysManager;
          executorService = Executors.newScheduledThreadPool(1, new NamedThreadFactory("ConsoleSession-Scavenger"));
          return super.configure(name, params);
      }
  
      @Override
      public boolean start() {
          int consoleCleanupInterval = ConsoleAccessManager.ConsoleSessionCleanupInterval.value();
          if (consoleCleanupInterval > 0) {
              logger.info(String.format("The ConsoleSessionCleanupTask will run every %s hours", consoleCleanupInterval));
              executorService.scheduleWithFixedDelay(new ConsoleSessionCleanupTask(), consoleCleanupInterval, consoleCleanupInterval, TimeUnit.HOURS);
          }
          return true;
      }
  
      @Override
      public String getConfigComponentName() {
          return ConsoleAccessManager.class.getName();
      }
  
      @Override
      public ConfigKey<?>[] getConfigKeys() {
          return new ConfigKey[] {
                  ConsoleAccessManager.ConsoleSessionCleanupInterval,
                  ConsoleAccessManager.ConsoleSessionCleanupRetentionHours
          };
      }
  
      public class ConsoleSessionCleanupTask extends ManagedContextRunnable {
          @Override
          protected void runInContext() {
              final GlobalLock gcLock = GlobalLock.getInternLock("ConsoleSession.Cleanup.Lock");
              try {
                  if (gcLock.lock(3)) {
                      try {
                          reallyRun();
                      } finally {
                          gcLock.unlock();
                      }
                  }
              } finally {
                  gcLock.releaseRef();
              }
          }
  
          private void reallyRun() {
              if (logger.isDebugEnabled()) {
                  logger.debug("Starting ConsoleSessionCleanupTask...");
              }
              Integer retentionHours = ConsoleAccessManager.ConsoleSessionCleanupRetentionHours.value();
              Date dateBefore = DateTime.now().minusHours(retentionHours).toDate();
              if (logger.isDebugEnabled()) {
                  logger.debug(String.format("Retention hours: %s, checking for removed console session " +
                          "records to expunge older than: %s", retentionHours, dateBefore));
              }
              int sessionsExpunged = consoleSessionDao.expungeSessionsOlderThanDate(dateBefore);
              if (logger.isDebugEnabled()) {
                  logger.debug(sessionsExpunged > 0 ?
                          String.format("Expunged %s removed console session records", sessionsExpunged) :
                          "No removed console session records expunged on this cleanup task run");
              }
          }
      }
  
      @Override
      public ListResponse<ConsoleSessionResponse> listConsoleSessions(ListConsoleSessionsCmd cmd) {
          Pair<List<ConsoleSessionVO>, Integer> consoleSessions = listConsoleSessionsInternal(cmd);
          ListResponse<ConsoleSessionResponse> response = new ListResponse<>();
  
          ResponseObject.ResponseView responseView = ResponseObject.ResponseView.Restricted;
          Long callerId = CallContext.current().getCallingAccountId();
          if (accountManager.isRootAdmin(callerId)) {
              responseView = ResponseObject.ResponseView.Full;
          }
  
          List<ConsoleSessionResponse> consoleSessionResponses = new ArrayList<>();
          for (ConsoleSessionVO consoleSession : consoleSessions.first()) {
              ConsoleSessionResponse consoleSessionResponse = responseGenerator.createConsoleSessionResponse(consoleSession, responseView);
              consoleSessionResponses.add(consoleSessionResponse);
          }
  
          response.setResponses(consoleSessionResponses, consoleSessions.second());
          return response;
      }
  
      protected Pair<List<ConsoleSessionVO>, Integer> listConsoleSessionsInternal(ListConsoleSessionsCmd cmd) {
          CallContext caller = CallContext.current();
          long domainId = getBaseDomainIdToListConsoleSessions(cmd.getDomainId());
          Long accountId = cmd.getAccountId();
          Long userId = cmd.getUserId();
          boolean isRecursive = cmd.isRecursive();
  
          boolean isCallerNormalUser = accountManager.isNormalUser(caller.getCallingAccountId());
          if (isCallerNormalUser) {
              accountId = caller.getCallingAccountId();
              userId = caller.getCallingUserId();
          }
  
          List<Long> domainIds = isRecursive ? domainDao.getDomainAndChildrenIds(domainId) : List.of(domainId);
  
          return consoleSessionDao.listConsoleSessions(cmd.getId(), domainIds, accountId, userId,
                  cmd.getHostId(), cmd.getStartDate(), cmd.getEndDate(), cmd.getVmId(),
                  cmd.getConsoleEndpointCreatorAddress(), cmd.getClientAddress(), cmd.isActiveOnly(),
                  cmd.getAcquired(), cmd.getPageSizeVal(), cmd.getStartIndex());
      }
  
      /**
       * Determines the base domain ID for listing console sessions.
       *
       * If no domain ID is provided, returns the caller's domain ID. Otherwise,
       * checks if the caller has access to that domain and returns the provided domain ID.
       *
       * @param domainId The domain ID to check, can be null
       * @return The base domain ID to use for listing console sessions
       * @throws PermissionDeniedException if the caller does not have access to the specified domain
       */
      protected long getBaseDomainIdToListConsoleSessions(Long domainId) {
          Account caller = CallContext.current().getCallingAccount();
          if (domainId == null) {
              return caller.getDomainId();
          }
  
          Domain domain = domainDao.findById(domainId);
          if (domain == null) {
              throw new InvalidParameterValueException(String.format("Unable to find domain with ID [%s]. Verify the informed domain and try again.", domainId));
          }
  
          accountManager.checkAccess(caller, domain);
          return domainId;
      }
  
      @Override
      public ConsoleSession listConsoleSessionById(long id) {
          return consoleSessionDao.findByIdIncludingRemoved(id);
      }
  
      @Override
      public ConsoleEndpoint generateConsoleEndpoint(Long vmId, String extraSecurityToken, String clientAddress) {
          try {
              if (ObjectUtils.anyNull(accountManager, virtualMachineManager, managementServer)) {
                  return new ConsoleEndpoint(false, null, "Console service is not ready");
              }
  
              if (keysManager.getHashKey() == null) {
                  String msg = "Console access denied. Ticket service is not ready yet";
                  logger.debug(msg);
                  return new ConsoleEndpoint(false, null, msg);
              }
  
              Account account = CallContext.current().getCallingAccount();
  
              // Do a sanity check here to make sure the user hasn't already been deleted
              if (account == null) {
                  logger.debug("Invalid user/account, reject console access");
                  return new ConsoleEndpoint(false, null,"Access denied. Invalid or inconsistent account is found");
              }
  
              VirtualMachine vm = entityManager.findById(VirtualMachine.class, vmId);
              if (vm == null) {
                  logger.info("Invalid console servlet command parameter: " + vmId);
                  return new ConsoleEndpoint(false, null, "Cannot find VM with ID " + vmId);
              }
  
              if (!checkSessionPermission(vm, account)) {
                  return new ConsoleEndpoint(false, null, "Permission denied");
              }
  
              DataCenter zone = dataCenterDao.findById(vm.getDataCenterId());
              if (zone != null && DataCenter.Type.Edge.equals(zone.getType())) {
                  String errorMsg = "Console access is not supported for Edge zones";
                  logger.error(errorMsg);
                  return new ConsoleEndpoint(false, null, errorMsg);
              }
  
              String sessionUuid = UUID.randomUUID().toString();
              return generateAccessEndpoint(vmId, sessionUuid, extraSecurityToken, clientAddress);
          } catch (Exception e) {
              String errorMsg = String.format("Unexpected exception in ConsoleAccessManager - vmId: %s (%s), clientAddress: %s",
                      vmId, entityManager.findById(VirtualMachine.class, vmId), clientAddress);
              logger.error(errorMsg, e);
              return new ConsoleEndpoint(false, null, "Server Internal Error: " + e.getMessage());
          }
      }
  
      @Override
      public boolean isSessionAllowed(String sessionUuid) {
          return consoleSessionDao.isSessionAllowed(sessionUuid);
      }
  
      @Override
      public void removeSessions(String[] sessionUuids) {
          if (ArrayUtils.isNotEmpty(sessionUuids)) {
              for (String sessionUuid : sessionUuids) {
                  removeSession(sessionUuid);
              }
          }
      }
  
      protected void removeSession(String sessionUuid) {
          consoleSessionDao.removeSession(sessionUuid);
      }
  
      @Override
      public void acquireSession(String sessionUuid, String clientAddress) {
          consoleSessionDao.acquireSession(sessionUuid, clientAddress);
      }
  
      protected boolean checkSessionPermission(VirtualMachine vm, Account account) {
          if (accountManager.isRootAdmin(account.getId())) {
              return true;
          }
  
          switch (vm.getType()) {
              case User:
                  try {
                      accountManager.checkAccess(account, null, true, vm);
                  } catch (PermissionDeniedException ex) {
                      if (accountManager.isNormalUser(account.getId())) {
                          if (logger.isDebugEnabled()) {
                              logger.debug("VM access is denied for VM {}. VM owner " +
                                      "account {} does not match the account id in session {} and " +
                                      "caller is a normal user", vm,
                                      accountManager.getAccount(vm.getAccountId()), account);
                          }
                      } else if ((accountManager.isDomainAdmin(account.getId())
                              || account.getType() == Account.Type.READ_ONLY_ADMIN) && logger.isDebugEnabled()) {
                          logger.debug("VM access is denied for VM {}. VM owner account {}" +
                                  " does not match the account id in session {} and the " +
                                  "domain-admin caller does not manage the target domain",
                                  vm, accountManager.getAccount(vm.getAccountId()), account);
                      }
                      return false;
                  }
                  break;
  
              case DomainRouter:
              case ConsoleProxy:
              case SecondaryStorageVm:
                  return false;
  
              default:
                  logger.warn("Unrecoginized virtual machine type, deny access by default. type: " + vm.getType());
                  return false;
          }
  
          return true;
      }
  
      private ConsoleEndpoint generateAccessEndpoint(Long vmId, String sessionUuid, String extraSecurityToken, String clientAddress) {
          VirtualMachine vm = virtualMachineManager.findById(vmId);
          String msg;
          if (vm == null) {
              msg = "VM " + vmId + " does not exist, sending blank response for console access request";
              logger.warn(msg);
              throw new CloudRuntimeException(msg);
          }
  
          if (unsupportedConsoleVMState.contains(vm.getState())) {
              msg = String.format("VM %s must be running to connect console, sending blank response for console access request", vm);
              logger.warn(msg);
              throw new CloudRuntimeException(msg);
          }
  
          Long hostId = vm.getState() != VirtualMachine.State.Migrating ? vm.getHostId() : vm.getLastHostId();
          if (hostId == null) {
              msg = String.format("VM %s lost host info, sending blank response for console access request", vm);
              logger.warn(msg);
              throw new CloudRuntimeException(msg);
          }
  
          HostVO host = managementServer.getHostBy(hostId);
          if (host == null) {
              msg = String.format("Host for VM %s does not exist, sending blank response for console access request", vm);
              logger.warn(msg);
              throw new CloudRuntimeException(msg);
          }
  
          if (Hypervisor.HypervisorType.LXC.equals(vm.getHypervisorType())) {
              throw new CloudRuntimeException("Console access is not supported for LXC");
          }
  
          String rootUrl = managementServer.getConsoleAccessUrlRoot(vmId);
          if (rootUrl == null) {
              throw new CloudRuntimeException("Console access will be ready in a few minutes. Please try it again later.");
          }
  
          ConsoleEndpoint consoleEndpoint = composeConsoleAccessEndpoint(rootUrl, vm, host, clientAddress, sessionUuid, extraSecurityToken);
          logger.debug("The console URL is: " + consoleEndpoint.getUrl());
          return consoleEndpoint;
      }
  
      protected ConsoleConnectionDetails getConsoleConnectionDetailsForExternalVm(ConsoleConnectionDetails details,
                              VirtualMachine vm, HostVO host) {
          Answer answer = managementServer.getExternalVmConsole(vm, host);
          if (answer == null) {
              logger.error("Unable to get console access details for external {} on {}: answer is null.", vm, host);
              return null;
          }
          if (!answer.getResult()) {
              logger.error("Unable to get console access details for external {} on {}: answer result is false. Reason: {}", vm, host, answer.getDetails());
              return null;
          }
          if (!(answer instanceof GetExternalConsoleAnswer)) {
              logger.error("Unable to get console access details for external {} on {}: answer is not of type GetExternalConsoleAnswer.", vm, host);
              return null;
          }
          GetExternalConsoleAnswer getExternalConsoleAnswer = (GetExternalConsoleAnswer) answer;
          details.setModeFromExternalProtocol(getExternalConsoleAnswer.getProtocol());
          details.setDirectUrl(getExternalConsoleAnswer.getUrl());
          details.setHost(getExternalConsoleAnswer.getHost());
          if (getExternalConsoleAnswer.getPort() != null) {
              details.setPort(getExternalConsoleAnswer.getPort());
          }
          if (StringUtils.isNotBlank(getExternalConsoleAnswer.getPassword())) {
              details.setSid(getExternalConsoleAnswer.getPassword());
              details.setSessionRequiresNewViewer(getExternalConsoleAnswer.isPasswordOneTimeUseOnly());
          }
          return details;
      }
  
      protected Pair<String, Integer> getHostAndPortForKVMMaintenanceHostIfNeeded(HostVO host,
                              Map<String, String> vmDetails) {
          if (!Hypervisor.HypervisorType.KVM.equals(host.getHypervisorType())) {
              return null;
          }
          if(!MAINTENANCE_RESOURCE_STATES.contains(host.getResourceState())) {
              return null;
          }
          String address = vmDetails.get(VmDetailConstants.KVM_VNC_ADDRESS);
          String port = vmDetails.get(VmDetailConstants.KVM_VNC_PORT);
          if (ObjectUtils.allNotNull(address, port)) {
              return new Pair<>(address, Integer.valueOf(port));
          }
          logger.warn("KVM Host in ErrorInMaintenance/ErrorInPrepareForMaintenance but " +
                  "no VNC Address/Port was available. Falling back to default one from MS.");
          return null;
      }
  
      protected ConsoleConnectionDetails getConsoleConnectionDetails(VirtualMachine vm, HostVO host) {
          String locale = null;
          String tag = vm.getUuid();
          String displayName = vm.getHostName();
          if (vm instanceof UserVm) {
              displayName = ((UserVm) vm).getDisplayName();
          }
          Map<String, String> vmDetails = vmInstanceDetailsDao.listDetailsKeyPairs(vm.getId(),
                  List.of(VmDetailConstants.KEYBOARD, VmDetailConstants.KVM_VNC_ADDRESS, VmDetailConstants.KVM_VNC_PORT));
          if (vmDetails.get(VmDetailConstants.KEYBOARD) != null) {
              locale = vmDetails.get(VmDetailConstants.KEYBOARD);
          }
          ConsoleConnectionDetails details = new ConsoleConnectionDetails(vm.getVncPassword(), locale, tag, displayName);
          if (Hypervisor.HypervisorType.External.equals(host.getHypervisorType())) {
              return getConsoleConnectionDetailsForExternalVm(details, vm, host);
          }
          Pair<String, Integer> hostPortInfo = getHostAndPortForKVMMaintenanceHostIfNeeded(host, vmDetails);
          if (hostPortInfo == null) {
              hostPortInfo = managementServer.getVncPort(vm);
          }
          logger.debug("Retrieved VNC host and port info :[{}, {}] for {} on {}", hostPortInfo.first(),
                  hostPortInfo.second(), vm, host);
          Ternary<String, String, String> parsedHostInfo = parseHostInfo(hostPortInfo.first());
          details.setHost(parsedHostInfo.first());
          details.setTunnelUrl(parsedHostInfo.second());
          details.setTunnelSession(parsedHostInfo.third());
          details.setPort(hostPortInfo.second());
          if (hostPortInfo.second() == -9) {
              details.setUsingRDP(true);
              details.setPort(Integer.parseInt(managementServer.findDetail(host.getId(), "rdp.server.port")
                      .getValue()));
              logger.debug("HyperV RDP port for {} on {} is: {}", vm, host, details.getPort());
          }
          return details;
      }
  
      protected ConsoleEndpoint composeConsoleAccessEndpoint(String rootUrl, VirtualMachine vm, HostVO hostVo, String addr,
                           String sessionUuid, String extraSecurityToken) {
          ConsoleConnectionDetails result = getConsoleConnectionDetails(vm, hostVo);
          if (result == null) {
              return new ConsoleEndpoint(false, null, "Console access to this instance cannot be provided");
          }
  
          if (ConsoleConnectionDetails.Mode.Direct.equals(result.getMode())) {
              persistConsoleSession(sessionUuid, vm.getId(), hostVo.getId(), addr);
              return new ConsoleEndpoint(true, result.getDirectUrl());
          }
  
          String ticket = genAccessTicket(result.getHost(), String.valueOf(result.getPort()), result.getSid(),
                  result.getTag(), sessionUuid);
          ConsoleProxyPasswordBasedEncryptor encryptor = new ConsoleProxyPasswordBasedEncryptor(getEncryptorPassword());
          ConsoleProxyClientParam param = generateConsoleProxyClientParam(result, ticket, sessionUuid, addr,
                  extraSecurityToken, vm, hostVo);
          String token = encryptor.encryptObject(ConsoleProxyClientParam.class, param);
          int vncPort = consoleProxyManager.getVncPort(vm.getDataCenterId());
  
          String url = generateConsoleAccessUrl(rootUrl, param, token, vncPort, vm, hostVo, result.getLocale());
  
          logger.debug("Adding allowed session: {}", sessionUuid);
          persistConsoleSession(sessionUuid, vm.getId(), hostVo.getId(), addr);
          managementServer.setConsoleAccessForVm(vm.getId(), sessionUuid);
  
          ConsoleEndpoint consoleEndpoint = new ConsoleEndpoint(true, url);
          consoleEndpoint.setWebsocketHost(managementServer.getConsoleAccessAddress(vm.getId()));
          consoleEndpoint.setWebsocketPort(String.valueOf(vncPort));
          consoleEndpoint.setWebsocketPath(WEB_SOCKET_PATH);
          consoleEndpoint.setWebsocketToken(token);
          if (StringUtils.isNotBlank(param.getExtraSecurityToken())) {
              consoleEndpoint.setWebsocketExtra(param.getExtraSecurityToken());
          }
          return consoleEndpoint;
      }
  
      protected void persistConsoleSession(String sessionUuid, long instanceId, long hostId, String consoleEndpointCreatorAddress) {
          CallContext caller = CallContext.current();
  
          ConsoleSessionVO consoleSessionVo = new ConsoleSessionVO();
          consoleSessionVo.setUuid(sessionUuid);
          consoleSessionVo.setDomainId(caller.getCallingAccount().getDomainId());
          consoleSessionVo.setAccountId(caller.getCallingAccountId());
          consoleSessionVo.setUserId(caller.getCallingUserId());
          consoleSessionVo.setInstanceId(instanceId);
          consoleSessionVo.setHostId(hostId);
          consoleSessionVo.setConsoleEndpointCreatorAddress(consoleEndpointCreatorAddress);
          consoleSessionDao.persist(consoleSessionVo);
      }
  
      protected String generateConsoleAccessUrl(String rootUrl, ConsoleProxyClientParam param, String token, int vncPort,
                                              VirtualMachine vm, HostVO hostVo, String locale) {
          StringBuilder sb = new StringBuilder(rootUrl);
          if (param.getHypervHost() != null || !ConsoleProxyManager.NoVncConsoleDefault.value()) {
              sb.append("/ajax?token=").append(token);
          } else {
              String showDot = NoVncConsoleShowDot.valueIn(vm.getDataCenterId()) ? "true" : "false";
              sb.append("/resource/noVNC/vnc.html")
                      .append("?autoconnect=true&show_dot=").append(showDot)
                      .append("&port=").append(vncPort)
                      .append("&token=").append(token);
              if (requiresVncOverWebSocketConnection(vm, hostVo) && StringUtils.isNotBlank(locale)) {
                  sb.append("&language=").append(locale);
              }
          }
  
          if (StringUtils.isNotBlank(param.getExtraSecurityToken())) {
              sb.append("&extra=").append(param.getExtraSecurityToken());
          }
  
          // for console access, we need guest OS type to help implement keyboard
          long guestOs = vm.getGuestOSId();
          GuestOSVO guestOsVo = managementServer.getGuestOs(guestOs);
          if (guestOsVo.getCategoryId() == 6)
              sb.append("&guest=windows");
  
          if (logger.isDebugEnabled()) {
              logger.debug("Compose console url: {}", sb);
          }
          return sb.toString().startsWith("https") ? sb.toString() : "http:" + sb;
      }
  
      protected ConsoleProxyClientParam generateConsoleProxyClientParam(ConsoleConnectionDetails details, String ticket,
                          String sessionUuid, String addr, String extraSecurityToken, VirtualMachine vm, HostVO host) {
          ConsoleProxyClientParam param = new ConsoleProxyClientParam();
          param.setClientHostAddress(details.getHost());
          param.setClientHostPort(details.getPort());
          param.setClientHostPassword(details.getSid());
          param.setClientTag(details.getTag());
          param.setClientDisplayName(details.getDisplayName());
          param.setTicket(ticket);
          param.setSessionUuid(sessionUuid);
          param.setSourceIP(addr);
          param.setSessionRequiresNewViewer(details.isSessionRequiresNewViewer());
  
          if (StringUtils.isNotBlank(extraSecurityToken)) {
              param.setExtraSecurityToken(extraSecurityToken);
              logger.debug("Added security token for client validation");
          }
  
          if (requiresVncOverWebSocketConnection(vm, host)) {
              setWebsocketUrl(vm, param);
          }
  
          if (details.getLocale() != null) {
              param.setLocale(details.getLocale());
          }
  
          if (details.isUsingRDP()) {
              //For Hyperv Client Host Address will send Instance id
              param.setHypervHost(host.getPrivateIpAddress());
              param.setUsername(managementServer.findDetail(host.getId(), "username").getValue());
              param.setPassword(managementServer.findDetail(host.getId(), "password").getValue());
          }
          if (ObjectUtils.allNotNull(details.getTunnelUrl(), details.getTunnelSession())) {
              param.setClientTunnelUrl(details.getTunnelUrl());
              param.setClientTunnelSession(details.getTunnelSession());
          }
          return param;
      }
  
      public Ternary<String, String, String> parseHostInfo(String hostInfo) {
          String host = null;
          String tunnelUrl = null;
          String tunnelSession = null;
  
          logger.info("Parse host info returned from executing GetVNCPortCommand. host info: " + hostInfo);
  
          if (hostInfo != null) {
              if (hostInfo.startsWith("consoleurl")) {
                  String[] tokens = hostInfo.split("&");
  
                  if (hostInfo.length() > 19 && hostInfo.indexOf('/', 19) > 19) {
                      host = hostInfo.substring(19, hostInfo.indexOf('/', 19)).trim();
                      tunnelUrl = tokens[0].substring("consoleurl=".length());
                      tunnelSession = tokens[1].split("=")[1];
                  } else {
                      host = "";
                  }
              } else if (hostInfo.startsWith("instanceId")) {
                  host = hostInfo.substring(hostInfo.indexOf('=') + 1);
              } else {
                  host = hostInfo;
              }
          } else {
              host = hostInfo;
          }
  
          return new Ternary<>(host, tunnelUrl, tunnelSession);
      }
  
      /**
       * Since VMware 7.0 VNC servers are deprecated, it uses a ticket to create a VNC over websocket connection
       * Check: https://docs.vmware.com/en/VMware-vSphere/7.0/rn/vsphere-esxi-vcenter-server-70-release-notes.html
       */
      private boolean requiresVncOverWebSocketConnection(VirtualMachine vm, HostVO hostVo) {
          return vm.getHypervisorType() == Hypervisor.HypervisorType.VMware && hostVo.getHypervisorVersion().compareTo("7.0") >= 0;
      }
  
      @Override
      public String genAccessTicket(String host, String port, String sid, String tag, String sessionUuid) {
          return genAccessTicket(host, port, sid, tag, new Date(), sessionUuid);
      }
  
      @Override
      public String genAccessTicket(String host, String port, String sid, String tag, Date normalizedHashTime, String sessionUuid) {
          String params = "host=" + host + "&port=" + port + "&sid=" + sid + "&tag=" + tag + "&session=" + sessionUuid;
  
          try {
              Mac mac = Mac.getInstance("HmacSHA512");
  
              long ts = normalizedHashTime.getTime();
              ts = ts / 60000;        // round up to 1 minute
              String secretKey = secretKeysManager.getHashKey();
  
              SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(), mac.getAlgorithm());
              mac.init(keySpec);
              mac.update(params.getBytes());
              mac.update(String.valueOf(ts).getBytes());
  
              byte[] encryptedBytes = mac.doFinal();
  
              return Base64.encodeBase64String(encryptedBytes);
          } catch (Exception e) {
              logger.error("Unexpected exception ", e);
          }
          return "";
      }
  
      protected String getEncryptorPassword() {
          String key = keysManager.getEncryptionKey();
          String iv = keysManager.getEncryptionIV();
  
          ConsoleProxyPasswordBasedEncryptor.KeyIVPair keyIvPair = new ConsoleProxyPasswordBasedEncryptor.KeyIVPair(key, iv);
          return gson.toJson(keyIvPair);
      }
  
      /**
       * Sets the URL to establish a VNC over websocket connection
       */
      private void setWebsocketUrl(VirtualMachine vm, ConsoleProxyClientParam param) {
          String ticket = acquireVncTicketForVmwareVm(vm);
          if (StringUtils.isBlank(ticket)) {
              logger.error(String.format("Could not obtain VNC ticket for VM %s", vm));
              return;
          }
          String wsUrl = composeWebsocketUrlForVmwareVm(ticket, param);
          param.setWebsocketUrl(wsUrl);
      }
  
      /**
       * Format expected: wss://<ESXi_HOST_IP>:443/ticket/<TICKET_ID>
       */
      private String composeWebsocketUrlForVmwareVm(String ticket, ConsoleProxyClientParam param) {
          param.setClientHostPort(443);
          return String.format("wss://%s:%s/ticket/%s", param.getClientHostAddress(), param.getClientHostPort(), ticket);
      }
  
      /**
       * Acquires a ticket to be used for console proxy as described in 'Removal of VNC Server from ESXi' on:
       * https://docs.vmware.com/en/VMware-vSphere/7.0/rn/vsphere-esxi-vcenter-server-70-release-notes.html
       */
      private String acquireVncTicketForVmwareVm(VirtualMachine vm) {
          try {
              logger.info("Acquiring VNC ticket for VM = {}", vm);
              GetVmVncTicketCommand cmd = new GetVmVncTicketCommand(vm.getInstanceName());
              Answer answer = agentManager.send(vm.getHostId(), cmd);
              GetVmVncTicketAnswer ans = (GetVmVncTicketAnswer) answer;
              if (!ans.getResult()) {
                  logger.info("VNC ticket could not be acquired correctly: " + ans.getDetails());
              }
              return ans.getTicket();
          } catch (AgentUnavailableException | OperationTimedoutException e) {
              logger.error("Error acquiring ticket", e);
              return null;
          }
      }
  
      protected static class ConsoleConnectionDetails {
          public enum Mode {
              ConsoleProxy,
              Direct
          }
  
          private Mode mode = Mode.ConsoleProxy;
          private String host;
          private int port = -1;
          private String sid;
          private String locale;
          private String tag;
          private String displayName;
          private String tunnelUrl = null;
          private String tunnelSession = null;
          private boolean usingRDP;
          private String directUrl;
          private boolean sessionRequiresNewViewer = false;
  
          ConsoleConnectionDetails(String sid, String locale, String tag, String displayName) {
              this.sid = sid;
              this.locale = locale;
              this.tag = tag;
              this.displayName = displayName;
          }
  
          public Mode getMode() {
              return mode;
          }
  
          public void setModeFromExternalProtocol(String protocol) {
              this.mode = Mode.ConsoleProxy;
              if (StringUtils.isBlank(protocol)) {
                  return;
              }
              if (Mode.Direct.name().toLowerCase().equalsIgnoreCase(protocol)) {
                  this.mode = Mode.Direct;
              }
          }
  
          public String getHost() {
              return host;
          }
  
          public void setHost(String host) {
              this.host = host;
          }
  
          public int getPort() {
              return port;
          }
  
          public void setPort(int port) {
              this.port = port;
          }
  
          public String getSid() {
              return sid;
          }
  
          public void setSid(String sid) {
              this.sid = sid;
          }
  
          public String getLocale() {
              return locale;
          }
  
          public String getTag() {
              return tag;
          }
  
          public String getDisplayName() {
              return displayName;
          }
  
          public String getTunnelUrl() {
              return tunnelUrl;
          }
  
          public void setTunnelUrl(String tunnelUrl) {
              this.tunnelUrl = tunnelUrl;
          }
  
          public String getTunnelSession() {
              return tunnelSession;
          }
  
          public void setTunnelSession(String tunnelSession) {
              this.tunnelSession = tunnelSession;
          }
  
          public boolean isUsingRDP() {
              return usingRDP;
          }
  
          public void setUsingRDP(boolean usingRDP) {
              this.usingRDP = usingRDP;
          }
  
          public String getDirectUrl() {
              return directUrl;
          }
  
          public void setDirectUrl(String directUrl) {
              this.directUrl = directUrl;
          }
  
          public boolean isSessionRequiresNewViewer() {
              return sessionRequiresNewViewer;
          }
  
          public void setSessionRequiresNewViewer(boolean sessionRequiresNewViewer) {
              this.sessionRequiresNewViewer = sessionRequiresNewViewer;
          }
      }
  }
  