Compare commits

..

31 commits

Author SHA1 Message Date
Kegan Myers 3b8c09f8e8 Use fakemdnet endpoint for k8s usage 2020-11-08 06:34:17 +00:00
carbotaniuman 59fd85c628 Change default logging to warn 2020-09-04 08:45:34 -05:00
carbotaniuman 21699acc25 Fix random arbitrary shutdown by using exceptions 2020-09-01 12:00:32 -05:00
carbotaniuman 9ce7eccb47 Merge branch 'requery' into 'master'
Change logging and update spotless

Closes #74

See merge request mangadex-pub/mangadex_at_home!66
2020-08-27 14:23:24 +00:00
carbotaniuman 35567bba1d Change logging and update spotless 2020-08-27 14:23:24 +00:00
carbotaniuman dfd6777b45 Update CHANGELOG.md 2020-08-22 16:30:56 +00:00
carbotaniuman b889ce9afe Update dependencies and format 2020-08-22 11:08:09 -05:00
carbotaniuman 602a43d62e Merge branch 'no-libsodium' into 'master'
No libsodium

See merge request mangadex-pub/mangadex_at_home!65
2020-08-22 03:23:18 +00:00
carbotaniuman 591e28ca6e No libsodium 2020-08-22 03:23:18 +00:00
carbotaniuman b7b4ec7566 Simplify netty 2020-08-21 10:50:02 -05:00
carbotaniuman bf750427cd Revert "Update webui"
This reverts commit 2b910edf
2020-08-21 10:33:14 -05:00
carbotaniuman ba7539ddb7 Fix bug 2020-08-11 14:54:53 -05:00
carbotaniuman 2315a07601 Wowzas 2020-08-11 14:46:10 -05:00
carbotaniuman c64ae4e339 Third try at concurrency 2020-08-11 14:42:00 -05:00
carbotaniuman 89d9a9386f Format 2020-08-11 14:37:26 -05:00
carbotaniuman af73354d4f Fix minor pathological cases 2020-08-11 14:35:34 -05:00
carbotaniuman 535d21ec8e Fix associated bugs 2020-08-11 14:31:47 -05:00
carbotaniuman 9d07c18de1 Minor changes + new CLI 2020-08-11 14:12:01 -05:00
carbotaniuman 398ab05788 Massive refactor - separate settings reload/webui and the image server 2020-08-11 11:47:04 -05:00
carbotaniuman bf4192d584 Change logging defaults 2020-08-11 09:34:41 -05:00
carbotaniuman cbf509fced Start the release train 2020-08-10 17:05:53 -05:00
carbotaniuman 3dc6a5d8ae Do stuff 2020-08-07 18:20:39 -05:00
carbotaniuman 13b80cf02f Simplify code 2020-08-05 10:20:29 -05:00
carbotaniuman 33fea67a44 Fix reload settings to affect cache too 2020-08-05 10:18:07 -05:00
carbotaniuman ef481f12ca Remove useless comment 2020-08-05 10:08:57 -05:00
carbotaniuman 4c18c9c288 Fix some code 2020-08-05 10:07:16 -05:00
carbotaniuman 9dcfe3b51f Bump version 2020-08-03 14:45:43 -05:00
carbotaniuman 4a2e55270f Format stuff 2020-08-03 09:57:21 -05:00
carbotaniuman 2e14430d3d Update webui 2020-08-03 09:55:57 -05:00
carbotaniuman 2b910edf20 Update webui 2020-08-03 09:53:15 -05:00
carbotaniuman a4bd6ef121 Actually fix client_hostname thing 2020-08-03 09:51:30 -05:00
22 changed files with 4315 additions and 765 deletions

View file

@ -6,21 +6,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- [2020-07-13] Added reloading client setting without stopping client by [@radonbark].
### Changed
- [2020-07-29] Disallow unsafe ports [@m3ch_mania].
### Deprecated
### Removed
### Fixed
- [2020-07-29] Fixed stupid libsodium bugs [@carbotaniuman].
- [2020-07-29] Fixed issues from the Great Cache Propagation [@carbotaniuman].
### Security
## [1.2.2] - 2020-08-21
### Changed
- [2020-08-11] Moved to a Java implementation of NaCl [@carbotaniuman].
### Fixed
- [2020-08-11] Revert WebUI changes in 1.2.1 [@carbotaniuman].
## [1.2.1] - 2020-08-11
### Added
- [2020-08-11] New CLI for specifying database location, cache folder, and settings [@carbotaniuman].
### Changed
- [2020-08-11] Change logging defaults [@carbotaniuman].
### Fixed
- [2020-08-11] Bugs relating to `settings.json` changes [@carbotaniuman].
- [2020-08-11] Logs taking up an absurd amount of space [@carbotaniuman].
- [2020-08-11] Random crashes for no reason [@carbotaniuman].
- [2020-08-11] SQLException is now properly handled [@carbotaniuman].
## [1.2.0] - 2020-08-10
### Added
- [2020-07-13] Added reloading client setting without stopping client by [@radonbark].
### Changed
- [2020-07-29] Disallow unsafe ports [@m3ch_mania].
### Fixed
- [2020-07-29] Fixed stupid libsodium bugs [@carbotaniuman].
- [2020-07-29] Fixed issues from the Great Cache Propagation [@carbotaniuman].
- [2020-08-03] Fix `client_hostname` stuff [@carbotaniuman].
## [1.1.5] - 2020-07-05
### Added
- [2020-07-05] Added basic graph creation interface to dash [@RedMatriz].
@ -212,7 +242,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- [2020-06-11] Tweaked logging configuration to reduce log file sizes by [@carbotaniuman].
[Unreleased]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.5...HEAD
[Unreleased]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.2.2...HEAD
[1.2.2]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.2.1...1.2.2
[1.2.1]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.2.0...1.2.1
[1.2.0]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.5...1.2.0
[1.1.5]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.4...1.1.5
[1.1.4]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.3...1.1.4
[1.1.3]: https://gitlab.com/mangadex/mangadex_at_home/-/compare/1.1.2...1.1.3

View file

@ -1,7 +1,7 @@
FROM openjdk:15-alpine
WORKDIR /mangahome
COPY /build/libs/mangadex_at_home.jar .
RUN apk update && apk add --no-cache libsodium
RUN apk update
VOLUME "/mangahome/cache"
EXPOSE 443 8080
CMD java -Dfile-level=off -Dstdout-level=trace -jar mangadex_at_home.jar

View file

@ -1,79 +1,124 @@
plugins {
id "java"
id "org.jetbrains.kotlin.jvm" version "1.3.72"
id "application"
id "com.github.johnrengelman.shadow" version "5.2.0"
id "com.diffplug.gradle.spotless" version "4.4.0"
id "dev.afanasev.sekret" version "0.0.3"
}
group = "com.mangadex"
version = "git describe --tags --dirty".execute().text.trim()
mainClassName = "mdnet.base.Main"
repositories {
mavenCentral()
jcenter()
}
configurations {
runtime.exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-core"
runtime.exclude group: "com.sun.mail", module: "javax.mail"
}
dependencies {
compileOnly group: "dev.afanasev", name: "sekret-annotation", version: "0.0.3"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation "org.jetbrains.kotlin:kotlin-reflect"
implementation group: "commons-io", name: "commons-io", version: "2.7"
implementation group: "org.http4k", name: "http4k-core", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-format-jackson", version: "$http_4k_version"
implementation group: "com.fasterxml.jackson.datatype", name: "jackson-datatype-jsr310", version: "2.11.1"
implementation group: "org.http4k", name: "http4k-client-apache", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-server-netty", version: "$http_4k_version"
runtimeOnly group: "io.netty", name: "netty-tcnative-boringssl-static", version: "2.0.30.Final"
implementation group: "ch.qos.logback", name: "logback-classic", version: "1.3.0-alpha4"
implementation group: "org.jetbrains.exposed", name: "exposed-core", version: "$exposed_version"
implementation group: "org.jetbrains.exposed", name: "exposed-dao", version: "$exposed_version"
implementation group: "org.jetbrains.exposed", name: "exposed-jdbc", version: "$exposed_version"
implementation group: "org.xerial", name: "sqlite-jdbc", version: "3.30.1"
implementation "com.goterl.lazycode:lazysodium-java:4.2.6"
implementation "net.java.dev.jna:jna:5.5.0"
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
spotless {
lineEndings 'UNIX'
java {
targetExclude("build/generated/**/*")
eclipse()
removeUnusedImports()
trimTrailingWhitespace()
endWithNewline()
}
kotlin {
ktlint()
trimTrailingWhitespace()
endWithNewline()
}
}
tasks.register("generateVersion", Copy) {
def templateContext = [version: version]
inputs.properties templateContext
from "src/template/java"
into "$buildDir/generated/java"
expand templateContext
}
sourceSets.main.java.srcDir generateVersion.outputs.files
plugins {
id "java"
id "org.jetbrains.kotlin.jvm" version "1.4.0"
id "org.jetbrains.kotlin.kapt" version "1.4.0"
id "application"
id "com.github.johnrengelman.shadow" version "5.2.0"
id "com.diffplug.spotless" version "5.2.0"
id "dev.afanasev.sekret" version "0.0.7"
}
group = "com.mangadex"
version = "git describe --tags --dirty".execute().text.trim()
mainClassName = "mdnet.base.Main"
repositories {
mavenCentral()
jcenter()
}
configurations {
runtime.exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-core"
runtime.exclude group: "com.sun.mail", module: "javax.mail"
}
dependencies {
compileOnly group: "dev.afanasev", name: "sekret-annotation", version: "0.0.7"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation "org.jetbrains.kotlin:kotlin-reflect"
implementation group: "commons-io", name: "commons-io", version: "2.7"
implementation group: "org.http4k", name: "http4k-core", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-format-jackson", version: "$http_4k_version"
implementation group: "com.fasterxml.jackson.datatype", name: "jackson-datatype-jsr310", version: "2.11.1"
implementation group: "org.http4k", name: "http4k-client-apache4", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-server-netty", version: "$http_4k_version"
runtimeOnly group: "io.netty", name: "netty-tcnative-boringssl-static", version: "2.0.34.Final"
implementation group: "ch.qos.logback", name: "logback-classic", version: "1.3.0-alpha4"
implementation group: "org.jetbrains.exposed", name: "exposed-core", version: "$exposed_version"
implementation group: "org.jetbrains.exposed", name: "exposed-dao", version: "$exposed_version"
implementation group: "org.jetbrains.exposed", name: "exposed-jdbc", version: "$exposed_version"
implementation group: "org.xerial", name: "sqlite-jdbc", version: "3.32.3.2"
implementation "info.picocli:picocli:4.5.0"
kapt "info.picocli:picocli-codegen:4.5.0"
}
kapt {
arguments {
arg("project", "${project.group}/${project.name}")
}
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
spotless {
lineEndings 'UNIX'
java {
targetExclude("build/generated/**/*")
eclipse()
removeUnusedImports()
trimTrailingWhitespace()
endWithNewline()
}
kotlin {
ktlint()
trimTrailingWhitespace()
endWithNewline()
}
}
tasks.register("generateVersion", Copy) {
def templateContext = [version: version]
inputs.properties templateContext
from "src/template/java"
into "$buildDir/generated/java"
expand templateContext
}
sourceSets.main.java.srcDir generateVersion.outputs.files
tasks.register("depsize") {
description = 'Prints dependencies for "default" configuration'
doLast() {
listConfigurationDependencies(configurations.default)
}
}
tasks.register("depsize-all-configurations") {
description = 'Prints dependencies for all available configurations'
doLast() {
configurations
.findAll { it.isCanBeResolved() }
.each { listConfigurationDependencies(it) }
}
}
def listConfigurationDependencies(Configuration configuration) {
def formatStr = "%,10.2f"
def size = configuration.collect { it.length() / (1024 * 1024) }.sum()
def out = new StringBuffer()
out << "\nConfiguration name: \"${configuration.name}\"\n"
if (size) {
out << 'Total dependencies size:'.padRight(65)
out << "${String.format(formatStr, size)} Mb\n\n"
configuration.sort { -it.length() }
.each {
out << "${it.name}".padRight(65)
out << "${String.format(formatStr, (it.length() / 1024))} kb\n"
}
} else {
out << 'No dependencies found';
}
println(out)
}

View file

@ -1,2 +1,2 @@
http_4k_version=3.251.0
exposed_version=0.24.1
http_4k_version=3.258.0
exposed_version=0.26.2

View file

@ -13,9 +13,5 @@
"web_settings": { //delete this block to disable webui
"ui_hostname": "127.0.0.1", // "127.0.0.1" is the default and binds to localhost only
"ui_port": 8080
},
"experimental": {
"max_buffer_size_for_cache_hit": 0 // Size is n * 8kiB. 0 uses the JDK default (which is likely 8kiB).
// May improve diskIO at the cost of memory pressure. Testing needed
}
}

View file

@ -254,7 +254,9 @@ public final class DiskLruCache implements Closeable {
try {
readJournalLine(reader.readLine());
lineCount++;
} catch (EOFException endOfJournal) {
} catch (UnexpectedJournalLineException ignored) {
// just continue and hope nothing breaks
} catch (EOFException e) {
break;
}
}
@ -273,7 +275,7 @@ public final class DiskLruCache implements Closeable {
private void readJournalLine(String line) throws IOException {
int firstSpace = line.indexOf(' ');
if (firstSpace == -1) {
throw new IOException("unexpected journal line: " + line);
throw new UnexpectedJournalLineException(line);
}
int keyBegin = firstSpace + 1;
@ -305,7 +307,7 @@ public final class DiskLruCache implements Closeable {
} else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
// This work was already done by calling lruEntries.get().
} else {
throw new IOException("unexpected journal line: " + line);
throw new UnexpectedJournalLineException(line);
}
}

View file

@ -0,0 +1,9 @@
package mdnet.cache;
import java.io.IOException;
public class UnexpectedJournalLineException extends IOException {
public UnexpectedJournalLineException(String unexpectedLine) {
super("unexpected journal line: " + unexpectedLine);
}
}

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,7 @@ package mdnet.base
import java.time.Duration
object Constants {
const val CLIENT_BUILD = 16
const val CLIENT_BUILD = 19
@JvmField val MAX_AGE_CACHE: Duration = Duration.ofDays(14)

View file

@ -19,15 +19,48 @@ along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
package mdnet.base
import ch.qos.logback.classic.LoggerContext
import java.io.File
import kotlin.system.exitProcess
import mdnet.BuildInfo
import org.slf4j.LoggerFactory
import picocli.CommandLine
object Main {
private val LOGGER = LoggerFactory.getLogger(Main::class.java)
@JvmStatic
fun main(args: Array<String>) {
CommandLine(ClientArgs()).execute(*args)
}
fun dieWithError(e: Throwable): Nothing {
LOGGER.error(e) { "Critical Error" }
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
exitProcess(1)
}
fun dieWithError(error: String): Nothing {
LOGGER.error { "Critical Error: $error" }
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
exitProcess(1)
}
}
@CommandLine.Command(name = "java -jar <jar>", usageHelpWidth = 120, version = ["Client Version ${BuildInfo.VERSION} (Build ${Constants.CLIENT_BUILD})"])
data class ClientArgs(
@field:CommandLine.Option(names = ["-s", "--settings"], defaultValue = "settings.json", paramLabel = "<settings>", description = ["the settings file (default: \${DEFAULT-VALUE})"])
var settingsFile: File = File("settings.json"),
@field:CommandLine.Option(names = ["-d", "--database"], defaultValue = "cache\${sys:file.separator}data.db", paramLabel = "<settings>", description = ["the database file (default: \${DEFAULT-VALUE})"])
var databaseFile: File = File("cache${File.separator}data.db"),
@field:CommandLine.Option(names = ["-c", "--cache"], defaultValue = "cache", paramLabel = "<settings>", description = ["the cache folder (default: \${DEFAULT-VALUE})"])
var cacheFolder: File = File("cache"),
@field:CommandLine.Option(names = ["-h", "--help"], usageHelp = true, description = ["show this help message and exit"])
var helpRequested: Boolean = false,
@field:CommandLine.Option(names = ["-v", "--version"], versionHelp = true, description = ["show the version message and exit"])
var versionRequested: Boolean = false
) : Runnable {
override fun run() {
println(
"Mangadex@Home Client Version ${BuildInfo.VERSION} (Build ${Constants.CLIENT_BUILD}) initializing"
)
@ -48,28 +81,11 @@ object Main {
along with Mangadex@Home. If not, see <https://www.gnu.org/licenses/>.
""".trimIndent())
var file = "settings.json"
if (args.size == 1) {
file = args[0]
} else if (args.isNotEmpty()) {
dieWithError("Expected one argument: path to config file, or nothing")
}
val client = MangaDexClient(file)
Runtime.getRuntime().addShutdownHook(Thread { client.shutdown() })
val client = MangaDexClient(settingsFile, databaseFile, cacheFolder)
Runtime.getRuntime().addShutdownHook(Thread {
client.shutdown()
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
})
client.runLoop()
}
fun dieWithError(e: Throwable): Nothing {
LOGGER.error(e) { "Critical Error" }
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
exitProcess(1)
}
fun dieWithError(error: String): Nothing {
LOGGER.error { "Critical Error: $error" }
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
exitProcess(1)
}
}

View file

@ -19,7 +19,7 @@ along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
/* ktlint-disable no-wildcard-imports */
package mdnet.base
import ch.qos.logback.classic.LoggerContext
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
@ -27,343 +27,152 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import java.io.File
import java.io.FileReader
import java.io.FileWriter
import java.io.IOException
import java.time.Instant
import java.util.*
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import java.util.regex.Pattern
import mdnet.base.Main.dieWithError
import mdnet.base.data.Statistics
import mdnet.base.server.getServer
import mdnet.base.server.getUiServer
import mdnet.base.settings.ClientSettings
import mdnet.base.settings.ServerSettings
import mdnet.base.settings.*
import mdnet.cache.DiskLruCache
import mdnet.cache.HeaderMismatchException
import org.http4k.server.Http4kServer
import org.jetbrains.exposed.sql.Database
import org.slf4j.LoggerFactory
private const val CLIENT_KEY_LENGTH = 52
// Exception class to handle when Client Settings have invalid values
class ClientSettingsException(message: String) : Exception(message)
sealed class State
// server is not running
data class Uninitialized(val clientSettings: ClientSettings) : State()
// server has shut down
object Shutdown : State()
// server is in the process of shutting down
data class GracefulShutdown(val lastRunning: Running, val counts: Int = 0, val nextState: State = Uninitialized(lastRunning.clientSettings), val action: () -> Unit = {}) : State()
// server is currently running
data class Running(val server: Http4kServer, val settings: ServerSettings, val clientSettings: ClientSettings) : State()
// clientSettings must only be accessed from the thread on the executorService
class MangaDexClient(private val clientSettingsFile: String) {
// this must remain singlethreaded because of how the state mechanism works
private val executorService = Executors.newSingleThreadScheduledExecutor()
// state must only be accessed from the thread on the executorService
private var state: State
class MangaDexClient(private val settingsFile: File, databaseFile: File, cacheFolder: File) {
// this must remain single-threaded because of how the state mechanism works
private val executor = Executors.newSingleThreadScheduledExecutor()
private lateinit var scheduledFuture: ScheduledFuture<*>
private var serverHandler: ServerHandler
private val statsMap: MutableMap<Instant, Statistics> = Collections
.synchronizedMap(object : LinkedHashMap<Instant, Statistics>(240) {
override fun removeEldestEntry(eldest: Map.Entry<Instant, Statistics>): Boolean {
return this.size > 240
}
})
private val statistics: AtomicReference<Statistics> = AtomicReference(
Statistics()
)
private val isHandled: AtomicBoolean = AtomicBoolean(false)
private var webUi: Http4kServer? = null
private val database: Database
private val cache: DiskLruCache
private var settings: ClientSettings
// state that must only be accessed from the thread on the executor
private var imageServer: ServerManager? = null
private var webUi: Http4kServer? = null
// end protected state
init {
// Read ClientSettings
val clientSettings = try {
settings = try {
readClientSettings()
} catch (e: UnrecognizedPropertyException) {
dieWithError("'${e.propertyName}' is not a valid setting")
} catch (e: JsonProcessingException) {
dieWithError(e)
} catch (ignored: IOException) {
ClientSettings().also {
LOGGER.warn { "Settings file $clientSettingsFile not found, generating file" }
try {
FileWriter(clientSettingsFile).use { writer -> JACKSON.writeValue(writer, it) }
} catch (e: IOException) {
dieWithError(e)
}
}
} catch (e: ClientSettingsException) {
dieWithError(e)
} catch (e: IOException) {
dieWithError(e)
}
// Initialize things that depend on Client Settings
LOGGER.info { "Client settings loaded: $clientSettings" }
state = Uninitialized(clientSettings)
serverHandler = ServerHandler(clientSettings)
LOGGER.info { "Client settings loaded: $settings" }
database = Database.connect("jdbc:sqlite:$databaseFile", "org.sqlite.JDBC")
// Initialize everything else
try {
cache = DiskLruCache.open(
File("cache"), 1, 1,
clientSettings.maxCacheSizeInMebibytes * 1024 * 1024 /* MiB to bytes */
cacheFolder, 1, 1,
(settings.maxCacheSizeInMebibytes * 1024 * 1024 * 0.8).toLong() /* MiB to bytes */
)
cache.get("statistics")?.use {
statistics.set(JACKSON.readValue<Statistics>(it.getInputStream(0)))
}
} catch (e: HeaderMismatchException) {
LOGGER.warn { "Cache version may be outdated - remove if necessary" }
dieWithError(e)
} catch (e: IOException) {
LOGGER.warn { "Cache may be corrupt - remove if necessary" }
dieWithError(e)
}
}
fun runLoop() {
loginAndStartServer()
statsMap[Instant.now()] = statistics.get()
startWebUi()
LOGGER.info { "Mangadex@Home Client initialized. Starting normal operation." }
LOGGER.info { "Mangadex@Home Client initialized - starting normal operation." }
executorService.scheduleAtFixedRate({
scheduledFuture = executor.scheduleWithFixedDelay({
try {
if (state is Running || state is GracefulShutdown || state is Uninitialized) {
statistics.updateAndGet {
it.copy(bytesOnDisk = cache.size())
}
statsMap[Instant.now()] = statistics.get()
val editor = cache.edit("statistics")
if (editor != null) {
JACKSON.writeValue(editor.newOutputStream(0), statistics.get())
editor.commit()
}
}
} catch (e: Exception) {
LOGGER.warn(e) { "Statistics update failed" }
}
}, 15, 15, TimeUnit.SECONDS)
var lastBytesSent = statistics.get().bytesSent
executorService.scheduleAtFixedRate({
try {
lastBytesSent = statistics.get().bytesSent
val state = this.state
if (state is GracefulShutdown) {
LOGGER.info { "Aborting graceful shutdown started due to hourly bandwidth limit" }
this.state = state.lastRunning
}
if (state is Uninitialized) {
LOGGER.info { "Restarting server stopped due to hourly bandwidth limit" }
loginAndStartServer()
}
} catch (e: Exception) {
LOGGER.warn(e) { "Hourly bandwidth check failed" }
}
}, 1, 1, TimeUnit.HOURS)
executorService.scheduleAtFixedRate({
try {
val state = this.state
if (state is GracefulShutdown) {
val timesToWait = state.lastRunning.clientSettings.gracefulShutdownWaitSeconds / 15
when {
state.counts == 0 -> {
LOGGER.info { "Starting graceful shutdown" }
logout()
isHandled.set(false)
this.state = state.copy(counts = state.counts + 1)
}
state.counts == timesToWait || !isHandled.get() -> {
if (!isHandled.get()) {
LOGGER.info { "No requests received, shutting down" }
} else {
LOGGER.info { "Max tries attempted (${state.counts} out of $timesToWait), shutting down" }
}
stopServer(state.nextState)
state.action()
}
else -> {
LOGGER.info {
"Waiting another 15 seconds for graceful shutdown (${state.counts} out of $timesToWait)"
}
isHandled.set(false)
this.state = state.copy(counts = state.counts + 1)
}
}
}
} catch (e: Exception) {
LOGGER.warn(e) { "Main loop failed" }
}
}, 15, 15, TimeUnit.SECONDS)
executorService.scheduleWithFixedDelay({
try {
val state = this.state
if (state is Running) {
val currentBytesSent = statistics.get().bytesSent - lastBytesSent
if (state.clientSettings.maxMebibytesPerHour != 0L && state.clientSettings.maxMebibytesPerHour * 1024 * 1024 /* MiB to bytes */ < currentBytesSent) {
LOGGER.info { "Shutting down server as hourly bandwidth limit reached" }
this.state = GracefulShutdown(lastRunning = state)
} else {
pingControl()
}
}
} catch (e: Exception) {
LOGGER.warn(e) { "Graceful shutdown checker failed" }
}
}, 45, 45, TimeUnit.SECONDS)
// Check every minute to see if client settings have changed
executorService.scheduleWithFixedDelay({
try {
val state = this.state
if (state is Running) {
reloadClientSettings()
}
// this blocks the executor, so no worries about concurrency
reloadClientSettings()
} catch (e: Exception) {
LOGGER.warn(e) { "Reload of ClientSettings failed" }
}
}, 60, 60, TimeUnit.SECONDS)
}, 1, 1, TimeUnit.MINUTES)
startImageServer()
startWebUi()
}
private fun pingControl() {
val state = this.state as Running
val newSettings = serverHandler.pingControl(state.settings)
if (newSettings != null) {
LOGGER.info { "Server settings received: $newSettings" }
if (newSettings.latestBuild > Constants.CLIENT_BUILD) {
LOGGER.warn {
"Outdated build detected! Latest: ${newSettings.latestBuild}, Current: ${Constants.CLIENT_BUILD}"
}
}
if (newSettings.tls != null || newSettings.imageServer != state.settings.imageServer) {
// certificates or upstream url must have changed, restart webserver
LOGGER.info { "Doing internal restart of HTTP server to refresh certs/upstream URL" }
this.state = GracefulShutdown(lastRunning = state) {
loginAndStartServer()
}
}
} else {
LOGGER.info { "Server ping failed - ignoring" }
}
}
private fun loginAndStartServer() {
val state = this.state as Uninitialized
val serverSettings = serverHandler.loginToControl()
?: dieWithError("Failed to get a login response from server - check API secret for validity")
val server = getServer(cache, serverSettings, state.clientSettings, statistics, isHandled).start()
if (serverSettings.latestBuild > Constants.CLIENT_BUILD) {
LOGGER.warn {
"Outdated build detected! Latest: ${serverSettings.latestBuild}, Current: ${Constants.CLIENT_BUILD}"
}
}
this.state = Running(server, serverSettings, state.clientSettings)
LOGGER.info { "Internal HTTP server was successfully started" }
}
private fun logout() {
serverHandler.logoutFromControl()
}
private fun stopServer(nextState: State) {
val state = this.state.let {
when (it) {
is Running ->
it
is GracefulShutdown ->
it.lastRunning
else ->
throw AssertionError()
}
}
LOGGER.info { "Shutting down HTTP server" }
state.server.stop()
LOGGER.info { "Internal HTTP server has shut down" }
this.state = nextState
}
/**
* Starts the WebUI if the ClientSettings demand it.
* Because this method checks if the WebUI is needed,
* it can be safely called before checking yourself
*/
// Precondition: settings must be filled with up-to-date settings and `imageServer` must not be null
private fun startWebUi() {
val state = this.state
// Grab the client settings if available
val clientSettings = state.let {
when (it) {
is Running ->
it.clientSettings
is Uninitialized ->
it.clientSettings
else ->
null
}
}
settings.webSettings?.let { webSettings ->
val imageServer = requireNotNull(imageServer)
// Only start the Web UI if the settings demand it
if (clientSettings?.webSettings != null) {
webUi = getUiServer(clientSettings.webSettings, statistics, statsMap)
webUi!!.start()
if (webUi != null) {
throw AssertionError()
}
LOGGER.info { "WebUI starting" }
webUi = getUiServer(webSettings, imageServer.statistics, imageServer.statsMap).also {
it.start()
}
LOGGER.info { "WebUI started" }
}
}
// Precondition: settings must be filled with up-to-date settings
private fun startImageServer() {
if (imageServer != null) {
throw AssertionError()
}
LOGGER.info { "Server manager starting" }
imageServer = ServerManager(settings.serverSettings, settings.devSettings, settings.maxCacheSizeInMebibytes, cache, database).also {
it.start()
}
LOGGER.info { "Server manager started" }
}
private fun stopImageServer() {
LOGGER.info { "Server manager stopping" }
requireNotNull(imageServer).shutdown()
imageServer = null
LOGGER.info { "Server manager stopped" }
}
private fun stopWebUi() {
LOGGER.info { "WebUI stopping" }
requireNotNull(webUi).stop()
webUi = null
LOGGER.info { "WebUI stopped" }
}
fun shutdown() {
LOGGER.info { "Mangadex@Home Client stopping" }
LOGGER.info { "Mangadex@Home Client shutting down" }
val latch = CountDownLatch(1)
executorService.schedule({
val state = this.state
if (state is Running) {
this.state = GracefulShutdown(state, nextState = Shutdown) {
latch.countDown()
}
} else if (state is GracefulShutdown) {
this.state = state.copy(nextState = Shutdown) {
latch.countDown()
}
} else if (state is Uninitialized || state is Shutdown) {
this.state = Shutdown
latch.countDown()
scheduledFuture.cancel(false)
executor.schedule({
if (webUi != null) {
stopWebUi()
}
if (imageServer != null) {
stopImageServer()
}
try {
cache.close()
} catch (e: IOException) {
LOGGER.error(e) { "Cache failed to close" }
}
latch.countDown()
}, 0, TimeUnit.SECONDS)
latch.await()
webUi?.close()
try {
cache.close()
} catch (e: IOException) {
LOGGER.error(e) { "Cache failed to close" }
}
executorService.shutdown()
LOGGER.info { "Mangadex@Home Client stopped" }
(LoggerFactory.getILoggerFactory() as LoggerContext).stop()
executor.shutdown()
LOGGER.info { "Mangadex@Home Client has shut down" }
}
/**
@ -371,128 +180,98 @@ class MangaDexClient(private val clientSettingsFile: String) {
* Web UI and/or the server if needed
*/
private fun reloadClientSettings() {
val state = this.state as Running
LOGGER.info { "Reloading client settings" }
LOGGER.info { "Checking client settings" }
try {
val newSettings = readClientSettings()
if (newSettings == state.clientSettings) {
LOGGER.info { "Client Settings have not changed" }
if (newSettings == settings) {
LOGGER.info { "Client settings unchanged" }
return
}
LOGGER.info { "New settings loaded: $newSettings" }
// Setting loaded without issue. Figure out
// if there are changes that require a restart
val restartServer = newSettings.clientSecret != state.clientSettings.clientSecret ||
newSettings.clientHostname != state.clientSettings.clientHostname ||
newSettings.clientPort != state.clientSettings.clientPort ||
newSettings.clientExternalPort != state.clientSettings.clientExternalPort ||
newSettings.threads != state.clientSettings.threads ||
newSettings.devSettings?.isDev != state.clientSettings.devSettings?.isDev
val stopWebUi = newSettings.webSettings != state.clientSettings.webSettings ||
newSettings.webSettings?.uiPort != state.clientSettings.webSettings?.uiPort ||
newSettings.webSettings?.uiHostname != state.clientSettings.webSettings?.uiHostname
val startWebUi = (stopWebUi && newSettings.webSettings != null)
cache.maxSize = (newSettings.maxCacheSizeInMebibytes * 1024 * 1024 * 0.8).toLong()
val restartServer = newSettings.serverSettings != settings.serverSettings ||
newSettings.devSettings != settings.devSettings
val stopWebUi = restartServer || newSettings.webSettings != settings.webSettings
val startWebUi = stopWebUi && newSettings.webSettings != null
// Stop the the WebUI if needed
if (stopWebUi) {
LOGGER.info { "Stopping WebUI to reload ClientSettings" }
webUi?.close()
webUi = null
if (webUi != null) {
stopWebUi()
}
}
if (restartServer) {
// If we are restarting the server
// We must do it gracefully and set
// the new settings later
LOGGER.info { "Stopping Server to reload ClientSettings" }
this.state = GracefulShutdown(state, nextState = Uninitialized(clientSettings = newSettings), action = {
serverHandler = ServerHandler(newSettings)
LOGGER.info { "Reloaded ClientSettings: $newSettings" }
LOGGER.info { "Starting Server after reloading ClientSettings" }
loginAndStartServer()
// Start the WebUI if we had to stop it
// and still want it
if (startWebUi) {
LOGGER.info { "Starting WebUI after reloading ClientSettings" }
startWebUi()
LOGGER.info { "Started WebUI after reloading ClientSettings" }
}
})
} else {
// If we aren't restarting the server
// We can update the settings now
this.state = state.copy(clientSettings = newSettings)
serverHandler.setClientSettings(newSettings)
LOGGER.info { "Reloaded ClientSettings: $newSettings" }
// Start the WebUI if we had to stop it
// and still want it
if (startWebUi) {
LOGGER.info { "Starting WebUI after reloading ClientSettings" }
startWebUi()
LOGGER.info { "Started WebUI after reloading ClientSettings" }
}
stopImageServer()
startImageServer()
}
if (startWebUi) {
startWebUi()
}
settings = newSettings
} catch (e: UnrecognizedPropertyException) {
LOGGER.warn { "Settings file is invalid: '$e.propertyName' is not a valid setting" }
} catch (e: JsonProcessingException) {
LOGGER.warn { "Settings file is invalid: $e.message" }
} catch (e: IOException) {
LOGGER.warn { "Settings file is could not be found: $e.message" }
} catch (e: ClientSettingsException) {
LOGGER.warn { "Can't reload client settings: $e.message" }
LOGGER.warn { "Settings file is invalid: $e.message" }
} catch (e: IOException) {
LOGGER.warn { "Error loading settings file: $e.message" }
}
}
private fun validateSettings(settings: ClientSettings) {
if (!isSecretValid(settings.clientSecret)) throw ClientSettingsException("Config Error: API Secret is invalid, must be 52 alphanumeric characters")
if (settings.clientPort == 0) {
throw ClientSettingsException("Config Error: Invalid port number")
}
if (settings.clientPort in Constants.RESTRICTED_PORTS) {
throw ClientSettingsException("Config Error: Unsafe port number")
}
if (settings.maxCacheSizeInMebibytes < 1024) {
throw ClientSettingsException("Config Error: Invalid max cache size, must be >= 1024 MiB (1GiB)")
}
if (settings.threads < 4) {
throw ClientSettingsException("Config Error: Invalid number of threads, must be >= 4")
fun isSecretValid(clientSecret: String): Boolean {
return Pattern.matches("^[a-zA-Z0-9]{$CLIENT_KEY_LENGTH}$", clientSecret)
}
if (settings.maxMebibytesPerHour < 0) {
throw ClientSettingsException("Config Error: Max bandwidth must be >= 0")
settings.serverSettings.let {
if (!isSecretValid(it.clientSecret)) {
throw ClientSettingsException("Config Error: API Secret is invalid, must be 52 alphanumeric characters")
}
if (it.clientPort == 0) {
throw ClientSettingsException("Config Error: Invalid port number")
}
if (it.clientPort in Constants.RESTRICTED_PORTS) {
throw ClientSettingsException("Config Error: Unsafe port number")
}
if (it.threads < 4) {
throw ClientSettingsException("Config Error: Invalid number of threads, must be >= 4")
}
if (it.maxMebibytesPerHour < 0) {
throw ClientSettingsException("Config Error: Max bandwidth must be >= 0")
}
if (it.maxKilobitsPerSecond < 0) {
throw ClientSettingsException("Config Error: Max burst rate must be >= 0")
}
if (it.gracefulShutdownWaitSeconds < 15) {
throw ClientSettingsException("Config Error: Graceful shutdown wait must be >= 15")
}
}
if (settings.maxKilobitsPerSecond < 0) {
throw ClientSettingsException("Config Error: Max burst rate must be >= 0")
}
if (settings.gracefulShutdownWaitSeconds < 15) {
throw ClientSettingsException("Config Error: Graceful shutdown wait must be >= 15")
}
if (settings.webSettings != null) {
if (settings.webSettings.uiPort == 0) {
settings.webSettings?.let {
if (it.uiPort == 0) {
throw ClientSettingsException("Config Error: Invalid UI port number")
}
}
if (settings.experimental != null) {
if (settings.experimental.maxBufferSizeForCacheHit < 0)
throw ClientSettingsException("Config Error: Max cache buffer multiple must be >= 0")
}
}
private fun readClientSettings(): ClientSettings {
return JACKSON.readValue<ClientSettings>(FileReader(clientSettingsFile)).apply(::validateSettings)
}
private fun isSecretValid(clientSecret: String): Boolean {
return Pattern.matches("^[a-zA-Z0-9]{$CLIENT_KEY_LENGTH}$", clientSecret)
return JACKSON.readValue<ClientSettings>(FileReader(settingsFile)).apply(::validateSettings)
}
companion object {
private const val CLIENT_KEY_LENGTH = 52
private val LOGGER = LoggerFactory.getLogger(MangaDexClient::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper()
private val JACKSON: ObjectMapper = jacksonObjectMapper().configure(JsonParser.Feature.ALLOW_COMMENTS, true)
}
}

View file

@ -20,10 +20,14 @@ package mdnet.base
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.KotlinModule
import java.net.InetAddress
import mdnet.base.ServerHandlerJackson.auto
import mdnet.base.settings.ClientSettings
import mdnet.base.settings.DevSettings
import mdnet.base.settings.RemoteSettings
import mdnet.base.settings.ServerSettings
import org.http4k.client.ApacheClient
import org.apache.http.client.config.RequestConfig
import org.apache.http.impl.client.HttpClients
import org.http4k.client.Apache4Client
import org.http4k.core.Body
import org.http4k.core.Method
import org.http4k.core.Request
@ -40,13 +44,22 @@ object ServerHandlerJackson : ConfigurableJackson(
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
)
class ServerHandler(private var settings: ClientSettings) {
private val client = ApacheClient()
class ServerHandler(private val serverSettings: ServerSettings, private val devSettings: DevSettings, private val maxCacheSizeInMebibytes: Long) {
private val client = Apache4Client(client = HttpClients.custom()
.setDefaultRequestConfig(
RequestConfig.custom()
.apply {
if (serverSettings.clientHostname != "0.0.0.0") {
setLocalAddress(InetAddress.getByName(serverSettings.clientHostname))
}
}
.build())
.build())
fun logoutFromControl(): Boolean {
LOGGER.info { "Disconnecting from the control server" }
val params = mapOf<String, Any>(
"secret" to settings.clientSecret
"secret" to serverSettings.clientSecret
)
val request = STRING_ANY_MAP_LENS(params, Request(Method.POST, getServerAddress() + "stop"))
@ -57,16 +70,16 @@ class ServerHandler(private var settings: ClientSettings) {
private fun getPingParams(tlsCreatedAt: String? = null): Map<String, Any> =
mapOf<String, Any>(
"secret" to settings.clientSecret,
"secret" to serverSettings.clientSecret,
"port" to let {
if (settings.clientExternalPort != 0) {
settings.clientExternalPort
if (serverSettings.clientExternalPort != 0) {
serverSettings.clientExternalPort
} else {
settings.clientPort
serverSettings.clientPort
}
},
"disk_space" to settings.maxCacheSizeInMebibytes * 1024 * 1024,
"network_speed" to settings.maxKilobitsPerSecond * 1000 / 8,
"disk_space" to maxCacheSizeInMebibytes * 1024 * 1024,
"network_speed" to serverSettings.maxKilobitsPerSecond * 1000 / 8,
"build_version" to Constants.CLIENT_BUILD
).let {
if (tlsCreatedAt != null) {
@ -76,7 +89,7 @@ class ServerHandler(private var settings: ClientSettings) {
}
}
fun loginToControl(): ServerSettings? {
fun loginToControl(): RemoteSettings? {
LOGGER.info { "Connecting to the control server" }
val request = STRING_ANY_MAP_LENS(getPingParams(), Request(Method.POST, getServerAddress() + "ping"))
@ -89,7 +102,7 @@ class ServerHandler(private var settings: ClientSettings) {
}
}
fun pingControl(old: ServerSettings): ServerSettings? {
fun pingControl(old: RemoteSettings): RemoteSettings? {
LOGGER.info { "Pinging the control server" }
val request = STRING_ANY_MAP_LENS(getPingParams(old.tls!!.createdAt), Request(Method.POST, getServerAddress() + "ping"))
@ -103,21 +116,17 @@ class ServerHandler(private var settings: ClientSettings) {
}
private fun getServerAddress(): String {
return if (settings.devSettings?.isDev != true)
return if (!devSettings.isDev)
SERVER_ADDRESS
else
SERVER_ADDRESS_DEV
}
fun setClientSettings(clientSettings: ClientSettings) {
this.settings = clientSettings
}
companion object {
private val LOGGER = LoggerFactory.getLogger(ServerHandler::class.java)
private val STRING_ANY_MAP_LENS = Body.auto<Map<String, Any>>().toLens()
private val SERVER_SETTINGS_LENS = Body.auto<ServerSettings>().toLens()
private const val SERVER_ADDRESS = "https://api.mangadex.network/"
private val SERVER_SETTINGS_LENS = Body.auto<RemoteSettings>().toLens()
private const val SERVER_ADDRESS = "http://fakemdnet/"
private const val SERVER_ADDRESS_DEV = "https://mangadex-test.net/"
}
}

View file

@ -0,0 +1,269 @@
package mdnet.base
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import java.lang.RuntimeException
import java.time.Instant
import java.util.Collections
import java.util.LinkedHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import mdnet.base.data.Statistics
import mdnet.base.server.getServer
import mdnet.base.settings.DevSettings
import mdnet.base.settings.RemoteSettings
import mdnet.base.settings.ServerSettings
import mdnet.cache.DiskLruCache
import org.http4k.server.Http4kServer
import org.jetbrains.exposed.sql.Database
import org.slf4j.LoggerFactory
sealed class State
// server is not running
data class Uninitialized(val serverSettings: ServerSettings, val devSettings: DevSettings) : State()
// server has shut down
object Shutdown : State()
// server is in the process of stopping
data class GracefulStop(val lastRunning: Running, val counts: Int = 0, val nextState: State = Uninitialized(lastRunning.serverSettings, lastRunning.devSettings), val action: () -> Unit = {}) : State()
// server is currently running
data class Running(val server: Http4kServer, val settings: RemoteSettings, val serverSettings: ServerSettings, val devSettings: DevSettings) : State()
class ServerManager(serverSettings: ServerSettings, devSettings: DevSettings, maxCacheSizeInMebibytes: Long, private val cache: DiskLruCache, private val database: Database) {
// this must remain single-threaded because of how the state mechanism works
private val executor = Executors.newSingleThreadScheduledExecutor()
// state that must only be accessed from the thread on the executor
private var state: State
private var serverHandler: ServerHandler
// end protected state
val statsMap: MutableMap<Instant, Statistics> = Collections
.synchronizedMap(object : LinkedHashMap<Instant, Statistics>(240) {
override fun removeEldestEntry(eldest: Map.Entry<Instant, Statistics>): Boolean {
return this.size > 240
}
})
val statistics: AtomicReference<Statistics> = AtomicReference(
Statistics()
)
private val isHandled: AtomicBoolean = AtomicBoolean(false)
init {
state = Uninitialized(serverSettings, devSettings)
serverHandler = ServerHandler(serverSettings, devSettings, maxCacheSizeInMebibytes)
cache.get("statistics")?.use {
try {
statistics.set(JACKSON.readValue<Statistics>(it.getInputStream(0)))
} catch (_: JsonProcessingException) {
cache.remove("statistics")
}
}
}
fun start() {
LOGGER.info { "Image server starting" }
loginAndStartServer()
statsMap[Instant.now()] = statistics.get()
executor.scheduleAtFixedRate({
try {
if (state is Running || state is GracefulStop || state is Uninitialized) {
statistics.updateAndGet {
it.copy(bytesOnDisk = cache.size())
}
statsMap[Instant.now()] = statistics.get()
val editor = cache.edit("statistics")
if (editor != null) {
JACKSON.writeValue(editor.newOutputStream(0), statistics.get())
editor.commit()
}
}
} catch (e: Exception) {
LOGGER.warn(e) { "Statistics update failed" }
}
}, 15, 15, TimeUnit.SECONDS)
var lastBytesSent = statistics.get().bytesSent
executor.scheduleAtFixedRate({
try {
lastBytesSent = statistics.get().bytesSent
val state = this.state
if (state is GracefulStop) {
LOGGER.info { "Aborting graceful shutdown started due to hourly bandwidth limit" }
this.state = state.lastRunning
}
if (state is Uninitialized) {
LOGGER.info { "Restarting server stopped due to hourly bandwidth limit" }
loginAndStartServer()
}
} catch (e: Exception) {
LOGGER.warn(e) { "Hourly bandwidth check failed" }
}
}, 1, 1, TimeUnit.HOURS)
executor.scheduleAtFixedRate({
try {
val state = this.state
if (state is GracefulStop) {
val timesToWait = state.lastRunning.serverSettings.gracefulShutdownWaitSeconds / 15
when {
state.counts == 0 -> {
LOGGER.info { "Starting graceful stop" }
logout()
isHandled.set(false)
this.state = state.copy(counts = state.counts + 1)
}
state.counts == timesToWait || !isHandled.get() -> {
if (!isHandled.get()) {
LOGGER.info { "No requests received, stopping" }
} else {
LOGGER.info { "Max tries attempted (${state.counts} out of $timesToWait), shutting down" }
}
stopServer(state.nextState)
state.action()
}
else -> {
LOGGER.info {
"Waiting another 15 seconds for graceful stop (${state.counts} out of $timesToWait)"
}
isHandled.set(false)
this.state = state.copy(counts = state.counts + 1)
}
}
}
} catch (e: Exception) {
LOGGER.error(e) { "Main loop failed" }
}
}, 15, 15, TimeUnit.SECONDS)
executor.scheduleWithFixedDelay({
try {
val state = this.state
if (state is Running) {
val currentBytesSent = statistics.get().bytesSent - lastBytesSent
if (state.serverSettings.maxMebibytesPerHour != 0L && state.serverSettings.maxMebibytesPerHour * 1024 * 1024 /* MiB to bytes */ < currentBytesSent) {
LOGGER.info { "Stopping image server as hourly bandwidth limit reached" }
this.state = GracefulStop(lastRunning = state)
} else {
pingControl()
}
}
} catch (e: Exception) {
LOGGER.warn(e) { "Graceful shutdown checker failed" }
}
}, 45, 45, TimeUnit.SECONDS)
LOGGER.info { "Image server has started" }
}
private fun pingControl() {
val state = this.state as Running
val newSettings = serverHandler.pingControl(state.settings)
if (newSettings != null) {
LOGGER.info { "Server settings received: $newSettings" }
if (newSettings.latestBuild > Constants.CLIENT_BUILD) {
LOGGER.warn {
"Outdated build detected! Latest: ${newSettings.latestBuild}, Current: ${Constants.CLIENT_BUILD}"
}
}
if (newSettings.tls != null || newSettings.imageServer != state.settings.imageServer) {
// certificates or upstream url must have changed, restart webserver
LOGGER.info { "Doing internal restart of HTTP server to refresh certs/upstream URL" }
this.state = GracefulStop(lastRunning = state) {
loginAndStartServer()
}
}
} else {
LOGGER.info { "Server ping failed - ignoring" }
}
}
private fun loginAndStartServer() {
val state = this.state as Uninitialized
val remoteSettings = serverHandler.loginToControl()
?: throw RuntimeException("Failed to get a login response from server")
val server = getServer(cache, database, remoteSettings, state.serverSettings, statistics, isHandled).start()
if (remoteSettings.latestBuild > Constants.CLIENT_BUILD) {
LOGGER.warn {
"Outdated build detected! Latest: ${remoteSettings.latestBuild}, Current: ${Constants.CLIENT_BUILD}"
}
}
this.state = Running(server, remoteSettings, state.serverSettings, state.devSettings)
LOGGER.info { "Internal HTTP server was successfully started" }
}
private fun logout() {
serverHandler.logoutFromControl()
}
private fun stopServer(nextState: State) {
val state = this.state.let {
when (it) {
is Running ->
it
is GracefulStop ->
it.lastRunning
else ->
throw AssertionError()
}
}
LOGGER.info { "Image server stopping" }
state.server.stop()
LOGGER.info { "Image server has stopped" }
this.state = nextState
}
fun shutdown() {
LOGGER.info { "Image server shutting down" }
val latch = CountDownLatch(1)
executor.schedule({
val state = this.state
if (state is Running) {
this.state = GracefulStop(state, nextState = Shutdown) {
latch.countDown()
}
} else if (state is GracefulStop) {
this.state = state.copy(nextState = Shutdown) {
latch.countDown()
}
} else if (state is Uninitialized || state is Shutdown) {
this.state = Shutdown
latch.countDown()
}
}, 0, TimeUnit.SECONDS)
latch.await()
executor.shutdown()
LOGGER.info { "Image server has shut down" }
}
companion object {
private val LOGGER = LoggerFactory.getLogger(ServerManager::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper().enable(SerializationFeature.INDENT_OUTPUT)
}
}

View file

@ -42,13 +42,12 @@ import java.net.SocketException
import java.security.PrivateKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
import javax.net.ssl.SSLException
import mdnet.base.Constants
import mdnet.base.data.Statistics
import mdnet.base.info
import mdnet.base.settings.ClientSettings
import mdnet.base.settings.ServerSettings
import mdnet.base.settings.TlsCert
import mdnet.base.trace
import org.http4k.core.HttpHandler
@ -57,17 +56,17 @@ import org.http4k.server.Http4kServer
import org.http4k.server.ServerConfig
import org.slf4j.LoggerFactory
private val LOGGER = LoggerFactory.getLogger("Application")
private val LOGGER = LoggerFactory.getLogger("AppNetty")
class Netty(private val tls: TlsCert, private val clientSettings: ClientSettings, private val statistics: AtomicReference<Statistics>) : ServerConfig {
class Netty(private val tls: TlsCert, private val serverSettings: ServerSettings, private val statistics: AtomicReference<Statistics>) : ServerConfig {
override fun toServer(httpHandler: HttpHandler): Http4kServer = object : Http4kServer {
private val masterGroup = NioEventLoopGroup(clientSettings.threads)
private val workerGroup = NioEventLoopGroup(clientSettings.threads)
private val masterGroup = NioEventLoopGroup(serverSettings.threads)
private val workerGroup = NioEventLoopGroup(serverSettings.threads)
private lateinit var closeFuture: ChannelFuture
private lateinit var address: InetSocketAddress
private val burstLimiter = object : GlobalTrafficShapingHandler(
workerGroup, clientSettings.maxKilobitsPerSecond * 1000L / 8L, 0, 50) {
workerGroup, serverSettings.maxKilobitsPerSecond * 1000L / 8L, 0, 50) {
override fun doAccounting(counter: TrafficCounter) {
statistics.getAndUpdate {
it.copy(bytesSent = it.bytesSent + counter.cumulativeWrittenBytes())
@ -77,7 +76,7 @@ class Netty(private val tls: TlsCert, private val clientSettings: ClientSettings
}
override fun start(): Http4kServer = apply {
LOGGER.info { "Starting Netty with ${clientSettings.threads} threads" }
LOGGER.info { "Starting Netty with ${serverSettings.threads} threads" }
val certs = getX509Certs(tls.certificate)
val sslContext = SslContextBuilder
@ -104,7 +103,7 @@ class Netty(private val tls: TlsCert, private val clientSettings: ClientSettings
ch.pipeline().addLast("streamer", ChunkedWriteHandler())
ch.pipeline().addLast("handler", Http4kChannelHandler(httpHandler))
ch.pipeline().addLast("handle_ssl", object : ChannelInboundHandlerAdapter() {
ch.pipeline().addLast("exceptions", object : ChannelInboundHandlerAdapter() {
override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
if (cause is SSLException || (cause is DecoderException && cause.cause is SSLException)) {
LOGGER.trace { "Ignored invalid SSL connection" }
@ -121,18 +120,18 @@ class Netty(private val tls: TlsCert, private val clientSettings: ClientSettings
.option(ChannelOption.SO_BACKLOG, 1000)
.childOption(ChannelOption.SO_KEEPALIVE, true)
val channel = bootstrap.bind(InetSocketAddress(clientSettings.clientHostname, clientSettings.clientPort)).sync().channel()
val channel = bootstrap.bind(InetSocketAddress(serverSettings.clientHostname, serverSettings.clientPort)).sync().channel()
address = channel.localAddress() as InetSocketAddress
closeFuture = channel.closeFuture()
}
override fun stop() = apply {
masterGroup.shutdownGracefully(1, 15, TimeUnit.SECONDS).sync()
workerGroup.shutdownGracefully(1, 15, TimeUnit.SECONDS).sync()
closeFuture.sync()
closeFuture.cancel(false)
workerGroup.shutdownGracefully()
masterGroup.shutdownGracefully()
}
override fun port(): Int = if (clientSettings.clientPort > 0) clientSettings.clientPort else address.port
override fun port(): Int = if (serverSettings.clientPort > 0) serverSettings.clientPort else address.port
}
}

View file

@ -32,7 +32,6 @@ import io.netty.handler.codec.http.HttpServerCodec
import io.netty.handler.codec.http.HttpServerKeepAliveHandler
import io.netty.handler.stream.ChunkedWriteHandler
import java.net.InetSocketAddress
import java.util.concurrent.TimeUnit
import org.http4k.core.HttpHandler
import org.http4k.server.Http4kChannelHandler
import org.http4k.server.Http4kServer
@ -67,9 +66,9 @@ class WebUiNetty(private val hostname: String, private val port: Int) : ServerCo
}
override fun stop() = apply {
masterGroup.shutdownGracefully(5, 15, TimeUnit.SECONDS).sync()
workerGroup.shutdownGracefully(5, 15, TimeUnit.SECONDS).sync()
closeFuture.sync()
closeFuture.cancel(false)
workerGroup.shutdownGracefully()
masterGroup.shutdownGracefully()
}
override fun port(): Int = address.port

View file

@ -1,121 +0,0 @@
/*
Mangadex@Home
Copyright (c) 2020, MangaDex Network
This file is part of MangaDex@Home.
MangaDex@Home is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
MangaDex@Home is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
*/
/* ktlint-disable no-wildcard-imports */
package mdnet.base.server
import java.net.InetAddress
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import mdnet.base.data.Statistics
import mdnet.base.info
import mdnet.base.netty.Netty
import mdnet.base.settings.ClientSettings
import mdnet.base.settings.ServerSettings
import mdnet.cache.DiskLruCache
import org.apache.http.client.config.CookieSpecs
import org.apache.http.client.config.RequestConfig
import org.apache.http.impl.client.HttpClients
import org.http4k.client.ApacheClient
import org.http4k.core.*
import org.http4k.filter.ServerFilters
import org.http4k.routing.bind
import org.http4k.routing.routes
import org.http4k.server.Http4kServer
import org.http4k.server.asServer
import org.jetbrains.exposed.sql.Database
import org.slf4j.LoggerFactory
private val LOGGER = LoggerFactory.getLogger("Application")
fun getServer(cache: DiskLruCache, serverSettings: ServerSettings, clientSettings: ClientSettings, statistics: AtomicReference<Statistics>, isHandled: AtomicBoolean): Http4kServer {
val database = Database.connect("jdbc:sqlite:cache/data.db", "org.sqlite.JDBC")
val client = ApacheClient(responseBodyMode = BodyMode.Stream, client = HttpClients.custom()
.disableConnectionState()
.setDefaultRequestConfig(
RequestConfig.custom()
.setCookieSpec(CookieSpecs.IGNORE_COOKIES)
.setConnectTimeout(3000)
.setSocketTimeout(3000)
.setConnectionRequestTimeout(3000)
.apply {
if (clientSettings.clientHostname != "0.0.0.0") {
setLocalAddress(InetAddress.getByName(clientSettings.clientHostname))
}
}
.build())
.setMaxConnTotal(3000)
.setMaxConnPerRoute(3000)
.build())
val imageServer = ImageServer(cache, database, statistics, serverSettings, clientSettings, client)
return timeRequest()
.then(catchAllHideDetails())
.then(ServerFilters.CatchLensFailure)
.then(setHandled(isHandled))
.then(addCommonHeaders())
.then(
routes(
"/data/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(dataSaver = false),
"/data-saver/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(dataSaver = true),
"/{token}/data/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(
dataSaver = false,
tokenized = true
),
"/{token}/data-saver/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(
dataSaver = true,
tokenized = true
)
)
)
.asServer(Netty(serverSettings.tls!!, clientSettings, statistics))
}
fun setHandled(isHandled: AtomicBoolean): Filter {
return Filter { next: HttpHandler ->
{
isHandled.set(true)
next(it)
}
}
}
fun timeRequest(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
val cleanedUri = request.uri.path.let {
if (it.startsWith("/data")) {
it
} else {
it.replaceBefore("/data", "/{token}")
}
}
LOGGER.info { "Request for $cleanedUri received from ${request.source?.address}" }
val start = System.currentTimeMillis()
val response = next(request)
val latency = System.currentTimeMillis() - start
LOGGER.info { "Request for $cleanedUri completed (TTFB) in ${latency}ms" }
response.header("X-Time-Taken", latency.toString())
}
}
}

View file

@ -25,62 +25,71 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.goterl.lazycode.lazysodium.LazySodiumJava
import com.goterl.lazycode.lazysodium.SodiumJava
import com.goterl.lazycode.lazysodium.exceptions.SodiumException
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.File
import java.io.InputStream
import java.security.MessageDigest
import java.time.Clock
import java.time.OffsetDateTime
import java.util.*
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream
import javax.crypto.spec.SecretKeySpec
import mdnet.base.Constants
import mdnet.base.data.ImageData
import mdnet.base.data.ImageDatum
import mdnet.base.data.Statistics
import mdnet.base.data.Token
import mdnet.base.info
import mdnet.base.settings.ClientSettings
import mdnet.base.netty.Netty
import mdnet.base.settings.RemoteSettings
import mdnet.base.settings.ServerSettings
import mdnet.base.trace
import mdnet.base.warn
import mdnet.cache.CachingInputStream
import mdnet.cache.DiskLruCache
import mdnet.security.TweetNaclFast
import org.apache.http.client.config.CookieSpecs
import org.apache.http.client.config.RequestConfig
import org.apache.http.impl.client.HttpClients
import org.http4k.client.Apache4Client
import org.http4k.core.*
import org.http4k.filter.CachingFilters
import org.http4k.filter.ServerFilters
import org.http4k.lens.Path
import org.http4k.routing.bind
import org.http4k.routing.routes
import org.http4k.server.Http4kServer
import org.http4k.server.asServer
import org.jetbrains.exposed.exceptions.ExposedSQLException
import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory
private val LOGGER = LoggerFactory.getLogger(ImageServer::class.java)
class ImageServer(
private val cache: DiskLruCache,
private val database: Database,
private val statistics: AtomicReference<Statistics>,
private val serverSettings: ServerSettings,
private val clientSettings: ClientSettings,
private val remoteSettings: RemoteSettings,
private val client: HttpHandler
) {
init {
transaction(database) {
SchemaUtils.create(ImageData)
synchronized(database) {
transaction(database) {
SchemaUtils.create(ImageData)
}
}
}
private val executor = Executors.newCachedThreadPool()
private val maxBufferSizeForCacheHit: Int? = clientSettings.experimental?.maxBufferSizeForCacheHit
?.takeUnless { it == 0 }
?.times(8 * 1024)
fun handler(dataSaver: Boolean, tokenized: Boolean = false): HttpHandler {
val box = TweetNaclFast.SecretBox(remoteSettings.tokenKey)
return baseHandler().then { request ->
val chapterHash = Path.of("chapterHash")(request)
val fileName = Path.of("fileName")(request)
@ -96,7 +105,7 @@ class ImageServer(
return@then Response(Status.FORBIDDEN)
}
if (tokenized || serverSettings.forceTokens) {
if (tokenized || remoteSettings.forceTokens) {
val tokenArr = Base64.getUrlDecoder().decode(Path.of("token")(request))
if (tokenArr.size < 24) {
LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
@ -104,17 +113,15 @@ class ImageServer(
}
val token = try {
JACKSON.readValue<Token>(
try {
SODIUM.cryptoBoxOpenEasyAfterNm(
tokenArr.sliceArray(24 until tokenArr.size), tokenArr.sliceArray(0 until 24), serverSettings.tokenKey
)
} catch (_: SodiumException) {
LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
return@then Response(Status.FORBIDDEN)
box.open(tokenArr.sliceArray(24 until tokenArr.size), tokenArr.sliceArray(0 until 24)).apply {
if (this == null) {
LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
return@then Response(Status.FORBIDDEN)
}
}
)
} catch (e: JsonProcessingException) {
LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
LOGGER.info(e) { "Request for $sanitizedUri rejected for invalid token" }
return@then Response(Status.FORBIDDEN)
}
@ -154,6 +161,7 @@ class ImageServer(
snapshot.close()
LOGGER.warn { "Removing broken cache file for $sanitizedUri" }
cache.removeUnsafe(imageId.toCacheId())
cache.flush()
}
request.handleCacheMiss(sanitizedUri, getRc4(rc4Bytes), imageId, imageDatum)
@ -196,16 +204,11 @@ class ImageServer(
}
LOGGER.info { "Request for $sanitizedUri hit cache" }
val cacheStream = snapshot.getInputStream(0)
val bufferSize = maxBufferSizeForCacheHit?.coerceAtMost(snapshot.getLength(0).toInt())
val bufferedStream = bufferSize?.let {
BufferedInputStream(cacheStream, bufferSize)
} ?: BufferedInputStream(cacheStream) // Todo: Move into builder. It's untidy having the null propagate all the way here but I'm tired and tomorrow is a fast day.
respondWithImage(
CipherInputStream(bufferedStream, cipher),
snapshot.getLength(0).toString(), imageDatum.contentType, imageDatum.lastModified,
true
CipherInputStream(BufferedInputStream(snapshot.getInputStream(0)), cipher),
snapshot.getLength(0).toString(), imageDatum.contentType, imageDatum.lastModified,
true
)
}
}
@ -217,7 +220,7 @@ class ImageServer(
it.copy(cacheMisses = it.cacheMisses + 1)
}
val mdResponse = client(Request(Method.GET, "${serverSettings.imageServer}$sanitizedUri"))
val mdResponse = client(Request(Method.GET, "${remoteSettings.imageServer}$sanitizedUri"))
if (mdResponse.status != Status.OK) {
LOGGER.trace { "Upstream query for $sanitizedUri errored with status ${mdResponse.status}" }
@ -231,7 +234,7 @@ class ImageServer(
val lastModified = mdResponse.header("Last-Modified")
if (!contentType.isImageMimetype()) {
LOGGER.trace { "Upstream query for $sanitizedUri returned bad mimetype $contentType" }
LOGGER.warn { "Upstream query for $sanitizedUri returned bad mimetype $contentType" }
mdResponse.close()
return Response(Status.INTERNAL_SERVER_ERROR)
}
@ -246,13 +249,20 @@ class ImageServer(
LOGGER.trace { "Request for $sanitizedUri is being cached and served" }
if (imageDatum == null) {
synchronized(database) {
transaction(database) {
ImageDatum.new(imageId) {
this.contentType = contentType
this.lastModified = lastModified
try {
synchronized(database) {
transaction(database) {
ImageDatum.new(imageId) {
this.contentType = contentType
this.lastModified = lastModified
}
}
}
} catch (_: ExposedSQLException) {
// some other code got to the database first, fall back to just serving
editor.abort()
LOGGER.trace { "Request for $sanitizedUri is being served" }
respondWithImage(mdResponse.body.stream, contentLength, contentType, lastModified, false)
}
}
@ -264,6 +274,7 @@ class ImageServer(
if (editor.getLength(0) == contentLength.toLong()) {
LOGGER.info { "Cache download for $sanitizedUri committed" }
editor.commit()
cache.flush()
} else {
LOGGER.warn { "Cache download for $sanitizedUri aborted" }
editor.abort()
@ -305,8 +316,6 @@ class ImageServer(
.header("X-Cache", if (cached) "HIT" else "MISS")
companion object {
private val SODIUM = LazySodiumJava(SodiumJava())
private val LOGGER = LoggerFactory.getLogger(ImageServer::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(JavaTimeModule())
@ -326,23 +335,75 @@ class ImageServer(
}
}
private fun getRc4(key: ByteArray): Cipher {
val rc4 = Cipher.getInstance("RC4")
rc4.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "RC4"))
return rc4
}
private fun md5Bytes(stringToHash: String): ByteArray {
val digest = MessageDigest.getInstance("MD5")
return digest.digest(stringToHash.toByteArray())
}
private fun printHexString(bytes: ByteArray): String {
val sb = StringBuilder()
for (b in bytes) {
sb.append(String.format("%02x", b))
}
return sb.toString()
}
private fun String.isImageMimetype() = this.toLowerCase().startsWith("image/")
fun getServer(cache: DiskLruCache, database: Database, remoteSettings: RemoteSettings, serverSettings: ServerSettings, statistics: AtomicReference<Statistics>, isHandled: AtomicBoolean): Http4kServer {
val client = Apache4Client(responseBodyMode = BodyMode.Stream, client = HttpClients.custom()
.disableConnectionState()
.setDefaultRequestConfig(
RequestConfig.custom()
.setCookieSpec(CookieSpecs.IGNORE_COOKIES)
.setConnectTimeout(3000)
.setSocketTimeout(3000)
.setConnectionRequestTimeout(3000)
.build())
.setMaxConnTotal(3000)
.setMaxConnPerRoute(3000)
.build())
val imageServer = ImageServer(cache, database, statistics, remoteSettings, client)
return timeRequest()
.then(catchAllHideDetails())
.then(ServerFilters.CatchLensFailure)
.then(setHandled(isHandled))
.then(addCommonHeaders())
.then(
routes(
"/data/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(dataSaver = false),
"/data-saver/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(dataSaver = true),
"/{token}/data/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(
dataSaver = false,
tokenized = true
),
"/{token}/data-saver/{chapterHash}/{fileName}" bind Method.GET to imageServer.handler(
dataSaver = true,
tokenized = true
)
)
)
.asServer(Netty(remoteSettings.tls!!, serverSettings, statistics))
}
fun setHandled(isHandled: AtomicBoolean): Filter {
return Filter { next: HttpHandler ->
{
isHandled.set(true)
next(it)
}
}
}
fun timeRequest(): Filter {
return Filter { next: HttpHandler ->
{ request: Request ->
val cleanedUri = request.uri.path.let {
if (it.startsWith("/data")) {
it
} else {
it.replaceBefore("/data", "/{token}")
}
}
LOGGER.info { "Request for $cleanedUri received from ${request.source?.address}" }
val start = System.currentTimeMillis()
val response = next(request)
val latency = System.currentTimeMillis() - start
LOGGER.info { "Request for $cleanedUri completed (TTFB) in ${latency}ms" }
response.header("X-Time-Taken", latency.toString())
}
}
}

View file

@ -0,0 +1,43 @@
/*
Mangadex@Home
Copyright (c) 2020, MangaDex Network
This file is part of MangaDex@Home.
MangaDex@Home is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
MangaDex@Home is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
*/
/* ktlint-disable no-wildcard-imports */
package mdnet.base.server
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
fun getRc4(key: ByteArray): Cipher {
val rc4 = Cipher.getInstance("RC4")
rc4.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "RC4"))
return rc4
}
fun md5Bytes(stringToHash: String): ByteArray {
val digest = MessageDigest.getInstance("MD5")
return digest.digest(stringToHash.toByteArray())
}
fun printHexString(bytes: ByteArray): String {
val sb = StringBuilder()
for (b in bytes) {
sb.append(String.format("%02x", b))
}
return sb.toString()
}

View file

@ -1,42 +0,0 @@
/*
Mangadex@Home
Copyright (c) 2020, MangaDex Network
This file is part of MangaDex@Home.
MangaDex@Home is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
MangaDex@Home is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
*/
/* ktlint-disable no-wildcard-imports */
package mdnet.base.server
import com.goterl.lazycode.lazysodium.LazySodiumJava
import com.goterl.lazycode.lazysodium.exceptions.SodiumException
import com.goterl.lazycode.lazysodium.interfaces.Box
@Throws(SodiumException::class)
fun LazySodiumJava.cryptoBoxOpenEasyAfterNm(cipherBytes: ByteArray, nonce: ByteArray, sharedKey: ByteArray): String {
if (!Box.Checker.checkNonce(nonce.size)) {
throw SodiumException("Incorrect nonce length.")
}
if (!Box.Checker.checkBeforeNmBytes(sharedKey.size)) {
throw SodiumException("Incorrect shared secret key length.")
}
val message = ByteArray(cipherBytes.size - Box.MACBYTES)
val res: Boolean = cryptoBoxOpenEasyAfterNm(message, cipherBytes, cipherBytes.size.toLong(), nonce, sharedKey)
if (!res) {
throw SodiumException("Could not fully complete shared secret key decryption.")
}
return str(message)
}

View file

@ -18,14 +18,52 @@ along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
*/
package mdnet.base.settings
import com.fasterxml.jackson.annotation.JsonUnwrapped
import com.fasterxml.jackson.databind.PropertyNamingStrategy
import com.fasterxml.jackson.databind.annotation.JsonNaming
import dev.afanasev.sekret.Secret
// client settings are verified correct in Main.kt
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class ClientSettings(
class ClientSettings(
val maxCacheSizeInMebibytes: Long = 20480,
val webSettings: WebSettings? = null,
val devSettings: DevSettings = DevSettings(isDev = false)
) {
// FIXME: jackson doesn't work with data classes and JsonUnwrapped
// fix this in 2.0 when we can break the settings file
// and remove the `@JsonUnwrapped`
@field:JsonUnwrapped
lateinit var serverSettings: ServerSettings
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ClientSettings
if (maxCacheSizeInMebibytes != other.maxCacheSizeInMebibytes) return false
if (webSettings != other.webSettings) return false
if (devSettings != other.devSettings) return false
if (serverSettings != other.serverSettings) return false
return true
}
override fun hashCode(): Int {
var result = maxCacheSizeInMebibytes.hashCode()
result = 31 * result + (webSettings?.hashCode() ?: 0)
result = 31 * result + devSettings.hashCode()
result = 31 * result + serverSettings.hashCode()
return result
}
override fun toString(): String {
return "ClientSettings(maxCacheSizeInMebibytes=$maxCacheSizeInMebibytes, webSettings=$webSettings, devSettings=$devSettings, serverSettings=$serverSettings)"
}
}
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class ServerSettings(
val maxMebibytesPerHour: Long = 0,
val maxKilobitsPerSecond: Long = 0,
val clientHostname: String = "0.0.0.0",
@ -33,10 +71,7 @@ data class ClientSettings(
val clientExternalPort: Int = 0,
@field:Secret val clientSecret: String = "PASTE-YOUR-SECRET-HERE",
val threads: Int = 4,
val gracefulShutdownWaitSeconds: Int = 60,
val webSettings: WebSettings? = null,
val devSettings: DevSettings? = null,
val experimental: Experimental? = null
val gracefulShutdownWaitSeconds: Int = 60
)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
@ -49,8 +84,3 @@ data class WebSettings(
data class DevSettings(
val isDev: Boolean = false
)
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class Experimental(
val maxBufferSizeForCacheHit: Int = 0
)

View file

@ -23,11 +23,11 @@ import com.fasterxml.jackson.databind.annotation.JsonNaming
import dev.afanasev.sekret.Secret
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class ServerSettings(
data class RemoteSettings(
val imageServer: String,
val latestBuild: Int,
val url: String,
val tokenKey: ByteArray,
@field:Secret val tokenKey: ByteArray,
val compromised: Boolean,
val paused: Boolean,
val forceTokens: Boolean = false,
@ -37,7 +37,7 @@ data class ServerSettings(
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ServerSettings
other as RemoteSettings
if (imageServer != other.imageServer) return false
if (latestBuild != other.latestBuild) return false
@ -60,10 +60,6 @@ data class ServerSettings(
result = 31 * result + (tls?.hashCode() ?: 0)
return result
}
override fun toString(): String {
return "ServerSettings(imageServer='$imageServer', latestBuild=$latestBuild, url='$url', tokenKey=$tokenKey, compromised=$compromised, paused=$paused, forceTokens=$forceTokens, tls=$tls)"
}
}
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)

View file

@ -1,15 +1,16 @@
<configuration>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${file-level:-TRACE}</level>
<level>${file-level:-WARN}</level>
</filter>
<file>log/latest.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>log/logFile.%d{yyyy-MM-dd_HH}.log</fileNamePattern>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>log/logFile.%d{yyyy-MM-dd_HH}.%i.log</fileNamePattern>
<maxHistory>12</maxHistory>
<totalSizeCap>5MB</totalSizeCap>
</rollingPolicy>
<maxFileSize>32MB</maxFileSize>
<totalSizeCap>256MB</totalSizeCap>
</rollingPolicy>-->
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss} %-5level %logger{36} - %msg%n</pattern>
@ -36,5 +37,6 @@
<appender-ref ref="ASYNC"/>
</root>
<logger name="Exposed" level="ERROR"/>
<logger name="io.netty" level="INFO"/>
</configuration>