diff --git a/README.md b/README.md index e986e4311..60054bcf8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ * [Prerequisites](#prerequisites) * [Project Setup](#project-setup) * [Running the project](#running-the-project) + * [Browser WebSocket Transport](#browser-websocket-transport) * [License](#license) * [Contact](#contact) @@ -125,6 +126,40 @@ Start the game server with the included run script. Use `./run -h` for more info Start the game server with `run-server.bat` +#### Browser WebSocket Transport + +Enable the listener in `Server/worldprops/default.conf`: + +```properties +websocket_enabled = true +websocket_port = 0 +``` + +When `websocket_port = 0`, the listener uses `53594 + world_id`. For world `1`, that is `53595`. + +Both plain WebSocket (`ws://`) and secure WebSocket (`wss://`) are supported. Use `ws://` for local HTTP testing. PWA requires HTTPS/WSS. + +Enable WSS with a Java keystore: + +```properties +websocket_tls_enabled = true +websocket_tls_keystore_path = "certs/dev-wss.p12" +websocket_tls_keystore_password = "" +``` + +The keystore password is optional. Leave `websocket_tls_keystore_password` blank when using a PKCS12 file exported with an empty password: + +```bash +openssl pkcs12 -export \ + -in fullchain.pem \ + -inkey privkey.pem \ + -out certs/dev-wss.p12 \ + -name websocket \ + -passout pass: +``` + +For local WSS development, create a certificate for your hostname or LAN IP that the browser will use, then point the server at a PKCS12 keystore. For production, use a normal certificate from certbot. Plain WS does not require a certificate. + #### Docker Make sure [Docker Engine](https://docs.docker.com/engine/install/) & [Docker Compose](https://docs.docker.com/compose/install/) plugin are installed first: diff --git a/Server/pom.xml b/Server/pom.xml index a18581177..5c93c44d2 100644 --- a/Server/pom.xml +++ b/Server/pom.xml @@ -91,6 +91,11 @@ 2.9.0 compile + + org.java-websocket + Java-WebSocket + 1.5.7 + diff --git a/Server/src/main/core/Server.kt b/Server/src/main/core/Server.kt index 80f29a8cf..cf46e2823 100644 --- a/Server/src/main/core/Server.kt +++ b/Server/src/main/core/Server.kt @@ -6,6 +6,8 @@ import core.game.system.SystemState import core.game.system.config.ServerConfigParser import core.game.world.GameWorld import core.net.NioReactor +import core.net.websocket.GameWebSocketServer +import core.net.websocket.WebSocketTls import core.tools.Log import core.tools.NetworkReachability import core.tools.TimeStamp @@ -44,6 +46,9 @@ object Server { @JvmStatic var reactor: NioReactor? = null + @JvmStatic + var webSocketServer: GameWebSocketServer? = null + var networkReachability = NetworkReachability.Reachable /** @@ -70,6 +75,16 @@ object Server { try { reactor = NioReactor.configure(43594 + GameWorld.settings?.worldId!!) reactor!!.start() + if (ServerConstants.WEBSOCKET_ENABLED) { + val websocketPort = if (ServerConstants.WEBSOCKET_PORT > 0) { + ServerConstants.WEBSOCKET_PORT + } else { + 53594 + GameWorld.settings?.worldId!! + } + webSocketServer = GameWebSocketServer(websocketPort, 1) + WebSocketTls.configure(webSocketServer!!) + webSocketServer!!.start() + } } catch (e: BindException) { log(this::class.java, Log.ERR, "Port " + (43594 + GameWorld.settings?.worldId!!) + " is already in use!") throw e diff --git a/Server/src/main/core/ServerConstants.kt b/Server/src/main/core/ServerConstants.kt index 4e40457f4..8c5f493a4 100644 --- a/Server/src/main/core/ServerConstants.kt +++ b/Server/src/main/core/ServerConstants.kt @@ -374,5 +374,20 @@ class ServerConstants { @JvmField var CONNECTIVITY_TIMEOUT = 500 + + @JvmField + var WEBSOCKET_ENABLED = false + + @JvmField + var WEBSOCKET_PORT = 0 + + @JvmField + var WEBSOCKET_TLS_ENABLED = false + + @JvmField + var WEBSOCKET_TLS_KEYSTORE_PATH = "" + + @JvmField + var WEBSOCKET_TLS_KEYSTORE_PASSWORD = "" } } diff --git a/Server/src/main/core/game/system/SystemTermination.java b/Server/src/main/core/game/system/SystemTermination.java index a1557f381..813b16fe3 100644 --- a/Server/src/main/core/game/system/SystemTermination.java +++ b/Server/src/main/core/game/system/SystemTermination.java @@ -45,6 +45,10 @@ public final class SystemTermination { log(this.getClass(), Log.INFO, "Stopping all bots..."); AIRepository.clearAllBots(); Server.getReactor().terminate(); + if (Server.getWebSocketServer() != null) { + Server.getWebSocketServer().stop(1000, "Server shutting down"); + Server.setWebSocketServer(null); + } log(this.getClass(), Log.INFO, "Stopping all pulses..."); GameWorld.getMajorUpdateWorker().stop(); for (Iterator it = Repository.getPlayers().iterator(); it.hasNext();) { diff --git a/Server/src/main/core/game/system/config/ServerConfigParser.kt b/Server/src/main/core/game/system/config/ServerConfigParser.kt index ebacf36a1..58e3e6ebf 100644 --- a/Server/src/main/core/game/system/config/ServerConfigParser.kt +++ b/Server/src/main/core/game/system/config/ServerConfigParser.kt @@ -176,6 +176,11 @@ object ServerConfigParser { ServerConstants.BOOSTED_TRAWLER_REWARDS = data.getBoolean("world.boosted_trawler_rewards", false) ServerConstants.CONNECTIVITY_CHECK_URL = data.getString("server.connectivity_check_url", "https://google.com,https://2009scape.org") ServerConstants.CONNECTIVITY_TIMEOUT = data.getLong("server.connectivity_timeout", 500L).toInt() + ServerConstants.WEBSOCKET_ENABLED = data.getBoolean("server.websocket_enabled", false) + ServerConstants.WEBSOCKET_PORT = data.getLong("server.websocket_port", 0L).toInt() + ServerConstants.WEBSOCKET_TLS_ENABLED = data.getBoolean("server.websocket_tls_enabled", false) + ServerConstants.WEBSOCKET_TLS_KEYSTORE_PATH = data.getString("server.websocket_tls_keystore_path", "") + ServerConstants.WEBSOCKET_TLS_KEYSTORE_PASSWORD = data.getString("server.websocket_tls_keystore_password", "") val logLevel = data.getString("server.log_level", "VERBOSE").uppercase() ServerConstants.LOG_LEVEL = parseEnumEntry(logLevel) ?: LogLevel.VERBOSE diff --git a/Server/src/main/core/net/IoSession.java b/Server/src/main/core/net/IoSession.java index 7e61dc28c..956b1d9da 100644 --- a/Server/src/main/core/net/IoSession.java +++ b/Server/src/main/core/net/IoSession.java @@ -122,12 +122,45 @@ public class IoSession { * @param service The executor service. */ public IoSession(SelectionKey key, ExecutorService service) { + this(key, service, resolveRemoteAddress(key)); + } + + /** + * Constructs a new {@code IoSession} with an explicit remote address. + * @param key The selection key. + * @param service The executor service. + * @param remoteAddress The remote address. + */ + public IoSession(SelectionKey key, ExecutorService service, String remoteAddress) { this.key = key; this.service = service; - this.address = getRemoteAddress().replaceAll("/", "").split(":")[0]; + this.address = normalizeRemoteAddress(remoteAddress); this.js5Queue = new JS5Queue(this); } + private static String resolveRemoteAddress(SelectionKey key) { + if (key == null || !(key.channel() instanceof SocketChannel)) { + return "127.0.0.1"; + } + try { + return ((SocketChannel) key.channel()).getRemoteAddress().toString(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private static String normalizeRemoteAddress(String remoteAddress) { + if (remoteAddress == null || remoteAddress.trim().isEmpty()) { + return "127.0.0.1"; + } + String normalized = remoteAddress.replace("/", ""); + int separator = normalized.lastIndexOf(':'); + if (separator > 0) { + return normalized.substring(0, separator); + } + return normalized; + } + /** * Fires a write event created using the current event producer. * @param context The event context. @@ -213,9 +246,11 @@ public class IoSession { return; } active = false; - key.cancel(); - SocketChannel channel = (SocketChannel) key.channel(); - channel.socket().close(); + if (key != null) { + key.cancel(); + SocketChannel channel = (SocketChannel) key.channel(); + channel.socket().close(); + } if (getPlayer() != null) { try { getPlayer().clear(); @@ -435,4 +470,4 @@ public class IoSession { this.clientInfo = clientInfo; } -} \ No newline at end of file +} diff --git a/Server/src/main/core/net/NioReactor.java b/Server/src/main/core/net/NioReactor.java index 42fea5a1d..fb07b826d 100644 --- a/Server/src/main/core/net/NioReactor.java +++ b/Server/src/main/core/net/NioReactor.java @@ -143,6 +143,21 @@ public final class NioReactor implements Runnable { */ public void terminate() { running = false; + try { + if (channel != null) { + if (channel.getChannel() != null) { + channel.getChannel().close(); + } + if (channel.getSocket() != null) { + channel.getSocket().close(); + } + channel.getSelector().wakeup(); + channel.getSelector().close(); + } + } catch (IOException e) { + e.printStackTrace(); + } + service.shutdownNow(); } } \ No newline at end of file diff --git a/Server/src/main/core/net/packet/PacketProcessor.kt b/Server/src/main/core/net/packet/PacketProcessor.kt index 6ec7d04ba..c05798490 100644 --- a/Server/src/main/core/net/packet/PacketProcessor.kt +++ b/Server/src/main/core/net/packet/PacketProcessor.kt @@ -10,7 +10,6 @@ import core.ServerConstants import core.api.getAttribute import core.api.log import core.api.sendMessage -import core.api.tryPop import core.api.utils.Vector import core.cache.def.impl.ItemDefinition import core.cache.def.impl.NPCDefinition @@ -62,21 +61,35 @@ import java.lang.Math.min import java.util.* object PacketProcessor { - val queue = LinkedList() + private var queue = LinkedList() + private var processingQueue = LinkedList() + private val queueLock = Any() @JvmStatic fun enqueue(pkt: Packet) { - queue.addLast(pkt) + synchronized(queueLock) { + queue.addLast(pkt) + } } @JvmStatic fun processQueue() { - var countThisCycle = queue.size + synchronized(queueLock) { + if (queue.isEmpty()) { + return + } + val queued = queue + queue = processingQueue + processingQueue = queued + } val sw = StringWriter() val pw = PrintWriter(sw) var pkt: Packet - while (countThisCycle-- > 0) { - pkt = queue.tryPop(Packet.NoProcess()) ?: return + while (processingQueue.isNotEmpty()) { + pkt = processingQueue.pollFirst() ?: return if (pkt is Packet.NoProcess) { - queue.clear() + synchronized(queueLock) { + queue.clear() + } + processingQueue.clear() return } try { diff --git a/Server/src/main/core/net/packet/PacketWriteQueue.kt b/Server/src/main/core/net/packet/PacketWriteQueue.kt index fad453300..ed3de3d43 100644 --- a/Server/src/main/core/net/packet/PacketWriteQueue.kt +++ b/Server/src/main/core/net/packet/PacketWriteQueue.kt @@ -1,18 +1,18 @@ package core.net.packet import core.api.log -import core.api.tryPop import core.net.packet.out.* import core.tools.Log import core.tools.SystemLogger import java.io.PrintWriter import java.io.StringWriter import java.util.* -import java.util.concurrent.locks.ReentrantLock class PacketWriteQueue { companion object { - private val packetsToWrite = LinkedList>() + private var packetsToWrite = LinkedList>() + private var packetsToFlush = LinkedList>() + private val queueLock = Any() @JvmStatic fun handle(packet: OutgoingPacket, context: T) { @@ -34,16 +34,25 @@ class PacketWriteQueue { log(this::class.java, Log.ERR, "${packet::class.java.simpleName} tried to queue with a null context!") return } - packetsToWrite.add(QueuedPacket(packet, context)) + synchronized(queueLock) { + packetsToWrite.add(QueuedPacket(packet, context)) + } } @JvmStatic fun flush() { - var countThisCycle = packetsToWrite.size + synchronized(queueLock) { + if (packetsToWrite.isEmpty()) { + return + } + val queued = packetsToWrite + packetsToWrite = packetsToFlush + packetsToFlush = queued + } val sw = StringWriter() val pw = PrintWriter(sw) - while (countThisCycle-- > 0) { - val pkt = packetsToWrite.tryPop(null) ?: continue + while (packetsToFlush.isNotEmpty()) { + val pkt = packetsToFlush.pollFirst() ?: continue try { write(pkt.out, pkt.context) } catch (e: Exception) { diff --git a/Server/src/main/core/net/websocket/GameWebSocketServer.kt b/Server/src/main/core/net/websocket/GameWebSocketServer.kt new file mode 100644 index 000000000..dcd7d3f3f --- /dev/null +++ b/Server/src/main/core/net/websocket/GameWebSocketServer.kt @@ -0,0 +1,73 @@ +package core.net.websocket + +import core.api.log +import core.net.IoSession +import core.tools.Log +import org.java_websocket.WebSocket +import org.java_websocket.handshake.ClientHandshake +import org.java_websocket.server.WebSocketServer +import java.net.InetSocketAddress +import java.nio.ByteBuffer +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +class GameWebSocketServer(port: Int, poolSize: Int) : WebSocketServer(InetSocketAddress(port)) { + private val service: ExecutorService = Executors.newFixedThreadPool(poolSize) { runnable -> + Thread(runnable, "WebSocketIo").apply { + isDaemon = true + } + } + private val sessions = ConcurrentHashMap() + + init { + // This pool processes websocket frames; it is not a cap on connected webclients. + setTcpNoDelay(true) + setReuseAddr(true) + } + + override fun onOpen(conn: WebSocket, handshake: ClientHandshake) { + sessions[conn] = WebSocketIoSession(conn, service) + } + + override fun onClose(conn: WebSocket, code: Int, reason: String, remote: Boolean) { + val session = sessions.remove(conn) + if (session != null && session.isActive) { + session.disconnect() + } + } + + override fun onMessage(conn: WebSocket, message: String) { + log(javaClass, Log.WARN, "Closing websocket client ${conn.remoteSocketAddress}: text frames are unsupported.") + conn.close(1003, "Binary frames only") + } + + override fun onMessage(conn: WebSocket, message: ByteBuffer) { + val session: IoSession = sessions[conn] ?: run { + conn.close(1011, "Session not initialized") + return + } + val copy = ByteBuffer.allocate(message.remaining()) + copy.put(message) + copy.flip() + service.execute(session.producer.produceReader(session, copy)) + } + + override fun onError(conn: WebSocket?, ex: Exception?) { + if (ex != null) { + log(javaClass, Log.ERR, "WebSocket server error: ${ex.message}") + } + if (conn != null) { + sessions.remove(conn)?.disconnect() + } + } + + override fun onStart() { + log(javaClass, Log.INFO, "WebSocket listener started on port $port.") + } + + override fun stop(timeout: Int, closeMessage: String) { + super.stop(timeout, closeMessage) + service.shutdownNow() + } +} diff --git a/Server/src/main/core/net/websocket/WebSocketIoSession.kt b/Server/src/main/core/net/websocket/WebSocketIoSession.kt new file mode 100644 index 000000000..49693ec09 --- /dev/null +++ b/Server/src/main/core/net/websocket/WebSocketIoSession.kt @@ -0,0 +1,41 @@ +package core.net.websocket + +import core.net.IoSession +import org.java_websocket.WebSocket +import org.java_websocket.exceptions.WebsocketNotConnectedException +import java.nio.ByteBuffer +import java.util.concurrent.ExecutorService + +class WebSocketIoSession( + private val socket: WebSocket, + service: ExecutorService +) : IoSession(null, service, socket.remoteSocketAddress?.toString() ?: "127.0.0.1") { + + override fun queue(buffer: ByteBuffer?) { + if (buffer == null || !socket.isOpen) { + return + } + val copy = buffer.slice() + val data = ByteArray(copy.remaining()) + copy.get(data) + try { + socket.send(data) + } catch (e: WebsocketNotConnectedException) { + disconnect() + } + } + + override fun write() { + // WebSocket sessions bypass the SelectionKey-backed TCP write queue. + } + + override fun disconnect() { + if (!isActive) { + return + } + super.disconnect() + if (socket.isOpen) { + socket.close() + } + } +} diff --git a/Server/src/main/core/net/websocket/WebSocketTls.kt b/Server/src/main/core/net/websocket/WebSocketTls.kt new file mode 100644 index 000000000..981afe060 --- /dev/null +++ b/Server/src/main/core/net/websocket/WebSocketTls.kt @@ -0,0 +1,45 @@ +package core.net.websocket + +import core.ServerConstants +import core.api.log +import core.game.system.config.ServerConfigParser +import core.tools.Log +import org.java_websocket.server.DefaultSSLWebSocketServerFactory +import java.io.FileInputStream +import java.security.KeyStore +import javax.net.ssl.KeyManagerFactory +import javax.net.ssl.SSLContext + +object WebSocketTls { + + private const val KEYSTORE_TYPE = "PKCS12" + + fun configure(server: GameWebSocketServer) { + if (!ServerConstants.WEBSOCKET_TLS_ENABLED) { + return + } + server.setWebSocketFactory(DefaultSSLWebSocketServerFactory(createContext())) + } + + private fun createContext(): SSLContext { + val keystorePath = ServerConstants.WEBSOCKET_TLS_KEYSTORE_PATH.trim() + if (keystorePath.isEmpty()) { + throw IllegalStateException("server.websocket_tls_enabled is true but websocket_tls_keystore_path is blank") + } + val resolvedKeystorePath = ServerConfigParser.parsePath(keystorePath) + val keystorePassword = ServerConstants.WEBSOCKET_TLS_KEYSTORE_PASSWORD.toCharArray() + + val keyStore = KeyStore.getInstance(KEYSTORE_TYPE) + FileInputStream(resolvedKeystorePath).use { input -> + keyStore.load(input, keystorePassword) + } + + val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) + keyManagerFactory.init(keyStore, keystorePassword) + + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(keyManagerFactory.keyManagers, null, null) + log(WebSocketTls::class.java, Log.INFO, "Configured secure websocket listener using keystore $resolvedKeystorePath.") + return sslContext + } +} diff --git a/Server/worldprops/default.conf b/Server/worldprops/default.conf index ed0a4fa62..308db7417 100644 --- a/Server/worldprops/default.conf +++ b/Server/worldprops/default.conf @@ -25,6 +25,17 @@ daily_accounts_per_ip = 3 watchdog_enabled = true connectivity_check_url = "https://google.com,https://2009scape.org" connectivity_timeout = 500 +# Enables a websocket listener for browser clients. The websocket stream carries the +# same raw binary protocol as TCP; each websocket binary frame is treated as a byte chunk. +websocket_enabled = false +# Leave at 0 to use the default websocket port of 53594 + world_id. +websocket_port = 0 +# Enables TLS on the websocket listener so browser clients can connect over wss://. +websocket_tls_enabled = false +# PKCS12 keystore path, e.g. certs/dev-wss.p12. +websocket_tls_keystore_path = "" +# Optional. Leave blank for a PKCS12 file exported with an empty password. +websocket_tls_keystore_password = "" [database] database_name = "global"