diff --git a/miniweb-core/src/main/java/com/devsmart/miniweb/Server.java b/miniweb-core/src/main/java/com/devsmart/miniweb/Server.java index d581dfd..93a6972 100644 --- a/miniweb-core/src/main/java/com/devsmart/miniweb/Server.java +++ b/miniweb-core/src/main/java/com/devsmart/miniweb/Server.java @@ -24,10 +24,23 @@ import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; +import java.net.SocketAddress; import java.net.SocketException; -import java.net.SocketTimeoutException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.stream.Stream; + +import javax.net.ServerSocketFactory; +import javax.net.ssl.KeyManager; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; public class Server { @@ -43,7 +56,23 @@ public class Server { private ServerSocket mServerSocket; private SocketListener mListenThread; - private boolean mRunning = false; + private final Set mActiveConnections = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private volatile boolean mRunning = false; + private SSLContext mSslContext; + private boolean mSslEnabled = false; + private String[] mEnabledProtocols = new String[0]; + + public void configureSslContext(KeyManager[] keyManagers, TrustManager[] trustManagers) { + try { + mSslContext = SSLContext.getInstance("TLS"); + mSslContext.init(keyManagers, trustManagers, null); + mSslEnabled = true; + } catch (Exception e) { + mSslContext = null; + mSslEnabled = false; + throw new IllegalStateException("Could not initialize SSLContext", e); + } + } public void start() throws IOException { if (mRunning) { @@ -51,9 +80,17 @@ public void start() throws IOException { return; } - mServerSocket = new ServerSocket(); + mServerSocket = ServerSocketFactory.getDefault().createServerSocket(); mServerSocket.setReuseAddress(true); mServerSocket.bind(new InetSocketAddress(port)); + + if (mSslEnabled) { + List supported = Arrays.asList(mSslContext.getSupportedSSLParameters().getProtocols()); + mEnabledProtocols = Stream.of("TLSv1.3", "TLSv1.2") + .filter(supported::contains) + .toArray(String[]::new); + } + if (mIsDebugBuild) { LOGGER.info("Server started listening on {}", mServerSocket.getLocalSocketAddress()); } @@ -73,11 +110,23 @@ public void shutdown() { } catch (IOException e) { LOGGER.error("Could not close socket:", e); } + // Force-close accepted connections: keep-alive clients hold sockets open with a + // worker thread blocked in handleRequest(), and would otherwise get one more + // response served with this server's stale routes after shutdown. + for (RemoteConnection remoteConnection : mActiveConnections) { + try { + remoteConnection.connection.shutdown(); + } catch (IOException e) { + LOGGER.warn("Error shutting down connection {}: {}", remoteConnection.connection, e.getMessage()); + } + } try { mListenThread.join(); - mListenThread = null; } catch (InterruptedException e) { - LOGGER.error("", e); + LOGGER.error("Interrupted waiting for listener thread shutdown", e); + Thread.currentThread().interrupt(); + } finally { + mListenThread = null; } LOGGER.info("Server shutdown"); } @@ -86,15 +135,35 @@ public void shutdown() { private class WorkerTask implements Runnable { private final HttpService httpservice; - private final RemoteConnection remoteConnection; + private Socket socket; - public WorkerTask(HttpService service, RemoteConnection connection) { + WorkerTask(HttpService service, Socket socket) { httpservice = service; - remoteConnection = connection; + this.socket = socket; } @Override public void run() { + SocketAddress remoteAddress = socket.getRemoteSocketAddress(); + SSLSafeHttpServerConnection connection = new SSLSafeHttpServerConnection(); + RemoteConnection remoteConnection; + try { + if (mSslEnabled) { + socket = startSsl(socket); + } + connection.bind(socket, new BasicHttpParams()); + remoteConnection = new RemoteConnection(socket.getInetAddress(), connection); + mActiveConnections.add(remoteConnection); + } catch (SSLException e) { + LOGGER.warn("TLS handshake failed from {}: {}", remoteAddress, e.getMessage()); + closeSocket(socket); + return; + } catch (Exception e) { + LOGGER.warn("Failed to establish connection from {}: {}", remoteAddress, e.getMessage()); + closeSocket(socket); + return; + } + try { while(mRunning && remoteConnection.connection.isOpen()) { BasicHttpContext context = new BasicHttpContext(); @@ -115,16 +184,15 @@ public void run() { } catch (Exception e){ LOGGER.warn("unknown error - {}", remoteConnection.connection, e); } finally { + mActiveConnections.remove(remoteConnection); LOGGER.info("Closing connection {}", remoteConnection.connection); - closeConnection(); - } - } - - public void closeConnection() { - try { - remoteConnection.connection.close(); - } catch (IOException e){ - LOGGER.error("", e); + try { + if (remoteConnection.connection.isOpen()) { + remoteConnection.connection.close(); + } + } catch (UnsupportedOperationException | IOException e) { + LOGGER.error("Error closing connection", e); + } } } } @@ -156,28 +224,74 @@ public void run() { if (mIsDebugBuild) { LOGGER.info("accepting connection from: {}", socket.getRemoteSocketAddress()); } - - DefaultHttpServerConnection connection = new DefaultHttpServerConnection(); - connection.bind(socket, new BasicHttpParams()); - RemoteConnection remoteConnection = new RemoteConnection(socket.getInetAddress(), connection); - - mWorkerThreads.execute(new WorkerTask(httpService, remoteConnection)); - } catch (SocketTimeoutException e) { - continue; + mWorkerThreads.execute(new WorkerTask(httpService, socket)); + socket = null; // handed off to the worker; this loop must not close it } catch (SocketException e) { LOGGER.info("SocketListener shutting down"); mRunning = false; } catch (IOException e) { - LOGGER.error("", e); + LOGGER.error("I/O error", e); mRunning = false; } } - if (socket != null) { - try { - socket.close(); - LOGGER.info("Connection is closed properly"); - } catch (IOException e) { - LOGGER.error("Can't close connection. Reason: ", e); + closeSocket(socket); + } + } + + private static void closeSocket(Socket socket) { + if (socket != null) { + try { + socket.close(); + LOGGER.info("Connection is closed properly"); + } catch (IOException e) { + LOGGER.error("Can't close connection. Reason: ", e); + } + } + } + + private SSLSocket startSsl(Socket socket) throws IOException { + String peerIp = socket.getInetAddress().getHostAddress(); + SSLSocket sslSocket = (SSLSocket) mSslContext.getSocketFactory() + .createSocket(socket, peerIp, socket.getPort(), true); + try { + sslSocket.setUseClientMode(false); + sslSocket.setNeedClientAuth(true); + if (mEnabledProtocols.length > 0) { + sslSocket.setEnabledProtocols(mEnabledProtocols); + } + return sslSocket; + } catch (RuntimeException e) { + closeSocket(sslSocket); + throw e; + } + } + + // HttpCore's DefaultHttpServerConnection.close() calls Socket.shutdownOutput(), which Android's SSL + // socket implementation does not support and other layers may have already closed. This subclass + // makes close() tolerant of both so a normal teardown never surfaces as a spurious error. + public static class SSLSafeHttpServerConnection extends DefaultHttpServerConnection { + @Override + public void close() throws IOException { + // Already closed (e.g. force-closed by shutdown()): nothing to do, and re-closing would only + // risk another shutdownOutput() failure. + Socket socket = getSocket(); + if (socket != null && socket.isClosed()) { + return; + } + try { + super.close(); + } catch (UnsupportedOperationException e) { + // Catch the specific Android error + // verify the socket exists and force-close it to prevent leaks + if (getSocket() != null) { + getSocket().close(); + } + } catch (IOException e) { + // The socket was closed concurrently (e.g. during shutdown) so the buffered flush in + // super.close() failed; release the socket without escalating this to an error. + Socket current = getSocket(); + if (current != null && !current.isClosed()) { + current.close(); } } } diff --git a/miniweb-core/src/main/java/com/devsmart/miniweb/ServerBuilder.java b/miniweb-core/src/main/java/com/devsmart/miniweb/ServerBuilder.java index e1be5fb..e32c8a0 100644 --- a/miniweb-core/src/main/java/com/devsmart/miniweb/ServerBuilder.java +++ b/miniweb-core/src/main/java/com/devsmart/miniweb/ServerBuilder.java @@ -1,16 +1,19 @@ package com.devsmart.miniweb; - - import com.google.gson.Gson; import com.google.gson.GsonBuilder; import org.apache.http.protocol.HttpRequestHandler; import org.apache.http.protocol.HttpRequestHandlerResolver; import com.devsmart.miniweb.handlers.FileSystemRequestHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.File; +import javax.net.ssl.KeyManager; +import javax.net.ssl.TrustManager; + public class ServerBuilder { private int mPort = 8080; @@ -19,6 +22,8 @@ public class ServerBuilder { private UriRequestHandlerResolver mUriMapper = new UriRequestHandlerResolver(); private Gson mGson = new GsonBuilder().create(); private boolean mIsDebugBuild; + private KeyManager[] mKeyManagers; + private TrustManager[] mTrustManagers; public ServerBuilder setDebugBuild(boolean isDebug) { mIsDebugBuild = isDebug; @@ -87,7 +92,14 @@ public Server create() { mRequestHandler = mUriMapper; } server.requestHandlerResolver = mRequestHandler; - + if (mKeyManagers != null && mTrustManagers != null) { + server.configureSslContext(mKeyManagers, mTrustManagers); + } return server; } + + public void setSslConfigs(KeyManager[] keyManagers, TrustManager[] trustManagers) { + mKeyManagers = keyManagers; + mTrustManagers = trustManagers; + } }