All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.seeq.link.agent.DefaultSeeqWsConnection Maven / Gradle / Ivy

There is a newer version: 66.0.0-v202410141803
Show newest version
package com.seeq.link.agent;

import java.io.EOFException;
import java.io.IOException;
import java.net.ConnectException;
import java.net.InetSocketAddress;
import java.net.ProxySelector;
import java.net.URI;
import java.nio.ByteBuffer;
import java.security.KeyStore;
import java.time.Duration;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.HttpProxy;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.eclipse.jetty.websocket.api.CloseStatus;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.UpgradeException;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketFrame;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
import org.eclipse.jetty.websocket.api.extensions.Frame;
import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
import org.eclipse.jetty.websocket.client.WebSocketClient;
import org.eclipse.jetty.websocket.common.OpCode;

import com.seeq.link.agent.interfaces.LoginAuthManager;
import com.seeq.link.agent.interfaces.SeeqWsConnection;
import com.seeq.link.agent.interfaces.SeeqWsConnectionPool;
import com.seeq.link.agent.interfaces.TooManyPingsLostException;
import com.seeq.link.agent.interfaces.WsPingManager;
import com.seeq.link.sdk.BaseConnection;
import com.seeq.link.sdk.interfaces.CertificateService;
import com.seeq.link.sdk.utilities.Event;
import com.seeq.link.sdk.utilities.EventListener;
import com.seeq.utilities.SeeqNames;
import com.seeq.utilities.exception.OperationCanceledException;

import lombok.extern.slf4j.Slf4j;


/**
 * Handles a WebSocket-based connection to the Seeq Application Server.
 */
@WebSocket
@Slf4j
public class DefaultSeeqWsConnection extends BaseConnection implements SeeqWsConnection {

    private static final ByteBuffer EMPTY_PAYLOAD = ByteBuffer.wrap(new byte[0]);
    private final String linkURL;
    private final int poolMemberIndex;
    private final LoginAuthManager loginAuthManager;
    /**
     * Disconnect whenever the agent key file is modified so that we log out and the reconnect logic reads in the new
     * key and connects with the new credentials.
     */
    private final EventListener onAgentKeyChanged = this.disconnectOnChange("Agent key");
    /**
     * Disconnect whenever the Pre-provisioned one-time password is modified in the vault so that we continue the
     * provisioning logic.
     */
    private final EventListener onPreProvisionedOTPChanged =
            this.disconnectOnChange("Pre-provisioned one-time password");
    /**
     * Disconnect whenever the provisioned password is modified, another agent finished the provisioning, we can login.
     */
    private final EventListener onProvisionedPasswordChanged = this.disconnectOnChange("Provisioned password");
    private final CertificateService certificateService;
    private final boolean isRemoteAgent;
    private final String agentIdentification;
    private final SeeqWsConnectionPool pool;
    private final boolean connectedOnDegradedState;
    private final WsPingManager pingManager;
    private Session session;

    private final Event messageReceivedEvent = new Event<>();

    private final ResourceBundle agentStrings = ResourceBundle.getBundle("AgentStringsBundle");

    private HttpClient httpClient;
    private WebSocketClient webSocketClient;
    private final ExecutorService incomingMessageThreadPool;
    private final ExecutorService outgoingMessageThreadPool;
    public static final int INCOMING_MESSAGE_THREAD_POOL_SIZE = 10;
    private Event.ListenerRegistration messageReceivedListener;

    @Override
    public Event getMessageReceivedEvent() {
        return this.messageReceivedEvent;
    }

    @Override
    public void enableKeepAlive(Duration newInterval, Duration newTimeout) {
        this.pingManager.enableKeepAlive(newInterval, newTimeout);
    }

    @Slf4j
    public static class UnhandledExceptionHandler implements Thread.UncaughtExceptionHandler {

        @Override
        public void uncaughtException(Thread t, Throwable e) {
            LOG.error("Uncaught exception in thread: {}", t.getName(), e);
        }
    }

    public DefaultSeeqWsConnection(SeeqWsConnectionPool pool, int poolMemberIndex, String linkURL,
            LoginAuthManager loginAuthManager, CertificateService certificateService, boolean isRemoteAgent,
            String agentIdentification, ExecutorService incomingMessageThreadPool,
            ExecutorService outgoingMessageThreadPool, boolean connectedOnDegradedState,
            WsPingManager pingManager) {
        this.pool = pool;
        this.poolMemberIndex = poolMemberIndex;
        this.linkURL = linkURL;
        this.loginAuthManager = loginAuthManager;
        this.loginAuthManager.getAgentKeyModifiedEvent().add(this.onAgentKeyChanged);
        this.loginAuthManager.getPreProvisionedOneTimePasswordModifiedEvent().add(this.onPreProvisionedOTPChanged);
        this.loginAuthManager.getProvisionedPasswordModifiedEvent().add(this.onProvisionedPasswordChanged);
        this.certificateService = certificateService;
        this.isRemoteAgent = isRemoteAgent;
        this.agentIdentification = agentIdentification;
        this.pingManager = pingManager;
        this.incomingMessageThreadPool = incomingMessageThreadPool;
        this.outgoingMessageThreadPool = outgoingMessageThreadPool;
        this.connectedOnDegradedState = connectedOnDegradedState;

        if (this.isRemoteAgent) {
            // Tell Java to use the system-wide proxy settings in case someone setup a proxy in Internet Explorer
            System.setProperty("java.net.useSystemProxies", "true");
        }
    }

    @Override
    public void initialize() {
        LOG.info("'{}' being initialized", this.getConnectionId());

        // We set short reconnect delays here because we want to reconnect as quickly as possible
        this.setMinReconnectDelay(Duration.ofSeconds(1));
        this.setMaxReconnectDelay(Duration.ofSeconds(1));

        // Any received message on this WebSocket will be passed to the pool
        this.messageReceivedListener = this.getMessageReceivedEvent().add(this.pool::onWebsocketMessageReceived);

        LOG.info("'{}' initialized", this.getConnectionId());
    }

    @Override
    public void destroy() {
        LOG.info("'{}' being destroyed", this.getConnectionId());

        this.messageReceivedListener.remove();
        this.messageReceivedListener = null;

        this.disable();

        this.loginAuthManager.getAgentKeyModifiedEvent().remove(this.onAgentKeyChanged);
        this.loginAuthManager.getPreProvisionedOneTimePasswordModifiedEvent().remove(this.onPreProvisionedOTPChanged);
        this.loginAuthManager.getProvisionedPasswordModifiedEvent().remove(this.onProvisionedPasswordChanged);

        try {
            this.shutdownWebSocketClient();
            this.shutdownHttpClient();
        } catch (Exception e) {
            LOG.error("Error shutting down the clients", e);
        }

        LOG.info("'{}' destroyed", this.getConnectionId());
    }

    private boolean sendPing(byte[] message) {
        if (this.getState() != ConnectionState.CONNECTED) {
            return false;
        }

        LOG.trace("Sending ping using {}", this.getConnectionId());
        // Last-chance check for request cancellation; beyond this point, we can't guarantee that a message won't be
        // sent.
        OperationCanceledException.checkForCancellation();

        ByteBuffer messageBuffer = ByteBuffer.wrap(message);
        try {
            // sendPing is sending an asyncFrame and it doesn't return a Future, so we can't use the same pattern as
            // in sendMessage.
            this.session.getRemote().sendPing(messageBuffer);
        } catch (IOException e) {
            LOG.error("Error sending ping to Seeq Server:", e);
            return false;
        }
        return true;
    }

    @Override
    public boolean sendMessage(byte[] message) {
        if (this.getState() != ConnectionState.CONNECTED) {
            return false;
        }

        LOG.debug("Sending message using {}", this.getConnectionId());
        // Last-chance check for request cancellation; beyond this point, we can't guarantee that a message won't be
        // sent (although it may not be, since the Future.get() calls also check for thread interruption).
        OperationCanceledException.checkForCancellation();

        ByteBuffer messageBuffer = ByteBuffer.wrap(message);
        Callable> messageSendTask = () -> this.session.getRemote().sendBytesByFuture(messageBuffer);
        Future> dispatchFuture = this.outgoingMessageThreadPool.submit(messageSendTask);

        try {
            // We have a 120-second "sanity" timeout; no message should take this long to transfer.
            // CRAB-14864 (Dave H): We wait for the send to complete on the incoming message thread, *not* the outgoing
            // message thread.  This is to fulfill the contract of SeeqWsConnection.sendMessage(), which is synchronous.
            dispatchFuture
                    .get()                         // <--- dispatch future
                    .get(120, TimeUnit.SECONDS);   // <--- sendBytesByFuture future
            return true;
        } catch (InterruptedException ex) {
            LOG.debug("Agent request thread {} interrupted during sendMessage(); most likely cause is request " +
                    "cancellation", Thread.currentThread().getName());
            return false;
        } catch (ExecutionException ex) {
            LOG.error("Error sending message to Seeq Server:", ex);
            return false;
        } catch (TimeoutException ex) {
            LOG.error("Timeout sending message to Seeq Server.");
            return false;
        }
    }

    /** Handles the act of connecting to the Seeq Application Server. */
    @Override
    protected void connect() {
        this.setState(ConnectionState.CONNECTING,
                String.format(this.agentStrings.getString("ConnectingWithDefaultCredentials"), this.linkURL));

        String authorizationToken = this.loginAuthManager.getAuthToken();
        if (authorizationToken == null) {
            LOG.info("Waiting for authentication token, can't connect...");
            this.setState(ConnectionState.DISCONNECTED, this.agentStrings.getString("Disconnected"));
            return;
        }

        try {
            this.ensureStarted();
            ClientUpgradeRequest upgradeRequest = new ClientUpgradeRequest();
            upgradeRequest.setHeader(SeeqNames.API.Headers.Auth, authorizationToken);
            // The identityPath will get stripped off and replaced if we're connecting through Nginx. This is for
            // local agent connections that don't go through Nginx.
            upgradeRequest.setHeader(SeeqNames.API.Headers.IdentityPath, this.loginAuthManager.getIdentityPath());
            upgradeRequest.setHeader(SeeqNames.API.Headers.PoolId, this.agentIdentification);
            if (this.connectedOnDegradedState) {
                upgradeRequest.setHeader(SeeqNames.API.Headers.PoolConnectedOnDegradedState, "true");
            }
            this.webSocketClient.connect(this, new URI(this.linkURL), upgradeRequest).get();
            this.pingManager.start(this.getConnectionId(), this::sendPing);
        } catch (Exception e) {
            // When the pool destroys a connection it is disabled and its connection monitor thread is interrupted
            if (this.getState() == ConnectionState.DISABLED) {
                if (e instanceof OperationCanceledException) {
                    throw (OperationCanceledException) e;
                }
                if (e instanceof InterruptedException) {
                    throw new OperationCanceledException("Thread interrupted", e);
                }
            }
            this.handleConnectException(e.getCause() != null ? e.getCause() : e);
            this.setState(ConnectionState.DISCONNECTED, this.agentStrings.getString("Disconnected"));
        }
    }

    private void ensureStarted() throws Exception {
        if (this.webSocketClient != null) {
            return;
        }
        // Context: While the proxy configuration of the JVM (which is inherited from the OS, including Windows, in
        // which the JVM is running) is automatically applied for normal HTTP connections, web sockets do not get the
        // same treatment. This is a problem, as a websocket should get routed the same way as HTTP traffic almost
        // always, especially in corporate environments that tend to proxy every internet connection.
        //
        // Search for proxies first by using the ws:// protocol and then the http:// protocol. XOM didn't
        // have the ws:// protocol set in their proxy configuration, and Dustin isn't sure if anyone does. If
        // you're reading this then you probably are fixing a bug in this area and know more than me. This
        // was modified for CRAB-17622 to check both protocols for proxy configuration.
        Optional maybeProxy = Stream.of(this.linkURL, this.linkURL.replaceFirst("ws", "http"))
                .flatMap(url -> ProxySelector.getDefault().select(URI.create(url)).stream()
                        .peek(proxy -> LOG.debug("Java's ProxySelector for the agent-link websocket " +
                                "connection returned Proxy of type '{}' at address '{}' to use for the " +
                                "destination url of '{}'.", proxy.type(), proxy.address(), url)))
                .filter(p -> p.address() instanceof InetSocketAddress)
                .map(p -> (InetSocketAddress) p.address())
                .map(a -> new HttpProxy(a.getHostName(), a.getPort()))
                // Collect to an intermediate list so the peek above prints for all elements
                .collect(Collectors.toList())
                .stream()
                .findFirst();

        SslContextFactory sslContextFactory = new SslContextFactory.Client();
        KeyStore keyStore = this.certificateService.getKeyStore();
        sslContextFactory.setTrustStore(keyStore);

        this.httpClient = new HttpClient(sslContextFactory);

        if (this.isRemoteAgent && maybeProxy.isPresent()) {
            LOG.info("Using '{}' as the proxy for the agent-link websocket connection", maybeProxy.get());
            this.httpClient.getProxyConfiguration().getProxies().add(maybeProxy.get());
        } else if (this.isRemoteAgent) {
            LOG.info("No suitable proxy found for the agent-link websocket connection. Connecting directly.");
        } else {
            LOG.debug("This is a local agent. Ignoring any HTTP proxies and connecting the agent-link websocket " +
                    "directly.");
        }

        this.httpClient.start();

        this.webSocketClient = new WebSocketClient(this.httpClient);

        // The websocket may be idle 5 min before closing. It is the default value but we want it set explicitly to
        // be clear and to not be affected by jetty migrations.
        this.webSocketClient.setMaxIdleTimeout(300000);

        // Keep this is sync with C# DotNetWebSocketConnection#connect
        // It must be bigger than the maximum number of websockets per pool (currently 10) * heartbeat period
        this.webSocketClient.setAsyncWriteTimeout(120000);

        // Ensure that we can receive messages up to 50MB in size
        this.webSocketClient.getPolicy().setMaxBinaryMessageSize(50 * 1024 * 1024);

        try {
            this.webSocketClient.start();
        } catch (Exception e) {
            this.shutdownWebSocketClient();
            this.shutdownHttpClient();

            LOG.error("Error starting WebSocketClient", e);
            throw e;
        }
    }

    private void shutdownWebSocketClient() {
        try {
            this.webSocketClient.setStopTimeout(3_000);
            this.webSocketClient.stop();
        } catch (Exception e) {
            LOG.debug("Failed to gracefully stop the webSocketClient", e);
        } finally {
            this.webSocketClient.destroy();
        }
    }

    private void shutdownHttpClient() {
        try {
            this.httpClient.setStopTimeout(3_000);
            this.httpClient.stop();
        } catch (Exception e) {
            LOG.debug("Failed to gracefully stop the httpClient", e);
        } finally {
            this.httpClient.destroy();
        }
    }

    @Override
    protected void monitor() {
        if (this.getState() == ConnectionState.CONNECTED) {
            try {
                this.pingManager.checkStalePings();
            } catch (TooManyPingsLostException e) {
                LOG.error("Disconnecting the WebSocket. Reason: {}", e.getMessage());
                this.disconnect();
            }
        }
    }

    /**
     * Disconnect from Seeq Application Server and ensure socket has been closed.
     */
    @Override
    protected void disconnect() {
        this.pingManager.stop();

        Session theSession;
        synchronized (this) {
            theSession = this.session;
            this.session = null;
        }
        if (theSession == null) {
            return;
        }

        try {
            if (theSession.isOpen()) {
                theSession.close(new CloseStatus(1000, "Socket closed by webSocketClient"));
            }
        } catch (Exception e) {
            LOG.debug("Failed to close the session", e);
        }

        this.setState(ConnectionState.DISCONNECTED, this.agentStrings.getString("Disconnected"));
    }

    @OnWebSocketConnect
    public void onSocketOpen(Session session) {
        synchronized (this) {
            this.session = session;
        }

        this.setState(ConnectionState.CONNECTED,
                String.format(this.agentStrings.getString("Connected"), this.getConnectionId()));
    }

    @OnWebSocketClose
    public void onSocketClose(int statusCode, String reason) {
        LOG.info("SeeqConnection.OnSocketClose() received (code: " + statusCode + ", reason: " + reason + ")");
        this.disconnect();
    }

    @OnWebSocketMessage
    public void onSocketMessage(byte[] buffer, int offset, int length) {
        final byte[] message = Arrays.copyOfRange(buffer, offset, length);

        // All message handlers are placed onto a worker thread so that the network does not block.
        this.incomingMessageThreadPool.submit(() -> this.onMessageReceivedCallback(message));
    }

    @OnWebSocketFrame
    public void onWebSocketFrame(Frame frame) {
        if (OpCode.PONG == frame.getOpCode()) {
            ByteBuffer payload = frame.getPayload() != null ? frame.getPayload() : EMPTY_PAYLOAD;
            // copy of the ByteBuffer to preserve the data for later use without the risk of it being modified
            byte[] messageArray = copyByteBuffer(payload).array();
            if (this.pingManager.hasPongFormat(messageArray)) {
                this.pingManager.handlePong(messageArray);
            }
        }
    }

    private static ByteBuffer copyByteBuffer(ByteBuffer src) {
        ByteBuffer dest = ByteBuffer.allocate(src.remaining());
        dest.put(src);
        dest.flip();
        return dest;
    }

    private void onMessageReceivedCallback(byte[] message) {
        this.messageReceivedEvent.dispatch(this, new MessageReceivedEventArgs(this, message));
    }

    @OnWebSocketError
    public void onSocketError(Session session, Throwable cause) {
        this.handleConnectException(cause);
    }

    private void handleConnectException(Throwable cause) {
        boolean bootingUp = false;
        if (cause.getClass().equals(ConnectException.class)) {
            ConnectException connectException = (ConnectException) cause;
            if (connectException.getMessage().contains("no further information")) {
                bootingUp = true;
            }
        } else if (cause.getClass().equals(UpgradeException.class)) {
            UpgradeException upgradeException = (UpgradeException) cause;
            if (upgradeException.getMessage().contains("got <404>") ||
                    upgradeException.getMessage().contains("0 null") ||
                    upgradeException.getMessage().contains("400 Unknown Version") ||
                    upgradeException.getMessage().contains("404 Not Found")) {
                bootingUp = true;
            }
        } else if (cause.getClass().equals(EOFException.class)) {
            EOFException eofException = (EOFException) cause;
            if (eofException.getMessage().contains("Reading WebSocket Upgrade response")) {
                bootingUp = true;
            }
        }

        if (bootingUp) {
            LOG.info("WebSocket connection refused, server may be booting up, unresponsive, inaccessible or down. " +
                    "Retrying shortly...");
        } else {
            LOG.error("Socket error", cause);
        }
    }

    /**
     * The connection String for the Seeq Application Server. This is the same as the URL property.
     */
    @Override
    public String getConnectionId() {
        return this.linkURL + " [#" + this.poolMemberIndex + "]";
    }

    @Override
    protected void setState(ConnectionState newState, String message) {
        super.setState(newState, message);
        this.pool.onWebSocketChangeState(newState);
    }

    @Override
    public boolean equals(Object o) {
        // We override equals and hashCode because pool.destroyExcessConnections will remove connections
        // equals to a certain value
        if (this == o) {return true;}
        if (o == null || this.getClass() != o.getClass()) {return false;}
        DefaultSeeqWsConnection that = (DefaultSeeqWsConnection) o;
        return this.poolMemberIndex == that.poolMemberIndex &&
                Objects.equals(this.linkURL, that.linkURL);
    }

    @Override
    public int hashCode() {
        return Objects.hash(this.linkURL, this.poolMemberIndex);
    }

    @Override
    public String toString() {
        return "#" + this.poolMemberIndex + " " + this.getState();
    }

    private EventListener disconnectOnChange(String label) {
        return (source, message) -> {
            if (DefaultSeeqWsConnection.this.getState() != ConnectionState.DISABLED &&
                    DefaultSeeqWsConnection.this.loginAuthManager.shouldRestartDueToSecretsChange()) {
                LOG.info(label + " changed, disconnecting and then reconnecting");
                DefaultSeeqWsConnection.this.disconnect();
            }
        };
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy