Add initial JAGGRAB, JS5 and HTTP servers

Signed-off-by: Graham <gpe@openrs2.org>
pull/132/head
Graham 3 years ago
parent 65b3e1315a
commit bc018a3b0f
  1. 8
      game/build.gradle.kts
  2. 17
      game/src/main/kotlin/org/openrs2/game/GameModule.kt
  3. 12
      game/src/main/kotlin/org/openrs2/game/GameServer.kt
  4. 53
      game/src/main/kotlin/org/openrs2/game/net/FileProvider.kt
  5. 62
      game/src/main/kotlin/org/openrs2/game/net/NetworkService.kt
  6. 24
      game/src/main/kotlin/org/openrs2/game/net/Rs2ChannelInitializer.kt
  7. 52
      game/src/main/kotlin/org/openrs2/game/net/http/HttpChannelHandler.kt
  8. 27
      game/src/main/kotlin/org/openrs2/game/net/http/HttpChannelInitializer.kt
  9. 34
      game/src/main/kotlin/org/openrs2/game/net/jaggrab/JaggrabChannelHandler.kt
  10. 40
      game/src/main/kotlin/org/openrs2/game/net/js5/Js5ChannelHandler.kt
  11. 43
      game/src/main/kotlin/org/openrs2/game/net/js5/Js5Client.kt
  12. 128
      game/src/main/kotlin/org/openrs2/game/net/js5/Js5Service.kt
  13. 79
      game/src/main/kotlin/org/openrs2/game/net/login/LoginChannelHandler.kt
  14. 3
      gradle/libs.versions.toml
  15. 2
      protocol/build.gradle.kts

@ -11,7 +11,15 @@ application {
dependencies {
api(libs.clikt)
implementation(projects.buffer)
implementation(projects.cache)
implementation(projects.conf)
implementation(projects.inject)
implementation(projects.net)
implementation(projects.protocol)
implementation(projects.util)
implementation(libs.guava)
implementation(libs.netty.codec.http)
}
publishing {

@ -1,9 +1,24 @@
package org.openrs2.game
import com.google.common.util.concurrent.Service
import com.google.inject.AbstractModule
import com.google.inject.multibindings.Multibinder
import org.openrs2.buffer.BufferModule
import org.openrs2.cache.CacheModule
import org.openrs2.conf.ConfigModule
import org.openrs2.game.net.NetworkService
import org.openrs2.game.net.js5.Js5Service
import org.openrs2.net.NetworkModule
public object GameModule : AbstractModule() {
override fun configure() {
// empty
install(BufferModule)
install(CacheModule)
install(ConfigModule)
install(NetworkModule)
val binder = Multibinder.newSetBinder(binder(), Service::class.java)
binder.addBinding().to(Js5Service::class.java)
binder.addBinding().to(NetworkService::class.java)
}
}

@ -1,10 +1,18 @@
package org.openrs2.game
import com.google.common.util.concurrent.Service
import com.google.common.util.concurrent.ServiceManager
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
public class GameServer {
public class GameServer @Inject constructor(
services: Set<Service>
) {
private val serviceManager = ServiceManager(services)
public fun run() {
TODO()
serviceManager.startAsync().awaitHealthy()
serviceManager.awaitStopped()
}
}

@ -0,0 +1,53 @@
package org.openrs2.game.net
import io.netty.channel.DefaultFileRegion
import io.netty.channel.FileRegion
import java.nio.channels.FileChannel
import java.nio.file.Files
import java.nio.file.Path
import javax.inject.Singleton
@Singleton
public class FileProvider {
public fun get(uri: String): FileRegion? {
if (!uri.startsWith("/")) {
return null
}
var path = ROOT.resolve(uri.substring(1)).toAbsolutePath().normalize()
if (!path.startsWith(ROOT)) {
return null
}
if (!Files.exists(path)) {
path = stripChecksum(path)
}
if (!Files.isRegularFile(path)) {
return null
}
val channel = FileChannel.open(path)
return DefaultFileRegion(channel, 0, channel.size())
}
private fun stripChecksum(path: Path): Path {
val name = path.fileName.toString()
val extensionIndex = name.lastIndexOf('.')
if (extensionIndex == -1) {
return path
}
val checksumIndex = name.lastIndexOf('_', extensionIndex)
if (checksumIndex == -1) {
return path
}
return path.resolveSibling(name.substring(0, checksumIndex) + name.substring(extensionIndex))
}
private companion object {
private val ROOT = Path.of("nonfree/var/cache/client").toAbsolutePath().normalize()
}
}

@ -0,0 +1,62 @@
package org.openrs2.game.net
import com.google.common.util.concurrent.AbstractService
import io.netty.channel.EventLoopGroup
import org.openrs2.game.net.http.HttpChannelInitializer
import org.openrs2.net.BootstrapFactory
import org.openrs2.net.asCompletableFuture
import java.util.concurrent.CompletableFuture
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
public class NetworkService @Inject constructor(
private val bootstrapFactory: BootstrapFactory,
private val httpInitializer: HttpChannelInitializer,
private val rs2Initializer: Rs2ChannelInitializer
) : AbstractService() {
private lateinit var group: EventLoopGroup
override fun doStart() {
group = bootstrapFactory.createEventLoopGroup()
val httpFuture = bootstrapFactory.createServerBootstrap(group)
.childHandler(httpInitializer)
.bind(HTTP_PORT)
.asCompletableFuture()
val rs2Initializer = bootstrapFactory.createServerBootstrap(group)
.childHandler(rs2Initializer)
val rs2PrimaryFuture = rs2Initializer.bind(RS2_PRIMARY_PORT)
.asCompletableFuture()
val rs2SecondaryFuture = rs2Initializer.bind(RS2_SECONDARY_PORT)
.asCompletableFuture()
CompletableFuture.allOf(httpFuture, rs2PrimaryFuture, rs2SecondaryFuture).handle { _, ex ->
if (ex != null) {
notifyFailed(ex)
} else {
notifyStarted()
}
}
}
override fun doStop() {
group.shutdownGracefully().addListener { future ->
if (future.isSuccess) {
notifyStopped()
} else {
notifyFailed(future.cause())
}
}
}
private companion object {
// TODO(gpe): make these configurable
private const val RS2_PRIMARY_PORT = 40001
private const val RS2_SECONDARY_PORT = 50001
private const val HTTP_PORT = 7001
}
}

@ -0,0 +1,24 @@
package org.openrs2.game.net
import io.netty.channel.Channel
import io.netty.channel.ChannelInitializer
import org.openrs2.game.net.login.LoginChannelHandler
import org.openrs2.protocol.Protocol
import org.openrs2.protocol.Rs2Decoder
import org.openrs2.protocol.Rs2Encoder
import javax.inject.Inject
import javax.inject.Provider
import javax.inject.Singleton
@Singleton
public class Rs2ChannelInitializer @Inject constructor(
private val handlerProvider: Provider<LoginChannelHandler>
) : ChannelInitializer<Channel>() {
override fun initChannel(ch: Channel) {
ch.pipeline().addLast(
Rs2Decoder(Protocol.LOGIN_UPSTREAM),
Rs2Encoder(Protocol.LOGIN_DOWNSTREAM),
handlerProvider.get()
)
}
}

@ -0,0 +1,52 @@
package org.openrs2.game.net.http
import io.netty.channel.ChannelFutureListener
import io.netty.channel.ChannelHandler
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.SimpleChannelInboundHandler
import io.netty.handler.codec.http.DefaultHttpResponse
import io.netty.handler.codec.http.HttpHeaderNames
import io.netty.handler.codec.http.HttpHeaderValues
import io.netty.handler.codec.http.HttpRequest
import io.netty.handler.codec.http.HttpResponse
import io.netty.handler.codec.http.HttpResponseStatus
import io.netty.handler.codec.http.HttpVersion
import org.openrs2.game.net.FileProvider
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@ChannelHandler.Sharable
public class HttpChannelHandler @Inject constructor(
private val fileProvider: FileProvider
) : SimpleChannelInboundHandler<HttpRequest>() {
override fun channelActive(ctx: ChannelHandlerContext) {
ctx.read()
}
override fun channelRead0(ctx: ChannelHandlerContext, msg: HttpRequest) {
val file = fileProvider.get(msg.uri())
if (file == null) {
ctx.write(createResponse(HttpResponseStatus.NOT_FOUND)).addListener(ChannelFutureListener.CLOSE)
return
}
val response = createResponse(HttpResponseStatus.OK)
response.headers().add(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.APPLICATION_OCTET_STREAM)
response.headers().add(HttpHeaderNames.CONTENT_LENGTH, file.count())
ctx.write(response, ctx.voidPromise())
ctx.write(file).addListener(ChannelFutureListener.CLOSE)
}
override fun channelReadComplete(ctx: ChannelHandlerContext) {
ctx.flush()
}
private fun createResponse(status: HttpResponseStatus): HttpResponse {
val response = DefaultHttpResponse(HttpVersion.HTTP_1_1, status)
response.headers().add(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE)
response.headers().add(HttpHeaderNames.SERVER, "OpenRS2")
return response
}
}

@ -0,0 +1,27 @@
package org.openrs2.game.net.http
import io.netty.channel.Channel
import io.netty.channel.ChannelInitializer
import io.netty.handler.codec.http.HttpObjectAggregator
import io.netty.handler.codec.http.HttpRequestDecoder
import io.netty.handler.codec.http.HttpResponseEncoder
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
public class HttpChannelInitializer @Inject constructor(
private val handler: HttpChannelHandler
) : ChannelInitializer<Channel>() {
override fun initChannel(ch: Channel) {
ch.pipeline().addLast(
HttpRequestDecoder(),
HttpResponseEncoder(),
HttpObjectAggregator(MAX_CONTENT_LENGTH),
handler
)
}
private companion object {
private const val MAX_CONTENT_LENGTH = 65536
}
}

@ -0,0 +1,34 @@
package org.openrs2.game.net.jaggrab
import io.netty.channel.ChannelFutureListener
import io.netty.channel.ChannelHandler
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.SimpleChannelInboundHandler
import org.openrs2.game.net.FileProvider
import org.openrs2.protocol.jaggrab.JaggrabRequest
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@ChannelHandler.Sharable
public class JaggrabChannelHandler @Inject constructor(
private val fileProvider: FileProvider
) : SimpleChannelInboundHandler<JaggrabRequest>() {
override fun handlerAdded(ctx: ChannelHandlerContext) {
ctx.read()
}
override fun channelRead0(ctx: ChannelHandlerContext, msg: JaggrabRequest) {
val file = fileProvider.get(msg.path)
if (file == null) {
ctx.close()
return
}
ctx.write(file).addListener(ChannelFutureListener.CLOSE)
}
override fun channelReadComplete(ctx: ChannelHandlerContext) {
ctx.flush()
}
}

@ -0,0 +1,40 @@
package org.openrs2.game.net.js5
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.SimpleChannelInboundHandler
import org.openrs2.protocol.js5.Js5Request
import org.openrs2.protocol.js5.XorEncoder
import javax.inject.Inject
public class Js5ChannelHandler @Inject constructor(
private val service: Js5Service
) : SimpleChannelInboundHandler<Js5Request>() {
private lateinit var client: Js5Client
override fun handlerAdded(ctx: ChannelHandlerContext) {
client = Js5Client(ctx.read())
}
override fun channelRead0(ctx: ChannelHandlerContext, msg: Js5Request) {
when (msg) {
is Js5Request.Group -> service.push(client, msg)
is Js5Request.Rekey -> handleRekey(ctx, msg)
is Js5Request.Disconnect -> ctx.close()
}
}
private fun handleRekey(ctx: ChannelHandlerContext, msg: Js5Request.Rekey) {
val encoder = ctx.pipeline().get(XorEncoder::class.java)
encoder.key = msg.key
}
override fun channelReadComplete(ctx: ChannelHandlerContext) {
service.readIfNotFull(client)
}
override fun channelWritabilityChanged(ctx: ChannelHandlerContext) {
if (ctx.channel().isWritable) {
service.notifyIfNotEmpty(client)
}
}
}

@ -0,0 +1,43 @@
package org.openrs2.game.net.js5
import io.netty.channel.ChannelHandlerContext
import org.openrs2.protocol.js5.Js5Request
public class Js5Client(
public val ctx: ChannelHandlerContext
) {
private val urgent = ArrayDeque<Js5Request.Group>()
private val prefetch = ArrayDeque<Js5Request.Group>()
public fun push(request: Js5Request.Group) {
if (request.prefetch) {
prefetch += request
} else {
urgent += request
}
}
public fun pop(): Js5Request.Group? {
val request = urgent.removeFirstOrNull()
if (request != null) {
return request
}
return prefetch.removeFirstOrNull()
}
public fun isNotFull(): Boolean {
return urgent.size < MAX_QUEUE_SIZE && prefetch.size < MAX_QUEUE_SIZE
}
public fun isNotEmpty(): Boolean {
return urgent.isNotEmpty() || prefetch.isNotEmpty()
}
public fun isReady(): Boolean {
return ctx.channel().isWritable && isNotEmpty()
}
private companion object {
private const val MAX_QUEUE_SIZE = 20
}
}

@ -0,0 +1,128 @@
package org.openrs2.game.net.js5
import com.google.common.util.concurrent.AbstractExecutionThreadService
import io.netty.buffer.ByteBufAllocator
import org.openrs2.buffer.use
import org.openrs2.cache.Js5Archive
import org.openrs2.cache.Js5Compression
import org.openrs2.cache.Js5CompressionType
import org.openrs2.cache.Js5MasterIndex
import org.openrs2.cache.Store
import org.openrs2.cache.VersionTrailer
import org.openrs2.protocol.js5.Js5Request
import org.openrs2.protocol.js5.Js5Response
import org.openrs2.util.collect.UniqueQueue
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
public class Js5Service @Inject constructor(
private val store: Store,
private val masterIndex: Js5MasterIndex,
private val alloc: ByteBufAllocator
) : AbstractExecutionThreadService() {
private val lock = Object()
private val clients = UniqueQueue<Js5Client>()
override fun run() {
while (true) {
var client: Js5Client
var request: Js5Request.Group
synchronized(lock) {
while (true) {
if (!isRunning) {
return
}
val next = clients.poll()
if (next == null) {
lock.wait()
continue
}
client = next
request = client.pop() ?: continue
break
}
}
serve(client, request)
}
}
private fun serve(client: Js5Client, request: Js5Request.Group) {
val ctx = client.ctx
if (!ctx.channel().isActive) {
return
}
val buf = if (request.archive == Js5Archive.ARCHIVESET && request.group == Js5Archive.ARCHIVESET) {
alloc.buffer().use { uncompressed ->
masterIndex.write(uncompressed)
Js5Compression.compress(uncompressed, Js5CompressionType.UNCOMPRESSED).use { compressed ->
compressed.retain()
}
}
} else {
store.read(request.archive, request.group).use { buf ->
if (request.archive != Js5Archive.ARCHIVESET) {
VersionTrailer.strip(buf)
}
buf.retain()
}
}
val response = Js5Response(request.prefetch, request.archive, request.group, buf)
ctx.writeAndFlush(response, ctx.voidPromise())
synchronized(lock) {
if (client.isReady()) {
clients.add(client)
}
if (client.isNotFull()) {
ctx.read()
}
}
}
public fun push(client: Js5Client, request: Js5Request.Group) {
synchronized(lock) {
client.push(request)
if (client.isReady()) {
clients.add(client)
lock.notifyAll()
}
if (client.isNotFull()) {
client.ctx.read()
}
}
}
public fun readIfNotFull(client: Js5Client) {
synchronized(lock) {
if (client.isNotFull()) {
client.ctx.read()
}
}
}
public fun notifyIfNotEmpty(client: Js5Client) {
synchronized(lock) {
if (client.isNotEmpty()) {
lock.notifyAll()
}
}
}
override fun triggerShutdown() {
synchronized(lock) {
lock.notifyAll()
}
}
}

@ -0,0 +1,79 @@
package org.openrs2.game.net.login
import io.netty.buffer.Unpooled
import io.netty.channel.ChannelFutureListener
import io.netty.channel.ChannelHandlerContext
import io.netty.channel.SimpleChannelInboundHandler
import io.netty.handler.codec.DelimiterBasedFrameDecoder
import io.netty.handler.codec.string.StringDecoder
import org.openrs2.buffer.copiedBuffer
import org.openrs2.game.net.jaggrab.JaggrabChannelHandler
import org.openrs2.game.net.js5.Js5ChannelHandler
import org.openrs2.protocol.Rs2Decoder
import org.openrs2.protocol.Rs2Encoder
import org.openrs2.protocol.jaggrab.JaggrabRequestDecoder
import org.openrs2.protocol.js5.Js5RequestDecoder
import org.openrs2.protocol.js5.Js5ResponseEncoder
import org.openrs2.protocol.js5.XorDecoder
import org.openrs2.protocol.login.LoginRequest
import org.openrs2.protocol.login.LoginResponse
import javax.inject.Inject
import javax.inject.Provider
public class LoginChannelHandler @Inject constructor(
private val js5HandlerProvider: Provider<Js5ChannelHandler>,
private val jaggrabHandler: JaggrabChannelHandler
) : SimpleChannelInboundHandler<LoginRequest>() {
override fun channelActive(ctx: ChannelHandlerContext) {
ctx.read()
}
override fun channelRead0(ctx: ChannelHandlerContext, msg: LoginRequest) {
when (msg) {
is LoginRequest.InitJs5RemoteConnection -> handleInitJs5RemoteConnection(ctx, msg)
is LoginRequest.InitJaggrabConnection -> handleInitJaggrabConnection(ctx)
}
}
private fun handleInitJs5RemoteConnection(ctx: ChannelHandlerContext, msg: LoginRequest.InitJs5RemoteConnection) {
if (msg.build != BUILD) {
ctx.write(LoginResponse.ClientOutOfDate).addListener(ChannelFutureListener.CLOSE)
return
}
ctx.pipeline().addLast(
XorDecoder(),
Js5RequestDecoder(),
Js5ResponseEncoder,
js5HandlerProvider.get()
)
ctx.pipeline().remove(Rs2Decoder::class.java)
ctx.write(LoginResponse.Js5Ok).addListener {
ctx.pipeline().remove(Rs2Encoder::class.java)
ctx.pipeline().remove(this)
}
}
private fun handleInitJaggrabConnection(ctx: ChannelHandlerContext) {
ctx.pipeline().addLast(
DelimiterBasedFrameDecoder(JAGGRAB_MAX_FRAME_LENGTH, JAGGRAB_DELIMITER),
StringDecoder(Charsets.UTF_8),
JaggrabRequestDecoder,
jaggrabHandler
)
ctx.pipeline().remove(Rs2Decoder::class.java)
ctx.pipeline().remove(Rs2Encoder::class.java)
ctx.pipeline().remove(this)
}
override fun channelReadComplete(ctx: ChannelHandlerContext) {
ctx.flush()
}
private companion object {
private const val BUILD = 550
private const val JAGGRAB_MAX_FRAME_LENGTH = 4096
private val JAGGRAB_DELIMITER = Unpooled.unreleasableBuffer(copiedBuffer("\n\n"))
}
}

@ -51,7 +51,8 @@ ktor-thymeleaf = { module = "io.ktor:ktor-thymeleaf", version.ref = "ktor" }
ktor-webjars = { module = "io.ktor:ktor-webjars", version.ref = "ktor" }
logback = { module = "ch.qos.logback:logback-classic", version = "1.2.3" }
netty-buffer = { module = "io.netty:netty-buffer", version.ref = "netty" }
netty-codec = { module = "io.netty:netty-codec", version.ref = "netty" }
netty-codec-core = { module = "io.netty:netty-codec", version.ref = "netty" }
netty-codec-http = { module = "io.netty:netty-codec-http", version.ref = "netty" }
netty-transport = { module = "io.netty:netty-transport", version.ref = "netty" }
openrs2-natives = { module = "org.openrs2:openrs2-natives-all", version = "3.2.0" }
postgres = { module = "org.postgresql:postgresql", version = "42.2.20" }

@ -5,7 +5,7 @@ plugins {
dependencies {
api(projects.crypto)
api(libs.netty.codec)
api(libs.netty.codec.core)
implementation(projects.buffer)
}

Loading…
Cancel
Save