progress towards multi-server handling
- bump some versions - validate Factorio server ID & token -
This commit is contained in:
parent
3821996eeb
commit
4bc46f9753
28 changed files with 577 additions and 139 deletions
|
|
@ -65,7 +65,7 @@ class FactorioModPortalPublishClient(
|
||||||
expectSuccess = false
|
expectSuccess = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun uploadMod() = runBlocking {
|
fun uploadMod(): Unit = runBlocking {
|
||||||
client().useToRun {
|
client().useToRun {
|
||||||
val initUploadResponse = initUpload()
|
val initUploadResponse = initUpload()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ abstract class FactorioModPublishTask @Inject constructor(
|
||||||
|
|
||||||
|
|
||||||
@TaskAction
|
@TaskAction
|
||||||
fun deploy() = runBlocking {
|
fun deploy(): Unit = runBlocking {
|
||||||
|
|
||||||
val userInputHandler = services.get<UserInputHandler>()
|
val userInputHandler = services.get<UserInputHandler>()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,12 @@ scrimage = "4.0.31" # https://github.com/sksamuel/scrimage/r
|
||||||
semver = "1.2.0" # https://github.com/swiftzer/semver/releases
|
semver = "1.2.0" # https://github.com/swiftzer/semver/releases
|
||||||
slf4j = "1.7.36"
|
slf4j = "1.7.36"
|
||||||
|
|
||||||
kotlinx-serialization = "1.3.3" # https://github.com/Kotlin/kotlinx.serialization/releases
|
kotlinx-serialization = "1.4.0" # https://github.com/Kotlin/kotlinx.serialization/releases
|
||||||
kxs-charleskornKaml = "0.44.0"
|
kxs-charleskornKaml = "0.44.0"
|
||||||
kxs-mamoeYamlkt = "0.12.0"
|
kxs-mamoeYamlkt = "0.12.0"
|
||||||
kxs-kxsTsGen = "0.2.1"
|
kxs-kxsTsGen = "0.2.1"
|
||||||
|
|
||||||
kotlinJsWrappers = "1.0.0-pre.367" # https://search.maven.org/artifact/org.jetbrains.kotlin-wrappers/kotlin-wrappers-bom
|
kotlinJsWrappers = "1.0.0-pre.378" # https://search.maven.org/artifact/org.jetbrains.kotlin-wrappers/kotlin-wrappers-bom
|
||||||
|
|
||||||
kotlinx-cli = "0.3.4"
|
kotlinx-cli = "0.3.4"
|
||||||
kotlinx-coroutines = "1.6.4" # https://github.com/Kotlin/kotlinx.coroutines/releases
|
kotlinx-coroutines = "1.6.4" # https://github.com/Kotlin/kotlinx.coroutines/releases
|
||||||
|
|
@ -37,21 +37,22 @@ kSvgDsl = "v2.2.0"
|
||||||
|
|
||||||
ktor = "2.1.0" # https://github.com/ktorio/ktor/releases
|
ktor = "2.1.0" # https://github.com/ktorio/ktor/releases
|
||||||
|
|
||||||
hoplite = "2.5.2" # https://github.com/sksamuel/hoplite/releases
|
hoplite = "2.6.1" # https://github.com/sksamuel/hoplite/releases
|
||||||
|
|
||||||
#simpleSyslogParser = "0.0.6" # https://search.maven.org/artifact/com.github.palindromicity/simple-syslog
|
simpleSyslogParser = "1.0.0" # https://search.maven.org/artifact/com.github.palindromicity/simple-syslog
|
||||||
simpleSyslogParser = "cbf7d86559" # https://jitpack.io/#palindromicity/simple-syslog/
|
|
||||||
|
|
||||||
kotkaStreams = "22.08.06"
|
kotkaStreams = "22.08.06"
|
||||||
|
|
||||||
marykRocksDb = "7.3.1-1" # https://search.maven.org/artifact/io.maryk.rocksdb/rocksdb-multiplatform
|
marykRocksDb = "7.3.1-1" # https://search.maven.org/artifact/io.maryk.rocksdb/rocksdb-multiplatform
|
||||||
|
|
||||||
|
klogging = "0.4.9"
|
||||||
|
|
||||||
## npm ##
|
## npm ##
|
||||||
|
|
||||||
npm-luaTypes = "2.12.0"
|
npm-luaTypes = "2.12.0"
|
||||||
npm-typescriptToLua = "1.9.0"
|
npm-typescriptToLua = "1.9.0"
|
||||||
npm-typedFactorio = "1.7.0"
|
#npm-typedFactorio = "1.6.1" # https://github.com/GlassBricks/typed-factorio/blob/main/Changelog.md
|
||||||
|
npm-typedFactorio = "1.7.0" # for factorio 1.1.61
|
||||||
npm-typescript = "4.7.4"
|
npm-typescript = "4.7.4"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -63,7 +64,7 @@ mockk = "1.12.5"
|
||||||
|
|
||||||
|
|
||||||
## infra ##
|
## infra ##
|
||||||
factorio = "1.1.64"
|
factorio = "1.1.68"
|
||||||
confluent-kafkaConnect = "7.0.1"
|
confluent-kafkaConnect = "7.0.1"
|
||||||
grafana = "8.5.0"
|
grafana = "8.5.0"
|
||||||
prometheus = "v2.34.0"
|
prometheus = "v2.34.0"
|
||||||
|
|
@ -191,7 +192,6 @@ ktorNetwork-core = { group = "io.ktor", name = "ktor-network"
|
||||||
ktorNetwork-tls = { group = "io.ktor", name = "ktor-network-tls" }
|
ktorNetwork-tls = { group = "io.ktor", name = "ktor-network-tls" }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### jackson ##
|
### jackson ##
|
||||||
#jackson-bom = { group = " com.fasterxml.jackson.module", name = ":jackson-module-kotlin", version = "2.13.+" }
|
#jackson-bom = { group = " com.fasterxml.jackson.module", name = ":jackson-module-kotlin", version = "2.13.+" }
|
||||||
#jackson-kotlin = { group = " com.fasterxml.jackson.module", name = ":jackson-module-kotlin", version = "2.13.+" }
|
#jackson-kotlin = { group = " com.fasterxml.jackson.module", name = ":jackson-module-kotlin", version = "2.13.+" }
|
||||||
|
|
@ -203,6 +203,9 @@ logback-core = { group = "ch.qos.logback", name = "logback-core",
|
||||||
logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "qosLogback" }
|
logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "qosLogback" }
|
||||||
logback-encoder = { group = "net.logstash.logback", name = "logstash-logback-encoder", version = "6.6" }
|
logback-encoder = { group = "net.logstash.logback", name = "logstash-logback-encoder", version = "6.6" }
|
||||||
|
|
||||||
|
|
||||||
|
klogging = { group = "io.klogging", name = "klogging-jvm", version.ref = "klogging" }
|
||||||
|
|
||||||
## gcp ##
|
## gcp ##
|
||||||
#gcp-bom = { group = "com.google.cloud", name = "libraries-bom", version = "23.1.0" }
|
#gcp-bom = { group = "com.google.cloud", name = "libraries-bom", version = "23.1.0" }
|
||||||
#gcp-pubsub = { group = "com.google.cloud", name = "google-cloud-pubsub" }
|
#gcp-pubsub = { group = "com.google.cloud", name = "google-cloud-pubsub" }
|
||||||
|
|
@ -237,7 +240,6 @@ hoplite-datetime = { group = "com.sksamuel.hoplite", name = "hoplite-datetime",
|
||||||
hoplite-yaml = { group = "com.sksamuel.hoplite", name = "hoplite-yaml", version.ref = "hoplite" }
|
hoplite-yaml = { group = "com.sksamuel.hoplite", name = "hoplite-yaml", version.ref = "hoplite" }
|
||||||
|
|
||||||
|
|
||||||
#simpleSyslogParser = { group = "com.github.palindromicity", name = "simple-syslog", version.ref = "simpleSyslogParser" }
|
|
||||||
simpleSyslogParser = { group = "com.github.palindromicity", name = "simple-syslog", version.ref = "simpleSyslogParser" }
|
simpleSyslogParser = { group = "com.github.palindromicity", name = "simple-syslog", version.ref = "simpleSyslogParser" }
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,9 +76,39 @@ data class EntityIdentifiersData(
|
||||||
) : EntityIdentifiers
|
) : EntityIdentifiers
|
||||||
|
|
||||||
|
|
||||||
|
/** Friendly identifier, used in URLs. Should be human-readable. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("kafkatorio.id.FactorioServerId")
|
@SerialName("kafkatorio.id.FactorioServerId")
|
||||||
@JvmInline
|
@JvmInline
|
||||||
value class FactorioServerId(private val id: String) {
|
value class FactorioServerId(val id: String) : CharSequence by id {
|
||||||
override fun toString() = id
|
|
||||||
|
init {
|
||||||
|
require(id.isNotBlank()) { "require FactorioServerId is not blank: '$id'" }
|
||||||
|
require(id.matches(validIdRegex)) { "require FactorioServerId matches $validIdRegex: '$id'" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = id
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val validIdRegex = Regex("""^[a-zA-Z0-9\-_]+""" + "\$")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Short identifier, used in tokens. Might not be human-readable. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("kafkatorio.id.FactorioServerToken")
|
||||||
|
@JvmInline
|
||||||
|
value class FactorioServerToken(private val id: String) : CharSequence by id {
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(id.isNotBlank()) { "require FactorioServerToken is not blank: '$id'" }
|
||||||
|
require(id.matches(FactorioServerId.validIdRegex)) { "require FactorioServerToken matches ${validIdRegex}: '$id'" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = id
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val validIdRegex = Regex("^[a-zA-Z0-9]+\$")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ value class ServerMapTilePngFilename(val value: String) {
|
||||||
chunkX: Int,
|
chunkX: Int,
|
||||||
chunkY: Int,
|
chunkY: Int,
|
||||||
) : this(
|
) : this(
|
||||||
"servers/${serverId}" +
|
"servers/${serverId.id}" +
|
||||||
"/map" +
|
"/map" +
|
||||||
"/layers/${layer.dir}" +
|
"/layers/${layer.dir}" +
|
||||||
"/s${surfaceIndex}" +
|
"/s${surfaceIndex}" +
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package dev.adamko.kafkatorio.schema.common
|
||||||
|
|
||||||
|
import kotlin.jvm.JvmInline
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("kafkatorio.common.MaskedValue")
|
||||||
|
@JvmInline
|
||||||
|
value class MaskedValue(val value: String) {
|
||||||
|
override fun toString(): String = "Secret(***)"
|
||||||
|
}
|
||||||
|
|
@ -24,4 +24,16 @@ sealed class KafkatorioPacketData
|
||||||
data class KafkatorioPacketDataError(
|
data class KafkatorioPacketDataError(
|
||||||
val message: String? = null,
|
val message: String? = null,
|
||||||
val rawValue: String? = null,
|
val rawValue: String? = null,
|
||||||
) : KafkatorioPacketData()
|
) : KafkatorioPacketData() {
|
||||||
|
constructor(
|
||||||
|
exception: Exception,
|
||||||
|
rawValue: String? = null
|
||||||
|
) : this(
|
||||||
|
message = """
|
||||||
|
${exception::class.qualifiedName} ${exception.message}
|
||||||
|
|
||||||
|
${exception.stackTraceToString()}
|
||||||
|
""".trimIndent(),
|
||||||
|
rawValue = rawValue,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ dependencies {
|
||||||
implementation(libs.ktorSerialization.kotlinxJson)
|
implementation(libs.ktorSerialization.kotlinxJson)
|
||||||
|
|
||||||
with(libs.ktorServer) {
|
with(libs.ktorServer) {
|
||||||
|
implementation(auth)
|
||||||
|
implementation(authJwt)
|
||||||
implementation(core)
|
implementation(core)
|
||||||
implementation(callId)
|
implementation(callId)
|
||||||
implementation(callLogging)
|
implementation(callLogging)
|
||||||
|
|
@ -59,9 +61,13 @@ dependencies {
|
||||||
}
|
}
|
||||||
//</editor-fold>
|
//</editor-fold>
|
||||||
|
|
||||||
|
|
||||||
implementation(libs.simpleSyslogParser)
|
implementation(libs.simpleSyslogParser)
|
||||||
|
|
||||||
|
// https://search.maven.org/artifact/org.bouncycastle/bcprov-jdk15on
|
||||||
|
implementation("org.bouncycastle:bcprov-jdk15on:1.70")
|
||||||
|
implementation("com.brendangoldberg:kotlin-jwt:1.1.0")
|
||||||
|
implementation("com.auth0:java-jwt:4.0.0")
|
||||||
|
|
||||||
implementation(libs.kafka.kotkaStreams) {
|
implementation(libs.kafka.kotkaStreams) {
|
||||||
isChanging = false
|
isChanging = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ package dev.adamko.kafkatorio.server.config
|
||||||
import com.sksamuel.hoplite.ConfigLoaderBuilder
|
import com.sksamuel.hoplite.ConfigLoaderBuilder
|
||||||
import com.sksamuel.hoplite.addEnvironmentSource
|
import com.sksamuel.hoplite.addEnvironmentSource
|
||||||
import com.sksamuel.hoplite.addResourceOrFileSource
|
import com.sksamuel.hoplite.addResourceOrFileSource
|
||||||
|
import dev.adamko.kafkatorio.schema.common.FactorioServerId
|
||||||
|
import dev.adamko.kafkatorio.schema.common.FactorioServerToken
|
||||||
|
import dev.adamko.kafkatorio.schema.common.MaskedValue
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,6 +18,10 @@ data class ApplicationProperties(
|
||||||
|
|
||||||
val socketServerHost: String,
|
val socketServerHost: String,
|
||||||
val socketServerPort: Int,
|
val socketServerPort: Int,
|
||||||
|
|
||||||
|
val jwtSecret: MaskedValue,
|
||||||
|
|
||||||
|
val kafkatorioServers: Map<FactorioServerToken, FactorioServerId>
|
||||||
) {
|
) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -23,7 +30,7 @@ data class ApplicationProperties(
|
||||||
ConfigLoaderBuilder.default()
|
ConfigLoaderBuilder.default()
|
||||||
.addDefaults()
|
.addDefaults()
|
||||||
.allowUnresolvedSubstitutions()
|
.allowUnresolvedSubstitutions()
|
||||||
.allowEmptyTree()
|
.allowEmptySources()
|
||||||
.addEnvironmentSource()
|
.addEnvironmentSource()
|
||||||
.addResourceOrFileSource("/.secret.config.yml", optional = true)
|
.addResourceOrFileSource("/.secret.config.yml", optional = true)
|
||||||
.addResourceOrFileSource("/config.yml", optional = false)
|
.addResourceOrFileSource("/config.yml", optional = false)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package dev.adamko.kafkatorio.server
|
||||||
|
|
||||||
import dev.adamko.kafkatorio.server.config.ApplicationProperties
|
import dev.adamko.kafkatorio.server.config.ApplicationProperties
|
||||||
import dev.adamko.kafkatorio.server.processor.eventsProcessor
|
import dev.adamko.kafkatorio.server.processor.eventsProcessor
|
||||||
|
import dev.adamko.kafkatorio.server.security.Authenticator
|
||||||
import dev.adamko.kafkatorio.server.socket.SyslogSocketServer
|
import dev.adamko.kafkatorio.server.socket.SyslogSocketServer
|
||||||
import dev.adamko.kafkatorio.server.web.startWebServer
|
import dev.adamko.kafkatorio.server.web.startWebServer
|
||||||
import dev.adamko.kafkatorio.server.web.websocket.WebmapWebsocketServer
|
import dev.adamko.kafkatorio.server.web.websocket.WebmapWebsocketServer
|
||||||
|
|
@ -15,6 +16,7 @@ suspend fun main(): Unit = coroutineScope {
|
||||||
|
|
||||||
val websocketServer = WebmapWebsocketServer()
|
val websocketServer = WebmapWebsocketServer()
|
||||||
val syslogSocketServer = SyslogSocketServer(appProps)
|
val syslogSocketServer = SyslogSocketServer(appProps)
|
||||||
|
val authenticator = Authenticator(appProps)
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
startWebServer(appProps, websocketServer)
|
startWebServer(appProps, websocketServer)
|
||||||
|
|
@ -25,6 +27,6 @@ suspend fun main(): Unit = coroutineScope {
|
||||||
}
|
}
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
eventsProcessor(appProps, syslogSocketServer, websocketServer)
|
eventsProcessor(appProps, syslogSocketServer, websocketServer, authenticator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
package dev.adamko.kafkatorio.server.processor
|
||||||
|
|
||||||
|
import com.github.palindromicity.syslog.dsl.SyslogFieldKeys
|
||||||
|
import dev.adamko.kafkatorio.schema.common.FactorioServerId
|
||||||
|
import dev.adamko.kafkatorio.server.config.ApplicationProperties
|
||||||
|
import dev.adamko.kafkatorio.server.config.jsonMapper
|
||||||
|
import dev.adamko.kafkatorio.server.security.Authenticator
|
||||||
|
import dev.adamko.kafkatorio.server.socket.SyslogMsg
|
||||||
|
import dev.adamko.kafkatorio.server.socket.SyslogSocketServer
|
||||||
|
import dev.adamko.kotka.kxs.kafkaSerializer
|
||||||
|
import java.util.Properties
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.job
|
||||||
|
import org.apache.kafka.clients.producer.KafkaProducer
|
||||||
|
import org.apache.kafka.clients.producer.ProducerRecord
|
||||||
|
import org.apache.kafka.common.header.internals.RecordHeader
|
||||||
|
import org.apache.kafka.common.serialization.Serdes
|
||||||
|
import org.apache.kafka.streams.StreamsConfig
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consume Syslog messages, verifies each, and forwards them to a Kafka topic.
|
||||||
|
*/
|
||||||
|
class KafkatorioPacketKafkaProducer(
|
||||||
|
private val appProps: ApplicationProperties,
|
||||||
|
private val syslogServer: SyslogSocketServer,
|
||||||
|
private val authenticator: Authenticator,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
private val props: Properties = appProps.kafkaStreamsConfig.toProperties()
|
||||||
|
|
||||||
|
init {
|
||||||
|
props.updateValue(StreamsConfig.APPLICATION_ID_CONFIG) { "$it.KafkatorioPacketKafkaProducer" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val producer: KafkaProducer<FactorioServerId, String> = KafkaProducer(
|
||||||
|
props.apply {
|
||||||
|
updateValue(StreamsConfig.APPLICATION_ID_CONFIG) { "$it.KafkatorioPacketKafkaProducer" }
|
||||||
|
},
|
||||||
|
jsonMapper.kafkaSerializer(FactorioServerId.serializer()),
|
||||||
|
Serdes.String().serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val dlqProducer: KafkaProducer<String, String> = KafkaProducer(
|
||||||
|
props.apply {
|
||||||
|
updateValue(StreamsConfig.APPLICATION_ID_CONFIG) { "$it.KafkatorioPacketKafkaProducer.dlq" }
|
||||||
|
},
|
||||||
|
Serdes.String().serializer(),
|
||||||
|
Serdes.String().serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun launch() {
|
||||||
|
coroutineContext.job.invokeOnCompletion {
|
||||||
|
this@KafkatorioPacketKafkaProducer.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
syslogServer.messages
|
||||||
|
.filter {
|
||||||
|
(it.message ?: "").run {
|
||||||
|
startsWith("KafkatorioPacket:::") || startsWith("KafkatorioPacket encoded:::")
|
||||||
|
}
|
||||||
|
}.onEach { syslog ->
|
||||||
|
runCatching {
|
||||||
|
getFactorioServerId(syslog)
|
||||||
|
}.fold(
|
||||||
|
onSuccess = { serverId -> produceMessage(serverId, syslog) },
|
||||||
|
onFailure = { ex ->
|
||||||
|
produceDlqMessage(syslog, "${ex::class.java.name}: ${ex.message}")
|
||||||
|
|
||||||
|
if (ex is CancellationException) throw ex
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun getFactorioServerId(syslog: SyslogMsg): FactorioServerId {
|
||||||
|
val token = syslog.headerAppName
|
||||||
|
if (token.isNullOrBlank()) {
|
||||||
|
error("no token in syslog message. msg: ${syslog.src}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val serverToken = authenticator.verifyJwt(token)
|
||||||
|
if (serverToken.isNullOrBlank()) {
|
||||||
|
error("could not decode token $token. msg: ${syslog.src}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val serverId = appProps.kafkatorioServers[serverToken]
|
||||||
|
if (serverId.isNullOrBlank()) {
|
||||||
|
error("no server ID for token $serverToken. msg: ${syslog.src}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverId
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun produceMessage(
|
||||||
|
serverId: FactorioServerId,
|
||||||
|
syslog: SyslogMsg,
|
||||||
|
) {
|
||||||
|
val kafkatorioMessage = syslog.message?.substringAfter(":::")
|
||||||
|
|
||||||
|
val record = ProducerRecord<FactorioServerId, String>(
|
||||||
|
TOPIC_SRC_SERVER_LOG,
|
||||||
|
serverId,
|
||||||
|
kafkatorioMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
syslog.copyHeadersTo(record)
|
||||||
|
|
||||||
|
producer.send(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun produceDlqMessage(syslog: SyslogMsg, errorMessage: String) {
|
||||||
|
log("error: $errorMessage, $syslog")
|
||||||
|
|
||||||
|
val record = ProducerRecord(
|
||||||
|
TOPIC_SRC_SERVER_LOG_DLQ,
|
||||||
|
"unknown",
|
||||||
|
errorMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
syslog.copyHeadersTo(record)
|
||||||
|
|
||||||
|
dlqProducer.send(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
producer.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private fun log(msg: String) = println("[KafkatorioPacketKafkaProducer] $msg")
|
||||||
|
|
||||||
|
|
||||||
|
private fun Properties.updateValue(
|
||||||
|
key: String,
|
||||||
|
update: (oldValue: String) -> String
|
||||||
|
) {
|
||||||
|
val newValue = update(getValue(key) as String)
|
||||||
|
setProperty(key, newValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun SyslogMsg.copyHeadersTo(record: ProducerRecord<*, *>) {
|
||||||
|
val headers = SyslogFieldKeys.values()
|
||||||
|
.filter { "HEADER_" in it.name }
|
||||||
|
.mapNotNull { headerKey ->
|
||||||
|
val headerValue = src[headerKey.field]
|
||||||
|
when {
|
||||||
|
headerValue.isNullOrBlank() -> null
|
||||||
|
else -> RecordHeader(
|
||||||
|
headerKey.field,
|
||||||
|
headerValue.toByteArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.forEach { header ->
|
||||||
|
record.headers().add(header)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package dev.adamko.kafkatorio.server.processor
|
package dev.adamko.kafkatorio.server.processor
|
||||||
|
|
||||||
import com.github.palindromicity.syslog.dsl.SyslogFieldKeys
|
|
||||||
import dev.adamko.kafkatorio.schema.common.FactorioServerId
|
import dev.adamko.kafkatorio.schema.common.FactorioServerId
|
||||||
import dev.adamko.kafkatorio.schema.common.PrototypeHashCode
|
import dev.adamko.kafkatorio.schema.common.PrototypeHashCode
|
||||||
import dev.adamko.kafkatorio.schema.common.ServerMapChunkId
|
import dev.adamko.kafkatorio.schema.common.ServerMapChunkId
|
||||||
|
|
@ -31,17 +30,10 @@ import kotlinx.coroutines.CoroutineName
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.filter
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import kotlinx.coroutines.job
|
import kotlinx.coroutines.job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import org.apache.kafka.clients.producer.KafkaProducer
|
|
||||||
import org.apache.kafka.clients.producer.ProducerConfig
|
import org.apache.kafka.clients.producer.ProducerConfig
|
||||||
import org.apache.kafka.clients.producer.ProducerRecord
|
|
||||||
import org.apache.kafka.common.header.internals.RecordHeader
|
|
||||||
import org.apache.kafka.common.serialization.Serdes
|
|
||||||
import org.apache.kafka.streams.KafkaStreams
|
import org.apache.kafka.streams.KafkaStreams
|
||||||
import org.apache.kafka.streams.StreamsBuilder
|
import org.apache.kafka.streams.StreamsBuilder
|
||||||
import org.apache.kafka.streams.StreamsConfig
|
import org.apache.kafka.streams.StreamsConfig
|
||||||
|
|
@ -54,6 +46,7 @@ internal class KafkatorioTopology(
|
||||||
private val appProps: ApplicationProperties,
|
private val appProps: ApplicationProperties,
|
||||||
private val wsServer: WebmapWebsocketServer,
|
private val wsServer: WebmapWebsocketServer,
|
||||||
private val syslogServer: SyslogSocketServer,
|
private val syslogServer: SyslogSocketServer,
|
||||||
|
private val kafkatorioPacketKafkaProducer: KafkatorioPacketKafkaProducer,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val builderScope = CoroutineScope(CoroutineName("KafkaTopology"))
|
private val builderScope = CoroutineScope(CoroutineName("KafkaTopology"))
|
||||||
|
|
@ -168,60 +161,10 @@ internal class KafkatorioTopology(
|
||||||
|
|
||||||
|
|
||||||
/** Receive log messages from the syslog socket, and send them to a Kafka topic */
|
/** Receive log messages from the syslog socket, and send them to a Kafka topic */
|
||||||
private fun syslogProducer(
|
private fun syslogProducer() {
|
||||||
id: String = "syslog-producer"
|
builderScope.launch {
|
||||||
) {
|
kafkatorioPacketKafkaProducer.launch()
|
||||||
val props: Properties = appProps.kafkaStreamsConfig.toProperties()
|
|
||||||
|
|
||||||
val appId = props.compute(StreamsConfig.APPLICATION_ID_CONFIG) { _, v -> "$v.$id" } as String
|
|
||||||
props.setProperty(StreamsConfig.APPLICATION_ID_CONFIG, appId)
|
|
||||||
|
|
||||||
|
|
||||||
val producer: KafkaProducer<String, String> = KafkaProducer(
|
|
||||||
props,
|
|
||||||
Serdes.String().serializer(),
|
|
||||||
Serdes.String().serializer(),
|
|
||||||
)
|
|
||||||
|
|
||||||
builderScope.coroutineContext.job.invokeOnCompletion {
|
|
||||||
producer.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
syslogServer.messages
|
|
||||||
.filter {
|
|
||||||
(it.message ?: "").run {
|
|
||||||
startsWith("KafkatorioPacket:::") || startsWith("KafkatorioPacket encoded:::")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEach { syslogMsg ->
|
|
||||||
|
|
||||||
val kafkatorioMessage = syslogMsg.message?.substringAfter(":::")
|
|
||||||
|
|
||||||
val record = ProducerRecord<String, String>(
|
|
||||||
TOPIC_SRC_SERVER_LOG,
|
|
||||||
"syslog-test",
|
|
||||||
kafkatorioMessage,
|
|
||||||
)
|
|
||||||
|
|
||||||
val headers = SyslogFieldKeys.values()
|
|
||||||
.filter { "HEADER_" in it.name }
|
|
||||||
.mapNotNull { headerKey ->
|
|
||||||
val headerValue = syslogMsg.src[headerKey.field]
|
|
||||||
when {
|
|
||||||
headerValue.isNullOrBlank() -> null
|
|
||||||
else -> RecordHeader(
|
|
||||||
headerKey.field,
|
|
||||||
headerValue.toByteArray()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
headers.forEach { header ->
|
|
||||||
record.headers().add(header)
|
|
||||||
}
|
|
||||||
|
|
||||||
producer.send(record)
|
|
||||||
}.launchIn(builderScope)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -251,6 +194,7 @@ internal class KafkatorioTopology(
|
||||||
when (newState) {
|
when (newState) {
|
||||||
KafkaStreams.State.NOT_RUNNING,
|
KafkaStreams.State.NOT_RUNNING,
|
||||||
KafkaStreams.State.ERROR -> if (cont.isActive) cont.resume(newState)
|
KafkaStreams.State.ERROR -> if (cont.isActive) cont.resume(newState)
|
||||||
|
|
||||||
else -> Unit // do nothing
|
else -> Unit // do nothing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package dev.adamko.kafkatorio.server.processor
|
||||||
|
|
||||||
import dev.adamko.kafkatorio.server.config.ApplicationProperties
|
import dev.adamko.kafkatorio.server.config.ApplicationProperties
|
||||||
import dev.adamko.kafkatorio.server.processor.admin.KafkatorioKafkaAdmin
|
import dev.adamko.kafkatorio.server.processor.admin.KafkatorioKafkaAdmin
|
||||||
|
import dev.adamko.kafkatorio.server.security.Authenticator
|
||||||
import dev.adamko.kafkatorio.server.socket.SyslogSocketServer
|
import dev.adamko.kafkatorio.server.socket.SyslogSocketServer
|
||||||
import dev.adamko.kafkatorio.server.web.websocket.WebmapWebsocketServer
|
import dev.adamko.kafkatorio.server.web.websocket.WebmapWebsocketServer
|
||||||
import kotlinx.coroutines.awaitCancellation
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
|
@ -14,12 +15,15 @@ suspend fun eventsProcessor(
|
||||||
appProps: ApplicationProperties,
|
appProps: ApplicationProperties,
|
||||||
syslogServer: SyslogSocketServer,
|
syslogServer: SyslogSocketServer,
|
||||||
wsServer: WebmapWebsocketServer,
|
wsServer: WebmapWebsocketServer,
|
||||||
|
authenticator: Authenticator,
|
||||||
): Unit = coroutineScope {
|
): Unit = coroutineScope {
|
||||||
|
|
||||||
val admin = KafkatorioKafkaAdmin(appProps)
|
val admin = KafkatorioKafkaAdmin(appProps)
|
||||||
admin.createKafkatorioTopics()
|
admin.createKafkatorioTopics()
|
||||||
|
|
||||||
val topology = KafkatorioTopology(appProps, wsServer, syslogServer)
|
val kafkatorioPacketKafkaProducer = KafkatorioPacketKafkaProducer(appProps, syslogServer, authenticator)
|
||||||
|
|
||||||
|
val topology = KafkatorioTopology(appProps, wsServer, syslogServer, kafkatorioPacketKafkaProducer)
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
topology.start()
|
topology.start()
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import kotlin.reflect.KClass
|
||||||
private const val DOMAIN = "kafkatorio"
|
private const val DOMAIN = "kafkatorio"
|
||||||
|
|
||||||
const val TOPIC_SRC_SERVER_LOG = "$DOMAIN.src.server-log"
|
const val TOPIC_SRC_SERVER_LOG = "$DOMAIN.src.server-log"
|
||||||
|
const val TOPIC_SRC_SERVER_LOG_DLQ = "$DOMAIN.src.server-log.dlq"
|
||||||
|
|
||||||
//const val TOPIC_GROUPED_MAP_CHUNKS_STATE = "$DOMAIN.state.map-chunks.grouped"
|
//const val TOPIC_GROUPED_MAP_CHUNKS_STATE = "$DOMAIN.state.map-chunks.grouped"
|
||||||
|
|
||||||
|
|
@ -41,6 +42,7 @@ const val TOPIC_BROADCAST_TO_WEBSOCKET = "$DOMAIN.broadcast.websocket"
|
||||||
|
|
||||||
fun allTopics(): Set<String> = buildSet {
|
fun allTopics(): Set<String> = buildSet {
|
||||||
add(TOPIC_SRC_SERVER_LOG)
|
add(TOPIC_SRC_SERVER_LOG)
|
||||||
|
add(TOPIC_SRC_SERVER_LOG_DLQ)
|
||||||
|
|
||||||
// add(TOPIC_GROUPED_MAP_CHUNKS_STATE)
|
// add(TOPIC_GROUPED_MAP_CHUNKS_STATE)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,11 @@ import dev.adamko.kotka.extensions.component1
|
||||||
import dev.adamko.kotka.extensions.component2
|
import dev.adamko.kotka.extensions.component2
|
||||||
import dev.adamko.kotka.extensions.consumedAs
|
import dev.adamko.kotka.extensions.consumedAs
|
||||||
import dev.adamko.kotka.extensions.producedAs
|
import dev.adamko.kotka.extensions.producedAs
|
||||||
import dev.adamko.kotka.extensions.streams.map
|
import dev.adamko.kotka.extensions.streams.mapValues
|
||||||
import dev.adamko.kotka.extensions.streams.to
|
import dev.adamko.kotka.extensions.streams.to
|
||||||
import dev.adamko.kotka.kxs.serde
|
import dev.adamko.kotka.kxs.serde
|
||||||
import java.util.zip.Inflater
|
import java.util.zip.Inflater
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.coroutines.CancellationException
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import okio.ByteString
|
import okio.ByteString
|
||||||
import okio.ByteString.Companion.decodeBase64
|
import okio.ByteString.Companion.decodeBase64
|
||||||
|
|
@ -34,29 +34,32 @@ fun factorioServerPacketStream(
|
||||||
TOPIC_SRC_SERVER_LOG,
|
TOPIC_SRC_SERVER_LOG,
|
||||||
consumedAs(
|
consumedAs(
|
||||||
"read-raw-packets-from-server",
|
"read-raw-packets-from-server",
|
||||||
Serdes.String(),
|
jsonMapper.serde(FactorioServerId.serializer()),
|
||||||
Serdes.String(),
|
Serdes.String(),
|
||||||
)
|
)
|
||||||
).map("decode-packets") { serverId: String, value: String ->
|
).mapValues("decode-packets") { _: FactorioServerId, value: String? ->
|
||||||
val packet = runCatching {
|
|
||||||
val decoded = decodeFactorioEncodedString(value)
|
val packet = try {
|
||||||
jsonMapper.decodeFromString<KafkatorioPacket>(decoded)
|
val decoded = decodeFactorioEncodedString(value!!)
|
||||||
}.getOrElse { e ->
|
jsonMapper.decodeFromString(KafkatorioPacket.serializer(), decoded)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
|
||||||
KafkatorioPacket(
|
KafkatorioPacket(
|
||||||
modVersion = "unknown",
|
modVersion = "unknown",
|
||||||
tick = Tick(0u),
|
tick = Tick(0u),
|
||||||
data = KafkatorioPacketDataError(
|
data = KafkatorioPacketDataError(
|
||||||
message = e.message,
|
exception = e,
|
||||||
rawValue = value,
|
rawValue = value,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (packet.data is KafkatorioPacketDataError) {
|
if (packet.data is KafkatorioPacketDataError) {
|
||||||
println("error parsing $TOPIC_SRC_SERVER_LOG message: ${packet.data}")
|
println("error parsing $TOPIC_SRC_SERVER_LOG message: $packet")
|
||||||
}
|
}
|
||||||
|
|
||||||
FactorioServerId(serverId) to packet
|
packet
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
package dev.adamko.kafkatorio.server.security
|
||||||
|
|
||||||
|
import com.auth0.jwt.JWT
|
||||||
|
import com.auth0.jwt.algorithms.Algorithm
|
||||||
|
import com.auth0.jwt.interfaces.DecodedJWT
|
||||||
|
import dev.adamko.kafkatorio.schema.common.FactorioServerToken
|
||||||
|
import dev.adamko.kafkatorio.server.config.ApplicationProperties
|
||||||
|
import kotlin.reflect.jvm.jvmName
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okio.ByteString.Companion.decodeBase64
|
||||||
|
|
||||||
|
|
||||||
|
class Authenticator(
|
||||||
|
appProps: ApplicationProperties,
|
||||||
|
) {
|
||||||
|
private val algorithm: Algorithm = Algorithm.HMAC256(appProps.jwtSecret.value)
|
||||||
|
|
||||||
|
private val verifier = JWT.require(algorithm).build()
|
||||||
|
|
||||||
|
fun verifyJwt(token: String): FactorioServerToken? {
|
||||||
|
return runCatching {
|
||||||
|
val decodedJwt: DecodedJWT? = verifier.verify(token)
|
||||||
|
|
||||||
|
val payloadJson = decodedJwt?.payload?.decodeBase64()?.string(Charsets.UTF_8) ?: "{}"
|
||||||
|
|
||||||
|
jsonMapper.decodeFromString(
|
||||||
|
KafkatorioJwtPayload.serializer(),
|
||||||
|
payloadJson,
|
||||||
|
).serverToken
|
||||||
|
|
||||||
|
}.onFailure { e ->
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
|
||||||
|
println("[Authenticator] Could not decode JWT ${e::class.jvmName}: ${e.message}")
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val jsonMapper = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("kafkatorio.auth.KafkatorioJwtPayload")
|
||||||
|
private data class KafkatorioJwtPayload(
|
||||||
|
@SerialName("sub")
|
||||||
|
val serverToken: FactorioServerToken,
|
||||||
|
/** unix epoch seconds*/
|
||||||
|
@SerialName("iat")
|
||||||
|
val issuedAtTime: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
//// https://github.com/auth0/java-jwt/blob/f799e589c3d43de318c7ca0f0c36e462297db973/lib/src/test/java/com/auth0/jwt/PemUtils.java
|
||||||
|
//private object PemUtils {
|
||||||
|
//
|
||||||
|
// fun readPublicKey(filepath: String, algorithm: String): PublicKey {
|
||||||
|
// val bytes = parsePemFile(File(filepath))
|
||||||
|
// return getPublicKey(bytes, algorithm)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//// fun readPublicKey(reader: Reader, algorithm: String): PublicKey {
|
||||||
|
//// return getPublicKey(bytes, algorithm)
|
||||||
|
//// }
|
||||||
|
//
|
||||||
|
// fun readPrivateKey(filepath: String, algorithm: String): PrivateKey {
|
||||||
|
// val bytes = parsePemFile(File(filepath))
|
||||||
|
// return getPrivateKey(bytes, algorithm)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private fun parsePemFile(pemFile: File): ByteArray {
|
||||||
|
// return when {
|
||||||
|
// !pemFile.isFile ->
|
||||||
|
// throw FileNotFoundException("'${pemFile.absolutePath}' is not a file")
|
||||||
|
//
|
||||||
|
// !pemFile.exists() ->
|
||||||
|
// throw FileNotFoundException("The file '${pemFile.absolutePath}' doesn't exist.")
|
||||||
|
//
|
||||||
|
// else ->
|
||||||
|
// PemReader(FileReader(pemFile)).use { reader ->
|
||||||
|
// reader.readPemObject().content
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private fun getPublicKey(keyBytes: ByteArray, algorithm: String): PublicKey =
|
||||||
|
// getKey(algorithm) {
|
||||||
|
// val keySpec: EncodedKeySpec = X509EncodedKeySpec(keyBytes)
|
||||||
|
// generatePublic(keySpec)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private fun getPrivateKey(keyBytes: ByteArray, algorithm: String): PrivateKey =
|
||||||
|
// getKey(algorithm) {
|
||||||
|
// val keySpec: EncodedKeySpec = PKCS8EncodedKeySpec(keyBytes)
|
||||||
|
// generatePrivate(keySpec)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// private fun <K : Key> getKey(
|
||||||
|
// algorithm: String,
|
||||||
|
// generateKey: KeyFactory.() -> K
|
||||||
|
// ): K = runCatching {
|
||||||
|
// val kf = KeyFactory.getInstance(algorithm)
|
||||||
|
// kf.generateKey()
|
||||||
|
// }.getOrElse { e ->
|
||||||
|
// when (e) {
|
||||||
|
// is NoSuchAlgorithmException ->
|
||||||
|
// throw IllegalStateException(
|
||||||
|
// "Could not reconstruct key, algorithm '$algorithm' could not be found.",
|
||||||
|
// e
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// is InvalidKeySpecException ->
|
||||||
|
// throw IllegalStateException("Could not reconstruct key", e)
|
||||||
|
//
|
||||||
|
// else -> throw e
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
@ -3,7 +3,7 @@ package dev.adamko.kafkatorio.server.socket
|
||||||
import com.github.palindromicity.syslog.dsl.SyslogFieldKeys
|
import com.github.palindromicity.syslog.dsl.SyslogFieldKeys
|
||||||
|
|
||||||
data class SyslogMsg(
|
data class SyslogMsg(
|
||||||
val src: Map<String, String?>
|
val src: Map<String, String?>,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
val message: String?
|
val message: String?
|
||||||
|
|
@ -48,16 +48,4 @@ data class SyslogMsg(
|
||||||
val structuredElementIdPnamePattern: String?
|
val structuredElementIdPnamePattern: String?
|
||||||
get() = src[SyslogFieldKeys.STRUCTURED_ELEMENT_ID_PNAME_PATTERN.field]
|
get() = src[SyslogFieldKeys.STRUCTURED_ELEMENT_ID_PNAME_PATTERN.field]
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun Map<String, Any?>.toSyslogMsg(): SyslogMsg {
|
|
||||||
val map = entries.mapNotNull { (k, v) ->
|
|
||||||
when (v) {
|
|
||||||
is String -> k to v
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}.toMap()
|
|
||||||
return SyslogMsg(map)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ import com.github.palindromicity.syslog.SyslogParser
|
||||||
import com.github.palindromicity.syslog.SyslogParserBuilder
|
import com.github.palindromicity.syslog.SyslogParserBuilder
|
||||||
import com.github.palindromicity.syslog.SyslogSpecification
|
import com.github.palindromicity.syslog.SyslogSpecification
|
||||||
import dev.adamko.kafkatorio.server.config.ApplicationProperties
|
import dev.adamko.kafkatorio.server.config.ApplicationProperties
|
||||||
import dev.adamko.kafkatorio.server.socket.SyslogMsg.Companion.toSyslogMsg
|
|
||||||
import io.ktor.network.selector.SelectorManager
|
import io.ktor.network.selector.SelectorManager
|
||||||
import io.ktor.network.sockets.Socket
|
import io.ktor.network.sockets.Socket
|
||||||
import io.ktor.network.sockets.aSocket
|
import io.ktor.network.sockets.aSocket
|
||||||
import io.ktor.network.sockets.openReadChannel
|
import io.ktor.network.sockets.openReadChannel
|
||||||
import io.ktor.utils.io.readUTF8Line
|
import io.ktor.utils.io.readUTF8Line
|
||||||
import java.util.EnumSet
|
import java.util.EnumSet
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
|
@ -30,10 +30,8 @@ class SyslogSocketServer(
|
||||||
appProps: ApplicationProperties,
|
appProps: ApplicationProperties,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val selectorManager: SelectorManager = SelectorManager(Dispatchers.IO)
|
/** Incoming messages */
|
||||||
|
private val serverSocketTcp = aSocket(SelectorManager(Dispatchers.IO))
|
||||||
|
|
||||||
private val serverSocketTcp = aSocket(selectorManager)
|
|
||||||
.tcp()
|
.tcp()
|
||||||
.bind(
|
.bind(
|
||||||
appProps.socketServerHost,
|
appProps.socketServerHost,
|
||||||
|
|
@ -50,8 +48,7 @@ class SyslogSocketServer(
|
||||||
NilPolicy.OMIT,
|
NilPolicy.OMIT,
|
||||||
EnumSet.of(AllowableDeviations.PRIORITY, AllowableDeviations.VERSION),
|
EnumSet.of(AllowableDeviations.PRIORITY, AllowableDeviations.VERSION),
|
||||||
)
|
)
|
||||||
)
|
).build()
|
||||||
.build()
|
|
||||||
|
|
||||||
|
|
||||||
private val _messages: MutableSharedFlow<SyslogMsg> = MutableSharedFlow()
|
private val _messages: MutableSharedFlow<SyslogMsg> = MutableSharedFlow()
|
||||||
|
|
@ -74,31 +71,30 @@ class SyslogSocketServer(
|
||||||
channel.readUTF8Line()
|
channel.readUTF8Line()
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = { line ->
|
onSuccess = { line ->
|
||||||
when (line) {
|
if (line == null) {
|
||||||
null -> {
|
log("received 'null' from ${socket.description()} - closing connection")
|
||||||
log("received 'null' from ${socket.description()} - closing connection")
|
socket.close()
|
||||||
socket.close()
|
|
||||||
}
|
|
||||||
// else -> log("received '$line' from ${socket.description()}")
|
|
||||||
}
|
}
|
||||||
|
// log("received '$line' from ${socket.description()}")
|
||||||
line
|
line
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { ex ->
|
||||||
log("reading line from socket failed ${socket.description()}, cause: $e")
|
if (ex is CancellationException) throw ex
|
||||||
|
|
||||||
|
log("reading line from socket failed ${socket.description()}, cause: $ex")
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (line != null) {
|
if (line != null) {
|
||||||
|
val syslog = SyslogMsg(syslogParser.parseLine(line))
|
||||||
val syslog = syslogParser.parseLine(line).toSyslogMsg()
|
|
||||||
|
|
||||||
_messages.emit(syslog)
|
_messages.emit(syslog)
|
||||||
}
|
}
|
||||||
|
|
||||||
yield()
|
yield()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log("closing socket ${socket.description()}, cause: $e")
|
if (e is CancellationException) throw e
|
||||||
|
log("closing socket ${socket.description()}, cause: ${e::class.java.name} ${e.message}")
|
||||||
|
e.printStackTrace()
|
||||||
withContext(Dispatchers.IO) { socket.close() }
|
withContext(Dispatchers.IO) { socket.close() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import dev.adamko.kafkatorio.schema.common.ServerMapTileLayer
|
||||||
import dev.adamko.kafkatorio.schema.common.ServerMapTilePngFilename
|
import dev.adamko.kafkatorio.schema.common.ServerMapTilePngFilename
|
||||||
import dev.adamko.kafkatorio.schema.common.SurfaceIndex
|
import dev.adamko.kafkatorio.schema.common.SurfaceIndex
|
||||||
import dev.adamko.kafkatorio.server.config.ApplicationProperties
|
import dev.adamko.kafkatorio.server.config.ApplicationProperties
|
||||||
|
import dev.adamko.kafkatorio.server.config.jsonMapper
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.http.content.EntityTagVersion
|
import io.ktor.http.content.EntityTagVersion
|
||||||
import io.ktor.http.content.versions
|
import io.ktor.http.content.versions
|
||||||
|
|
@ -21,6 +22,7 @@ import io.ktor.util.combineSafe
|
||||||
import io.ktor.util.pipeline.PipelineContext
|
import io.ktor.util.pipeline.PipelineContext
|
||||||
import kotlin.io.path.Path
|
import kotlin.io.path.Path
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
|
||||||
|
|
||||||
fun Routing.factorioServersRoutes(
|
fun Routing.factorioServersRoutes(
|
||||||
|
|
@ -28,7 +30,9 @@ fun Routing.factorioServersRoutes(
|
||||||
) {
|
) {
|
||||||
|
|
||||||
get<FactorioServers> {
|
get<FactorioServers> {
|
||||||
call.respond(HttpStatusCode.NotImplemented)
|
val serverIds = appProps.kafkatorioServers.values.toSet().sortedBy { it.id }
|
||||||
|
val serverIdsJson = jsonMapper.encodeToJsonElement(serverIds)
|
||||||
|
call.respond(serverIdsJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
get<FactorioServer.MapData.Tile> { tile ->
|
get<FactorioServer.MapData.Tile> { tile ->
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import io.ktor.websocket.send
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlin.time.TimeSource
|
import kotlin.time.TimeSource
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
|
@ -57,6 +58,8 @@ class WebmapWebsocketServer {
|
||||||
if (client.isActive) {
|
if (client.isActive) {
|
||||||
client.outgoing.send(Frame.Text(msg))
|
client.outgoing.send(Frame.Text(msg))
|
||||||
}
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
if (e is CancellationException) throw e
|
||||||
}
|
}
|
||||||
}.launchIn(client)
|
}.launchIn(client)
|
||||||
|
|
||||||
|
|
@ -68,6 +71,8 @@ class WebmapWebsocketServer {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
runCatching {
|
runCatching {
|
||||||
client.send("elapsed ${startTime.elapsedNow()}")
|
client.send("elapsed ${startTime.elapsedNow()}")
|
||||||
|
}.onFailure { e ->
|
||||||
|
if (e is CancellationException) throw e
|
||||||
}
|
}
|
||||||
delay(10.seconds)
|
delay(10.seconds)
|
||||||
yield()
|
yield()
|
||||||
|
|
@ -100,7 +105,11 @@ class WebmapWebsocketServer {
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
// log("sent message to client $name: $frame")
|
// log("sent message to client $name: $frame")
|
||||||
},
|
},
|
||||||
onFailure = { e -> log("failed to send message to client $name: $e") }
|
onFailure = { e ->
|
||||||
|
if (e is CancellationException) throw e
|
||||||
|
|
||||||
|
log("failed to send message to client $name: $e")
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean = name == other
|
override fun equals(other: Any?): Boolean = name == other
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package dev.adamko.kafkatorio.server.processor.topology
|
||||||
|
|
||||||
|
import dev.adamko.kafkatorio.library.kxsBinary
|
||||||
|
import dev.adamko.kafkatorio.schema.common.ColourHex
|
||||||
|
import dev.adamko.kafkatorio.schema.common.FactorioServerId
|
||||||
|
import dev.adamko.kafkatorio.schema.common.ServerMapChunkId
|
||||||
|
import dev.adamko.kotka.kxs.serde
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.toJavaDuration
|
||||||
|
import org.apache.kafka.streams.StreamsBuilder
|
||||||
|
import org.apache.kafka.streams.TestInputTopic
|
||||||
|
import org.apache.kafka.streams.TopologyTestDriver
|
||||||
|
|
||||||
|
class FactorioServerPacketsTest : FunSpec({
|
||||||
|
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
private class Scenario(
|
||||||
|
streamsBuilder: StreamsBuilder = StreamsBuilder(),
|
||||||
|
|
||||||
|
groupedMapChunksInputTopicName: String = "kafkatorio.state.map-chunk.terrain.colour",
|
||||||
|
|
||||||
|
val serverId: FactorioServerId = FactorioServerId("test-server-id"),
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
init {
|
||||||
|
factorioServerPacketStream(streamsBuilder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val testDriver: TopologyTestDriver = TopologyTestDriver(streamsBuilder.build())
|
||||||
|
|
||||||
|
val groupedMapChunksInputTopic: TestInputTopic<ServerMapChunkId, ServerMapChunkTiles<ColourHex>> =
|
||||||
|
testDriver.createInputTopic(
|
||||||
|
groupedMapChunksInputTopicName,
|
||||||
|
kxsBinary.serde<ServerMapChunkId>().serializer(),
|
||||||
|
kxsBinary.serde<ServerMapChunkTiles<ColourHex>>().serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
operator fun plusAssign(wallClockTime: Duration) {
|
||||||
|
testDriver.advanceWallClockTime(wallClockTime.toJavaDuration())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
testDriver.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package dev.adamko.kafkatorio.server.security
|
||||||
|
|
||||||
|
import dev.adamko.kafkatorio.schema.common.MaskedValue
|
||||||
|
import dev.adamko.kafkatorio.server.config.ApplicationProperties
|
||||||
|
import dev.adamko.kafkatorio.server.config.PortVal
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
|
import kotlin.io.path.Path
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticatorTest : FunSpec({
|
||||||
|
|
||||||
|
test("verify correct JWT is verified") {
|
||||||
|
|
||||||
|
val authenticator = Authenticator(
|
||||||
|
testApplicationProperties(
|
||||||
|
jwtSecret = "123 456 789"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = authenticator.verifyJwt(jwtMessage)
|
||||||
|
|
||||||
|
result.shouldNotBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("verify invalid JWT returns null") {
|
||||||
|
|
||||||
|
val authenticator = Authenticator(
|
||||||
|
testApplicationProperties(
|
||||||
|
jwtSecret = "wrong"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = authenticator.verifyJwt(jwtMessage)
|
||||||
|
|
||||||
|
result.shouldBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
}) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Key: `123 465 789` */
|
||||||
|
private val jwtMessage = """
|
||||||
|
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.ToFLkPBs_EKF4B2bRQyVPfkKrB-x6WgyAq6KsR9oHwg
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private fun testApplicationProperties(
|
||||||
|
jwtSecret: String,
|
||||||
|
) = ApplicationProperties(
|
||||||
|
webPort = PortVal.DEFAULT,
|
||||||
|
serverDataDir = Path(""),
|
||||||
|
kafkaAdminConfig = emptyMap(),
|
||||||
|
kafkaStreamsConfig = emptyMap(),
|
||||||
|
socketServerHost = "",
|
||||||
|
socketServerPort = 9999,
|
||||||
|
jwtSecret = MaskedValue(jwtSecret),
|
||||||
|
kafkatorioServers = emptyMap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -53,8 +53,14 @@ tasks.dockerUp {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val kafkatorioServerToken: String by project
|
||||||
|
|
||||||
|
|
||||||
tasks.dockerEnvUpdate {
|
tasks.dockerEnvUpdate {
|
||||||
properties("FACTORIO_VERSION" to libs.versions.factorio.get())
|
properties(
|
||||||
|
"FACTORIO_VERSION" to libs.versions.factorio.get(),
|
||||||
|
"KAFKATORIO_TOKEN" to kafkatorioServerToken,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
# Do not edit manually. This file was created with task 'dockerEnvUpdate'
|
|
||||||
COMPOSE_PROJECT_NAME=kafkatorio
|
|
||||||
FACTORIO_VERSION=1.1.64
|
|
||||||
KAFKATORIO_VERSION=0.8.0
|
|
||||||
5
modules/infra-factorio-server/src/.gitignore
vendored
5
modules/infra-factorio-server/src/.gitignore
vendored
|
|
@ -1 +1,4 @@
|
||||||
factorio-server
|
factorio-server/
|
||||||
|
|
||||||
|
# .env contains secret token
|
||||||
|
.env
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,9 @@ services:
|
||||||
driver: syslog
|
driver: syslog
|
||||||
options:
|
options:
|
||||||
syslog-address: "tcp://host.docker.internal:12034" # pc
|
syslog-address: "tcp://host.docker.internal:12034" # pc
|
||||||
# syslog-address: "tcp://localhost:12034" # nuc
|
#syslog-address: "tcp://localhost:12034" # nuc
|
||||||
syslog-format: "rfc5424"
|
syslog-format: "rfc5424"
|
||||||
tag: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJrZXkiOiIwMTIzNDU2Nzg5YWJjZGVmIn0.rozxe6m4HmvQIVtESq2kT_CumIw7cFK2zbeKc5aUwEg"
|
tag: ${KAFKATORIO_TOKEN}
|
||||||
|
|
||||||
|
|
||||||
# correct horse battery staple
|
|
||||||
|
|
||||||
# environment:
|
# environment:
|
||||||
# RCON_PORT: 27715
|
# RCON_PORT: 27715
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,10 @@ kotlin {
|
||||||
kvision("kvision-state")
|
kvision("kvision-state")
|
||||||
kvision("kvision-chart")
|
kvision("kvision-chart")
|
||||||
kvision("kvision-maps")
|
kvision("kvision-maps")
|
||||||
|
kvision("kvision-rest")
|
||||||
kvision("kvision-redux")
|
kvision("kvision-redux")
|
||||||
kvision("kvision-state-flow")
|
kvision("kvision-state-flow")
|
||||||
|
kvision("kvision-routing-navigo-ng")
|
||||||
|
|
||||||
implementation(devNpm("http-proxy-middleware", "^2.0.6"))
|
implementation(devNpm("http-proxy-middleware", "^2.0.6"))
|
||||||
implementation(dependencies.platform(devNpm("http-proxy-middleware", "^2.0.6")))
|
implementation(dependencies.platform(devNpm("http-proxy-middleware", "^2.0.6")))
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,13 @@ class FactorioMap {
|
||||||
companion object {
|
companion object {
|
||||||
const val DYNAMIC_RELOAD_ATT = "dynamic-reload"
|
const val DYNAMIC_RELOAD_ATT = "dynamic-reload"
|
||||||
|
|
||||||
private fun tileLayerUrlTemplate(layer: ServerMapTileLayer): String =
|
private val serverName: String
|
||||||
"/kafkatorio/data/servers/syslog-test/map/layers/${layer.dir}/s1/z{z}/x{x}/y{y}.png"
|
get() = window.location.pathname
|
||||||
|
.substringAfter("kafkatorio/data/servers/")
|
||||||
|
.substringBefore("/")
|
||||||
|
|
||||||
|
private fun tileLayerUrlTemplate(layer: ServerMapTileLayer): String {
|
||||||
|
return "/kafkatorio/data/servers/$serverName/map/layers/${layer.dir}/s1/z{z}/x{x}/y{y}.png"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue