Added support for websocket connections (disabled by default)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user