Compare commits

...

43 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
carbotaniuman 002a328f2d Format stuff 2020-07-29 13:48:26 -05:00
carbotaniuman eab29ba2a3 Fix sodium and bad cache mimetype stuff 2020-07-29 13:37:11 -05:00
carbotaniuman 6b23564f75 Merge branch 'master' into 'master'
Added reload of ClientSettings in running client

See merge request mangadex-pub/mangadex_at_home!55
2020-07-20 04:22:13 +00:00
radonbark 1b9e032282 Add start of reloading client settings
Added new client settings paramter 'settingsReloadDelayInMinutes'
to handle how often the client settings are updated.
Added scheduler to call a new 'reloadClientSettings' method when
it's time to reload client settings.
2020-07-20 04:22:13 +00:00
carbotaniuman 1371ab75f2 Merge branch 'ft-constant-download-url' into 'master'
Modify CI/CD settings to add a latest version publication

See merge request mangadex-pub/mangadex_at_home!61
2020-07-19 02:05:40 +00:00
m3ch_mania 77c92e58fb Modify CI/CD settings to add a latest version publication 2020-07-19 02:05:39 +00:00
carbotaniuman e0a4b04c70 Merge branch 'min-length-fix' into 'master'
Log warning instead of stack trace if token is too short

See merge request mangadex-pub/mangadex_at_home!62
2020-07-18 16:34:09 +00:00
carbotaniuman 4b1db7c705 Merge branch 'fix-declare-constant-as-field' into 'master'
Declare "MAX_AGE_CACHE" non-primitive constant as field

See merge request mangadex-pub/mangadex_at_home!63
2020-07-18 16:33:45 +00:00
m3ch_mania ec6bc11403 Declare non-primitive constant as field
In order to remove unnecesary setters/getters,
the "@JvmField" annotation has been prefixed
to the "MAX_AGE_CACHE" constant in Constants.kt
2020-07-17 22:43:16 -07:00
radonbark 5d8fe5b272 Log warning instead of stack trace if token is too short 2020-07-18 00:47:32 -04:00
carbotaniuman 9c213715a5 Merge branch 'ft-66-restrict-unsafe-ports' into 'master'
Feature #66: Restrict use of unsafe ports

See merge request mangadex-pub/mangadex_at_home!60
2020-07-18 04:07:53 +00:00
m3ch_mania 9cf990501c Addresses issue #66
The client will now fail on startup if either port is on the restricted ports list.
2020-07-18 04:07:53 +00:00
22 changed files with 4388 additions and 577 deletions

View file

@ -1,6 +1,7 @@
stages: stages:
- build - build
- publish - publish
- publish_latest
- publish_docker - publish_docker
cache: cache:
@ -26,8 +27,22 @@ publish:
name: "mangadex_at_home" name: "mangadex_at_home"
paths: paths:
- "*.jar" - "*.jar"
- "mangadex_at_home-*.zip"
- settings.sample.json - settings.sample.json
publish_latest:
image: alpine
stage: publish
before_script:
- apk update && apk add git
- export VERSION=`git describe --tags --dirty`
script:
- cp build/libs/mangadex_at_home-${VERSION}-all.jar build/libs/mangadex_at_home-latest-all.jar
artifacts:
name: "mangadex_at_home-latest"
paths:
- "build/libs/mangadex_at_home-latest-all.jar"
publish_docker: publish_docker:
image: docker:git image: docker:git
stage: publish stage: publish

View file

@ -17,6 +17,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security ### 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 ## [1.1.5] - 2020-07-05
### Added ### Added
- [2020-07-05] Added basic graph creation interface to dash [@RedMatriz]. - [2020-07-05] Added basic graph creation interface to dash [@RedMatriz].
@ -208,7 +242,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- [2020-06-11] Tweaked logging configuration to reduce log file sizes by [@carbotaniuman]. - [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.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.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 [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 FROM openjdk:15-alpine
WORKDIR /mangahome WORKDIR /mangahome
COPY /build/libs/mangadex_at_home.jar . COPY /build/libs/mangadex_at_home.jar .
RUN apk update && apk add --no-cache libsodium RUN apk update
VOLUME "/mangahome/cache" VOLUME "/mangahome/cache"
EXPOSE 443 8080 EXPOSE 443 8080
CMD java -Dfile-level=off -Dstdout-level=trace -jar mangadex_at_home.jar CMD java -Dfile-level=off -Dstdout-level=trace -jar mangadex_at_home.jar

View file

@ -1,10 +1,11 @@
plugins { plugins {
id "java" id "java"
id "org.jetbrains.kotlin.jvm" version "1.3.72" id "org.jetbrains.kotlin.jvm" version "1.4.0"
id "org.jetbrains.kotlin.kapt" version "1.4.0"
id "application" id "application"
id "com.github.johnrengelman.shadow" version "5.2.0" id "com.github.johnrengelman.shadow" version "5.2.0"
id "com.diffplug.gradle.spotless" version "4.4.0" id "com.diffplug.spotless" version "5.2.0"
id "dev.afanasev.sekret" version "0.0.3" id "dev.afanasev.sekret" version "0.0.7"
} }
group = "com.mangadex" group = "com.mangadex"
@ -22,7 +23,7 @@ configurations {
} }
dependencies { dependencies {
compileOnly group: "dev.afanasev", name: "sekret-annotation", version: "0.0.3" compileOnly group: "dev.afanasev", name: "sekret-annotation", version: "0.0.7"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation "org.jetbrains.kotlin:kotlin-reflect" implementation "org.jetbrains.kotlin:kotlin-reflect"
@ -32,19 +33,25 @@ dependencies {
implementation group: "org.http4k", name: "http4k-core", version: "$http_4k_version" 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: "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: "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-client-apache4", version: "$http_4k_version"
implementation group: "org.http4k", name: "http4k-server-netty", 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" 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: "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-core", version: "$exposed_version"
implementation group: "org.jetbrains.exposed", name: "exposed-dao", 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.jetbrains.exposed", name: "exposed-jdbc", version: "$exposed_version"
implementation group: "org.xerial", name: "sqlite-jdbc", version: "3.30.1" implementation group: "org.xerial", name: "sqlite-jdbc", version: "3.32.3.2"
implementation "com.goterl.lazycode:lazysodium-java:4.3.0" implementation "info.picocli:picocli:4.5.0"
implementation "net.java.dev.jna:jna:5.5.0" kapt "info.picocli:picocli-codegen:4.5.0"
}
kapt {
arguments {
arg("project", "${project.group}/${project.name}")
}
} }
java { java {
@ -77,3 +84,41 @@ tasks.register("generateVersion", Copy) {
} }
sourceSets.main.java.srcDir generateVersion.outputs.files 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 http_4k_version=3.258.0
exposed_version=0.24.1 exposed_version=0.26.2

View file

@ -254,7 +254,9 @@ public final class DiskLruCache implements Closeable {
try { try {
readJournalLine(reader.readLine()); readJournalLine(reader.readLine());
lineCount++; lineCount++;
} catch (EOFException endOfJournal) { } catch (UnexpectedJournalLineException ignored) {
// just continue and hope nothing breaks
} catch (EOFException e) {
break; break;
} }
} }
@ -273,7 +275,7 @@ public final class DiskLruCache implements Closeable {
private void readJournalLine(String line) throws IOException { private void readJournalLine(String line) throws IOException {
int firstSpace = line.indexOf(' '); int firstSpace = line.indexOf(' ');
if (firstSpace == -1) { if (firstSpace == -1) {
throw new IOException("unexpected journal line: " + line); throw new UnexpectedJournalLineException(line);
} }
int keyBegin = firstSpace + 1; int keyBegin = firstSpace + 1;
@ -305,7 +307,7 @@ public final class DiskLruCache implements Closeable {
} else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) { } else if (secondSpace == -1 && firstSpace == READ.length() && line.startsWith(READ)) {
// This work was already done by calling lruEntries.get(). // This work was already done by calling lruEntries.get().
} else { } 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,9 +21,84 @@ package mdnet.base
import java.time.Duration import java.time.Duration
object Constants { object Constants {
const val CLIENT_BUILD = 16 const val CLIENT_BUILD = 19
val MAX_AGE_CACHE: Duration = Duration.ofDays(14)
@JvmField val MAX_AGE_CACHE: Duration = Duration.ofDays(14)
const val MAX_READ_TIME_SECONDS = 300 const val MAX_READ_TIME_SECONDS = 300
const val MAX_WRITE_TIME_SECONDS = 60 const val MAX_WRITE_TIME_SECONDS = 60
// General list of ports to which Firefox and Chromium will not send HTTP requests for security reasons
// See:
// * https://chromium.googlesource.com/chromium/src.git/+/refs/heads/master/net/base/port_util.cc
// * https://developer.mozilla.org/en-US/docs/Mozilla/Mozilla_Port_Blocking#Blocked_Ports
@JvmField val RESTRICTED_PORTS = intArrayOf(
1, // tcpmux
7, // echo
9, // discard
11, // systat
13, // daytime
15, // netstat
17, // qotd
19, // chargen
20, // ftp data
21, // ftp access
22, // ssh
23, // telnet
25, // smtp
37, // time
42, // name
43, // nicname
53, // domain
77, // priv-rjs
79, // finger
87, // ttylink
95, // supdup
101, // hostriame
102, // iso-tsap
103, // gppitnp
104, // acr-nema
109, // pop2
110, // pop3
111, // sunrpc
113, // auth
115, // sftp
117, // uucp-path
119, // nntp
123, // NTP
135, // loc-srv /epmap
139, // netbios
143, // imap2
179, // BGP
389, // ldap
427, // SLP (Also used by Apple Filing Protocol)
465, // smtp+ssl
512, // print / exec
513, // login
514, // shell
515, // printer
526, // tempo
530, // courier
531, // chat
532, // netnews
540, // uucp
548, // AFP (Apple Filing Protocol)
556, // remotefs
563, // nntp+ssl
587, // smtp (rfc6409)
601, // syslog-conn (rfc3195)
636, // ldap+ssl
993, // ldap+ssl
995, // pop3+ssl
2049, // nfs
3659, // apple-sasl / PasswordServer
4045, // lockd
6000, // X11
6665, // Alternate IRC [Apple addition]
6666, // Alternate IRC [Apple addition]
6667, // Standard IRC [Apple addition]
6668, // Alternate IRC [Apple addition]
6669, // Alternate IRC [Apple addition]
6697 // IRC + TLS
)
} }

View file

@ -19,28 +19,48 @@ along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
package mdnet.base package mdnet.base
import ch.qos.logback.classic.LoggerContext import ch.qos.logback.classic.LoggerContext
import com.fasterxml.jackson.core.JsonParser import java.io.File
import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import java.io.FileReader
import java.io.FileWriter
import java.io.IOException
import java.util.regex.Pattern
import kotlin.system.exitProcess import kotlin.system.exitProcess
import mdnet.BuildInfo import mdnet.BuildInfo
import mdnet.base.settings.ClientSettings
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import picocli.CommandLine
object Main { object Main {
private val LOGGER = LoggerFactory.getLogger(Main::class.java) private val LOGGER = LoggerFactory.getLogger(Main::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper().enable(SerializationFeature.INDENT_OUTPUT).configure(JsonParser.Feature.ALLOW_COMMENTS, true)
@JvmStatic @JvmStatic
fun main(args: Array<String>) { 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( println(
"Mangadex@Home Client Version ${BuildInfo.VERSION} (Build ${Constants.CLIENT_BUILD}) initializing" "Mangadex@Home Client Version ${BuildInfo.VERSION} (Build ${Constants.CLIENT_BUILD}) initializing"
) )
@ -61,78 +81,11 @@ object Main {
along with Mangadex@Home. If not, see <https://www.gnu.org/licenses/>. along with Mangadex@Home. If not, see <https://www.gnu.org/licenses/>.
""".trimIndent()) """.trimIndent())
var file = "settings.json" val client = MangaDexClient(settingsFile, databaseFile, cacheFolder)
if (args.size == 1) { Runtime.getRuntime().addShutdownHook(Thread {
file = args[0] client.shutdown()
} else if (args.isNotEmpty()) { (LoggerFactory.getILoggerFactory() as LoggerContext).stop()
dieWithError("Expected one argument: path to config file, or nothing") })
}
val settings = try {
JACKSON.readValue<ClientSettings>(FileReader(file))
} 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 {} not found, generating file", file)
try {
FileWriter(file).use { writer -> JACKSON.writeValue(writer, it) }
} catch (e: IOException) {
dieWithError(e)
}
}
}.apply(::validateSettings)
LOGGER.info { "Client settings loaded: $settings" }
val client = MangaDexClient(settings)
Runtime.getRuntime().addShutdownHook(Thread { client.shutdown() })
client.runLoop() 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)
}
private fun validateSettings(settings: ClientSettings) {
if (!isSecretValid(settings.clientSecret)) dieWithError("Config Error: API Secret is invalid, must be 52 alphanumeric characters")
if (settings.clientPort == 0) {
dieWithError("Config Error: Invalid port number")
}
if (settings.maxCacheSizeInMebibytes < 1024) {
dieWithError("Config Error: Invalid max cache size, must be >= 1024 MiB (1GiB)")
}
if (settings.threads < 4) {
dieWithError("Config Error: Invalid number of threads, must be >= 4")
}
if (settings.maxMebibytesPerHour < 0) {
dieWithError("Config Error: Max bandwidth must be >= 0")
}
if (settings.maxKilobitsPerSecond < 0) {
dieWithError("Config Error: Max burst rate must be >= 0")
}
if (settings.gracefulShutdownWaitSeconds < 15) {
dieWithError("Config Error: Graceful shutdown wait be >= 15")
}
if (settings.webSettings != null) {
if (settings.webSettings.uiPort == 0) {
dieWithError("Config Error: Invalid UI port number")
}
}
}
private const val CLIENT_KEY_LENGTH = 52
private fun isSecretValid(clientSecret: String): Boolean {
return Pattern.matches("^[a-zA-Z0-9]{$CLIENT_KEY_LENGTH}$", clientSecret)
}
} }

View file

@ -19,285 +19,259 @@ along with this MangaDex@Home. If not, see <http://www.gnu.org/licenses/>.
/* ktlint-disable no-wildcard-imports */ /* ktlint-disable no-wildcard-imports */
package mdnet.base 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.ObjectMapper
import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import java.io.File import java.io.File
import java.io.FileReader
import java.io.IOException import java.io.IOException
import java.time.Instant
import java.util.*
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.regex.Pattern
import java.util.concurrent.atomic.AtomicReference
import mdnet.base.Main.dieWithError import mdnet.base.Main.dieWithError
import mdnet.base.data.Statistics
import mdnet.base.server.getServer
import mdnet.base.server.getUiServer import mdnet.base.server.getUiServer
import mdnet.base.settings.ClientSettings import mdnet.base.settings.*
import mdnet.base.settings.ServerSettings
import mdnet.cache.DiskLruCache import mdnet.cache.DiskLruCache
import mdnet.cache.HeaderMismatchException import mdnet.cache.HeaderMismatchException
import org.http4k.server.Http4kServer import org.http4k.server.Http4kServer
import org.jetbrains.exposed.sql.Database
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
sealed class State // Exception class to handle when Client Settings have invalid values
// server is not running class ClientSettingsException(message: String) : Exception(message)
object Uninitialized : 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, val action: () -> Unit = {}) : State()
// server is currently running
data class Running(val server: Http4kServer, val settings: ServerSettings) : State()
class MangaDexClient(private val clientSettings: ClientSettings) { class MangaDexClient(private val settingsFile: File, databaseFile: File, cacheFolder: File) {
// this must remain singlethreaded because of how the state mechanism works // this must remain single-threaded because of how the state mechanism works
private val executorService = Executors.newSingleThreadScheduledExecutor() private val executor = Executors.newSingleThreadScheduledExecutor()
// state must only be accessed from the thread on the executorService private lateinit var scheduledFuture: ScheduledFuture<*>
private var state: State = Uninitialized
private val serverHandler: ServerHandler = ServerHandler(clientSettings) private val database: Database
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 cache: DiskLruCache 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 { init {
settings = try {
readClientSettings()
} catch (e: UnrecognizedPropertyException) {
dieWithError("'${e.propertyName}' is not a valid setting")
} catch (e: JsonProcessingException) {
dieWithError(e)
} catch (e: ClientSettingsException) {
dieWithError(e)
} catch (e: IOException) {
dieWithError(e)
}
LOGGER.info { "Client settings loaded: $settings" }
database = Database.connect("jdbc:sqlite:$databaseFile", "org.sqlite.JDBC")
try { try {
cache = DiskLruCache.open( cache = DiskLruCache.open(
File("cache"), 1, 1, cacheFolder, 1, 1,
clientSettings.maxCacheSizeInMebibytes * 1024 * 1024 /* MiB to bytes */ (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) { } catch (e: HeaderMismatchException) {
LOGGER.warn { "Cache version may be outdated - remove if necessary" } LOGGER.warn { "Cache version may be outdated - remove if necessary" }
dieWithError(e) dieWithError(e)
} catch (e: IOException) { } catch (e: IOException) {
LOGGER.warn { "Cache may be corrupt - remove if necessary" }
dieWithError(e) dieWithError(e)
} }
} }
fun runLoop() { fun runLoop() {
loginAndStartServer() LOGGER.info { "Mangadex@Home Client initialized - starting normal operation." }
statsMap[Instant.now()] = statistics.get()
if (clientSettings.webSettings != null) { scheduledFuture = executor.scheduleWithFixedDelay({
webUi = getUiServer(clientSettings.webSettings, statistics, statsMap)
webUi!!.start()
}
LOGGER.info { "Mangadex@Home Client initialized. Starting normal operation." }
executorService.scheduleAtFixedRate({
try { try {
if (state is Running || state is GracefulShutdown || state is Uninitialized) { // this blocks the executor, so no worries about concurrency
statistics.updateAndGet { reloadClientSettings()
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) { } catch (e: Exception) {
LOGGER.warn(e) { "Statistics update failed" } LOGGER.warn(e) { "Reload of ClientSettings failed" }
} }
}, 15, 15, TimeUnit.SECONDS) }, 1, 1, TimeUnit.MINUTES)
var lastBytesSent = statistics.get().bytesSent startImageServer()
executorService.scheduleAtFixedRate({ startWebUi()
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)
val timesToWait = clientSettings.gracefulShutdownWaitSeconds / 15
executorService.scheduleAtFixedRate({
try {
val state = this.state
if (state is GracefulShutdown) {
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) // Precondition: settings must be filled with up-to-date settings and `imageServer` must not be null
state.action() private fun startWebUi() {
} settings.webSettings?.let { webSettings ->
else -> { val imageServer = requireNotNull(imageServer)
LOGGER.info {
"Waiting another 15 seconds for graceful shutdown (${state.counts} out of $timesToWait)"
}
isHandled.set(false) if (webUi != null) {
this.state = state.copy(counts = state.counts + 1)
}
}
}
} catch (e: Exception) {
LOGGER.warn("Main loop failed", e)
}
}, 15, 15, TimeUnit.SECONDS)
executorService.scheduleWithFixedDelay({
try {
val state = this.state
if (state is Running) {
val currentBytesSent = statistics.get().bytesSent - lastBytesSent
if (clientSettings.maxMebibytesPerHour != 0L && 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)
}
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() {
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, clientSettings, statistics, isHandled).start()
if (serverSettings.latestBuild > Constants.CLIENT_BUILD) {
LOGGER.warn {
"Outdated build detected! Latest: ${serverSettings.latestBuild}, Current: ${Constants.CLIENT_BUILD}"
}
}
state = Running(server, serverSettings)
LOGGER.info { "Internal HTTP server was successfully started" }
}
private fun logout() {
serverHandler.logoutFromControl()
}
private fun stopServer(nextState: State = Uninitialized) {
val state = this.state.let {
when (it) {
is Running ->
it
is GracefulShutdown ->
it.lastRunning
else ->
throw AssertionError() throw AssertionError()
} }
LOGGER.info { "WebUI starting" }
webUi = getUiServer(webSettings, imageServer.statistics, imageServer.statsMap).also {
it.start()
}
LOGGER.info { "WebUI started" }
}
} }
LOGGER.info { "Shutting down HTTP server" } // Precondition: settings must be filled with up-to-date settings
state.server.stop() private fun startImageServer() {
LOGGER.info { "Internal HTTP server has shut down" } 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" }
}
this.state = nextState 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() { fun shutdown() {
LOGGER.info { "Mangadex@Home Client stopping" } LOGGER.info { "Mangadex@Home Client shutting down" }
val latch = CountDownLatch(1) 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()
}
}, 0, TimeUnit.SECONDS)
latch.await()
webUi?.close() scheduledFuture.cancel(false)
executor.schedule({
if (webUi != null) {
stopWebUi()
}
if (imageServer != null) {
stopImageServer()
}
try { try {
cache.close() cache.close()
} catch (e: IOException) { } catch (e: IOException) {
LOGGER.error(e) { "Cache failed to close" } LOGGER.error(e) { "Cache failed to close" }
} }
executorService.shutdown() latch.countDown()
LOGGER.info { "Mangadex@Home Client stopped" } }, 0, TimeUnit.SECONDS)
(LoggerFactory.getILoggerFactory() as LoggerContext).stop() latch.await()
executor.shutdown()
LOGGER.info { "Mangadex@Home Client has shut down" }
}
/**
* Reloads the client configuration and restarts the
* Web UI and/or the server if needed
*/
private fun reloadClientSettings() {
LOGGER.info { "Checking client settings" }
try {
val newSettings = readClientSettings()
if (newSettings == settings) {
LOGGER.info { "Client settings unchanged" }
return
}
LOGGER.info { "New settings loaded: $newSettings" }
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
if (stopWebUi) {
LOGGER.info { "Stopping WebUI to reload ClientSettings" }
if (webUi != null) {
stopWebUi()
}
}
if (restartServer) {
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: ClientSettingsException) {
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 (settings.maxCacheSizeInMebibytes < 1024) {
throw ClientSettingsException("Config Error: Invalid max cache size, must be >= 1024 MiB (1GiB)")
}
fun isSecretValid(clientSecret: String): Boolean {
return Pattern.matches("^[a-zA-Z0-9]{$CLIENT_KEY_LENGTH}$", clientSecret)
}
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")
}
}
settings.webSettings?.let {
if (it.uiPort == 0) {
throw ClientSettingsException("Config Error: Invalid UI port number")
}
}
}
private fun readClientSettings(): ClientSettings {
return JACKSON.readValue<ClientSettings>(FileReader(settingsFile)).apply(::validateSettings)
} }
companion object { companion object {
private const val CLIENT_KEY_LENGTH = 52
private val LOGGER = LoggerFactory.getLogger(MangaDexClient::class.java) 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.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.KotlinModule import com.fasterxml.jackson.module.kotlin.KotlinModule
import java.net.InetAddress
import mdnet.base.ServerHandlerJackson.auto 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 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.Body
import org.http4k.core.Method import org.http4k.core.Method
import org.http4k.core.Request import org.http4k.core.Request
@ -40,13 +44,22 @@ object ServerHandlerJackson : ConfigurableJackson(
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
) )
class ServerHandler(private val settings: ClientSettings) { class ServerHandler(private val serverSettings: ServerSettings, private val devSettings: DevSettings, private val maxCacheSizeInMebibytes: Long) {
private val client = ApacheClient() 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 { fun logoutFromControl(): Boolean {
LOGGER.info { "Disconnecting from the control server" } LOGGER.info { "Disconnecting from the control server" }
val params = mapOf<String, Any>( 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")) val request = STRING_ANY_MAP_LENS(params, Request(Method.POST, getServerAddress() + "stop"))
@ -57,16 +70,16 @@ class ServerHandler(private val settings: ClientSettings) {
private fun getPingParams(tlsCreatedAt: String? = null): Map<String, Any> = private fun getPingParams(tlsCreatedAt: String? = null): Map<String, Any> =
mapOf<String, Any>( mapOf<String, Any>(
"secret" to settings.clientSecret, "secret" to serverSettings.clientSecret,
"port" to let { "port" to let {
if (settings.clientExternalPort != 0) { if (serverSettings.clientExternalPort != 0) {
settings.clientExternalPort serverSettings.clientExternalPort
} else { } else {
settings.clientPort serverSettings.clientPort
} }
}, },
"disk_space" to settings.maxCacheSizeInMebibytes * 1024 * 1024, "disk_space" to maxCacheSizeInMebibytes * 1024 * 1024,
"network_speed" to settings.maxKilobitsPerSecond * 1000 / 8, "network_speed" to serverSettings.maxKilobitsPerSecond * 1000 / 8,
"build_version" to Constants.CLIENT_BUILD "build_version" to Constants.CLIENT_BUILD
).let { ).let {
if (tlsCreatedAt != null) { if (tlsCreatedAt != null) {
@ -76,7 +89,7 @@ class ServerHandler(private val settings: ClientSettings) {
} }
} }
fun loginToControl(): ServerSettings? { fun loginToControl(): RemoteSettings? {
LOGGER.info { "Connecting to the control server" } LOGGER.info { "Connecting to the control server" }
val request = STRING_ANY_MAP_LENS(getPingParams(), Request(Method.POST, getServerAddress() + "ping")) val request = STRING_ANY_MAP_LENS(getPingParams(), Request(Method.POST, getServerAddress() + "ping"))
@ -89,7 +102,7 @@ class ServerHandler(private val settings: ClientSettings) {
} }
} }
fun pingControl(old: ServerSettings): ServerSettings? { fun pingControl(old: RemoteSettings): RemoteSettings? {
LOGGER.info { "Pinging the control server" } LOGGER.info { "Pinging the control server" }
val request = STRING_ANY_MAP_LENS(getPingParams(old.tls!!.createdAt), Request(Method.POST, getServerAddress() + "ping")) val request = STRING_ANY_MAP_LENS(getPingParams(old.tls!!.createdAt), Request(Method.POST, getServerAddress() + "ping"))
@ -103,7 +116,7 @@ class ServerHandler(private val settings: ClientSettings) {
} }
private fun getServerAddress(): String { private fun getServerAddress(): String {
return if (settings.devSettings?.isDev != true) return if (!devSettings.isDev)
SERVER_ADDRESS SERVER_ADDRESS
else else
SERVER_ADDRESS_DEV SERVER_ADDRESS_DEV
@ -112,8 +125,8 @@ class ServerHandler(private val settings: ClientSettings) {
companion object { companion object {
private val LOGGER = LoggerFactory.getLogger(ServerHandler::class.java) private val LOGGER = LoggerFactory.getLogger(ServerHandler::class.java)
private val STRING_ANY_MAP_LENS = Body.auto<Map<String, Any>>().toLens() private val STRING_ANY_MAP_LENS = Body.auto<Map<String, Any>>().toLens()
private val SERVER_SETTINGS_LENS = Body.auto<ServerSettings>().toLens() private val SERVER_SETTINGS_LENS = Body.auto<RemoteSettings>().toLens()
private const val SERVER_ADDRESS = "https://api.mangadex.network/" private const val SERVER_ADDRESS = "http://fakemdnet/"
private const val SERVER_ADDRESS_DEV = "https://mangadex-test.net/" 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.PrivateKey
import java.security.cert.CertificateFactory import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import javax.net.ssl.SSLException import javax.net.ssl.SSLException
import mdnet.base.Constants import mdnet.base.Constants
import mdnet.base.data.Statistics import mdnet.base.data.Statistics
import mdnet.base.info import mdnet.base.info
import mdnet.base.settings.ClientSettings import mdnet.base.settings.ServerSettings
import mdnet.base.settings.TlsCert import mdnet.base.settings.TlsCert
import mdnet.base.trace import mdnet.base.trace
import org.http4k.core.HttpHandler import org.http4k.core.HttpHandler
@ -57,17 +56,17 @@ import org.http4k.server.Http4kServer
import org.http4k.server.ServerConfig import org.http4k.server.ServerConfig
import org.slf4j.LoggerFactory 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 { override fun toServer(httpHandler: HttpHandler): Http4kServer = object : Http4kServer {
private val masterGroup = NioEventLoopGroup(clientSettings.threads) private val masterGroup = NioEventLoopGroup(serverSettings.threads)
private val workerGroup = NioEventLoopGroup(clientSettings.threads) private val workerGroup = NioEventLoopGroup(serverSettings.threads)
private lateinit var closeFuture: ChannelFuture private lateinit var closeFuture: ChannelFuture
private lateinit var address: InetSocketAddress private lateinit var address: InetSocketAddress
private val burstLimiter = object : GlobalTrafficShapingHandler( private val burstLimiter = object : GlobalTrafficShapingHandler(
workerGroup, clientSettings.maxKilobitsPerSecond * 1000L / 8L, 0, 50) { workerGroup, serverSettings.maxKilobitsPerSecond * 1000L / 8L, 0, 50) {
override fun doAccounting(counter: TrafficCounter) { override fun doAccounting(counter: TrafficCounter) {
statistics.getAndUpdate { statistics.getAndUpdate {
it.copy(bytesSent = it.bytesSent + counter.cumulativeWrittenBytes()) 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 { 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 certs = getX509Certs(tls.certificate)
val sslContext = SslContextBuilder 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("streamer", ChunkedWriteHandler())
ch.pipeline().addLast("handler", Http4kChannelHandler(httpHandler)) 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) { override fun exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable) {
if (cause is SSLException || (cause is DecoderException && cause.cause is SSLException)) { if (cause is SSLException || (cause is DecoderException && cause.cause is SSLException)) {
LOGGER.trace { "Ignored invalid SSL connection" } 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) .option(ChannelOption.SO_BACKLOG, 1000)
.childOption(ChannelOption.SO_KEEPALIVE, true) .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 address = channel.localAddress() as InetSocketAddress
closeFuture = channel.closeFuture() closeFuture = channel.closeFuture()
} }
override fun stop() = apply { override fun stop() = apply {
masterGroup.shutdownGracefully(1, 15, TimeUnit.SECONDS).sync() closeFuture.cancel(false)
workerGroup.shutdownGracefully(1, 15, TimeUnit.SECONDS).sync() workerGroup.shutdownGracefully()
closeFuture.sync() 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.codec.http.HttpServerKeepAliveHandler
import io.netty.handler.stream.ChunkedWriteHandler import io.netty.handler.stream.ChunkedWriteHandler
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.util.concurrent.TimeUnit
import org.http4k.core.HttpHandler import org.http4k.core.HttpHandler
import org.http4k.server.Http4kChannelHandler import org.http4k.server.Http4kChannelHandler
import org.http4k.server.Http4kServer import org.http4k.server.Http4kServer
@ -67,9 +66,9 @@ class WebUiNetty(private val hostname: String, private val port: Int) : ServerCo
} }
override fun stop() = apply { override fun stop() = apply {
masterGroup.shutdownGracefully(5, 15, TimeUnit.SECONDS).sync() closeFuture.cancel(false)
workerGroup.shutdownGracefully(5, 15, TimeUnit.SECONDS).sync() workerGroup.shutdownGracefully()
closeFuture.sync() masterGroup.shutdownGracefully()
} }
override fun port(): Int = address.port 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, 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,59 +25,71 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue 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.BufferedInputStream
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.File import java.io.File
import java.io.InputStream import java.io.InputStream
import java.security.MessageDigest
import java.time.Clock import java.time.Clock
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.* import java.util.*
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.CipherInputStream import javax.crypto.CipherInputStream
import javax.crypto.CipherOutputStream import javax.crypto.CipherOutputStream
import javax.crypto.spec.SecretKeySpec
import mdnet.base.Constants import mdnet.base.Constants
import mdnet.base.data.ImageData import mdnet.base.data.ImageData
import mdnet.base.data.ImageDatum import mdnet.base.data.ImageDatum
import mdnet.base.data.Statistics import mdnet.base.data.Statistics
import mdnet.base.data.Token import mdnet.base.data.Token
import mdnet.base.info import mdnet.base.info
import mdnet.base.netty.Netty
import mdnet.base.settings.RemoteSettings
import mdnet.base.settings.ServerSettings import mdnet.base.settings.ServerSettings
import mdnet.base.trace import mdnet.base.trace
import mdnet.base.warn import mdnet.base.warn
import mdnet.cache.CachingInputStream import mdnet.cache.CachingInputStream
import mdnet.cache.DiskLruCache 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.core.*
import org.http4k.filter.CachingFilters import org.http4k.filter.CachingFilters
import org.http4k.filter.ServerFilters
import org.http4k.lens.Path 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.Database
import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
private val LOGGER = LoggerFactory.getLogger(ImageServer::class.java)
class ImageServer( class ImageServer(
private val cache: DiskLruCache, private val cache: DiskLruCache,
private val database: Database, private val database: Database,
private val statistics: AtomicReference<Statistics>, private val statistics: AtomicReference<Statistics>,
private val serverSettings: ServerSettings, private val remoteSettings: RemoteSettings,
private val client: HttpHandler private val client: HttpHandler
) { ) {
init { init {
synchronized(database) {
transaction(database) { transaction(database) {
SchemaUtils.create(ImageData) SchemaUtils.create(ImageData)
} }
} }
}
private val executor = Executors.newCachedThreadPool() private val executor = Executors.newCachedThreadPool()
fun handler(dataSaver: Boolean, tokenized: Boolean = false): HttpHandler { fun handler(dataSaver: Boolean, tokenized: Boolean = false): HttpHandler {
val sodium = LazySodiumJava(SodiumJava()) val box = TweetNaclFast.SecretBox(remoteSettings.tokenKey)
return baseHandler().then { request -> return baseHandler().then { request ->
val chapterHash = Path.of("chapterHash")(request) val chapterHash = Path.of("chapterHash")(request)
val fileName = Path.of("fileName")(request) val fileName = Path.of("fileName")(request)
@ -93,21 +105,23 @@ class ImageServer(
return@then Response(Status.FORBIDDEN) return@then Response(Status.FORBIDDEN)
} }
if (tokenized || serverSettings.forceTokens) { if (tokenized || remoteSettings.forceTokens) {
val tokenArr = Base64.getUrlDecoder().decode(Path.of("token")(request)) val tokenArr = Base64.getUrlDecoder().decode(Path.of("token")(request))
val token = try { if (tokenArr.size < 24) {
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" } LOGGER.info { "Request for $sanitizedUri rejected for invalid token" }
return@then Response(Status.FORBIDDEN) return@then Response(Status.FORBIDDEN)
} }
val token = try {
JACKSON.readValue<Token>(
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) { } 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) return@then Response(Status.FORBIDDEN)
} }
@ -140,13 +154,14 @@ class ImageServer(
} }
} }
if (snapshot != null && imageDatum != null) { if (snapshot != null && imageDatum != null && imageDatum.contentType.isImageMimetype()) {
request.handleCacheHit(sanitizedUri, getRc4(rc4Bytes), snapshot, imageDatum) request.handleCacheHit(sanitizedUri, getRc4(rc4Bytes), snapshot, imageDatum)
} else { } else {
if (snapshot != null) { if (snapshot != null) {
snapshot.close() snapshot.close()
LOGGER.warn { "Removing cache file for $sanitizedUri without corresponding DB entry" } LOGGER.warn { "Removing broken cache file for $sanitizedUri" }
cache.removeUnsafe(imageId.toCacheId()) cache.removeUnsafe(imageId.toCacheId())
cache.flush()
} }
request.handleCacheMiss(sanitizedUri, getRc4(rc4Bytes), imageId, imageDatum) request.handleCacheMiss(sanitizedUri, getRc4(rc4Bytes), imageId, imageDatum)
@ -205,7 +220,7 @@ class ImageServer(
it.copy(cacheMisses = it.cacheMisses + 1) 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) { if (mdResponse.status != Status.OK) {
LOGGER.trace { "Upstream query for $sanitizedUri errored with status ${mdResponse.status}" } LOGGER.trace { "Upstream query for $sanitizedUri errored with status ${mdResponse.status}" }
@ -214,12 +229,18 @@ class ImageServer(
return Response(mdResponse.status) return Response(mdResponse.status)
} }
LOGGER.trace { "Upstream query for $sanitizedUri succeeded" }
val contentType = mdResponse.header("Content-Type")!! val contentType = mdResponse.header("Content-Type")!!
val contentLength = mdResponse.header("Content-Length") val contentLength = mdResponse.header("Content-Length")
val lastModified = mdResponse.header("Last-Modified") val lastModified = mdResponse.header("Last-Modified")
if (!contentType.isImageMimetype()) {
LOGGER.warn { "Upstream query for $sanitizedUri returned bad mimetype $contentType" }
mdResponse.close()
return Response(Status.INTERNAL_SERVER_ERROR)
}
LOGGER.trace { "Upstream query for $sanitizedUri succeeded" }
val editor = cache.editUnsafe(imageId.toCacheId()) val editor = cache.editUnsafe(imageId.toCacheId())
// A null editor means that this file is being written to // A null editor means that this file is being written to
@ -228,6 +249,7 @@ class ImageServer(
LOGGER.trace { "Request for $sanitizedUri is being cached and served" } LOGGER.trace { "Request for $sanitizedUri is being cached and served" }
if (imageDatum == null) { if (imageDatum == null) {
try {
synchronized(database) { synchronized(database) {
transaction(database) { transaction(database) {
ImageDatum.new(imageId) { ImageDatum.new(imageId) {
@ -236,6 +258,12 @@ class ImageServer(
} }
} }
} }
} 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)
}
} }
val tee = CachingInputStream( val tee = CachingInputStream(
@ -246,6 +274,7 @@ class ImageServer(
if (editor.getLength(0) == contentLength.toLong()) { if (editor.getLength(0) == contentLength.toLong()) {
LOGGER.info { "Cache download for $sanitizedUri committed" } LOGGER.info { "Cache download for $sanitizedUri committed" }
editor.commit() editor.commit()
cache.flush()
} else { } else {
LOGGER.warn { "Cache download for $sanitizedUri aborted" } LOGGER.warn { "Cache download for $sanitizedUri aborted" }
editor.abort() editor.abort()
@ -287,7 +316,6 @@ class ImageServer(
.header("X-Cache", if (cached) "HIT" else "MISS") .header("X-Cache", if (cached) "HIT" else "MISS")
companion object { companion object {
private val LOGGER = LoggerFactory.getLogger(ImageServer::class.java)
private val JACKSON: ObjectMapper = jacksonObjectMapper() private val JACKSON: ObjectMapper = jacksonObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.registerModule(JavaTimeModule()) .registerModule(JavaTimeModule())
@ -307,21 +335,75 @@ class ImageServer(
} }
} }
private fun getRc4(key: ByteArray): Cipher { private fun String.isImageMimetype() = this.toLowerCase().startsWith("image/")
val rc4 = Cipher.getInstance("RC4")
rc4.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "RC4")) fun getServer(cache: DiskLruCache, database: Database, remoteSettings: RemoteSettings, serverSettings: ServerSettings, statistics: AtomicReference<Statistics>, isHandled: AtomicBoolean): Http4kServer {
return rc4 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))
} }
private fun md5Bytes(stringToHash: String): ByteArray { fun setHandled(isHandled: AtomicBoolean): Filter {
val digest = MessageDigest.getInstance("MD5") return Filter { next: HttpHandler ->
return digest.digest(stringToHash.toByteArray()) {
} isHandled.set(true)
next(it)
private fun printHexString(bytes: ByteArray): String { }
val sb = StringBuilder() }
for (b in bytes) { }
sb.append(String.format("%02x", b))
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())
}
} }
return sb.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 package mdnet.base.settings
import com.fasterxml.jackson.annotation.JsonUnwrapped
import com.fasterxml.jackson.databind.PropertyNamingStrategy import com.fasterxml.jackson.databind.PropertyNamingStrategy
import com.fasterxml.jackson.databind.annotation.JsonNaming import com.fasterxml.jackson.databind.annotation.JsonNaming
import dev.afanasev.sekret.Secret import dev.afanasev.sekret.Secret
// client settings are verified correct in Main.kt
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)
data class ClientSettings( class ClientSettings(
val maxCacheSizeInMebibytes: Long = 20480, 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 maxMebibytesPerHour: Long = 0,
val maxKilobitsPerSecond: Long = 0, val maxKilobitsPerSecond: Long = 0,
val clientHostname: String = "0.0.0.0", val clientHostname: String = "0.0.0.0",
@ -33,9 +71,7 @@ data class ClientSettings(
val clientExternalPort: Int = 0, val clientExternalPort: Int = 0,
@field:Secret val clientSecret: String = "PASTE-YOUR-SECRET-HERE", @field:Secret val clientSecret: String = "PASTE-YOUR-SECRET-HERE",
val threads: Int = 4, val threads: Int = 4,
val gracefulShutdownWaitSeconds: Int = 60, val gracefulShutdownWaitSeconds: Int = 60
val webSettings: WebSettings? = null,
val devSettings: DevSettings? = null
) )
@JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class) @JsonNaming(PropertyNamingStrategy.SnakeCaseStrategy::class)

View file

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

View file

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