Added support for websocket connections (disabled by default)

This commit is contained in:
downthecrop
2026-07-03 11:10:44 +00:00
committed by Ryan
parent 76523c0130
commit afeffc1b98
14 changed files with 340 additions and 19 deletions
+35
View File
@@ -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 = "<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:
+5
View File
@@ -91,6 +91,11 @@
<version>2.9.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.5.7</version>
</dependency>
</dependencies>
<build>
+15
View File
@@ -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
+15
View File
@@ -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 = ""
}
}
@@ -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<Player> it = Repository.getPlayers().iterator(); it.hasNext();) {
@@ -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) ?: LogLevel.VERBOSE
+36 -1
View File
@@ -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;
if (key != null) {
key.cancel();
SocketChannel channel = (SocketChannel) key.channel();
channel.socket().close();
}
if (getPlayer() != null) {
try {
getPlayer().clear();
+15
View File
@@ -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();
}
}
@@ -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<Packet>()
private var queue = LinkedList<Packet>()
private var processingQueue = LinkedList<Packet>()
private val queueLock = Any()
@JvmStatic fun enqueue(pkt: Packet) {
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) {
synchronized(queueLock) {
queue.clear()
}
processingQueue.clear()
return
}
try {
@@ -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<QueuedPacket<*>>()
private var packetsToWrite = LinkedList<QueuedPacket<*>>()
private var packetsToFlush = LinkedList<QueuedPacket<*>>()
private val queueLock = Any()
@JvmStatic
fun <T> handle(packet: OutgoingPacket<T>, 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
}
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) {
@@ -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<WebSocket, WebSocketIoSession>()
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()
}
}
@@ -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()
}
}
}
@@ -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
}
}
+11
View File
@@ -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"