From abafed20e195e27f473b0c4abd66d6fea19a5934 Mon Sep 17 00:00:00 2001 From: 00asdf Date: Mon, 21 Apr 2025 20:44:52 +0200 Subject: [PATCH] working http server --- .gitignore | 1 + .../asdf00/visionfive/hc/GlobalDataStore.kt | 10 ++ .../asdf00/visionfive/hc/{ => gpio}/Gpio.kt | 2 +- .../visionfive/hc/{ => gpio}/MotorControl.kt | 2 +- .../visionfive/hc/server/LoginBehavior.kt | 74 +++++++++ .../dev/asdf00/visionfive/hc/server/Main.kt | 157 ++++++++++++++++++ .../visionfive/hc/server/WebServerBuilder.kt | 101 +++++++++++ src/test/kotlin/Sender.kt | 11 ++ 8 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt rename src/main/kotlin/dev/asdf00/visionfive/hc/{ => gpio}/Gpio.kt (98%) rename src/main/kotlin/dev/asdf00/visionfive/hc/{ => gpio}/MotorControl.kt (98%) create mode 100644 src/main/kotlin/dev/asdf00/visionfive/hc/server/LoginBehavior.kt create mode 100644 src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt create mode 100644 src/main/kotlin/dev/asdf00/visionfive/hc/server/WebServerBuilder.kt create mode 100644 src/test/kotlin/Sender.kt diff --git a/.gitignore b/.gitignore index 59477e2..3d901d4 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ bin/ ### Mac OS ### .DS_Store /.idea/misc.xml +/userlist.txt diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt new file mode 100644 index 0000000..3eed029 --- /dev/null +++ b/src/main/kotlin/dev/asdf00/visionfive/hc/GlobalDataStore.kt @@ -0,0 +1,10 @@ +package dev.asdf00.visionfive.hc + +import java.util.* +import java.util.concurrent.CopyOnWriteArraySet + +object GlobalDataStore { + @Volatile var temperature = 0.0 + @Volatile var tempUpdated = Date() + val allowedTempUpdates = CopyOnWriteArraySet() +} \ No newline at end of file diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/Gpio.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/gpio/Gpio.kt similarity index 98% rename from src/main/kotlin/dev/asdf00/visionfive/hc/Gpio.kt rename to src/main/kotlin/dev/asdf00/visionfive/hc/gpio/Gpio.kt index b9d3bff..802978e 100644 --- a/src/main/kotlin/dev/asdf00/visionfive/hc/Gpio.kt +++ b/src/main/kotlin/dev/asdf00/visionfive/hc/gpio/Gpio.kt @@ -1,4 +1,4 @@ -package dev.asdf00.visionfive.hc +package dev.asdf00.visionfive.hc.gpio import java.nio.file.Files import java.nio.file.Path diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/MotorControl.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/gpio/MotorControl.kt similarity index 98% rename from src/main/kotlin/dev/asdf00/visionfive/hc/MotorControl.kt rename to src/main/kotlin/dev/asdf00/visionfive/hc/gpio/MotorControl.kt index 7812ab6..727e2c9 100644 --- a/src/main/kotlin/dev/asdf00/visionfive/hc/MotorControl.kt +++ b/src/main/kotlin/dev/asdf00/visionfive/hc/gpio/MotorControl.kt @@ -1,4 +1,4 @@ -package dev.asdf00.visionfive.hc +package dev.asdf00.visionfive.hc.gpio import kotlin.math.abs diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/server/LoginBehavior.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/server/LoginBehavior.kt new file mode 100644 index 0000000..464cb76 --- /dev/null +++ b/src/main/kotlin/dev/asdf00/visionfive/hc/server/LoginBehavior.kt @@ -0,0 +1,74 @@ +package dev.asdf00.visionfive.hc.server + +import java.nio.file.Files +import java.security.SecureRandom +import java.util.* +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.PBEKeySpec +import kotlin.io.path.Path + +data class PwdHash(val hash: String, val salt: String) + +object LoginBehavior { + object Users { + private val FILE_PATH get() = Path("userlist.txt") + private val users = loadFromFile() + + operator fun get(userName: String) = users[userName] + operator fun set(userName: String, password: String) { + val salt = getRandomBase64(16) + users[userName] = PwdHash(hashPassword(password, salt), salt) + Files.writeString(FILE_PATH, users.map { + "${it.key};${it.value.hash};${it.value.salt}" + }.joinToString("\n")) + } + + private fun loadFromFile(): MutableMap { + if (Files.exists(FILE_PATH)) { + return Files.readString(FILE_PATH) + .split("\n") + .map { it.split(";") } + .filter { it.size == 3 } + .associateBy({ it[0] }, { PwdHash(it[1], it[2]) }) + .toMutableMap() + } else { + Files.writeString(FILE_PATH, "") + return mutableMapOf() + } + } + + fun remove(userName: String) = users.remove(userName) == null + } + + private val loggedInUsers = mutableMapOf() + + fun login(userName: String, password: String) = Users[userName]?.let { + if (it.hash != hashPassword(password, it.salt)) + null + else { + var cookie: String + do { + cookie = getRandomBase64().replace("=", "_") + } while (cookie in loggedInUsers.keys) + loggedInUsers[cookie] = userName + cookie + } + } + + fun isLoggedIn(cookie: String) = loggedInUsers[cookie] + + fun logout(cookie: String) = loggedInUsers.remove(cookie) != null + + private fun hashPassword(password: String, salt: String, iterations: Int = 8, keyLength: Int = 256): String { + val spec = PBEKeySpec(password.toCharArray(), Base64.getDecoder().decode(salt), iterations, keyLength) + val skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val hash = skf.generateSecret(spec).encoded + return Base64.getEncoder().encodeToString(hash) + } + + private fun getRandomBase64(len: Int = 8): String { + val bytes = ByteArray(len) + SecureRandom.getInstanceStrong().nextBytes(bytes) + return Base64.getEncoder().encodeToString(bytes) + } +} diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt new file mode 100644 index 0000000..42e916d --- /dev/null +++ b/src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt @@ -0,0 +1,157 @@ +package dev.asdf00.visionfive.hc.server + +import com.sun.net.httpserver.HttpExchange +import dev.asdf00.visionfive.hc.GlobalDataStore +import java.nio.charset.StandardCharsets +import java.util.* + +fun main() { + val server = buildWebServer(8030) { + // data collection + endpoint("temp") handler@{ exchange -> + if (exchange.remoteAddress.address.toString() !in GlobalDataStore.allowedTempUpdates) { + exchange.replyCat(403) + } + exchange.requestURI.query.split("&").forEach { + if (it.startsWith("t=")) { + it.substring(2).toDoubleOrNull()?.let { + GlobalDataStore.temperature = it + GlobalDataStore.tempUpdated = Date() + exchange.replyCat(200) + } ?: run { + exchange.replyCat(400) + } + return@handler + } + } + exchange.replyCat(400) + } + + // main landing page + endpoint("") { exchange -> + exchange.responseHeaders.add("Cache-Control", "no-store") + val token = getLoginToken(exchange) + LoginBehavior.isLoggedIn(token)?.let { uname -> + // logged in + exchange.sendReply( + //@formatter:off + 200, ContentType.HTML, """ + + + VFHeatControl Landing Page + + +

Welcome to HeatControl

+

You are logged in as ${uname}. To log out, click here.

+
+

+ The current recorded temperature is currently ${String.format("%.1f",GlobalDataStore.temperature)} C +

+ + + """.trimIndent().uft8() + //@formatter:on + ) + } ?: run { + // logged out + exchange.sendReply( + 200, ContentType.HTML, """ + + + VFHeatControl Landing Page + + +

Welcome to HeatControl

+

Please log in to view the current temperature

+
+

+ Login Form +
+

+
+
+
+
+ +
+

+ + + """.trimIndent().uft8() + ) + } + } + + // login logic + path("login") { + endpoint("in") handler@{ exchange -> + if (exchange.requestMethod != "POST") { + exchange.sendReply(400, ContentType.PLAIN, "wrong request method".uft8()) + return@handler + } + val (userName, pwd) = exchange.requestBody.readAllBytes().utf8().split("&").map { + it.split("=").let { + if (it.size == 2) it[1] else "" + } + }.let { + if (it.size == 2) Pair(it[0], it[1]) else Pair("", "") + } + LoginBehavior.login(userName, pwd)?.let { + exchange.responseHeaders.add("Set-Cookie", "auth=${it}; Max-Age=600; Path=/") + exchange.sendReply( + 200, + ContentType.HTML, + "".uft8() + ) + } ?: run { + exchange.sendReply( + 400, + ContentType.HTML, + "".uft8() + ) + } + } + + endpoint("out") { exchange -> + val token = getLoginToken(exchange) + if (LoginBehavior.logout(token)) { + exchange.responseHeaders.add("Set-Cookie", "auth=; Path=/") + exchange.sendReply( + 200, + ContentType.HTML, + "".uft8() + ) + } else { + exchange.sendReply( + 400, + ContentType.HTML, + "".uft8() + ) + } + } + } + } + + server.start() + println("HttpServer is running, press CTRL+C to exit") + print("IP address of temperature sensor: ") + GlobalDataStore.allowedTempUpdates += "/${readln()}" + while (!Thread.interrupted()) { + println() + print("Username to add: ") + val userName = readln() + print("Password for '$userName': ") + val pwd = readln() + LoginBehavior.Users[userName] = pwd + } +} + +fun String.uft8() = toByteArray(StandardCharsets.UTF_8) + +fun ByteArray.utf8() = String(this, StandardCharsets.UTF_8) + +private fun getLoginToken(exchange: HttpExchange) = + exchange.requestHeaders["Cookie"] + ?.let { if (it.size > 0) it[0] else null } + ?.let { it.split("=").let { if (it.size == 2) it[1] else "" } } + ?: "" diff --git a/src/main/kotlin/dev/asdf00/visionfive/hc/server/WebServerBuilder.kt b/src/main/kotlin/dev/asdf00/visionfive/hc/server/WebServerBuilder.kt new file mode 100644 index 0000000..09c1dbf --- /dev/null +++ b/src/main/kotlin/dev/asdf00/visionfive/hc/server/WebServerBuilder.kt @@ -0,0 +1,101 @@ +package dev.asdf00.visionfive.hc.server + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import java.net.InetSocketAddress +import java.nio.charset.StandardCharsets +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +fun buildWebServer(port: Int, rootPath: String = "", executor: Executor = Executors.newSingleThreadExecutor(), bld: WebServerBuilder.() -> Unit): HttpServer { + val bdr = WebServerBuilder(rootPath) + bdr.bld() + + val server = HttpServer.create(InetSocketAddress(port), 0) + server.executor = executor + + fun WebServerBuilder.collect(prevPath: String = "") { + val curPath = + if (ownPath == "") + prevPath + else + "$prevPath/$ownPath" + if (curPath.endsWith("/")) { + throw IllegalStateException("Invalid path '$curPath' encountered while building server!") + } + for (ep in endpoints) { + server.createContext("$curPath/${ep.path}", ep.code) + } + for (inner in innerPaths) { + inner.collect(curPath) + } + } + + bdr.collect() + return server +} + +// ===================================================================================================================== +// ServerBuilder +// ===================================================================================================================== + +class WebServerBuilder(internal val ownPath: String) { + internal val endpoints = mutableListOf() + internal val innerPaths = mutableListOf() + internal val usedPaths = mutableSetOf() + + fun path(innerPath: String, bld: WebServerBuilder.() -> Unit) { + if (!usedPaths.add(innerPath)){ + throw IllegalStateException("'$innerPath' already exists within '$ownPath'!") + } + val bdr = WebServerBuilder(innerPath) + bdr.bld() + innerPaths.add(bdr) + } + + fun endpoint(location: String, code: (HttpExchange) -> Unit) { + if (!usedPaths.add(location)) { + throw IllegalStateException("'$location' already exists within '$ownPath'!") + } + usedPaths.add(location) + endpoints.add(Endpoint(location, code)) + } + + fun String.endpoint(location: String, type: ContentType = ContentType.HTML, rCode: Int = 200) { + endpoint(location) { + it.sendReply(rCode, type, toByteArray(StandardCharsets.UTF_8)) + } + } + +} + +// ===================================================================================================================== +// Helper Types +// ===================================================================================================================== + +enum class ContentType(val text: String) { + PLAIN("text/plain"), + HTML("text/html"), + JS("application/javascript"), + JSON("application/json") +} + +data class Endpoint( + val path: String, + val code: (HttpExchange) -> Unit) + +// ===================================================================================================================== +// Helper Methods +// ===================================================================================================================== + +fun HttpExchange.sendReply(code: Int, type: ContentType, data: ByteArray) { + responseHeaders.add("Content-type", type.text) + sendResponseHeaders(code, data.size.toLong()) + responseBody.use { + it.write(data) + } +} + +fun HttpExchange.replyCat(code: Int) { + this.sendReply(code, ContentType.HTML, "".uft8()) +} diff --git a/src/test/kotlin/Sender.kt b/src/test/kotlin/Sender.kt new file mode 100644 index 0000000..eb68f77 --- /dev/null +++ b/src/test/kotlin/Sender.kt @@ -0,0 +1,11 @@ +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +fun main() { + val client = HttpClient.newHttpClient() + // val req = HttpRequest.newBuilder(URI.create("http://192.168.178.71:8030/temp?t=34.5")).GET().build() + val req = HttpRequest.newBuilder(URI.create("http://127.0.0.1:8030/temp?t=34.5")).GET().build() + println("response: ${client.send(req, HttpResponse.BodyHandlers.ofString()).statusCode()}") +} \ No newline at end of file