From 4bd5b0d236fd048f9c938f9f018ba8cc5e8cd103 Mon Sep 17 00:00:00 2001 From: SquidDev Date: Sat, 29 Jul 2017 19:06:36 +0100 Subject: [PATCH 1/2] Remove HTTPTask, queueing the event when it has finished executing This means we don't have to have lots of shared state between the run and whenFinished method, and allows for easier chaining of futures later on. --- .../computercraft/core/apis/HTTPAPI.java | 30 +++--- .../core/apis/http/HTTPCheck.java | 25 ++--- .../core/apis/http/HTTPExecutor.java | 36 +++++++ .../core/apis/http/HTTPRequest.java | 95 ++++++------------- .../core/apis/http/HTTPTask.java | 61 ------------ 5 files changed, 86 insertions(+), 161 deletions(-) create mode 100644 src/main/java/dan200/computercraft/core/apis/http/HTTPExecutor.java delete mode 100644 src/main/java/dan200/computercraft/core/apis/http/HTTPTask.java diff --git a/src/main/java/dan200/computercraft/core/apis/HTTPAPI.java b/src/main/java/dan200/computercraft/core/apis/HTTPAPI.java index 67ccc4413..925938629 100644 --- a/src/main/java/dan200/computercraft/core/apis/HTTPAPI.java +++ b/src/main/java/dan200/computercraft/core/apis/HTTPAPI.java @@ -10,18 +10,19 @@ import dan200.computercraft.api.lua.ILuaContext; import dan200.computercraft.api.lua.LuaException; import dan200.computercraft.core.apis.http.HTTPCheck; import dan200.computercraft.core.apis.http.HTTPRequest; -import dan200.computercraft.core.apis.http.HTTPTask; +import dan200.computercraft.core.apis.http.HTTPExecutor; import javax.annotation.Nonnull; import java.net.URL; import java.util.*; +import java.util.concurrent.Future; import static dan200.computercraft.core.apis.ArgumentHelper.*; public class HTTPAPI implements ILuaAPI { private final IAPIEnvironment m_apiEnvironment; - private final List m_httpTasks; + private final List> m_httpTasks; public HTTPAPI( IAPIEnvironment environment ) { @@ -48,15 +49,11 @@ public class HTTPAPI implements ILuaAPI // Wait for all of our http requests synchronized( m_httpTasks ) { - Iterator it = m_httpTasks.iterator(); + Iterator> it = m_httpTasks.iterator(); while( it.hasNext() ) { - final HTTPTask h = it.next(); - if( h.isFinished() ) - { - h.whenFinished( m_apiEnvironment ); - it.remove(); - } + final Future h = it.next(); + if( h.isDone() ) it.remove(); } } } @@ -66,9 +63,9 @@ public class HTTPAPI implements ILuaAPI { synchronized( m_httpTasks ) { - for( HTTPTask r : m_httpTasks ) + for( Future r : m_httpTasks ) { - r.cancel(); + r.cancel( false ); } m_httpTasks.clear(); } @@ -125,10 +122,10 @@ public class HTTPAPI implements ILuaAPI try { URL url = HTTPRequest.checkURL( urlString ); - HTTPRequest request = new HTTPRequest( urlString, url, postString, headers, binary ); + HTTPRequest request = new HTTPRequest( m_apiEnvironment, urlString, url, postString, headers, binary ); synchronized( m_httpTasks ) { - m_httpTasks.add( HTTPTask.submit( request ) ); + m_httpTasks.add( HTTPExecutor.EXECUTOR.submit( request ) ); } return new Object[] { true }; } @@ -147,9 +144,10 @@ public class HTTPAPI implements ILuaAPI try { URL url = HTTPRequest.checkURL( urlString ); - HTTPCheck check = new HTTPCheck( urlString, url ); - synchronized( m_httpTasks ) { - m_httpTasks.add( HTTPTask.submit( check ) ); + HTTPCheck check = new HTTPCheck( m_apiEnvironment, urlString, url ); + synchronized( m_httpTasks ) + { + m_httpTasks.add( HTTPExecutor.EXECUTOR.submit( check ) ); } return new Object[] { true }; } diff --git a/src/main/java/dan200/computercraft/core/apis/http/HTTPCheck.java b/src/main/java/dan200/computercraft/core/apis/http/HTTPCheck.java index ee3da0072..401d11e95 100644 --- a/src/main/java/dan200/computercraft/core/apis/http/HTTPCheck.java +++ b/src/main/java/dan200/computercraft/core/apis/http/HTTPCheck.java @@ -5,14 +5,15 @@ import dan200.computercraft.core.apis.IAPIEnvironment; import java.net.URL; -public class HTTPCheck implements HTTPTask.IHTTPTask +public class HTTPCheck implements Runnable { + private final IAPIEnvironment environment; private final String urlString; private final URL url; - private String error; - public HTTPCheck( String urlString, URL url ) + public HTTPCheck( IAPIEnvironment environment, String urlString, URL url ) { + this.environment = environment; this.urlString = urlString; this.url = url; } @@ -22,24 +23,12 @@ public class HTTPCheck implements HTTPTask.IHTTPTask { try { - HTTPRequest.checkHost( url ); + HTTPRequest.checkHost( url.getHost() ); + environment.queueEvent( "http_check", new Object[] { urlString, true } ); } catch( HTTPRequestException e ) { - error = e.getMessage(); - } - } - - @Override - public void whenFinished( IAPIEnvironment environment ) - { - if( error == null ) - { - environment.queueEvent( "http_check", new Object[] { urlString, true } ); - } - else - { - environment.queueEvent( "http_check", new Object[] { urlString, false, error } ); + environment.queueEvent( "http_check", new Object[] { urlString, false, e.getMessage() } ); } } } diff --git a/src/main/java/dan200/computercraft/core/apis/http/HTTPExecutor.java b/src/main/java/dan200/computercraft/core/apis/http/HTTPExecutor.java new file mode 100644 index 000000000..75c44eb05 --- /dev/null +++ b/src/main/java/dan200/computercraft/core/apis/http/HTTPExecutor.java @@ -0,0 +1,36 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2017. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ + +package dan200.computercraft.core.apis.http; + +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.ThreadFactoryBuilder; + +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Just a shared object for executing simple HTTP related tasks. + */ +public final class HTTPExecutor +{ + public static final ListeningExecutorService EXECUTOR = MoreExecutors.listeningDecorator( new ThreadPoolExecutor( + 4, Integer.MAX_VALUE, + 60L, TimeUnit.SECONDS, + new SynchronousQueue(), + new ThreadFactoryBuilder() + .setDaemon( true ) + .setPriority( Thread.MIN_PRIORITY + (Thread.NORM_PRIORITY - Thread.MIN_PRIORITY) / 2 ) + .setNameFormat( "ComputerCraft-HTTP-%d" ) + .build() + ) ); + + private HTTPExecutor() + { + } +} diff --git a/src/main/java/dan200/computercraft/core/apis/http/HTTPRequest.java b/src/main/java/dan200/computercraft/core/apis/http/HTTPRequest.java index ff106af4b..a12929091 100644 --- a/src/main/java/dan200/computercraft/core/apis/http/HTTPRequest.java +++ b/src/main/java/dan200/computercraft/core/apis/http/HTTPRequest.java @@ -25,7 +25,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -public class HTTPRequest implements HTTPTask.IHTTPTask +public class HTTPRequest implements Runnable { public static URL checkURL( String urlString ) throws HTTPRequestException { @@ -55,11 +55,11 @@ public class HTTPRequest implements HTTPTask.IHTTPTask return url; } - public static InetAddress checkHost( URL url ) throws HTTPRequestException + public static InetAddress checkHost( String host ) throws HTTPRequestException { try { - InetAddress resolved = InetAddress.getByName( url.getHost() ); + InetAddress resolved = InetAddress.getByName( host ); if( !ComputerCraft.http_whitelist.matches( resolved ) || ComputerCraft.http_blacklist.matches( resolved ) ) { throw new HTTPRequestException( "Domain not permitted" ); @@ -73,22 +73,16 @@ public class HTTPRequest implements HTTPTask.IHTTPTask } } + private final IAPIEnvironment m_environment; private final URL m_url; private final String m_urlString; private final String m_postText; private final Map m_headers; - - private boolean m_success = false; - private String m_encoding; - private byte[] m_result; private boolean m_binary; - private int m_responseCode = -1; - private Map m_responseHeaders; - private String m_errorMessage; - public HTTPRequest( String urlString, URL url, final String postText, final Map headers, boolean binary ) throws HTTPRequestException + public HTTPRequest( IAPIEnvironment environment, String urlString, URL url, final String postText, final Map headers, boolean binary ) throws HTTPRequestException { - // Parse the URL + m_environment = environment; m_urlString = urlString; m_url = url; m_binary = binary; @@ -96,28 +90,19 @@ public class HTTPRequest implements HTTPTask.IHTTPTask m_headers = headers; } - public InputStream getContents() - { - byte[] result = m_result; - if( result != null ) - { - return new ByteArrayInputStream( result ); - } - return null; - } - @Override public void run() { // First verify the address is allowed. try { - checkHost( m_url ); + checkHost( m_url.getHost() ); } catch( HTTPRequestException e ) { - m_success = false; - m_errorMessage = e.getMessage(); + // Queue the failure event if not. + String error = e.getMessage(); + m_environment.queueEvent( "http_failure", new Object[] { m_urlString, error == null ? "Could not connect" : error, null } ); return; } @@ -186,58 +171,36 @@ public class HTTPRequest implements HTTPTask.IHTTPTask byte[] result = ByteStreams.toByteArray( is ); is.close(); - // We completed - m_success = responseSuccess; - m_result = result; - m_responseCode = connection.getResponseCode(); - m_encoding = connection.getContentEncoding(); - + // We've got some sort of response, so let's build a resulting object. Joiner joiner = Joiner.on( ',' ); - Map headers = m_responseHeaders = new HashMap(); + Map headers = new HashMap(); for( Map.Entry> header : connection.getHeaderFields().entrySet() ) { headers.put( header.getKey(), joiner.join( header.getValue() ) ); } + InputStream contents = new ByteArrayInputStream( result ); + ILuaObject stream = wrapStream( + m_binary ? new BinaryInputHandle( contents ) : new EncodedInputHandle( contents, connection.getContentEncoding() ), + connection.getResponseCode(), headers + ); + connection.disconnect(); // disconnect + + // Queue the appropriate event. + if( responseSuccess ) + { + m_environment.queueEvent( "http_success", new Object[] { m_urlString, stream } ); + } + else + { + m_environment.queueEvent( "http_failure", new Object[] { m_urlString, "Could not connect", stream } ); + } } catch( IOException e ) { // There was an error - m_success = false; - } - } - - @Override - public void whenFinished( IAPIEnvironment environment ) - { - final String url = m_urlString; - if( m_success ) - { - // Queue the "http_success" event - InputStream contents = getContents(); - Object result = wrapStream( - m_binary ? new BinaryInputHandle( contents ) : new EncodedInputHandle( contents, m_encoding ), - m_responseCode, m_responseHeaders - ); - environment.queueEvent( "http_success", new Object[] { url, result } ); - } - else - { - // Queue the "http_failure" event - String error = "Could not connect"; - if( m_errorMessage != null ) error = m_errorMessage; - - InputStream contents = getContents(); - Object result = null; - if( contents != null ) - { - result = wrapStream( - m_binary ? new BinaryInputHandle( contents ) : new EncodedInputHandle( contents, m_encoding ), - m_responseCode, m_responseHeaders - ); - } - environment.queueEvent( "http_failure", new Object[] { url, error, result } ); + m_environment.queueEvent( "http_failure", new Object[] { m_urlString, "Could not connect", null } ); } } diff --git a/src/main/java/dan200/computercraft/core/apis/http/HTTPTask.java b/src/main/java/dan200/computercraft/core/apis/http/HTTPTask.java deleted file mode 100644 index c510bf5d5..000000000 --- a/src/main/java/dan200/computercraft/core/apis/http/HTTPTask.java +++ /dev/null @@ -1,61 +0,0 @@ -package dan200.computercraft.core.apis.http; - -import com.google.common.util.concurrent.ThreadFactoryBuilder; -import dan200.computercraft.core.apis.IAPIEnvironment; - -import java.util.concurrent.*; - -/** - * A task which executes asynchronously on a new thread. - * - * This functions very similarly to a {@link Future}, but with an additional - * method which is called on the main thread when the task is completed. - */ -public class HTTPTask -{ - public interface IHTTPTask extends Runnable - { - void whenFinished( IAPIEnvironment environment ); - } - - private static final ExecutorService httpThreads = new ThreadPoolExecutor( - 4, Integer.MAX_VALUE, - 60L, TimeUnit.SECONDS, - new SynchronousQueue(), - new ThreadFactoryBuilder() - .setDaemon( true ) - .setPriority( Thread.MIN_PRIORITY + (Thread.NORM_PRIORITY - Thread.MIN_PRIORITY) / 2 ) - .setNameFormat( "ComputerCraft-HTTP-%d" ) - .build() - ); - - private final Future future; - private final IHTTPTask task; - - private HTTPTask( Future future, IHTTPTask task ) - { - this.future = future; - this.task = task; - } - - public static HTTPTask submit( IHTTPTask task ) - { - Future future = httpThreads.submit( task ); - return new HTTPTask( future, task ); - } - - public void cancel() - { - future.cancel( false ); - } - - public boolean isFinished() - { - return future.isDone(); - } - - public void whenFinished( IAPIEnvironment environment ) - { - task.whenFinished( environment ); - } -} From 30f4e0829f48ad2a17db949aee8ad36a4495690b Mon Sep 17 00:00:00 2001 From: SquidDev Date: Sun, 30 Jul 2017 17:56:47 +0100 Subject: [PATCH 2/2] Add websocket support to HTTP API This uses Netty's websocket functionality, meaning we do not have to depend on another library. As websockets do not fit neatly into the standard polling socket model, the API is significantly more event based than CCTweaks's. One uses http.websocket to connect, which will wait until a connection is established and then returns the connection object (an async variant is available). Once you have a websocket object, you can use .send(msg) to transmit a message. Incoming messages will fire a "websocket_message" event, with the URL and content as arguments. A convenience method (.receive()) exists to aid waiting for valid messages. --- .../dan200/computercraft/ComputerCraft.java | 6 + .../computercraft/core/apis/HTTPAPI.java | 85 +++++++- .../core/apis/http/HTTPExecutor.java | 9 + .../core/apis/http/WebsocketConnection.java | 186 ++++++++++++++++ .../core/apis/http/WebsocketConnector.java | 201 ++++++++++++++++++ .../assets/computercraft/lang/en_us.lang | 1 + 6 files changed, 483 insertions(+), 5 deletions(-) create mode 100644 src/main/java/dan200/computercraft/core/apis/http/WebsocketConnection.java create mode 100644 src/main/java/dan200/computercraft/core/apis/http/WebsocketConnector.java diff --git a/src/main/java/dan200/computercraft/ComputerCraft.java b/src/main/java/dan200/computercraft/ComputerCraft.java index af1438236..0aefbbee2 100644 --- a/src/main/java/dan200/computercraft/ComputerCraft.java +++ b/src/main/java/dan200/computercraft/ComputerCraft.java @@ -121,6 +121,7 @@ public class ComputerCraft }; public static boolean http_enable = true; + public static boolean http_websocket_enable = true; public static AddressPredicate http_whitelist = new AddressPredicate( DEFAULT_HTTP_WHITELIST ); public static AddressPredicate http_blacklist = new AddressPredicate( DEFAULT_HTTP_BLACKLIST ); public static boolean disable_lua51_features = false; @@ -200,6 +201,7 @@ public class ComputerCraft public static Configuration config; public static Property http_enable; + public static Property http_websocket_enable; public static Property http_whitelist; public static Property http_blacklist; public static Property disable_lua51_features; @@ -271,6 +273,9 @@ public class ComputerCraft Config.http_enable = Config.config.get( Configuration.CATEGORY_GENERAL, "http_enable", http_enable ); Config.http_enable.setComment( "Enable the \"http\" API on Computers (see \"http_whitelist\" and \"http_blacklist\" for more fine grained control than this)" ); + Config.http_websocket_enable = Config.config.get( Configuration.CATEGORY_GENERAL, "http_websocket_enable", http_websocket_enable ); + Config.http_websocket_enable.setComment( "Enable use of http websockets. This requires the \"http_enable\" option to also be true." ); + { ConfigCategory category = Config.config.getCategory( Configuration.CATEGORY_GENERAL ); Property currentProperty = category.get( "http_whitelist" ); @@ -362,6 +367,7 @@ public class ComputerCraft public static void syncConfig() { http_enable = Config.http_enable.getBoolean(); + http_websocket_enable = Config.http_websocket_enable.getBoolean(); http_whitelist = new AddressPredicate( Config.http_whitelist.getStringList() ); http_blacklist = new AddressPredicate( Config.http_blacklist.getStringList() ); disable_lua51_features = Config.disable_lua51_features.getBoolean(); diff --git a/src/main/java/dan200/computercraft/core/apis/HTTPAPI.java b/src/main/java/dan200/computercraft/core/apis/HTTPAPI.java index 925938629..8fd82d4ee 100644 --- a/src/main/java/dan200/computercraft/core/apis/HTTPAPI.java +++ b/src/main/java/dan200/computercraft/core/apis/HTTPAPI.java @@ -6,13 +6,18 @@ package dan200.computercraft.core.apis; +import dan200.computercraft.ComputerCraft; import dan200.computercraft.api.lua.ILuaContext; import dan200.computercraft.api.lua.LuaException; import dan200.computercraft.core.apis.http.HTTPCheck; -import dan200.computercraft.core.apis.http.HTTPRequest; import dan200.computercraft.core.apis.http.HTTPExecutor; +import dan200.computercraft.core.apis.http.HTTPRequest; +import dan200.computercraft.core.apis.http.WebsocketConnector; import javax.annotation.Nonnull; +import java.io.Closeable; +import java.io.IOException; +import java.net.URI; import java.net.URL; import java.util.*; import java.util.concurrent.Future; @@ -23,13 +28,15 @@ public class HTTPAPI implements ILuaAPI { private final IAPIEnvironment m_apiEnvironment; private final List> m_httpTasks; - + private final Set m_closeables; + public HTTPAPI( IAPIEnvironment environment ) { m_apiEnvironment = environment; m_httpTasks = new ArrayList<>(); + m_closeables = new HashSet<>(); } - + @Override public String[] getNames() { @@ -69,15 +76,30 @@ public class HTTPAPI implements ILuaAPI } m_httpTasks.clear(); } + synchronized( m_closeables ) + { + for( Closeable x : m_closeables ) + { + try + { + x.close(); + } + catch( IOException ignored ) + { + } + } + m_closeables.clear(); + } } @Nonnull @Override public String[] getMethodNames() { - return new String[] { + return new String[] { "request", - "checkURL" + "checkURL", + "websocket", }; } @@ -156,10 +178,63 @@ public class HTTPAPI implements ILuaAPI return new Object[] { false, e.getMessage() }; } } + case 2: // websocket + { + String address = getString( args, 0 ); + Map headerTbl = optTable( args, 1, Collections.emptyMap() ); + + HashMap headers = new HashMap( headerTbl.size() ); + for( Object key : headerTbl.keySet() ) + { + Object value = headerTbl.get( key ); + if( key instanceof String && value instanceof String ) + { + headers.put( (String) key, (String) value ); + } + } + + if( !ComputerCraft.http_websocket_enable ) + { + throw new LuaException( "Websocket connections are disabled" ); + } + + try + { + URI uri = WebsocketConnector.checkURI( address ); + int port = WebsocketConnector.getPort( uri ); + + Future connector = WebsocketConnector.createConnector( m_apiEnvironment, this, uri, address, port, headers ); + synchronized( m_httpTasks ) + { + m_httpTasks.add( connector ); + } + return new Object[] { true }; + } + catch( HTTPRequestException e ) + { + return new Object[] { false, e.getMessage() }; + } + } default: { return null; } } } + + public void addCloseable( Closeable closeable ) + { + synchronized( m_closeables ) + { + m_closeables.add( closeable ); + } + } + + public void removeCloseable( Closeable closeable ) + { + synchronized( m_closeables ) + { + m_closeables.remove( closeable ); + } + } } diff --git a/src/main/java/dan200/computercraft/core/apis/http/HTTPExecutor.java b/src/main/java/dan200/computercraft/core/apis/http/HTTPExecutor.java index 75c44eb05..a4f75a316 100644 --- a/src/main/java/dan200/computercraft/core/apis/http/HTTPExecutor.java +++ b/src/main/java/dan200/computercraft/core/apis/http/HTTPExecutor.java @@ -9,6 +9,8 @@ package dan200.computercraft.core.apis.http; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.ThreadFactoryBuilder; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.nio.NioEventLoopGroup; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; @@ -30,6 +32,13 @@ public final class HTTPExecutor .build() ) ); + public static final EventLoopGroup LOOP_GROUP = new NioEventLoopGroup( 4, new ThreadFactoryBuilder() + .setDaemon( true ) + .setPriority( Thread.MIN_PRIORITY + (Thread.NORM_PRIORITY - Thread.MIN_PRIORITY) / 2 ) + .setNameFormat( "ComputerCraft-Netty-%d" ) + .build() + ); + private HTTPExecutor() { } diff --git a/src/main/java/dan200/computercraft/core/apis/http/WebsocketConnection.java b/src/main/java/dan200/computercraft/core/apis/http/WebsocketConnection.java new file mode 100644 index 000000000..ddfc12110 --- /dev/null +++ b/src/main/java/dan200/computercraft/core/apis/http/WebsocketConnection.java @@ -0,0 +1,186 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2017. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ + +package dan200.computercraft.core.apis.http; + +import com.google.common.base.Objects; +import dan200.computercraft.api.lua.ILuaContext; +import dan200.computercraft.api.lua.ILuaObject; +import dan200.computercraft.api.lua.LuaException; +import dan200.computercraft.core.apis.HTTPAPI; +import dan200.computercraft.core.apis.IAPIEnvironment; +import io.netty.buffer.ByteBuf; +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.SimpleChannelInboundHandler; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.websocketx.*; +import io.netty.util.CharsetUtil; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.io.Closeable; +import java.io.IOException; + +public class WebsocketConnection extends SimpleChannelInboundHandler implements ILuaObject, Closeable +{ + public static final String SUCCESS_EVENT = "websocket_success"; + public static final String FAILURE_EVENT = "websocket_failure"; + public static final String CLOSE_EVENT = "websocket_closed"; + public static final String MESSAGE_EVENT = "websocket_message"; + + private final String url; + private final IAPIEnvironment computer; + private final HTTPAPI api; + private boolean open = true; + + private Channel channel; + private final WebSocketClientHandshaker handshaker; + + public WebsocketConnection( IAPIEnvironment computer, HTTPAPI api, WebSocketClientHandshaker handshaker, String url ) + { + this.computer = computer; + this.api = api; + this.handshaker = handshaker; + this.url = url; + + api.addCloseable( this ); + } + + private void close( boolean remove ) + { + open = false; + if( remove ) api.removeCloseable( this ); + + if( channel != null ) + { + channel.close(); + channel = null; + } + } + + @Override + public void close() throws IOException + { + close( false ); + } + + private void onClosed() + { + close( true ); + computer.queueEvent( CLOSE_EVENT, new Object[] { url } ); + } + + @Override + public void handlerAdded( ChannelHandlerContext ctx ) throws Exception + { + channel = ctx.channel(); + super.handlerAdded( ctx ); + } + + @Override + public void channelActive( ChannelHandlerContext ctx ) throws Exception + { + handshaker.handshake( ctx.channel() ); + super.channelActive( ctx ); + } + + @Override + public void channelInactive( ChannelHandlerContext ctx ) throws Exception + { + onClosed(); + super.channelInactive( ctx ); + } + + @Override + public void channelRead0( ChannelHandlerContext ctx, Object msg ) throws Exception + { + Channel ch = ctx.channel(); + if( !handshaker.isHandshakeComplete() ) + { + handshaker.finishHandshake( ch, (FullHttpResponse) msg ); + computer.queueEvent( SUCCESS_EVENT, new Object[] { url, this } ); + return; + } + + if( msg instanceof FullHttpResponse ) + { + FullHttpResponse response = (FullHttpResponse) msg; + throw new IllegalStateException( "Unexpected FullHttpResponse (getStatus=" + response.status() + ", content=" + response.content().toString( CharsetUtil.UTF_8 ) + ')' ); + } + + WebSocketFrame frame = (WebSocketFrame) msg; + if( frame instanceof TextWebSocketFrame ) + { + computer.queueEvent( MESSAGE_EVENT, new Object[] { url, ((TextWebSocketFrame) frame).text() } ); + } + else if( frame instanceof BinaryWebSocketFrame ) + { + ByteBuf data = frame.content(); + byte[] converted = new byte[ data.readableBytes() ]; + data.readBytes( converted ); + computer.queueEvent( MESSAGE_EVENT, new Object[] { url, data } ); + } + else if( frame instanceof CloseWebSocketFrame ) + { + ch.close(); + onClosed(); + } + } + + @Override + public void exceptionCaught( ChannelHandlerContext ctx, Throwable cause ) + { + ctx.close(); + computer.queueEvent( FAILURE_EVENT, new Object[] { + url, + cause instanceof WebSocketHandshakeException ? cause.getMessage() : "Could not connect" + } ); + } + + @Nonnull + @Override + public String[] getMethodNames() + { + return new String[] { "receive", "send", "close" }; + } + + @Nullable + @Override + public Object[] callMethod( @Nonnull ILuaContext context, int method, @Nonnull Object[] arguments ) throws LuaException, InterruptedException + { + switch( method ) + { + case 0: + while( true ) + { + checkOpen(); + Object[] event = context.pullEvent( MESSAGE_EVENT ); + if( event.length >= 3 && Objects.equal( event[ 1 ], url ) ) + { + return new Object[] { event[ 2 ] }; + } + } + case 1: + { + checkOpen(); + String text = arguments.length > 0 && arguments[ 0 ] != null ? arguments[ 0 ].toString() : ""; + channel.writeAndFlush( new TextWebSocketFrame( text ) ); + return null; + } + case 2: + close( true ); + return null; + default: + return null; + } + } + + private void checkOpen() throws LuaException + { + if( !open ) throw new LuaException( "attempt to use a closed file" ); + } +} diff --git a/src/main/java/dan200/computercraft/core/apis/http/WebsocketConnector.java b/src/main/java/dan200/computercraft/core/apis/http/WebsocketConnector.java new file mode 100644 index 000000000..16f8a50ea --- /dev/null +++ b/src/main/java/dan200/computercraft/core/apis/http/WebsocketConnector.java @@ -0,0 +1,201 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2017. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ + +package dan200.computercraft.core.apis.http; + +import dan200.computercraft.ComputerCraft; +import dan200.computercraft.core.apis.HTTPAPI; +import dan200.computercraft.core.apis.HTTPRequestException; +import dan200.computercraft.core.apis.IAPIEnvironment; +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.codec.http.DefaultHttpHeaders; +import io.netty.handler.codec.http.HttpClientCodec; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpObjectAggregator; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; +import io.netty.handler.codec.http.websocketx.WebSocketVersion; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; + +import javax.net.ssl.SSLException; +import javax.net.ssl.TrustManagerFactory; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.KeyStore; +import java.util.Map; +import java.util.concurrent.Future; + +/* + * Provides functionality to verify and connect to a remote websocket. + */ +public final class WebsocketConnector +{ + private static final Object lock = new Object(); + private static TrustManagerFactory trustManager; + + private WebsocketConnector() + { + } + + private static TrustManagerFactory getTrustManager() + { + if( trustManager != null ) return trustManager; + synchronized( lock ) + { + if( trustManager != null ) return trustManager; + + TrustManagerFactory tmf = null; + try + { + tmf = TrustManagerFactory.getInstance( TrustManagerFactory.getDefaultAlgorithm() ); + tmf.init( (KeyStore) null ); + } + catch( Exception e ) + { + ComputerCraft.log.error( "Cannot setup trust manager", e ); + } + + return trustManager = tmf; + } + } + + public static URI checkURI( String address ) throws HTTPRequestException + { + URI uri = null; + try + { + uri = new URI( address ); + } + catch( URISyntaxException ignored ) + { + } + + if( uri == null || uri.getHost() == null ) + { + try + { + uri = new URI( "ws://" + address ); + } + catch( URISyntaxException ignored ) + { + } + } + + if( uri == null || uri.getHost() == null ) throw new HTTPRequestException( "URL malformed" ); + + String scheme = uri.getScheme(); + if( scheme == null ) + { + try + { + uri = new URI( "ws://" + uri.toString() ); + } + catch( URISyntaxException e ) + { + throw new HTTPRequestException( "URL malformed" ); + } + } + else if( !scheme.equalsIgnoreCase( "wss" ) && !scheme.equalsIgnoreCase( "ws" ) ) + { + throw new HTTPRequestException( "Invalid scheme '" + scheme + "'" ); + } + + if( !ComputerCraft.http_whitelist.matches( uri.getHost() ) || ComputerCraft.http_blacklist.matches( uri.getHost() ) ) + { + throw new HTTPRequestException( "Domain not permitted" ); + } + + return uri; + } + + public static int getPort( URI uri ) throws HTTPRequestException + { + int port = uri.getPort(); + if( port >= 0 ) return port; + + String scheme = uri.getScheme(); + if( scheme.equalsIgnoreCase( "ws" ) ) + { + return 80; + } + else if( scheme.equalsIgnoreCase( "wss" ) ) + { + return 443; + } + else + { + throw new HTTPRequestException( "Invalid scheme '" + scheme + "'" ); + } + } + + public static Future createConnector( final IAPIEnvironment environment, final HTTPAPI api, final URI uri, final String address, final int port, final Map headers ) + { + return HTTPExecutor.EXECUTOR.submit( () -> { + InetAddress resolved; + try + { + resolved = HTTPRequest.checkHost( uri.getHost() ); + } + catch( HTTPRequestException e ) + { + environment.queueEvent( WebsocketConnection.FAILURE_EVENT, new Object[] { address, e.getMessage() } ); + return; + } + + InetSocketAddress socketAddress = new InetSocketAddress( resolved, uri.getPort() == -1 ? port : uri.getPort() ); + + final SslContext ssl; + if( uri.getScheme().equalsIgnoreCase( "wss" ) ) + { + try + { + ssl = SslContextBuilder.forClient().trustManager( getTrustManager() ).build(); + } + catch( SSLException e ) + { + environment.queueEvent( WebsocketConnection.FAILURE_EVENT, new Object[] { address, "Cannot create secure socket" } ); + return; + } + } + else + { + ssl = null; + } + + HttpHeaders httpHeaders = new DefaultHttpHeaders(); + for( Map.Entry header : headers.entrySet() ) + { + httpHeaders.add( header.getKey(), header.getValue() ); + } + + WebSocketClientHandshaker handshaker = WebSocketClientHandshakerFactory.newHandshaker( uri, WebSocketVersion.V13, null, false, httpHeaders ); + final WebsocketConnection connection = new WebsocketConnection( environment, api, handshaker, address ); + + new Bootstrap() + .group( HTTPExecutor.LOOP_GROUP ) + .channel( NioSocketChannel.class ) + .handler( new ChannelInitializer() + { + @Override + protected void initChannel( SocketChannel ch ) throws Exception + { + ChannelPipeline p = ch.pipeline(); + if( ssl != null ) p.addLast( ssl.newHandler( ch.alloc(), uri.getHost(), port ) ); + p.addLast( new HttpClientCodec(), new HttpObjectAggregator( 8192 ), connection ); + } + } ) + .remoteAddress( socketAddress ) + .connect(); + } ); + } +} diff --git a/src/main/resources/assets/computercraft/lang/en_us.lang b/src/main/resources/assets/computercraft/lang/en_us.lang index edc0b8a0d..709bbd714 100644 --- a/src/main/resources/assets/computercraft/lang/en_us.lang +++ b/src/main/resources/assets/computercraft/lang/en_us.lang @@ -43,6 +43,7 @@ gui.computercraft:wired_modem.peripheral_connected=Peripheral "%s" connected to gui.computercraft:wired_modem.peripheral_disconnected=Peripheral "%s" disconnected from network gui.computercraft:config.http_enable=Enable HTTP API +gui.computercraft:config.http_websocket_enable=Enable HTTP websockets gui.computercraft:config.http_whitelist=HTTP whitelist gui.computercraft:config.http_blacklist=HTTP blacklist gui.computercraft:config.disable_lua51_features=Disable Lua 5.1 features