package chatty.util.ffz; import chatty.util.DateTime; import static chatty.util.MiscUtil.getStackTrace; import chatty.util.SSLUtil; import chatty.util.TimedCounter; import chatty.util.ffz.WebsocketClient.MyConfigurator; import java.io.IOException; import java.net.URI; import java.nio.ByteBuffer; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ThreadLocalRandom; import java.util.logging.Logger; import javax.websocket.ClientEndpoint; import javax.websocket.ClientEndpointConfig; import javax.websocket.CloseReason; import javax.websocket.HandshakeResponse; import javax.websocket.OnClose; import javax.websocket.OnError; import javax.websocket.OnMessage; import javax.websocket.OnOpen; import javax.websocket.PongMessage; import javax.websocket.Session; import org.glassfish.tyrus.client.ClientManager; import org.glassfish.tyrus.client.ClientProperties; import org.glassfish.tyrus.client.SslEngineConfigurator; /** * Maintain the connection and handle sending/receiving commands correctly. * * @author tduva */ @ClientEndpoint(configurator = MyConfigurator.class) public class WebsocketClient { private final static Logger LOGGER = Logger.getLogger(WebsocketClient.class.getName()); private static final long PING_INTERVAL = 10*60*1000; // 10 minutes private final MessageHandler handler; private final TimedCounter disconnectsPerHour = new TimedCounter(60*60*1000, 0); private volatile boolean requestedDisconnect; private int connectionAttempts; private volatile boolean ssl; private ClientManager clientManager; private String[] servers; private boolean connecting; private volatile Session s; private int sentCount; private int receivedCount; private long timeConnected; private long timeLastMessageReceived; private long timeLastMessageSent; private long lastMeasuredLatency; private long timeLatencyMeasured; private int totalConnects; private final Map<Integer, String> commandId = new HashMap<>(); public WebsocketClient(MessageHandler handler) { this.handler = handler; } /** * The way it is now, this will only work once, because this is intended to * stay connected. * * @param servers Connect to one of these servers, randomly selected */ public synchronized void connect(String[] servers) { // Stuff may have to be changed to only run once/be stopped if this is // removed if (connecting) { return; } connecting = true; this.servers = servers; new Thread(new Runnable() { @Override public void run() { prepareConnection(); connectToRandomServer(); } }).start(); // Ping Timer (should only be started once) Timer ping = new Timer(true); ping.schedule(new TimerTask() { @Override public void run() { sendPing(); } }, PING_INTERVAL, PING_INTERVAL); } /** * Get Websocket status in text form, with some basic formatting. * * @return */ public synchronized String getStatus() { if (!connecting) { return "Not connected"; } if (s != null && s.isOpen()) { return String.format("Connected for %s\n" + "\tServer: %s\n" + "\tCommands sent: %d (last %s ago)\n" + "\tMessages received: %d (last %s ago)\n" + "\tLatency: %dms (%s)", DateTime.ago(timeConnected), s.getRequestURI(), sentCount, DateTime.ago(timeLastMessageSent), receivedCount, DateTime.ago(timeLastMessageReceived), lastMeasuredLatency, timeLatencyMeasured == 0 ? "not yet measured" : "measured "+DateTime.ago(timeLatencyMeasured)+" ago"); } return "Connecting.."; } /** * Create and configure a Client Manager. */ private void prepareConnection() { clientManager = ClientManager.createClient(); clientManager.getProperties().put(ClientProperties.RECONNECT_HANDLER, new Reconnect()); // Try to add Let's Encrypt cert for SSL try { SslEngineConfigurator sslEngineConfigurator = new SslEngineConfigurator( SSLUtil.getSSLContextWithLE(), true, false, false); clientManager.getProperties().put(ClientProperties.SSL_ENGINE_CONFIGURATOR, sslEngineConfigurator); ssl = true; } catch (Exception ex) { LOGGER.warning("Failed adding support for Lets Encrypt: " + ex); ssl = false; } } /** * Randomly select a server from the given list of servers. Prepend ws:// or * wss:// depending on whether this should use SSL or not. * * @param servers Array of servers, without protocol prefix * @param ssl * @return The server, including protocol prefix */ private static String getRandomServer(String[] servers, boolean ssl) { String server = servers[ThreadLocalRandom.current().nextInt(servers.length)]; if (ssl) { server = "wss://" + server; } else { server = "ws://" + server; } return server; } private void connectToRandomServer() { connect(getRandomServer(servers, ssl)); } private void connect(String server) { try { LOGGER.info("[FFZ-WS] Connecting to "+server); clientManager.asyncConnectToServer(this, new URI(server)); } catch (Exception ex) { LOGGER.warning("[FFZ-WS] Error connecting: "+ex); } } /** * Disconnect from the server. * * Currently connecting to the server only works once, since it's intended * to stay connected all the time, so using this should only be done when * the program is closed. */ public synchronized void disonnect() { try { requestedDisconnect = true; if (s != null) { s.close(); } } catch (IOException ex) { LOGGER.warning("Failed closing connection: "+ex); } } private class Reconnect extends ClientManager.ReconnectHandler { @Override public boolean onDisconnect(CloseReason closeReason) { if (requestedDisconnect) { return false; } disconnectsPerHour.increase(); connectionAttempts++; LOGGER.info("[FFZ-WS] Reconnecting in "+getDelay()+"s"); reconnect(getDelay()); return false; } @Override public boolean onConnectFailure(Exception exception) { if (requestedDisconnect) { LOGGER.info("[FFZ-WS] Cancelled reconnecting.."); return false; } connectionAttempts++; LOGGER.info(String.format("[FFZ-WS] Another connection attempt (%d) in %ds [%s/%s]", connectionAttempts, getDelay(), exception, exception.getCause().toString())); reconnect(getDelay()); return false; } @Override public long getDelay() { /** * Wait longer if connection doesn't succeed, however too many * disconnects after a successful connections in a short period of * time should slow down connecting as well, just in case. */ int disconnects = disconnectsPerHour.getCount(); return connectionAttempts*connectionAttempts+disconnects*disconnects; } private void reconnect(long delay) { // Reconnect manually, so that the server can be changed new java.util.Timer().schedule( new java.util.TimerTask() { @Override public void run() { connectToRandomServer(); } }, delay*1000 ); } } public static class MyConfigurator extends ClientEndpointConfig.Configurator { @Override public void beforeRequest(Map<String, List<String>> headers) { // Empty Origin, otherwise would default to server name headers.put("Origin", Arrays.asList("")); } @Override public void afterResponse(HandshakeResponse hr) { } } /** * Send a message to the server. Does nothing if the connection is not open. * * <p>You normally shouldn't use this directly. Use {@link sendCommand(String, String) sendCommand} * instead.</p> * * @param text */ private synchronized void send(String text) { if (s != null && s.isOpen()) { s.getAsyncRemote().sendText(text); System.out.println("SENT: "+text); handler.handleSent(text); timeLastMessageSent = System.currentTimeMillis(); } } /** * Send a command to the server. Does nothing if the connection is not open. * * <p>Automatically increases and prefixes the command counter.</p> * * @param command The command * @param param The parameter */ public synchronized void sendCommand(String command, String param) { if (s != null && s.isOpen()) { sentCount++; if (param == null) { send(String.format("%d %s", sentCount, command)); } else { send(String.format("%d %s %s", sentCount, command, param)); } commandId.put(sentCount, command); } } /** * Send a protocol-level Ping message with the current time as payload. Does * nothing if not currently connected. */ private synchronized void sendPing() { if (s != null && s.isOpen()) { try { ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES); buffer.putLong(0, System.currentTimeMillis()); s.getAsyncRemote().sendPing(buffer); System.out.println("Sending"+buffer); } catch (Exception ex) { LOGGER.warning("[FFZ-WS] Failed to send ping: "+ex); } } } /** * Handle message already parsed into id, command and parameters. * * @param id The message id * @param command The command * @param params The parameters */ private void handleCommand(int id, String command, String params) { String originCommand = commandId.get(id); if (originCommand == null) { originCommand = ""; } handler.handleCommand(id, command, params, originCommand); if (command.equals("error")) { LOGGER.warning("[FFZ-WS] Error: "+params); } } @OnOpen public synchronized void onOpen(Session session) { s = session; connectionAttempts = 0; sentCount = 0; receivedCount = 0; requestedDisconnect = false; totalConnects++; LOGGER.info("[FFZ-WS] Connected ("+totalConnects+")"); handler.handleConnect(); timeConnected = System.currentTimeMillis(); } @OnMessage public synchronized void onMessage(String message, Session session) { System.out.println("RECEIVED: " + message); timeLastMessageReceived = System.currentTimeMillis(); handler.handleReceived(message); receivedCount++; try { String[] split = message.split(" ", 3); int id = Integer.parseInt(split[0]); String command = split[1]; String params = ""; if (split.length == 3) { params = split[2]; } handleCommand(id, command, params); } catch (ArrayIndexOutOfBoundsException | NumberFormatException ex) { LOGGER.warning("[FFZ-WS] Invalid message: "+message); } } /** * Receive Pong response, take the time from the payload and calculate * latency. * * @param message */ @OnMessage public synchronized void onPong(PongMessage message) { try { long timeSent = message.getApplicationData().getLong(); long latency = System.currentTimeMillis() - timeSent; lastMeasuredLatency = latency; timeLatencyMeasured = System.currentTimeMillis(); if (latency > 200) { LOGGER.info(String.format("[FFZ-WS] High Latency (%dms)", System.currentTimeMillis() - timeSent)); } } catch (Exception ex) { LOGGER.warning("[FFZ-WS] Invalid Pong message: "+ex); } } @OnClose public synchronized void onClose(Session session, CloseReason closeReason) { s = null; LOGGER.info(String.format("[FFZ-WS] Session closed after %s [%s]", DateTime.ago(timeConnected), closeReason)); } @OnError public void onError(Session session, Throwable t) { LOGGER.warning("[FFZ-WS] ERROR: "+getStackTrace(t)); } public static interface MessageHandler { public void handleReceived(String text); public void handleSent(String sent); public void handleCommand(int id, String command, String params, String originCommand); public void handleConnect(); } }