diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 0e1b558..0ed39b6 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -25,7 +25,7 @@ You can also build it from scratch by cloning the repository and then running `m ```yaml services: v-rising-discord-bot: - image: ghcr.io/darkatra/v-rising-discord-bot:2.11.0-native # find the latest version here: https://github.com/DarkAtra/v-rising-discord-bot/releases + image: ghcr.io/darkatra/v-rising-discord-bot:2.12.0-native # find the latest version here: https://github.com/DarkAtra/v-rising-discord-bot/releases command: -Dagql.nativeTransport=false mem_reservation: 128M mem_limit: 256M diff --git a/pom.xml b/pom.xml index 7a5195a..f69f658 100644 --- a/pom.xml +++ b/pom.xml @@ -12,7 +12,7 @@ de.darkatra v-rising-discord-bot - 2.11.6 + 2.12.0 jar diff --git a/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseHelper.kt b/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseHelper.kt index eb7234f..6681eaa 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseHelper.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/migration/DatabaseHelper.kt @@ -3,8 +3,15 @@ package de.darkatra.vrising.discord.migration import org.dizitart.no2.Nitrite import org.dizitart.no2.collection.Document import org.dizitart.no2.collection.NitriteId +import org.dizitart.no2.common.Constants +import org.dizitart.no2.common.meta.Attributes import org.dizitart.no2.store.NitriteMap fun Nitrite.getNitriteMap(name: String): NitriteMap { return store.openMap(name, NitriteId::class.java, Document::class.java) } + +fun Nitrite.listAllCollectionNames(): List { + return store.openMap(Constants.META_MAP_NAME, String::class.java, Attributes::class.java).keys() + .filter { key -> !key.startsWith("\$nitrite") } +} diff --git a/src/main/kotlin/de/darkatra/vrising/discord/persistence/DatabaseConfiguration.kt b/src/main/kotlin/de/darkatra/vrising/discord/persistence/DatabaseConfiguration.kt index 30e4605..17ca519 100644 --- a/src/main/kotlin/de/darkatra/vrising/discord/persistence/DatabaseConfiguration.kt +++ b/src/main/kotlin/de/darkatra/vrising/discord/persistence/DatabaseConfiguration.kt @@ -2,17 +2,30 @@ package de.darkatra.vrising.discord.persistence import de.darkatra.vrising.discord.BotProperties import de.darkatra.vrising.discord.migration.SchemaEntityConverter +import de.darkatra.vrising.discord.migration.getNitriteMap +import de.darkatra.vrising.discord.migration.listAllCollectionNames import de.darkatra.vrising.discord.persistence.model.converter.ErrorEntityConverter import de.darkatra.vrising.discord.persistence.model.converter.PlayerActivityFeedEntityConverter import de.darkatra.vrising.discord.persistence.model.converter.PvpKillFeedEntityConverter import de.darkatra.vrising.discord.persistence.model.converter.ServerEntityConverter import de.darkatra.vrising.discord.persistence.model.converter.StatusMonitorEntityConverter import org.dizitart.no2.Nitrite +import org.dizitart.no2.NitriteBuilder +import org.dizitart.no2.exceptions.NitriteIOException import org.dizitart.no2.mvstore.MVStoreModule +import org.dizitart.no2.store.StoreModule +import org.slf4j.LoggerFactory import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import java.nio.charset.StandardCharsets +import java.nio.file.Files import java.nio.file.Path +import kotlin.io.path.absolutePathString +import kotlin.io.path.copyTo +import kotlin.io.path.deleteIfExists +import kotlin.io.path.exists +import kotlin.io.path.inputStream @Configuration @EnableConfigurationProperties(BotProperties::class) @@ -22,12 +35,61 @@ class DatabaseConfiguration( companion object { + private const val ENCRYPTED_MARKER = "H2encrypt" + private val logger by lazy { LoggerFactory.getLogger(DatabaseConfiguration::class.java) } + fun buildNitriteDatabase(databaseFile: Path, username: String? = null, password: String? = null): Nitrite { - val storeModule = MVStoreModule.withConfig() - .filePath(databaseFile.toAbsolutePath().toFile()) - .compress(true) - .build() + // version 2.12.0 introduced database encryption at rest. the following code attempts to perform the migration if necessary + val firstFewBytes = databaseFile.inputStream().readNBytes(ENCRYPTED_MARKER.length).toString(StandardCharsets.UTF_8) + if (firstFewBytes != ENCRYPTED_MARKER) { + + // if the automated migration was aborted while writing the files to disc, restore the backup + val unencryptedDatabaseBackupFile = Path.of(System.getProperty("java.io.tmpdir")).resolve("v-rising-bot.db.unencrypted") + if (unencryptedDatabaseBackupFile.exists()) { + logger.info("Found an unencrypted backup of the database at: ${unencryptedDatabaseBackupFile.absolutePathString()}") + unencryptedDatabaseBackupFile.copyTo(databaseFile, overwrite = true) + logger.info("Successfully restored the backup. Will re-attempt the migration.") + } + + logger.info("Attempting to encrypt the bot database with the provided database password.") + + // retry opening the database without encryption if we encounter an error + val unencryptedDatabase = try { + getNitriteBuilder(getStoreModule(databaseFile, null)).openOrCreate(username, password) + } catch (e: NitriteIOException) { + throw IllegalStateException("Could not encrypt the database.", e) + } + + unencryptedDatabaseBackupFile.deleteIfExists() + + // create an encrypted copy of the existing database + val tempDatabaseFile = Files.createTempFile("v-rising-bot", ".db") + + val encryptedDatabase = getNitriteBuilder(getStoreModule(tempDatabaseFile, password)).openOrCreate(username, password) + for (collectionName in unencryptedDatabase.listAllCollectionNames()) { + + val oldCollection = unencryptedDatabase.getNitriteMap(collectionName) + val newCollection = encryptedDatabase.getNitriteMap(collectionName) + + oldCollection.values().forEach { document -> newCollection.put(document.id, document) } + } + unencryptedDatabase.close() + encryptedDatabase.close() + + databaseFile.copyTo(unencryptedDatabaseBackupFile) + tempDatabaseFile.copyTo(databaseFile, overwrite = true) + + unencryptedDatabaseBackupFile.deleteIfExists() + tempDatabaseFile.deleteIfExists() + + logger.info("Successfully encrypted the database.") + } + + return getNitriteBuilder(getStoreModule(databaseFile, password)).openOrCreate(username, password) + } + + private fun getNitriteBuilder(storeModule: StoreModule): NitriteBuilder { return Nitrite.builder() .loadModule(storeModule) @@ -38,7 +100,15 @@ class DatabaseConfiguration( .registerEntityConverter(PvpKillFeedEntityConverter()) .registerEntityConverter(ServerEntityConverter()) .registerEntityConverter(StatusMonitorEntityConverter()) - .openOrCreate(username, password) + } + + private fun getStoreModule(databaseFile: Path, password: String?): MVStoreModule { + + return MVStoreModule.withConfig() + .filePath(databaseFile.toAbsolutePath().toFile()) + .encryptionKey(password?.let(String::toCharArray)) + .compress(true) + .build() } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 388f6f8..7322acd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,6 +9,9 @@ logging: level: root: info com.ibasco.agql: warn + # nitrite is logging some of the exceptions before throwing - disable all nitrite logs since we already log all exceptions + nitrite: off + nitrite-mvstore: off pattern: console: "%clr(%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX}){faint} %clr(%5p) %clr(${PID:-}){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m %mdc%n%wEx" diff --git a/src/test/kotlin/de/darkatra/vrising/discord/DatabaseConfigurationTestUtils.kt b/src/test/kotlin/de/darkatra/vrising/discord/DatabaseConfigurationTestUtils.kt index 32917d7..a572718 100644 --- a/src/test/kotlin/de/darkatra/vrising/discord/DatabaseConfigurationTestUtils.kt +++ b/src/test/kotlin/de/darkatra/vrising/discord/DatabaseConfigurationTestUtils.kt @@ -15,6 +15,9 @@ object DatabaseConfigurationTestUtils { val DATABASE_FILE_V1_2_x by lazy { DatabaseConfigurationTestUtils::class.java.getResource("/persistence/v1.2.db")!! } val DATABASE_FILE_V2_10_5 by lazy { DatabaseConfigurationTestUtils::class.java.getResource("/persistence/v2.10.5.db")!! } + val DATABASE_FILE_V2_10_5_WITH_PASSWORD by lazy { DatabaseConfigurationTestUtils::class.java.getResource("/persistence/v2.10.5-with-password.db")!! } + val DATABASE_FILE_V2_12_0_WITH_PASSWORD by lazy { DatabaseConfigurationTestUtils::class.java.getResource("/persistence/v2.12.0-with-password.db")!! } + private val logger by lazy { LoggerFactory.getLogger(javaClass) } fun getTestDatabase(fromTemplate: URL? = null, username: String? = null, password: String? = null): Nitrite { diff --git a/src/test/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationServiceTest.kt b/src/test/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationServiceTest.kt index 01c98f4..c583c18 100644 --- a/src/test/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationServiceTest.kt +++ b/src/test/kotlin/de/darkatra/vrising/discord/migration/DatabaseMigrationServiceTest.kt @@ -7,8 +7,12 @@ import de.darkatra.vrising.discord.persistence.model.Version import org.assertj.core.api.Assertions.assertThat import org.dizitart.no2.collection.Document import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.boot.test.system.CapturedOutput +import org.springframework.boot.test.system.OutputCaptureExtension import java.time.Instant +@ExtendWith(OutputCaptureExtension::class) class DatabaseMigrationServiceTest { @Test @@ -226,4 +230,87 @@ class DatabaseMigrationServiceTest { assertThat(server.statusMonitor!!.recentErrors).isEmpty() } } + + @Test + fun `should migrate schema of password secured database from 2_10_5 to 2_11_0`(capturedOutput: CapturedOutput) { + + DatabaseConfigurationTestUtils.getTestDatabase(DatabaseConfigurationTestUtils.DATABASE_FILE_V2_10_5_WITH_PASSWORD, "test", "test").use { database -> + + assertThat(capturedOutput.out).contains("Successfully encrypted the database.") + + val repository = database.getRepository(Schema::class.java) + repository.insert(Schema(appVersion = "V2.10.5")) + + val databaseMigrationService = DatabaseMigrationService( + database = database, + appVersionFromPom = "2.11.0" + ) + + database.getRepository(Server::class.java).use { serverRepository -> + assertThat(serverRepository.size()).isEqualTo(0) + } + + val oldDocument = database.getCollection("de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor").use { oldCollection -> + assertThat(oldCollection.size()).isEqualTo(1) + oldCollection.find().first() + } + + assertThat(databaseMigrationService.migrateToLatestVersion()).isTrue() + + database.getCollection("de.darkatra.vrising.discord.persistence.model.ServerStatusMonitor").use { oldCollection -> + assertThat(oldCollection.size()).isEqualTo(0) + } + + val server = database.getRepository(Server::class.java).use { serverRepository -> + assertThat(serverRepository.size()).isEqualTo(1) + serverRepository.find().first() + } + assertThat(server.id).isEqualTo(oldDocument["id"]) + @Suppress("DEPRECATION") + assertThat(server.version).isEqualTo(Version(1, Instant.ofEpochMilli(oldDocument["version"] as Long))) + assertThat(server.discordServerId).isEqualTo(oldDocument["discordServerId"]) + assertThat(server.hostname).isEqualTo(oldDocument["hostname"]) + assertThat(server.queryPort).isEqualTo(oldDocument["queryPort"]) + assertThat(server.apiHostname).isEqualTo(oldDocument["apiHostname"]) + assertThat(server.apiPort).isEqualTo(oldDocument["apiPort"]) + assertThat(server.apiUsername).isEqualTo(oldDocument["apiUsername"]) + assertThat(server.apiPassword).isEqualTo(oldDocument["apiPassword"]) + assertThat(server.pvpLeaderboard).isNull() + assertThat(server.playerActivityFeed).isNotNull() + assertThat(server.playerActivityFeed!!.status).isEqualTo(Status.ACTIVE) + assertThat(server.playerActivityFeed!!.discordChannelId).isEqualTo(oldDocument["playerActivityDiscordChannelId"]) + assertThat(server.playerActivityFeed!!.lastUpdated).isNotNull() + assertThat(server.playerActivityFeed!!.currentFailedAttempts).isEqualTo(0) + assertThat(server.playerActivityFeed!!.recentErrors).isEmpty() + assertThat(server.pvpKillFeed).isNotNull() + assertThat(server.pvpKillFeed!!.status).isEqualTo(Status.ACTIVE) + assertThat(server.pvpKillFeed!!.discordChannelId).isEqualTo(oldDocument["pvpKillFeedDiscordChannelId"]) + assertThat(server.pvpKillFeed!!.lastUpdated).isNotNull() + assertThat(server.pvpKillFeed!!.currentFailedAttempts).isEqualTo(0) + assertThat(server.pvpKillFeed!!.recentErrors).isEmpty() + assertThat(server.statusMonitor).isNotNull() + assertThat(server.statusMonitor!!.status).isNotNull() + assertThat(server.statusMonitor!!.status).isEqualTo(Status.ACTIVE) + assertThat(server.statusMonitor!!.discordChannelId).isEqualTo(oldDocument["discordChannelId"]) + assertThat(server.statusMonitor!!.displayServerDescription).isEqualTo(oldDocument["displayServerDescription"]) + assertThat(server.statusMonitor!!.displayPlayerGearLevel).isEqualTo(oldDocument["displayPlayerGearLevel"]) + assertThat(server.statusMonitor!!.currentEmbedMessageId).isEqualTo(oldDocument["currentEmbedMessageId"]) + assertThat(server.statusMonitor!!.currentFailedAttempts).isEqualTo(oldDocument["currentFailedAttempts"]) + assertThat(server.statusMonitor!!.currentFailedApiAttempts).isEqualTo(oldDocument["currentFailedApiAttempts"]) + assertThat(server.statusMonitor!!.recentErrors).isEmpty() + } + } + + @Test + fun `should not attempt to encrypt an already encrypted database`(capturedOutput: CapturedOutput) { + + DatabaseConfigurationTestUtils.getTestDatabase(DatabaseConfigurationTestUtils.DATABASE_FILE_V2_12_0_WITH_PASSWORD, "test", "test").use { database -> + + assertThat(capturedOutput.out).doesNotContain("Successfully encrypted the database.") + + database.getRepository(Server::class.java).use { serverRepository -> + assertThat(serverRepository.size()).isEqualTo(1) + } + } + } } diff --git a/src/test/resources/persistence/v2.10.5-with-password.db b/src/test/resources/persistence/v2.10.5-with-password.db new file mode 100644 index 0000000..0bdbada Binary files /dev/null and b/src/test/resources/persistence/v2.10.5-with-password.db differ diff --git a/src/test/resources/persistence/v2.12.0-with-password.db b/src/test/resources/persistence/v2.12.0-with-password.db new file mode 100644 index 0000000..bc0974b Binary files /dev/null and b/src/test/resources/persistence/v2.12.0-with-password.db differ