package com.gvaneyck.rtmp; import java.io.BufferedInputStream; import java.io.DataOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.security.KeyStore; import java.security.cert.X509Certificate; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Random; import java.util.Set; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLException; import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; import com.gvaneyck.rtmp.encoding.AMF3Decoder; import com.gvaneyck.rtmp.encoding.AMF3Encoder; import com.gvaneyck.rtmp.encoding.TypedObject; /** * A very basic RTMPS client * * @author Gabriel Van Eyck */ public class RTMPSClient { private static char[] passphrase = "changeit".toCharArray(); /** Server information */ protected String server; protected int port; protected String app; protected String swfUrl; protected String pageUrl; /** Connection information */ protected String DSId; /** Socket and streams */ protected SSLSocket sslsocket; protected InputStream in; protected DataOutputStream out; protected RTMPPacketReader pr; /** State information */ protected volatile boolean connected = false; protected volatile boolean reconnecting = false; protected int invokeID = 2; /** Used for generating handshake */ protected Random rand = new Random(); /** Encoder */ protected AMF3Encoder aec = new AMF3Encoder(); /** Pending invokes */ protected Set<Integer> pendingInvokes = Collections.synchronizedSet(new HashSet<Integer>()); /** Map of decoded packets */ private Map<Integer, TypedObject> results = Collections.synchronizedMap(new HashMap<Integer, TypedObject>()); /** Callback list */ protected Map<Integer, RTMPCallback> callbacks = Collections.synchronizedMap(new HashMap<Integer, RTMPCallback>()); /** Receive handler */ protected volatile RTMPCallback receiveCallback = null; /** * A simple test for doing the basic RTMPS connection to Riot * * @param args Unused */ public static void main(String[] args) { RTMPSClient client = new RTMPSClient("prod.na1.lol.riotgames.com", 2099, "", "app:/mod_ser.dat", null); try { client.connect(); if (client.isConnected()) System.out.println("Success"); else System.out.println("Failure"); } catch (Exception e) { e.printStackTrace(); } client.close(); } /** * Basic constructor, need to use setConnectionInfo */ public RTMPSClient() { } /** * Sets up the client with the given parameters * * @param server The RTMPS server address * @param port The RTMPS server port * @param app The app to use in the connect call * @param swfUrl The swf URL to use in the connect call * @param pageUrl The page URL to use in the connect call */ public RTMPSClient(String server, int port, String app, String swfUrl, String pageUrl) { setConnectionInfo(server, port, app, swfUrl, pageUrl); } /** * Sets up the client with the given parameters * * @param server The RTMPS server address * @param port The RTMPS server port * @param app The app to use in the connect call * @param swfUrl The swf URL to use in the connect call * @param pageUrl The page URL to use in the connect call */ public void setConnectionInfo(String server, int port, String app, String swfUrl, String pageUrl) { this.server = server; this.port = port; this.app = app; this.swfUrl = swfUrl; this.pageUrl = pageUrl; } /** * Wrapper for sleep * * @param ms The time to sleep */ protected void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { } } /** * Closes the connection */ public void close() { connected = false; // We could join here, but should leave that to the programmer // Typically close should be preceded by a call to join if necessary try { if (sslsocket != null) sslsocket.close(); } catch (IOException e) { // Do nothing // e.printStackTrace(); } // Reset pending invokes and callbacks so this connection can be // restarted pendingInvokes = Collections.synchronizedSet(new HashSet<Integer>()); callbacks = Collections.synchronizedMap(new HashMap<Integer, RTMPCallback>()); } /** * Does a threaded reconnect */ public void doReconnect() { if (reconnecting || !connected) return; Thread t = new Thread() { public void run() { reconnect(); } }; t.setName("RTMPSClient (reconnect)"); t.setDaemon(true); t.start(); } /** * Attempts a reconnect (connect until success) */ public void reconnect() { reconnecting = true; close(); // Attempt reconnects every 5s while (!isConnected()) { try { connect(); } catch (IOException e) { System.err.println("Error when reconnecting: "); e.printStackTrace(); // For debug purposes sleep(5000); } } reconnecting = false; } /** * Opens the socket with the default or a previously saved certificate * * @return A special TrustManager to save the certificate if necessary * @throws IOException */ private SavingTrustManager openSocketWithCert() throws IOException { try { // Load the default KeyStore or a saved one KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); File file = new File("certs/" + server + ".cert"); if (!file.exists() || !file.isFile()) file = new File(System.getProperty("java.home") + "/lib/security/cacerts"); InputStream in = new FileInputStream(file); ks.load(in, passphrase); // Set up the socket factory with the KeyStore SSLContext context = SSLContext.getInstance("TLS"); TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); tmf.init(ks); X509TrustManager defaultTrustManager = (X509TrustManager)tmf.getTrustManagers()[0]; SavingTrustManager tm = new SavingTrustManager(defaultTrustManager); context.init(null, new TrustManager[] { tm }, null); SSLSocketFactory factory = context.getSocketFactory(); sslsocket = (SSLSocket)factory.createSocket(server, port); return tm; } catch (Exception e) { // Hitting an exception here is very bad since we probably won't // recover // (unless it's a connectivity issue) // Rethrow as an IOException throw new IOException(e.getMessage()); } } /** * Downloads and installs a certificate if necessary * * @throws IOException */ private void getCertificate() throws IOException { try { SavingTrustManager tm = openSocketWithCert(); // Try to handshake the socket boolean success = false; try { sslsocket.startHandshake(); success = true; } catch (SSLException e) { sslsocket.close(); } // If we failed to handshake, save the certificate we got and try // again if (!success) { // Set up the directory if needed File dir = new File("certs"); if (!dir.isDirectory()) { dir.delete(); dir.mkdir(); } // Reload (default) KeyStore KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); File file = new File(System.getProperty("java.home") + "/lib/security/cacerts"); InputStream in = new FileInputStream(file); ks.load(in, passphrase); // Add certificate X509Certificate[] chain = tm.chain; if (chain == null) throw new Exception("Failed to obtain server certificate chain"); X509Certificate cert = chain[0]; String alias = server + "-1"; ks.setCertificateEntry(alias, cert); // Save certificate OutputStream out = new FileOutputStream("certs/" + server + ".cert"); ks.store(out, passphrase); out.close(); System.out.println("Installed cert for " + server); } } catch (Exception e) { // Hitting an exception here is very bad since we probably won't // recover // (unless it's a connectivity issue) // Rethrow as an IOException e.printStackTrace(); throw new IOException(e.getMessage()); } } /** * Attempts to connect given the previous connection information * * @throws IOException */ public void connect() throws IOException { try { sslsocket = (SSLSocket)SSLSocketFactory.getDefault().createSocket(server, port); in = new BufferedInputStream(sslsocket.getInputStream()); out = new DataOutputStream(sslsocket.getOutputStream()); doHandshake(); } catch (IOException e) { // If we failed to set up the socket, assume it's because we needed // a certificate getCertificate(); // And use the certificate openSocketWithCert(); // And try to handshake again in = new BufferedInputStream(sslsocket.getInputStream()); out = new DataOutputStream(sslsocket.getOutputStream()); doHandshake(); } // Start reading responses pr = new RTMPPacketReader(in); // Handle preconnect Messages? // -- 02 | 00 00 00 | 00 00 05 | 06 00 00 00 00 | 00 03 D0 90 02 // Connect Map<String, Object> params = new HashMap<String, Object>(); params.put("app", app); params.put("flashVer", "WIN 10,1,85,3"); params.put("swfUrl", swfUrl); params.put("tcUrl", "rtmps://" + server + ":" + port); params.put("fpad", false); params.put("capabilities", 239); params.put("audioCodecs", 3191); params.put("videoCodecs", 252); params.put("videoFunction", 1); params.put("pageUrl", pageUrl); params.put("objectEncoding", 3); byte[] connect = aec.encodeConnect(params); out.write(connect, 0, connect.length); out.flush(); while (!results.containsKey(1)) sleep(10); TypedObject result = results.get(1); DSId = result.getTO("data").getString("id"); connected = true; } /** * Executes a full RTMP handshake * * @throws IOException */ private void doHandshake() throws IOException { // C0 byte C0 = 0x03; out.write(C0); // C1 long timestampC1 = System.currentTimeMillis(); byte[] randC1 = new byte[1528]; rand.nextBytes(randC1); out.writeInt((int)timestampC1); out.writeInt(0); out.write(randC1, 0, 1528); out.flush(); // S0 byte S0 = (byte)in.read(); if (S0 != 0x03) throw new IOException("Server returned incorrect version in handshake: " + S0); // S1 byte[] S1 = new byte[1536]; in.read(S1, 0, 1536); // C2 long timestampS1 = System.currentTimeMillis(); out.write(S1, 0, 4); out.writeInt((int)timestampS1); out.write(S1, 8, 1528); out.flush(); // S2 byte[] S2 = new byte[1536]; for (int i = 0; i < S2.length; i++) S2[i] = (byte)in.read(); // in.read(S2, 0, 1536); // Validate handshake boolean valid = true; for (int i = 8; i < 1536; i++) { if (randC1[i - 8] != S2[i]) { valid = false; break; } } if (!valid) throw new IOException("Server returned invalid handshake"); } /** * Invokes something * * @param packet The packet completely setup just needing to be encoded * @return The invoke ID to use with getResult(), peekResult, and join() * @throws IOException */ public synchronized int invoke(TypedObject packet) throws IOException { int id = nextInvokeID(); pendingInvokes.add(id); try { byte[] data = aec.encodeInvoke(id, packet); out.write(data, 0, data.length); out.flush(); return id; } catch (IOException e) { // Clear the pending invoke pendingInvokes.remove(id); // Rethrow throw e; } } /** * Invokes something * * @param destination The destination * @param operation The operation * @param body The arguments * @return The invoke ID to use with getResult(), peekResult(), and join() * @throws IOException */ public synchronized int invoke(String destination, Object operation, Object body) throws IOException { return invoke(wrapBody(body, destination, operation)); } /** * Invokes something asynchronously * * @param destination The destination * @param operation The operation * @param body The arguments * @param cb The callback that will receive the result * @return The invoke ID to use with getResult(), peekResult(), and join() * @throws IOException */ public synchronized int invokeWithCallback(String destination, Object operation, Object body, RTMPCallback cb) throws IOException { callbacks.put(invokeID, cb); // Register the callback return invoke(destination, operation, body); } /** * Sets up a body in a full RemotingMessage with headers, etc. * * @param body The body to wrap * @param destination The destination * @param operation The operation * @return */ protected TypedObject wrapBody(Object body, String destination, Object operation) { TypedObject headers = new TypedObject(); headers.put("DSRequestTimeout", 60); headers.put("DSId", DSId); headers.put("DSEndpoint", "my-rtmps"); TypedObject ret = new TypedObject("flex.messaging.messages.RemotingMessage"); ret.put("destination", destination); ret.put("operation", operation); ret.put("source", null); ret.put("timestamp", 0); ret.put("messageId", AMF3Encoder.randomUID()); ret.put("timeToLive", 0); ret.put("clientId", null); ret.put("headers", headers); ret.put("body", body); return ret; } /** * Returns the next invoke ID to use * * @return The next invoke ID */ protected int nextInvokeID() { return invokeID++; } /** * Returns the connection status * * @return True if connected */ public boolean isConnected() { return connected; } /** * Removes and returns a result for a given invoke ID if it's ready * Returns null otherwise * * @param id The invoke ID * @return The invoke's result or null */ public TypedObject peekResult(int id) { if (results.containsKey(id)) { TypedObject ret = results.remove(id); return ret; } return null; } /** * Blocks and waits for the invoke's result to be ready, then removes and * returns it * * @param id The invoke ID * @return The invoke's result */ public TypedObject getResult(int id) { while (connected && !results.containsKey(id)) { sleep(10); } if (!connected) return null; TypedObject ret = results.remove(id); return ret; } /** * Waits until all results have been returned */ public void join() { while (!pendingInvokes.isEmpty()) { sleep(10); } } /** * Waits until the specified result returns */ public void join(int id) { while (connected && pendingInvokes.contains(id)) { sleep(10); } } /** * Cancels an invoke and related callback if any * * @param id The invoke ID to cancel */ public void cancel(int id) { // Remove from pending invokes (only affects join()) pendingInvokes.remove(id); // Check if we've already received the result if (peekResult(id) != null) return; // Signify a cancelled invoke by giving it a null callback else { callbacks.put(id, null); // Check for race condition if (peekResult(id) != null) callbacks.remove(id); } } /** * Sets the handler for receive packets (things like champ select) * * @param cb The handler to use */ public void setReceiveHandler(RTMPCallback cb) { receiveCallback = cb; } /** * Reads RTMP packets from a stream */ class RTMPPacketReader { /** The stream to read from */ private BufferedInputStream in; /** The AMF3 decoder */ private final AMF3Decoder adc = new AMF3Decoder(); /** * Starts a packet reader on the given stream * * @param stream The stream to read packets from */ public RTMPPacketReader(InputStream stream) { this.in = new BufferedInputStream(stream, 16384); Thread curThread = new Thread() { public void run() { parsePackets(this); } }; curThread.setName("RTMPSClient (PacketReader)"); curThread.setDaemon(true); curThread.start(); } private byte readByte(InputStream in) throws IOException { byte ret = (byte)in.read(); // System.out.println(String.format("%02X", ret)); return ret; } /** * The main loop for the packet reader */ private void parsePackets(Thread thread) { try { Map<Integer, Packet> packets = new HashMap<Integer, Packet>(); while (true) { // Parse the basic header byte basicHeader = readByte(in); int channel = basicHeader & 0x2F; int headerType = basicHeader & 0xC0; int headerSize = 0; if (headerType == 0x00) headerSize = 12; else if (headerType == 0x40) headerSize = 8; else if (headerType == 0x80) headerSize = 4; else if (headerType == 0xC0) headerSize = 1; // Retrieve the packet or make a new one if (!packets.containsKey(channel)) packets.put(channel, new Packet()); Packet p = packets.get(channel); // Parse the full header if (headerSize > 1) { byte[] header = new byte[headerSize - 1]; for (int i = 0; i < header.length; i++) header[i] = readByte(in); if (headerSize >= 8) { int size = 0; for (int i = 3; i < 6; i++) size = size * 256 + (header[i] & 0xFF); p.setSize(size); p.setType(header[6]); } } // Read rest of packet for (int i = 0; i < 128; i++) { byte b = readByte(in); p.add(b); if (p.isComplete()) break; } // Continue reading if we didn't complete a packet if (!p.isComplete()) continue; // Remove the read packet packets.remove(channel); // Decode result final TypedObject result; if (p.getType() == 0x14) // Connect result = adc.decodeConnect(p.getData()); else if (p.getType() == 0x11) // Invoke result = adc.decodeInvoke(p.getData()); else if (p.getType() == 0x06) // Set peer bandwidth { byte[] data = p.getData(); int windowSize = 0; for (int i = 0; i < 4; i++) windowSize = windowSize * 256 + (data[i] & 0xFF); int type = data[4]; continue; } else if (p.getType() == 0x03) // Ack { byte[] data = p.getData(); int ackSize = 0; for (int i = 0; i < 4; i++) ackSize = ackSize * 256 + (data[i] & 0xFF); continue; } else // Skip most messages { System.out.println("Unrecognized message type"); System.out.print(String.format("%02X ", p.getType())); for (byte b : p.getData()) System.out.print(String.format("%02X", b & 0xff)); System.out.println(); continue; } // Store result Integer id = result.getInt("invokeId"); // Receive handler if (id == null || id == 0) { if (receiveCallback != null) receiveCallback.callback(result); } // Callback handler else if (callbacks.containsKey(id)) { final RTMPCallback cb = callbacks.remove(id); if (cb != null) { // Thread the callback so it doesn't hang us Thread t = new Thread() { public void run() { cb.callback(result); } }; t.setName("RTMPSClient (Callback-" + id + ")"); t.start(); } } else { results.put(id, result); } pendingInvokes.remove(id); } } catch (IOException e) { if (!reconnecting && connected) { System.out.println("Error while reading from stream"); e.printStackTrace(); } } // Attempt to reconnect if this was an unintentional disconnect if (!reconnecting && connected) { doReconnect(); } } } }