// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.
package com.cloud.agent.resource.consoleproxy;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLConnection;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;

import javax.naming.ConfigurationException;

import com.cloud.agent.api.proxy.AllowConsoleAccessCommand;
import org.apache.cloudstack.managed.context.ManagedContextRunnable;

import com.cloud.agent.Agent.ExitStatus;
import com.cloud.agent.api.AgentControlAnswer;
import com.cloud.agent.api.Answer;
import com.cloud.agent.api.CheckHealthAnswer;
import com.cloud.agent.api.CheckHealthCommand;
import com.cloud.agent.api.Command;
import com.cloud.agent.api.ConsoleAccessAuthenticationAnswer;
import com.cloud.agent.api.ConsoleAccessAuthenticationCommand;
import com.cloud.agent.api.ConsoleProxyLoadReportCommand;
import com.cloud.agent.api.PingCommand;
import com.cloud.agent.api.ReadyAnswer;
import com.cloud.agent.api.ReadyCommand;
import com.cloud.agent.api.StartupCommand;
import com.cloud.agent.api.StartupProxyCommand;
import com.cloud.agent.api.proxy.CheckConsoleProxyLoadCommand;
import com.cloud.agent.api.proxy.ConsoleProxyLoadAnswer;
import com.cloud.agent.api.proxy.StartConsoleProxyAgentHttpHandlerCommand;
import com.cloud.agent.api.proxy.WatchConsoleProxyLoadCommand;
import com.cloud.exception.AgentControlChannelException;
import com.cloud.host.Host;
import com.cloud.host.Host.Type;
import com.cloud.resource.ServerResource;
import com.cloud.resource.ServerResourceBase;
import com.cloud.utils.NumbersUtil;
import com.cloud.utils.net.NetUtils;
import com.cloud.utils.script.Script;
import com.google.gson.Gson;

/**
 *
 * I don't want to introduce extra cross-cutting concerns into console proxy
 * process, as it involves configurations like zone/pod, agent auto self-upgrade
 * etc. I also don't want to introduce more module dependency issues into our
 * build system, cross-communication between this resource and console proxy
 * will be done through reflection. As a result, come out with following
 * solution to solve the problem of building a communication channel between
 * consoole proxy and management server.
 *
 * We will deploy an agent shell inside console proxy VM, and this agent shell
 * will launch current console proxy from within this special server resource,
 * through it console proxy can build a communication channel with management
 * server.
 *
 */
public class ConsoleProxyResource extends ServerResourceBase implements ServerResource {

    private final Properties properties = new Properties();
    private Thread consoleProxyMain = null;

    long proxyVmId;
    int proxyPort;

    String localGateway;
    String eth1Ip;
    String eth1Mask;
    String publicIp;

    @Override
    public Answer executeRequest(final Command cmd) {
        if (cmd instanceof CheckConsoleProxyLoadCommand) {
            return execute((CheckConsoleProxyLoadCommand)cmd);
        } else if (cmd instanceof WatchConsoleProxyLoadCommand) {
            return execute((WatchConsoleProxyLoadCommand)cmd);
        } else if (cmd instanceof ReadyCommand) {
            logger.info("Receive ReadyCommand, response with ReadyAnswer");
            return new ReadyAnswer((ReadyCommand)cmd);
        } else if (cmd instanceof CheckHealthCommand) {
            return new CheckHealthAnswer((CheckHealthCommand)cmd, true);
        } else if (cmd instanceof StartConsoleProxyAgentHttpHandlerCommand) {
            return execute((StartConsoleProxyAgentHttpHandlerCommand) cmd);
        } else if (cmd instanceof AllowConsoleAccessCommand) {
            return execute((AllowConsoleAccessCommand) cmd);
        } else {
            return Answer.createUnsupportedCommandAnswer(cmd);
        }
    }

    private Answer execute(AllowConsoleAccessCommand cmd) {
        String sessionUuid = cmd.getSessionUuid();
        try {
            Class<?> consoleProxyClazz = Class.forName("com.cloud.consoleproxy.ConsoleProxy");
            Method methodSetup = consoleProxyClazz.getMethod("addAllowedSession", String.class);
            methodSetup.invoke(null, sessionUuid);
            return new Answer(cmd);
        } catch (SecurityException | NoSuchMethodException | ClassNotFoundException | InvocationTargetException | IllegalAccessException e) {
            String errorMsg = "Unable to add allowed session due to: " + e.getMessage();
            logger.error(errorMsg, e);
            return new Answer(cmd, false, errorMsg);
        }
    }

    private Answer execute(StartConsoleProxyAgentHttpHandlerCommand cmd) {
        logger.info("Invoke launchConsoleProxy() in responding to StartConsoleProxyAgentHttpHandlerCommand");
        launchConsoleProxy(cmd.getKeystoreBits(), cmd.getKeystorePassword(), cmd.getEncryptorPassword(), cmd.isSourceIpCheckEnabled());
        return new Answer(cmd);
    }

    private void disableRpFilter() {
        try (FileWriter fstream = new FileWriter("/proc/sys/net/ipv4/conf/eth2/rp_filter");
             BufferedWriter out = new BufferedWriter(fstream);)
        {
            out.write("0");
        } catch (IOException e) {
            logger.warn("Unable to disable rp_filter");
        }
    }

    protected Answer execute(final CheckConsoleProxyLoadCommand cmd) {
        return executeProxyLoadScan(cmd, cmd.getProxyVmId(), cmd.getProxyVmName(), cmd.getProxyManagementIp(), cmd.getProxyCmdPort());
    }

    protected Answer execute(final WatchConsoleProxyLoadCommand cmd) {
        return executeProxyLoadScan(cmd, cmd.getProxyVmId(), cmd.getProxyVmName(), cmd.getProxyManagementIp(), cmd.getProxyCmdPort());
    }

    private Answer executeProxyLoadScan(final Command cmd, final long proxyVmId, final String proxyVmName, final String proxyManagementIp, final int cmdPort) {
        String result = null;

        final StringBuffer sb = new StringBuffer();
        sb.append("http://").append(proxyManagementIp).append(":" + cmdPort).append("/cmd/getstatus");

        boolean success = true;
        try {
            final URL url = new URL(sb.toString());
            final URLConnection conn = url.openConnection();

            final InputStream is = conn.getInputStream();
            final BufferedReader reader = new BufferedReader(new InputStreamReader(is,"UTF-8"));
            final StringBuilder sb2 = new StringBuilder();
            String line = null;
            try {
                while ((line = reader.readLine()) != null)
                    sb2.append(line + "\n");
                result = sb2.toString();
            } catch (final IOException e) {
                success = false;
            } finally {
                try {
                    is.close();
                } catch (final IOException e) {
                    logger.warn("Exception when closing , console proxy address : {}", proxyManagementIp);
                    success = false;
                }
            }
        } catch (final IOException e) {
            logger.warn("Unable to open console proxy command port url, console proxy address : {}", proxyManagementIp);
            success = false;
        }

        return new ConsoleProxyLoadAnswer(cmd, proxyVmId, proxyVmName, success, result);
    }

    @Override
    protected String getDefaultScriptsDir() {
        return null;
    }

    @Override
    public Type getType() {
        return Host.Type.ConsoleProxy;
    }

    @Override
    public synchronized StartupCommand[] initialize() {
        final StartupProxyCommand cmd = new StartupProxyCommand();
        fillNetworkInformation(cmd);
        cmd.setProxyPort(proxyPort);
        cmd.setProxyVmId(proxyVmId);
        if (publicIp != null)
            cmd.setPublicIpAddress(publicIp);
        return new StartupCommand[] {cmd};
    }

    @Override
    public void disconnected() {
    }

    @Override
    public PingCommand getCurrentStatus(long id) {
        return new PingCommand(Type.ConsoleProxy, id);
    }

    @Override
    public boolean configure(String name, Map<String, Object> params) throws ConfigurationException {
        localGateway = (String)params.get("localgw");
        eth1Mask = (String)params.get("eth1mask");
        eth1Ip = (String)params.get("eth1ip");
        if (eth1Ip != null) {
            params.put("private.network.device", "eth1");
        } else {
            logger.info("eth1ip parameter has not been configured, assuming that we are not inside a system vm");
        }

        String eth2ip = (String)params.get("eth2ip");
        if (eth2ip != null) {
            params.put("public.network.device", "eth2");
        } else {
            logger.info("eth2ip parameter is not found, assuming that we are not inside a system vm");
        }

        super.configure(name, params);

        for (Map.Entry<String, Object> entry : params.entrySet()) {
            properties.put(entry.getKey(), entry.getValue());
        }

        String value = (String)params.get("premium");
        if (value != null && value.equals("premium"))
            proxyPort = 443;
        else {
            value = (String)params.get("consoleproxy.httpListenPort");
            proxyPort = NumbersUtil.parseInt(value, 80);
        }

        value = (String)params.get("proxy_vm");
        proxyVmId = NumbersUtil.parseLong(value, 0);

        if (localGateway != null) {
            String mgmtHosts = (String)params.get("host");
            if (eth1Ip != null) {
                for (final String mgmtHost : mgmtHosts.split(",")) {
                    addRouteToInternalIpOrCidr(localGateway, eth1Ip, eth1Mask, mgmtHost);
                }
                String internalDns1 = (String) params.get("internaldns1");
                if (internalDns1 == null) {
                    logger.warn("No DNS entry found during configuration of ConsoleProxy");
                } else {
                    addRouteToInternalIpOrCidr(localGateway, eth1Ip, eth1Mask, internalDns1);
                }
                String internalDns2 = (String) params.get("internaldns2");
                if (internalDns2 != null) {
                    addRouteToInternalIpOrCidr(localGateway, eth1Ip, eth1Mask, internalDns2);
                }
            }
        }

        publicIp = (String)params.get("public.ip");

        value = (String)params.get("disable_rp_filter");
        if (value != null && value.equalsIgnoreCase("true")) {
            disableRpFilter();
        }

        logger.info("Receive proxyVmId in ConsoleProxyResource configuration as {}", proxyVmId);

        return true;
    }

    private void addRouteToInternalIpOrCidr(String localgw, String eth1ip, String eth1mask, String destIpOrCidr) {
        logger.debug("addRouteToInternalIp: localgw={}, eth1ip={}, eth1mask={}, destIp={}", localgw, eth1ip, eth1mask, destIpOrCidr);
        if (destIpOrCidr == null) {
            logger.debug("addRouteToInternalIp: destIp is null");
            return;
        }
        if (!NetUtils.isValidIp4(destIpOrCidr) && !NetUtils.isValidIp4Cidr(destIpOrCidr)) {
            logger.warn(" destIp is not a valid ip address or cidr destIp={}", destIpOrCidr);
            return;
        }
        boolean inSameSubnet = false;
        if (NetUtils.isValidIp4(destIpOrCidr)) {
            if (eth1ip != null && eth1mask != null) {
                inSameSubnet = NetUtils.sameSubnet(eth1ip, destIpOrCidr, eth1mask);
            } else {
                logger.warn("addRouteToInternalIp: unable to determine same subnet: eth1ip={}, dest ip={}, eth1mask={}", eth1ip, destIpOrCidr, eth1mask);
            }
        } else {
            inSameSubnet = NetUtils.isNetworkAWithinNetworkB(destIpOrCidr, NetUtils.ipAndNetMaskToCidr(eth1ip, eth1mask));
        }
        if (inSameSubnet) {
            logger.debug("addRouteToInternalIp: dest ip {} is in the same subnet as eth1 ip {}", destIpOrCidr, eth1ip);
            return;
        }
        Script command = new Script("/bin/bash", logger);
        command.add("-c");
        command.add("ip route delete " + destIpOrCidr);
        command.execute();
        command = new Script("/bin/bash", logger);
        command.add("-c");
        command.add("ip route add " + destIpOrCidr + " via " + localgw);
        String result = command.execute();
        if (result != null) {
            logger.warn("Error in configuring route to internal ip err={}", result);
        } else {
            logger.debug("addRouteToInternalIp: added route to internal ip={} via {}", destIpOrCidr, localgw);
        }
    }

    @Override
    public String getName() {
        return name;
    }

    private void launchConsoleProxy(final byte[] ksBits, final String ksPassword, final String encryptorPassword, final Boolean isSourceIpCheckEnabled) {
        final Object resource = this;
        logger.info("Building class loader for com.cloud.consoleproxy.ConsoleProxy");
        if (consoleProxyMain == null) {
            logger.info("Running com.cloud.consoleproxy.ConsoleProxy with encryptor password={}", encryptorPassword);
            consoleProxyMain = new Thread(new ManagedContextRunnable() {
                @Override
                protected void runInContext() {
                    try {
                        Class<?> consoleProxyClazz = Class.forName("com.cloud.consoleproxy.ConsoleProxy");
                        try {
                            logger.info("Invoke startWithContext()");
                            Method method = consoleProxyClazz.getMethod("startWithContext", Properties.class, Object.class, byte[].class, String.class, String.class, Boolean.class);
                            method.invoke(null, properties, resource, ksBits, ksPassword, encryptorPassword, isSourceIpCheckEnabled);
                        } catch (SecurityException e) {
                            logger.error("Unable to launch console proxy due to SecurityException", e);
                            System.exit(ExitStatus.Error.value());
                        } catch (NoSuchMethodException e) {
                            logger.error("Unable to launch console proxy due to NoSuchMethodException", e);
                            System.exit(ExitStatus.Error.value());
                        } catch (IllegalArgumentException e) {
                            logger.error("Unable to launch console proxy due to IllegalArgumentException", e);
                            System.exit(ExitStatus.Error.value());
                        } catch (IllegalAccessException e) {
                            logger.error("Unable to launch console proxy due to IllegalAccessException", e);
                            System.exit(ExitStatus.Error.value());
                        } catch (InvocationTargetException e) {
                            logger.error("Unable to launch console proxy due to InvocationTargetException {}", e.getTargetException().toString(), e);
                            System.exit(ExitStatus.Error.value());
                        }
                    } catch (final ClassNotFoundException e) {
                        logger.error("Unable to launch console proxy due to ClassNotFoundException");
                        System.exit(ExitStatus.Error.value());
                    }
                }
            }, "Console-Proxy-Main");
            consoleProxyMain.setDaemon(true);
            consoleProxyMain.start();
        } else {
            logger.info("com.cloud.consoleproxy.ConsoleProxy is already running");

            try {
                Class<?> consoleProxyClazz = Class.forName("com.cloud.consoleproxy.ConsoleProxy");
                Method methodSetup = consoleProxyClazz.getMethod("setEncryptorPassword", String.class);
                methodSetup.invoke(null, encryptorPassword);
                methodSetup = consoleProxyClazz.getMethod("setIsSourceIpCheckEnabled", Boolean.class);
                methodSetup.invoke(null, isSourceIpCheckEnabled);
            } catch (SecurityException e) {
                logger.error("Unable to launch console proxy due to SecurityException", e);
                System.exit(ExitStatus.Error.value());
            } catch (NoSuchMethodException e) {
                logger.error("Unable to launch console proxy due to NoSuchMethodException", e);
                System.exit(ExitStatus.Error.value());
            } catch (IllegalArgumentException e) {
                logger.error("Unable to launch console proxy due to IllegalArgumentException", e);
                System.exit(ExitStatus.Error.value());
            } catch (IllegalAccessException e) {
                logger.error("Unable to launch console proxy due to IllegalAccessException", e);
                System.exit(ExitStatus.Error.value());
            } catch (InvocationTargetException e) {
                logger.error("Unable to launch console proxy due to InvocationTargetException " + e.getTargetException().toString(), e);
                System.exit(ExitStatus.Error.value());
            } catch (final ClassNotFoundException e) {
                logger.error("Unable to launch console proxy due to ClassNotFoundException", e);
                System.exit(ExitStatus.Error.value());
            }
        }
    }

    public String authenticateConsoleAccess(String host, String port, String vmId, String sid, String ticket,
                                            Boolean isReauthentication, String sessionToken, String clientAddress) {
        ConsoleAccessAuthenticationCommand cmd = new ConsoleAccessAuthenticationCommand(host, port, vmId, sid, ticket, sessionToken, clientAddress);
        cmd.setReauthenticating(isReauthentication);

        ConsoleProxyAuthenticationResult result = new ConsoleProxyAuthenticationResult();
        result.setSuccess(false);
        result.setReauthentication(isReauthentication);

        try {
            AgentControlAnswer answer = getAgentControl().sendRequest(cmd, 10000);

            if (answer != null) {
                ConsoleAccessAuthenticationAnswer authAnswer = (ConsoleAccessAuthenticationAnswer)answer;
                result.setSuccess(authAnswer.succeeded());
                result.setHost(authAnswer.getHost());
                result.setPort(authAnswer.getPort());
                result.setTunnelUrl(authAnswer.getTunnelUrl());
                result.setTunnelSession(authAnswer.getTunnelSession());
            } else {
                logger.error("Authentication failed for vm: {} with sid: {}", vmId, sid);
            }
        } catch (AgentControlChannelException e) {
            logger.error("Unable to send out console access authentication request due to {}", e.getMessage(), e);
        }

        return new Gson().toJson(result);
    }

    public void reportLoadInfo(String gsonLoadInfo) {
        ConsoleProxyLoadReportCommand cmd = new ConsoleProxyLoadReportCommand(proxyVmId, gsonLoadInfo);
        try {
            getAgentControl().postRequest(cmd);
            logger.debug("Report proxy load info, proxy : {}, load: {}", proxyVmId, gsonLoadInfo);
        } catch (AgentControlChannelException e) {
            logger.error("Unable to send out load info due to {}", e.getMessage(), e);
        }
    }

    public void ensureRoute(String address) {
        if (localGateway != null) {
            logger.debug("Ensure route for {} via {}", address, localGateway);

            // this method won't be called in high frequency, serialize access
            // to script execution
            synchronized (this) {
                try {
                    addRouteToInternalIpOrCidr(localGateway, eth1Ip, eth1Mask, address);
                } catch (Throwable e) {
                    logger.warn("Unexpected exception while adding internal route to {}", address, e);
                }
            }
        }
    }

    @Override
    public void setName(String name) {
    }

    @Override
    public void setConfigParams(Map<String, Object> params) {
    }

    @Override
    public Map<String, Object> getConfigParams() {
        return new HashMap<String, Object>();
    }

    @Override
    public int getRunLevel() {
        return 0;
    }

    @Override
    public void setRunLevel(int level) {
    }
}
